mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge remote-tracking branch 'origin/main' into test/settle-ledger-poll
This commit is contained in:
commit
49acba440a
71 files changed
+5333
-264
No files matched your search
+61
-22
@@ -29943,19 +29943,19 @@ components:
|
||||
carry no code at all. ai_provider — ai_provider.invalid, ai_provider.no_fields; allergy —
|
||||
allergy.invalid; analytics — analytics.invalid_query; anamnesis — anamnesis.fact.conflict,
|
||||
anamnesis.fact.currentExists, anamnesis.fact.invalidValue; auth — auth.admin.required,
|
||||
auth.mfa.code_invalid, auth.missing, auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked,
|
||||
auth.scope.insufficient, auth.stepup.required, auth.token.expired, auth.token.invalid; backup —
|
||||
backup.payload.undecryptable, backup.section.missing; coach — coach.conversation.invalidTitle,
|
||||
coach.conversation.notFound, coach.fenced.attachmentConflict, coach.fenced.attachmentLimit,
|
||||
coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired,
|
||||
coach.fenced.attachmentUnavailable, coach.fenced.invalid, coach.request.invalid; coach-prefs —
|
||||
coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; consent — consent.ai.required; cycle —
|
||||
cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range,
|
||||
cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled,
|
||||
cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs —
|
||||
cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json;
|
||||
documents — documents.chat.conversationNotFound, documents.chat.invalid,
|
||||
documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed,
|
||||
auth.already_authenticated, auth.mfa.code_invalid, auth.missing, auth.refresh.invalid,
|
||||
auth.refresh.reuse, auth.refresh.revoked, auth.scope.insufficient, auth.stepup.required,
|
||||
auth.token.expired, auth.token.invalid; backup — backup.payload.undecryptable, backup.section.missing;
|
||||
coach — coach.conversation.invalidTitle, coach.conversation.notFound, coach.fenced.attachmentConflict,
|
||||
coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed,
|
||||
coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, coach.fenced.invalid,
|
||||
coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape;
|
||||
consent — consent.ai.required; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid,
|
||||
cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid,
|
||||
cycle.disabled, cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit;
|
||||
cycle-prefs — cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach —
|
||||
disable-coach.body.invalid_json; documents — documents.chat.conversationNotFound,
|
||||
documents.chat.invalid, documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed,
|
||||
documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound,
|
||||
documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound,
|
||||
documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge,
|
||||
@@ -44485,6 +44485,33 @@ components:
|
||||
- type: "null"
|
||||
description: v1.22 (#89) — per-user response timeout for AI generation, in seconds (10–600). Null = the built-in
|
||||
comprehensive-briefing default (~120 s). Mainly for slow local/self-hosted backends.
|
||||
serverProviderHealth:
|
||||
type: string
|
||||
enum:
|
||||
- healthy
|
||||
- unhealthy
|
||||
- unknown
|
||||
description: "Instance-wide health of the operator's shared providers (`admin-openai` / `admin-codex`), folded across
|
||||
all accounts and reduced to a tri-state: `healthy` when one of them demonstrably served somebody within the
|
||||
last 24 h and no credential sits in its auth-failure cooldown, `unhealthy` when the last outcome somebody
|
||||
observed was a failure, `unknown` when nothing has been tried lately on this instance (including a brand-new
|
||||
instance whose ledger is still empty) or the read failed. Deliberately carries no count, no timestamp and no
|
||||
account identity — 'the provider of this instance last worked' is a statement about the instance, not about
|
||||
other people. Fails closed for the offer: neither `unhealthy` nor `unknown` may be offered. They are not
|
||||
interchangeable for copy, though — `unhealthy` is a verdict a client may state, `unknown` is the absence of
|
||||
one and must not be reported as an outage. Reading this never probes a provider."
|
||||
serverProviderOffer:
|
||||
type: boolean
|
||||
description: "Whether the shared provider may honestly be offered to this caller in one tap. True only when all of: the
|
||||
operator's provider is the one that would serve them (`managedBy: \"server\"`), `serverProviderHealth` is
|
||||
`healthy`, the operator's assistant master + coach flags are on, the acting record holds its own credentials
|
||||
(a managed profile never does), the caller holds no receipt yet, and the instance is not running in demo
|
||||
mode (where the grant the tap makes is refused at the edge). Anything unknown makes it false."
|
||||
serverProviderConsent:
|
||||
type: boolean
|
||||
description: Whether this caller already holds an active `ai_full` / `ai_coach` receipt — the grant `POST
|
||||
/api/consent/ai/web` mints. When true there is nothing left to offer, and `serverProviderOffer` is false for
|
||||
that reason rather than for a bad one.
|
||||
required:
|
||||
- provider
|
||||
- model
|
||||
@@ -44500,6 +44527,9 @@ components:
|
||||
- compatModel
|
||||
- hasCompatKey
|
||||
- responseTimeoutSeconds
|
||||
- serverProviderHealth
|
||||
- serverProviderOffer
|
||||
- serverProviderConsent
|
||||
additionalProperties: false
|
||||
description: The calling user's AI-provider configuration plus the effective availability (`aiAvailable` + `managedBy`).
|
||||
Reports key presence + a masked preview only — never a plaintext key.
|
||||
@@ -64323,9 +64353,12 @@ components:
|
||||
- error_reauth
|
||||
- disconnected
|
||||
- parked
|
||||
description: Last recorded ledger state for this provider. `connected` is also the value a user who has never synced
|
||||
sees — the ledger row is created on the first attempt, and its absence reads as `connected`, not as an
|
||||
error.
|
||||
- unknown
|
||||
description: "Last recorded ledger state for this provider. `unknown` (added v1.38.19) is what a user who has never
|
||||
synced sees: the ledger row is created on the first attempt, and its absence is no claim about the
|
||||
connection in either direction — it used to read as `connected`, which is why a client must never treat this
|
||||
field as liveness. Read `syncHealth.verdict` for that, and treat `unknown` as `disconnected` anywhere a
|
||||
ledger state is painted."
|
||||
lastSuccessAt:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -65318,9 +65351,12 @@ components:
|
||||
- error_reauth
|
||||
- disconnected
|
||||
- parked
|
||||
description: Last recorded ledger state for this provider. `connected` is also the value a user who has never synced
|
||||
sees — the ledger row is created on the first attempt, and its absence reads as `connected`, not as an
|
||||
error.
|
||||
- unknown
|
||||
description: "Last recorded ledger state for this provider. `unknown` (added v1.38.19) is what a user who has never
|
||||
synced sees: the ledger row is created on the first attempt, and its absence is no claim about the
|
||||
connection in either direction — it used to read as `connected`, which is why a client must never treat this
|
||||
field as liveness. Read `syncHealth.verdict` for that, and treat `unknown` as `disconnected` anywhere a
|
||||
ledger state is painted."
|
||||
lastSuccessAt:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -65724,9 +65760,12 @@ components:
|
||||
- error_reauth
|
||||
- disconnected
|
||||
- parked
|
||||
description: Last recorded ledger state for this provider. `connected` is also the value a user who has never synced
|
||||
sees — the ledger row is created on the first attempt, and its absence reads as `connected`, not as an
|
||||
error.
|
||||
- unknown
|
||||
description: "Last recorded ledger state for this provider. `unknown` (added v1.38.19) is what a user who has never
|
||||
synced sees: the ledger row is created on the first attempt, and its absence is no claim about the
|
||||
connection in either direction — it used to read as `connected`, which is why a client must never treat this
|
||||
field as liveness. Read `syncHealth.verdict` for that, and treat `unknown` as `disconnected` anywhere a
|
||||
ledger state is painted."
|
||||
lastSuccessAt:
|
||||
anyOf:
|
||||
- type: string
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* The journey that would have caught the invite defect.
|
||||
*
|
||||
* Every layer of the invite chain had unit coverage and the chain was broken
|
||||
* anyway: the proxy test proved the edge admits `/invite/<token>`, the landing
|
||||
* page test proved it redirects onto `/auth/register?invite=…`, and neither
|
||||
* could see that in the real runtime the redirect arrives in an RSC flight
|
||||
* payload AFTER the shell has flushed — so the shell mounted on `/invite/…`,
|
||||
* read it as a protected route and raced its own `router.replace(
|
||||
* "/auth/login")` against it. The operator scanned his own QR in a private
|
||||
* window and landed on the sign-in page holding a one-time invitation the
|
||||
* sign-in page cannot use.
|
||||
*
|
||||
* So this spec is deliberately end to end and deliberately about the SEAM:
|
||||
* mint a real invitation in the admin UI, open the real URL in a genuinely
|
||||
* cookie-less context, and assert both where the visitor ends up and where
|
||||
* they never went.
|
||||
*
|
||||
* The second case is the other half of the same defect: the same link opened
|
||||
* in the admin's own window used to render the bare form, and submitting it
|
||||
* replaced his session cookie with the new account's — signing him out of his
|
||||
* admin account and into a stranger's onboarding.
|
||||
*
|
||||
* Neither case completes a signup: that would add a user to the shared e2e
|
||||
* database and spend the 5-per-15-minutes registration bucket every other
|
||||
* spec shares. What the signup does with the token is pinned in
|
||||
* `src/app/api/auth/register/__tests__/route.test.ts`.
|
||||
*/
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
/** Mint an invitation in the admin UI and return its `/invite/<token>` path. */
|
||||
async function mintInvite(page: import("@playwright/test").Page) {
|
||||
await page.goto("/admin/invites");
|
||||
await page.getByTestId("admin-invites-open-create").click();
|
||||
await page.getByTestId("admin-invites-submit-create").click();
|
||||
const minted = page.getByTestId("admin-invites-minted");
|
||||
await expect(minted).toBeVisible();
|
||||
const url = await page.getByTestId("admin-invites-minted-url").textContent();
|
||||
expect(url).toMatch(/\/invite\/hlv_[0-9a-f]{64}$/);
|
||||
// The minted URL carries whatever origin the instance is configured with
|
||||
// (`APP_URL` / `NEXT_PUBLIC_APP_URL`), which is not necessarily the one
|
||||
// Playwright is driving. The path is the part under test.
|
||||
return new URL(url!.trim()).pathname;
|
||||
}
|
||||
|
||||
test.describe("invitation links", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test("opens registration in a cookie-less window and never passes login", async ({
|
||||
page,
|
||||
browser,
|
||||
}) => {
|
||||
const invitePath = await mintInvite(page);
|
||||
|
||||
// The operator's private window: a context with no cookies at all, not a
|
||||
// page that merely has not logged in.
|
||||
const fresh = await browser.newContext({
|
||||
storageState: { cookies: [], origins: [] },
|
||||
});
|
||||
const guest = await fresh.newPage();
|
||||
const visited: string[] = [];
|
||||
guest.on("framenavigated", (frame) => {
|
||||
if (frame === guest.mainFrame()) visited.push(frame.url());
|
||||
});
|
||||
|
||||
try {
|
||||
await guest.goto(invitePath);
|
||||
await expect(guest).toHaveURL(
|
||||
/\/auth\/register\?invite=hlv_[0-9a-f]{64}/,
|
||||
);
|
||||
await expect(guest.getByTestId("register-invite-banner")).toBeVisible();
|
||||
// The form is reachable, not just the URL.
|
||||
await expect(guest.locator("#username")).toBeVisible();
|
||||
// The defect, named: the visitor must never have been bounced through
|
||||
// the sign-in page on the way.
|
||||
expect(visited.filter((u) => u.includes("/auth/login"))).toEqual([]);
|
||||
} finally {
|
||||
// Contexts a spec creates itself are invisible to the route-cleanup
|
||||
// fixture — see the header of `e2e/setup/test.ts`.
|
||||
await fresh.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows the signed-in panel in the admin's own window and keeps the session", async ({
|
||||
page,
|
||||
}) => {
|
||||
const invitePath = await mintInvite(page);
|
||||
|
||||
await page.goto(invitePath);
|
||||
await expect(page).toHaveURL(/\/auth\/register\?invite=hlv_[0-9a-f]{64}/);
|
||||
await expect(page.getByTestId("register-already-signed-in")).toBeVisible();
|
||||
// No form to submit, so no way to register over the live session.
|
||||
await expect(page.locator("#username")).toHaveCount(0);
|
||||
await expect(page.getByTestId("register-sign-out")).toBeVisible();
|
||||
|
||||
// The admin is still the admin.
|
||||
const me = await page.request.get("/api/auth/me");
|
||||
expect(me.status()).toBe(200);
|
||||
});
|
||||
|
||||
test("refuses a registration POST carried on a live session", async ({
|
||||
page,
|
||||
}) => {
|
||||
// The server half of the same rule, asserted through the wire rather than
|
||||
// through the UI: the panel above is an explanation, not the enforcement.
|
||||
const res = await page.request.post("/api/auth/register", {
|
||||
data: {
|
||||
email: "invite-over-session@healthlog.test",
|
||||
username: "invite-over-session",
|
||||
password: "ZJ4hN8x!Pq3vMr2C",
|
||||
},
|
||||
});
|
||||
expect(res.status()).toBe(409);
|
||||
const body = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
expect(body.meta?.errorCode).toBe("auth.already_authenticated");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* v1.38.19 — the AI offer at the end of the setup flow.
|
||||
*
|
||||
* The screen used to promise that insights "work for you right now" the
|
||||
* moment the operator had configured a key. It never knew whether the key
|
||||
* answered, and it never mentioned the consent receipt the egress actually
|
||||
* needs — so the person who believed it met a `consent.ai.required` refusal
|
||||
* on their first briefing. On 2026-09-11 the operator's own instance was
|
||||
* answering HTTP 500 from its OAuth proxy while the screen kept promising.
|
||||
*
|
||||
* Three runs, one account, and the only difference between them is a row in
|
||||
* `provider_health`:
|
||||
*
|
||||
* a success inside the freshness window → the offer, and one tap grants
|
||||
* a dead credential inside its cooldown → "not answering", and no button
|
||||
* no row at all → neither: the panel says nothing
|
||||
*
|
||||
* What is deliberately absent: any call to a provider. The flow's rule holds
|
||||
* — nothing in it talks to one — and a probe here would bill the operator for
|
||||
* every registration on the instance.
|
||||
*
|
||||
* Serial, and it restores the instance state it seeded: the tri-state folds
|
||||
* across accounts by design, so its inputs are instance-wide.
|
||||
*/
|
||||
import {
|
||||
E2E_SETUP_AI_OFFER,
|
||||
SETUP_AI_OFFER_STORAGE_STATE_PATH,
|
||||
} from "./setup/global-setup";
|
||||
import {
|
||||
clearAiConsent,
|
||||
clearOperatorProvider,
|
||||
clearSharedProviderResult,
|
||||
seedOperatorProvider,
|
||||
seedSharedProviderResult,
|
||||
} from "./setup/ai-offer-fixture";
|
||||
import { resetSetupFlow } from "./setup/setup-flow-fixture";
|
||||
import { expect, test } from "./setup/test";
|
||||
import {
|
||||
answer,
|
||||
answerEverything,
|
||||
complete,
|
||||
expectScreen,
|
||||
} from "./setup-flow-helpers";
|
||||
|
||||
const OFFER = '[data-slot="onboarding-ai-offer"]';
|
||||
const GRANT = '[data-slot="onboarding-ai-offer-grant"]';
|
||||
const UNAVAILABLE = '[data-slot="onboarding-ai-unavailable"]';
|
||||
const SHARED_KEY = '[data-slot="onboarding-ai-shared-key"]';
|
||||
/** The panel once it knows which variant it is; absent while the status is in
|
||||
* flight, so an assertion of absence cannot pass on an unanswered question. */
|
||||
const SETTLED = '[data-slot="onboarding-ai-panel"][data-ai-state]';
|
||||
|
||||
/** Walk the account to the done screen through the API, then open it. */
|
||||
async function openDoneScreen(page: import("@playwright/test").Page) {
|
||||
await page.goto("/onboarding");
|
||||
await answerEverything(page);
|
||||
// Q6 applies because the answers name glucose and weight; the flow does not
|
||||
// complete while a screen it owes is unanswered.
|
||||
await answer(page, {
|
||||
step: "units",
|
||||
units: { glucoseUnit: "mmol/L", unitPreference: "metric" },
|
||||
});
|
||||
await complete(page);
|
||||
await answer(page, { step: "first-result", status: "skipped" });
|
||||
await page.goto("/onboarding/done");
|
||||
await expectScreen(page, "done");
|
||||
// The panel itself is unconditional; the variant inside it is not.
|
||||
await expect(page.locator('[data-slot="onboarding-ai-panel"]')).toBeVisible();
|
||||
}
|
||||
|
||||
// One account, one instance-wide ledger. Two workers driving this at once
|
||||
// would each read the other's seed.
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test.describe("the setup flow's AI offer follows the shared provider's health", () => {
|
||||
test.use({ storageState: SETUP_AI_OFFER_STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetSetupFlow(E2E_SETUP_AI_OFFER.username);
|
||||
await clearAiConsent(E2E_SETUP_AI_OFFER.username);
|
||||
await seedOperatorProvider();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await clearOperatorProvider();
|
||||
await clearAiConsent(E2E_SETUP_AI_OFFER.username);
|
||||
});
|
||||
|
||||
test("offers one tap when the shared provider worked recently, and the tap leaves a receipt", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
await seedSharedProviderResult(E2E_SETUP_AI_OFFER.username, {
|
||||
result: "ok",
|
||||
okMinutesAgo: 30,
|
||||
});
|
||||
|
||||
// No receipt before the tap — otherwise the assertion after it proves
|
||||
// nothing about the button.
|
||||
const before = await page.request.get(
|
||||
"/api/consent/ai/latest?kind=ai_full",
|
||||
);
|
||||
expect(before.status()).toBe(200);
|
||||
expect(
|
||||
((await before.json()) as { data: { receipt: unknown } }).data.receipt,
|
||||
).toBeNull();
|
||||
|
||||
await openDoneScreen(page);
|
||||
|
||||
await expect(page.locator(SETTLED)).toHaveAttribute(
|
||||
"data-ai-state",
|
||||
"offer",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await expect(page.locator(OFFER)).toBeVisible();
|
||||
await expect(page.locator(UNAVAILABLE)).toHaveCount(0);
|
||||
|
||||
// No provider was called to decide this. The flow reads a projection.
|
||||
const probes: string[] = [];
|
||||
page.on("request", (req) => {
|
||||
const path = new URL(req.url()).pathname;
|
||||
if (path === "/api/ai/test" || path.startsWith("/api/insights/"))
|
||||
probes.push(path);
|
||||
});
|
||||
|
||||
await page.locator(GRANT).click();
|
||||
|
||||
// The line the person reads swaps to the granted one.
|
||||
await expect(page.locator(SHARED_KEY)).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator(GRANT)).toHaveCount(0);
|
||||
expect(probes, "the offer must not trigger a provider call").toEqual([]);
|
||||
|
||||
const after = await page.request.get("/api/consent/ai/latest?kind=ai_full");
|
||||
const receipt = (
|
||||
(await after.json()) as {
|
||||
data: { receipt: { kind: string; revokedAt: string | null } | null };
|
||||
}
|
||||
).data.receipt;
|
||||
expect(receipt).not.toBeNull();
|
||||
expect(receipt!.kind).toBe("ai_full");
|
||||
expect(receipt!.revokedAt).toBeNull();
|
||||
});
|
||||
|
||||
test("says the provider is not answering while its credential is benched, and offers no button", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
await seedSharedProviderResult(E2E_SETUP_AI_OFFER.username, {
|
||||
result: "auth_failed",
|
||||
});
|
||||
|
||||
await openDoneScreen(page);
|
||||
|
||||
await expect(page.locator(SETTLED)).toHaveAttribute(
|
||||
"data-ai-state",
|
||||
"unavailable",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await expect(page.locator(UNAVAILABLE)).toBeVisible();
|
||||
await expect(page.locator(GRANT)).toHaveCount(0);
|
||||
await expect(page.locator(OFFER)).toHaveCount(0);
|
||||
// The unproven promise is not shown to somebody who has not consented.
|
||||
await expect(page.locator(SHARED_KEY)).toHaveCount(0);
|
||||
|
||||
// The rest of the panel is untouched: the flow stays complete without it.
|
||||
await expect(
|
||||
page.locator('[data-slot="onboarding-ai-keyless"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-slot="onboarding-open-dashboard"]'),
|
||||
).toBeVisible();
|
||||
|
||||
// The grant route itself stays open — it is the user's own consent act
|
||||
// and is reachable from Settings at any time. What this case pins is the
|
||||
// decision the screen was handed, so read it back from the source.
|
||||
const status = await page.request.get("/api/user/ai-provider");
|
||||
const data = (
|
||||
(await status.json()) as {
|
||||
data: { serverProviderHealth: string; serverProviderOffer: boolean };
|
||||
}
|
||||
).data;
|
||||
expect(data.serverProviderHealth).toBe("unhealthy");
|
||||
expect(data.serverProviderOffer).toBe(false);
|
||||
});
|
||||
|
||||
test("claims nothing on an instance whose provider nobody has used yet", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
// The operator has configured a key and nothing has been asked of it —
|
||||
// the state of every deployment until its first AI call, and so the most
|
||||
// common way through this screen. The panel may not offer (nothing is
|
||||
// known to work) and may not report an outage (nothing failed).
|
||||
await clearSharedProviderResult();
|
||||
|
||||
await openDoneScreen(page);
|
||||
|
||||
await expect(page.locator(SETTLED)).toHaveAttribute(
|
||||
"data-ai-state",
|
||||
"neutral",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await expect(
|
||||
page.locator('[data-slot="onboarding-ai-keyless"]'),
|
||||
).toBeVisible();
|
||||
await expect(page.locator(UNAVAILABLE)).toHaveCount(0);
|
||||
await expect(page.locator(OFFER)).toHaveCount(0);
|
||||
await expect(page.locator(GRANT)).toHaveCount(0);
|
||||
await expect(page.locator(SHARED_KEY)).toHaveCount(0);
|
||||
|
||||
const status = await page.request.get("/api/user/ai-provider");
|
||||
const data = (
|
||||
(await status.json()) as {
|
||||
data: { serverProviderHealth: string; serverProviderOffer: boolean };
|
||||
}
|
||||
).data;
|
||||
expect(data.serverProviderHealth).toBe("unknown");
|
||||
expect(data.serverProviderOffer).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -118,7 +118,14 @@ const SCREENS: ScreenCase[] = [
|
||||
{
|
||||
screen: "first-result",
|
||||
path: "/onboarding/first-result",
|
||||
painted: (page) => page.locator('[data-slot="onboarding-task-connect"]'),
|
||||
// v1.38.19 — wait for the tile the screen SETTLED on, not for
|
||||
// the connect card. This sweep used to name the connect card, which is
|
||||
// painted for the few hundred milliseconds before the status envelope
|
||||
// resolves whatever the account's real state is — so it scanned a frame
|
||||
// the person may never see, and passed straight through the defect that
|
||||
// frame was hiding. `data-connect-slot` appears only once the screen has
|
||||
// decided, and it is on every one of the four tiles.
|
||||
painted: (page) => page.locator("[data-connect-slot]"),
|
||||
prepare: (page) => complete(page),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* v1.38.19 — the setup flow, path 5: a wearable on Q4.
|
||||
*
|
||||
* The connect arm is the only place in the flow that renders a claim about
|
||||
* real account state, and until this file no journey reached it: bp answers
|
||||
* `manual`, visit answers `file`, and the child path ends at confirm. The
|
||||
* a11y sweep does open the screen, but asserted the connect card was visible
|
||||
* — which it was, for the few hundred milliseconds before the status query
|
||||
* resolved. So the sweep passed THROUGH the defect it should have caught.
|
||||
*
|
||||
* Both cases here assert AFTER `GET /api/integrations/status` has settled,
|
||||
* which is the only moment at which the screen's claim means anything.
|
||||
*
|
||||
* Runs in one project (see `playwright.config.ts`): it resets and mutates its
|
||||
* own account.
|
||||
*/
|
||||
import pg from "pg";
|
||||
|
||||
import {
|
||||
E2E_SETUP_CONNECT,
|
||||
SETUP_CONNECT_STORAGE_STATE_PATH,
|
||||
} from "./setup/global-setup";
|
||||
import { resetSetupFlow } from "./setup/setup-flow-fixture";
|
||||
import type { Page, Response } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
import { answer, expectScreen, readMe } from "./setup-flow-helpers";
|
||||
|
||||
function pool(): pg.Pool {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) throw new Error("[setup-flow-connect] DATABASE_URL is not set");
|
||||
return new pg.Pool({ connectionString: url });
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the account's Oura back to "never connected". The journey's second case
|
||||
* writes a token column and a ledger row, neither of which `resetSetupFlow`
|
||||
* knows about — it resets the FLOW, and a leftover connection would make the
|
||||
* first case assert against the second case's state.
|
||||
*/
|
||||
async function clearOura(): Promise<void> {
|
||||
const db = pool();
|
||||
try {
|
||||
const { rows } = await db.query<{ id: string }>(
|
||||
`SELECT id FROM users WHERE username = $1`,
|
||||
[E2E_SETUP_CONNECT.username],
|
||||
);
|
||||
const id = rows[0]?.id;
|
||||
if (!id) throw new Error("[setup-flow-connect] no account");
|
||||
await db.query(
|
||||
`UPDATE users SET oura_access_token_encrypted = NULL WHERE id = $1`,
|
||||
[id],
|
||||
);
|
||||
await db.query(
|
||||
`DELETE FROM integration_statuses WHERE user_id = $1 AND integration = 'oura'`,
|
||||
[id],
|
||||
);
|
||||
} finally {
|
||||
await db.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A connection that has been delivering for an hour: a stored token (the
|
||||
* envelope reads `connected` off the column's presence alone) and a ledger
|
||||
* row whose last success is recent enough for the `fresh` verdict.
|
||||
*/
|
||||
async function seedDeliveringOura(): Promise<void> {
|
||||
const db = pool();
|
||||
try {
|
||||
const { rows } = await db.query<{ id: string }>(
|
||||
`SELECT id FROM users WHERE username = $1`,
|
||||
[E2E_SETUP_CONNECT.username],
|
||||
);
|
||||
const id = rows[0]?.id;
|
||||
if (!id) throw new Error("[setup-flow-connect] no account");
|
||||
await db.query(
|
||||
`UPDATE users SET oura_access_token_encrypted = 'e2e-placeholder' WHERE id = $1`,
|
||||
[id],
|
||||
);
|
||||
await db.query(
|
||||
`INSERT INTO integration_statuses
|
||||
(id, user_id, integration, state, last_success_at, last_attempt_at, created_at, updated_at)
|
||||
VALUES ($1, $2, 'oura', 'connected', NOW(), NOW(), NOW(), NOW())
|
||||
ON CONFLICT (user_id, integration) DO UPDATE
|
||||
SET state = 'connected',
|
||||
last_success_at = NOW(),
|
||||
last_attempt_at = NOW(),
|
||||
updated_at = NOW()`,
|
||||
[`e2e-connect-oura-${Date.now()}`, id],
|
||||
);
|
||||
} finally {
|
||||
await db.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the questions through the API, then open the screen under test.
|
||||
*
|
||||
* `areas` decides whether the priority has anywhere to fall to when the Q4
|
||||
* source turns out to be connected already: a Q2 area is a `log-reading` task,
|
||||
* an empty Q2 is nothing.
|
||||
*/
|
||||
async function reachFirstResult(
|
||||
page: Page,
|
||||
areas: readonly string[],
|
||||
): Promise<void> {
|
||||
await answer(page, { step: "who", recordTarget: "me" });
|
||||
await answer(page, {
|
||||
step: "areas",
|
||||
...(areas.length > 0 ? { areas } : { status: "skipped" }),
|
||||
});
|
||||
await answer(page, { step: "medication", medication: "no" });
|
||||
await answer(page, { step: "sources", sources: ["oura"] });
|
||||
await answer(page, { step: "visit", visit: "no" });
|
||||
const settled = page.waitForResponse(
|
||||
(res: Response) =>
|
||||
res.url().includes("/api/integrations/status") && res.status() === 200,
|
||||
);
|
||||
await page.goto("/onboarding/confirm");
|
||||
await expectScreen(page, "confirm");
|
||||
await page.locator('[data-slot="onboarding-next"]').click();
|
||||
await expectScreen(page, "first-result");
|
||||
// Every assertion below is about what the screen says once it KNOWS.
|
||||
await settled;
|
||||
}
|
||||
|
||||
// The three cases drive the one account, each setting up a different
|
||||
// connection state. Run in parallel they would each walk the others' state.
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test.describe("setup flow — a wearable on Q4", () => {
|
||||
test.use({ storageState: SETUP_CONNECT_STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetSetupFlow(E2E_SETUP_CONNECT.username);
|
||||
await clearOura();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await clearOura();
|
||||
});
|
||||
|
||||
test("never claims a connection the account does not have", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
await page.goto("/onboarding");
|
||||
await reachFirstResult(page, ["sleep"]);
|
||||
|
||||
// The tile the screen settled on: either the connect card, or — on an
|
||||
// instance with no Oura app configured — the credentials note. Never a
|
||||
// result: nothing was connected.
|
||||
const tile = page.locator("[data-connect-slot]");
|
||||
await expect(tile).toBeVisible({ timeout: 15_000 });
|
||||
const slot = await tile.getAttribute("data-connect-slot");
|
||||
expect(["connect", "credentials"]).toContain(slot);
|
||||
await expect(tile).toHaveAttribute("data-connect-state", "disconnected");
|
||||
await expect(
|
||||
page.locator('[data-slot="onboarding-first-result-done"]'),
|
||||
).toHaveCount(0);
|
||||
|
||||
// And the ledger was not stamped on the person's behalf.
|
||||
const me = await readMe(page);
|
||||
expect(me.onboarding.firstResult?.completedAt ?? null).toBeNull();
|
||||
});
|
||||
|
||||
test("offers the next task instead of a connection that already works", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
await seedDeliveringOura();
|
||||
await page.goto("/onboarding");
|
||||
await reachFirstResult(page, ["sleep"]);
|
||||
|
||||
// The answers name one area as well, so the priority has somewhere to
|
||||
// fall to and the flow ends on something the person can actually do.
|
||||
await expect(page.locator("section[data-task]")).toHaveAttribute(
|
||||
"data-task",
|
||||
"log-reading",
|
||||
);
|
||||
await expect(
|
||||
page.locator('[data-slot="onboarding-task-connect"]'),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("acknowledges a connection that is already delivering", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
await seedDeliveringOura();
|
||||
await page.goto("/onboarding");
|
||||
// Q2 passed over, so the connection is the only task the answers name.
|
||||
await reachFirstResult(page, []);
|
||||
|
||||
const tile = page.locator('[data-connect-slot="result"]');
|
||||
await expect(tile).toBeVisible({ timeout: 15_000 });
|
||||
await expect(tile).toHaveAttribute("data-connect-state", "fresh");
|
||||
|
||||
// The acknowledgment replaces the offer: the connect CTA is gone.
|
||||
await expect(
|
||||
page.locator('[data-slot="onboarding-task-connect"]'),
|
||||
).toHaveCount(0);
|
||||
|
||||
// A working connection is a result the flow may record.
|
||||
await expect
|
||||
.poll(async () => (await readMe(page)).onboarding.firstResult?.task, {
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toBe("connect-source");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* v1.38.19 — the instance state the shared-provider offer reads.
|
||||
*
|
||||
* Two rows decide what the setup flow's last screen may say, and neither has
|
||||
* a product endpoint that writes them the way this journey needs:
|
||||
*
|
||||
* `app_settings.admin_ai_key_encrypted` — presence alone makes the operator
|
||||
* the origin that would serve a user with no provider of their own
|
||||
* (`managedBy: "server"`). Nothing decrypts it on this path, so the value
|
||||
* below is a placeholder ciphertext and not a credential of any kind.
|
||||
*
|
||||
* `provider_health` — the ledger the tri-state folds. It is written only as
|
||||
* a side effect of a real completion, which this suite must never make, so
|
||||
* the journey states the outcome it wants to reason about instead.
|
||||
*
|
||||
* Both are INSTANCE-wide by nature: the projection deliberately folds across
|
||||
* accounts, because a fresh account's own ledger is empty by construction.
|
||||
* That is why the spec runs serially and puts both back afterwards.
|
||||
*
|
||||
* Seeded through `pg`, the same transport `global-setup.ts` and the other
|
||||
* fixtures use.
|
||||
*/
|
||||
import pg from "pg";
|
||||
|
||||
/** Not a key. Presence is the only thing read on this path. */
|
||||
const PLACEHOLDER_ADMIN_KEY = "e2e-admin-key-presence-only";
|
||||
|
||||
function connect(): pg.Pool {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
throw new Error("[ai-offer-fixture] DATABASE_URL is not set");
|
||||
}
|
||||
return new pg.Pool({ connectionString: url });
|
||||
}
|
||||
|
||||
async function userId(pool: pg.Pool, username: string): Promise<string> {
|
||||
const { rows } = await pool.query<{ id: string }>(
|
||||
`SELECT id FROM users WHERE username = $1`,
|
||||
[username],
|
||||
);
|
||||
const id = rows[0]?.id;
|
||||
if (!id) throw new Error(`[ai-offer-fixture] no account named ${username}`);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** The operator has configured a shared provider on this instance. */
|
||||
export async function seedOperatorProvider(): Promise<void> {
|
||||
const pool = connect();
|
||||
try {
|
||||
await pool.query(
|
||||
`INSERT INTO app_settings (id, admin_ai_key_encrypted)
|
||||
VALUES ('singleton', $1)
|
||||
ON CONFLICT (id) DO UPDATE SET admin_ai_key_encrypted = EXCLUDED.admin_ai_key_encrypted`,
|
||||
[PLACEHOLDER_ADMIN_KEY],
|
||||
);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
/** Put the instance back: no operator key, no ledger rows for either tag. */
|
||||
export async function clearOperatorProvider(): Promise<void> {
|
||||
const pool = connect();
|
||||
try {
|
||||
await pool.query(
|
||||
`UPDATE app_settings SET admin_ai_key_encrypted = NULL WHERE id = 'singleton'`,
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM provider_health
|
||||
WHERE provider_type IN ('admin-openai', 'admin-codex')`,
|
||||
);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* State one outcome for the operator's OpenAI provider.
|
||||
*
|
||||
* `ok` — it served somebody `okMinutesAgo` minutes ago.
|
||||
* `auth_failed` — its credential is dead and benched for another hour,
|
||||
* which is the shape the operator's own instance was in on
|
||||
* 2026-09-11 (a 500 from the OAuth proxy, then a 401).
|
||||
*/
|
||||
export async function seedSharedProviderResult(
|
||||
username: string,
|
||||
outcome: { result: "ok"; okMinutesAgo: number } | { result: "auth_failed" },
|
||||
): Promise<void> {
|
||||
const pool = connect();
|
||||
try {
|
||||
const id = await userId(pool, username);
|
||||
await pool.query(
|
||||
`DELETE FROM provider_health
|
||||
WHERE provider_type IN ('admin-openai', 'admin-codex')`,
|
||||
);
|
||||
if (outcome.result === "ok") {
|
||||
await pool.query(
|
||||
`INSERT INTO provider_health
|
||||
(id, user_id, provider_type, last_result, consecutive_failures,
|
||||
last_ok_at, updated_at)
|
||||
VALUES ($1, $2, 'admin-openai', 'ok', 0,
|
||||
NOW() - ($3 || ' minutes')::interval, NOW())`,
|
||||
[`e2e-ph-${id}`, id, String(outcome.okMinutesAgo)],
|
||||
);
|
||||
} else {
|
||||
await pool.query(
|
||||
`INSERT INTO provider_health
|
||||
(id, user_id, provider_type, last_result, last_status,
|
||||
consecutive_failures, last_failure_at, next_retry_at, updated_at)
|
||||
VALUES ($1, $2, 'admin-openai', 'auth_failed', 401, 1,
|
||||
NOW(), NOW() + interval '1 hour', NOW())`,
|
||||
[`e2e-ph-${id}`, id],
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The ledger holds nothing for either shared tag, with the operator's key
|
||||
* still in place: a brand-new instance before its first AI call, which is
|
||||
* what every deployment looks like until somebody makes one.
|
||||
*/
|
||||
export async function clearSharedProviderResult(): Promise<void> {
|
||||
const pool = connect();
|
||||
try {
|
||||
await pool.query(
|
||||
`DELETE FROM provider_health
|
||||
WHERE provider_type IN ('admin-openai', 'admin-codex')`,
|
||||
);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
/** No receipt on file — the state a fresh account reaches the screen in. */
|
||||
export async function clearAiConsent(username: string): Promise<void> {
|
||||
const pool = connect();
|
||||
try {
|
||||
const id = await userId(pool, username);
|
||||
await pool.query(`DELETE FROM consent_receipts WHERE user_id = $1`, [id]);
|
||||
await pool.query(`DELETE FROM rate_limits WHERE key = 'consent:' || $1`, [
|
||||
id,
|
||||
]);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
@@ -480,6 +480,21 @@ export const E2E_SETUP_A11Y = {
|
||||
role: "USER",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* v1.38.19 — the connect-source journey's account.
|
||||
*
|
||||
* The one arm of the flow that renders a claim about real account state had
|
||||
* no journey that reached it: bp answers `manual`, visit answers `file`, and
|
||||
* the child path ends at confirm. This account answers Q4 with a wearable, so
|
||||
* `/onboarding/first-result` really does render the connect step.
|
||||
*/
|
||||
export const E2E_SETUP_CONNECT = {
|
||||
email: "e2e-setup-connect@healthlog.test",
|
||||
username: "e2e-setup-connect",
|
||||
password: "Cn6!Zx4tB9wQm3Jf",
|
||||
role: "USER",
|
||||
} as const;
|
||||
|
||||
export const E2E_SETUP_LOCALE = {
|
||||
email: "e2e-setup-locale@healthlog.test",
|
||||
username: "e2e-setup-locale",
|
||||
@@ -487,6 +502,21 @@ export const E2E_SETUP_LOCALE = {
|
||||
role: "USER",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* v1.38.19 — the shared-provider offer's own account.
|
||||
*
|
||||
* Its journey seeds INSTANCE-wide state (the operator key's presence and the
|
||||
* provider-health ledger the tri-state folds) and mints a consent receipt on
|
||||
* the account, so it cannot share one with a journey that asserts a module
|
||||
* map or a tile order.
|
||||
*/
|
||||
export const E2E_SETUP_AI_OFFER = {
|
||||
email: "e2e-setup-ai-offer@healthlog.test",
|
||||
username: "e2e-setup-ai-offer",
|
||||
password: "Ao6!Zd3fB9qMv2Rk",
|
||||
role: "USER",
|
||||
} as const;
|
||||
|
||||
export const SETUP_BP_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateSetupBp.json",
|
||||
@@ -507,10 +537,18 @@ export const SETUP_A11Y_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateSetupA11y.json",
|
||||
);
|
||||
export const SETUP_CONNECT_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateSetupConnect.json",
|
||||
);
|
||||
export const SETUP_LOCALE_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateSetupLocale.json",
|
||||
);
|
||||
export const SETUP_AI_OFFER_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateSetupAiOffer.json",
|
||||
);
|
||||
|
||||
const SETUP_FLOW_ACCOUNTS = [
|
||||
E2E_SETUP_BP,
|
||||
@@ -519,6 +557,8 @@ const SETUP_FLOW_ACCOUNTS = [
|
||||
E2E_SETUP_CHILD,
|
||||
E2E_SETUP_A11Y,
|
||||
E2E_SETUP_LOCALE,
|
||||
E2E_SETUP_AI_OFFER,
|
||||
E2E_SETUP_CONNECT,
|
||||
] as const;
|
||||
|
||||
async function hashPassword(password: string): Promise<string> {
|
||||
@@ -1261,6 +1301,8 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
await capture(E2E_SETUP_VISIT, SETUP_VISIT_STORAGE_STATE_PATH);
|
||||
await capture(E2E_SETUP_A11Y, SETUP_A11Y_STORAGE_STATE_PATH);
|
||||
await capture(E2E_SETUP_LOCALE, SETUP_LOCALE_STORAGE_STATE_PATH);
|
||||
await capture(E2E_SETUP_CONNECT, SETUP_CONNECT_STORAGE_STATE_PATH);
|
||||
await capture(E2E_SETUP_AI_OFFER, SETUP_AI_OFFER_STORAGE_STATE_PATH);
|
||||
await capture(E2E_SETUP_CHILD, SETUP_CHILD_STORAGE_STATE_PATH);
|
||||
await pool.query(
|
||||
`UPDATE users SET totp_confirmed_at = $2 WHERE username = $1`,
|
||||
|
||||
+56
-4
@@ -413,6 +413,11 @@
|
||||
"loginFailed": "Anmeldung fehlgeschlagen",
|
||||
"registerFailed": "Registrierung fehlgeschlagen",
|
||||
"invitedBanner": "Du wurdest eingeladen — erstelle dein Konto, auch wenn die Registrierung sonst geschlossen ist.",
|
||||
"alreadySignedIn": {
|
||||
"title": "Du bist bereits angemeldet",
|
||||
"body": "Du bist als {username} angemeldet. Eine Einladung legt ein neues Konto an. Melde dich ab, um sie anzunehmen.",
|
||||
"signOut": "Abmelden und Einladung annehmen"
|
||||
},
|
||||
"createAccount": "Konto erstellen",
|
||||
"newAccount": "Neues Konto erstellen",
|
||||
"backToPasskey": "Zurück zu Passkey",
|
||||
@@ -4553,7 +4558,18 @@
|
||||
"ladderOauthBody": "Verbinde ein ChatGPT-Konto — keine Kosten pro Token, aber die Bedingungen der Verbraucherstufe können Inhalte fürs Training nutzen. Deshalb eine bewusste Wahl, nie der Standard für deine Gesundheitsdaten.",
|
||||
"keylessLine": "HealthLog ist auch ohne KI vollständig nutzbar. Deine Kacheln, Diagramme, Trends und Exporte funktionieren alle — KI ergänzt nur den Klartext obendrauf.",
|
||||
"setupCta": "Einrichten (etwa eine Minute)",
|
||||
"sharedKeyNote": "Diese Instanz stellt bereits einen gemeinsamen KI-Anbieter bereit, deshalb funktionieren Insights für dich sofort — keine Einrichtung nötig."
|
||||
"sharedKeyNote": "Diese Instanz stellt bereits einen gemeinsamen KI-Anbieter bereit, deshalb funktionieren Insights für dich sofort — keine Einrichtung nötig.",
|
||||
"offer": {
|
||||
"title": "Die Zusammenfassung dieser Instanz ist bereit.",
|
||||
"body": "Der Betreiber stellt einen Anbieter bereit, der zuletzt innerhalb der letzten 24 Stunden funktioniert hat. Mit einem Tipp schaltest du den Klartext ein; deine Daten gehen dann an diesen einen Anbieter, an niemanden sonst.",
|
||||
"cta": "Klartext einschalten",
|
||||
"granted": "Eingeschaltet. Die erste Zusammenfassung erscheint auf dem Dashboard.",
|
||||
"error": "Das Einschalten hat nicht geklappt. Versuch es gleich noch einmal."
|
||||
},
|
||||
"unavailable": {
|
||||
"title": "Der Anbieter dieser Instanz antwortet gerade nicht.",
|
||||
"body": "HealthLog funktioniert ohne Klartext vollständig. Du kannst später in den Einstellungen einen eigenen Anbieter hinterlegen."
|
||||
}
|
||||
},
|
||||
"steps": {
|
||||
"who": "Für wen diese Akte ist",
|
||||
@@ -4661,6 +4677,7 @@
|
||||
"alwaysOn": "Immer an: {modules} und die Kernwerte (Gewicht, Blutdruck, Puls).",
|
||||
"everythingElse": "Alles andere bleibt einen Klick entfernt unter",
|
||||
"modulesLink": "Einstellungen, Module",
|
||||
"switchesOff": "Nicht mehr in der Navigation: {modules}. Jederzeit unter Einstellungen, Module wieder einschaltbar.",
|
||||
"glucoseUnit": "Blutzucker",
|
||||
"unitPreference": "Einheiten",
|
||||
"language": "Sprache",
|
||||
@@ -4677,12 +4694,46 @@
|
||||
"connect-source": {
|
||||
"title": "{target} verbinden",
|
||||
"body": "Eine Verbindung, aus deinen Antworten gewählt. Danach kommen die Werte von selbst.",
|
||||
"howTo": "Die Verbindung wird auf der Integrationsseite hergestellt. Komm danach hierher zurück; dieser Bildschirm merkt, wenn sie steht.",
|
||||
"howTo": "Die Verbindung zu {target} wird auf der Integrationsseite hergestellt. Komm danach hierher zurück; dieser Bildschirm merkt, wenn sie steht.",
|
||||
"cta": "{target} verbinden",
|
||||
"checking": "Verbindung wird geprüft.",
|
||||
"waiting": "Noch nicht verbunden.",
|
||||
"done": "{target} ist verbunden.",
|
||||
"syncing": "Die erste Synchronisierung läuft. Die Werte erscheinen auf dem Dashboard, sobald sie ankommen."
|
||||
"openSettings": "Einstellungen → Integrationen öffnen",
|
||||
"noDate": "unbekannt",
|
||||
"needsCredentials": {
|
||||
"title": "Diese Instanz hat noch keine {target}-App hinterlegt.",
|
||||
"detail": "Trage deine eigenen Zugangsdaten unter Einstellungen → Integrationen ein, dann kann die Verbindung starten."
|
||||
},
|
||||
"state": {
|
||||
"pending_first_sync": {
|
||||
"title": "{target} ist verbunden.",
|
||||
"detail": "Die erste Synchronisierung läuft. Die Werte erscheinen auf dem Dashboard, sobald sie ankommen."
|
||||
},
|
||||
"fresh": {
|
||||
"title": "{target} ist schon verbunden.",
|
||||
"detail": "Letzter Wert {when}. Hier gibt es nichts mehr zu tun."
|
||||
},
|
||||
"stale": {
|
||||
"title": "{target} ist verbunden, aber es kommt gerade nichts an.",
|
||||
"detail": "Letzter Wert {when}. Die Verbindung selbst ist in Ordnung."
|
||||
},
|
||||
"stalled": {
|
||||
"title": "{target} ist verbunden, aber es wird nicht mehr synchronisiert.",
|
||||
"detail": "Letzter Versuch {when}. Prüfe die Verbindung unter Einstellungen → Integrationen."
|
||||
},
|
||||
"failing": {
|
||||
"title": "{target} ist verbunden, aber die Synchronisierung schlägt fehl.",
|
||||
"detail": "Ohne Erfolg seit {when}. Prüfe die Verbindung unter Einstellungen → Integrationen."
|
||||
},
|
||||
"reauth_required": {
|
||||
"title": "Die Verbindung zu {target} muss erneuert werden.",
|
||||
"detail": "Einmal neu anmelden, dann laufen die Werte weiter."
|
||||
},
|
||||
"parked": {
|
||||
"title": "Die Verbindung zu {target} ist pausiert.",
|
||||
"detail": "Prüfe die Verbindung unter Einstellungen → Integrationen."
|
||||
}
|
||||
}
|
||||
},
|
||||
"add-medication": {
|
||||
"title": "Dein erstes Medikament hinzufügen",
|
||||
@@ -10322,6 +10373,7 @@
|
||||
"invite": {
|
||||
"title": "Jemanden einladen",
|
||||
"description": "Vor der Zustimmung wird nichts geteilt.",
|
||||
"needsAccount": "Die Person braucht ein eigenes HealthLog-Konto auf dieser Instanz und findet die Einladung dort unter Einstellungen → Geteilter Zugriff.",
|
||||
"identifierLabel": "Benutzername oder E-Mail",
|
||||
"identifierPlaceholder": "Der Name, mit dem sie sich anmeldet",
|
||||
"identifierHint": "Die Person braucht ein Konto auf dieser Instanz. An eine E-Mail-Adresse ohne Registrierung lässt sich keine Einladung schicken.",
|
||||
|
||||
+56
-4
@@ -413,6 +413,11 @@
|
||||
"loginFailed": "Login failed",
|
||||
"registerFailed": "Registration failed",
|
||||
"invitedBanner": "You have been invited — create your account even while registration is otherwise closed.",
|
||||
"alreadySignedIn": {
|
||||
"title": "You are already signed in",
|
||||
"body": "You are signed in as {username}. An invitation creates a new account — sign out to accept it.",
|
||||
"signOut": "Sign out and accept the invitation"
|
||||
},
|
||||
"createAccount": "Create account",
|
||||
"newAccount": "Create new account",
|
||||
"backToPasskey": "Back to Passkey",
|
||||
@@ -4553,7 +4558,18 @@
|
||||
"ladderOauthBody": "Connect a ChatGPT account — no per-token cost, but the consumer tier's terms may use content for training, so it's a deliberate choice, never the default for your health data.",
|
||||
"keylessLine": "HealthLog is fully useful without AI. Your tiles, charts, trends and exports all work — AI only adds the plain-language read on top.",
|
||||
"setupCta": "Set it up (about a minute)",
|
||||
"sharedKeyNote": "This instance already provides a shared AI provider, so insights work for you right now — no setup needed."
|
||||
"sharedKeyNote": "This instance already provides a shared AI provider, so insights work for you right now — no setup needed.",
|
||||
"offer": {
|
||||
"title": "This instance can write your summary in plain language.",
|
||||
"body": "The operator provides a provider that last worked within the past 24 hours. One tap turns the plain-language read on; your data then goes to that one provider and to nobody else.",
|
||||
"cta": "Turn on plain language",
|
||||
"granted": "Turned on. The first summary appears on your dashboard.",
|
||||
"error": "Turning it on did not work. Give it another try in a moment."
|
||||
},
|
||||
"unavailable": {
|
||||
"title": "This instance’s provider is not answering right now.",
|
||||
"body": "HealthLog is fully useful without the plain-language read. You can add a provider of your own later under Settings."
|
||||
}
|
||||
},
|
||||
"steps": {
|
||||
"who": "Who this record is for",
|
||||
@@ -4661,6 +4677,7 @@
|
||||
"alwaysOn": "Always on: {modules}, and the core readings (weight, blood pressure, pulse).",
|
||||
"everythingElse": "Everything else stays one click away under",
|
||||
"modulesLink": "Settings, Modules",
|
||||
"switchesOff": "Out of the navigation: {modules}. You can switch any of them back on under Settings, Modules.",
|
||||
"glucoseUnit": "Blood glucose",
|
||||
"unitPreference": "Units",
|
||||
"language": "Language",
|
||||
@@ -4677,12 +4694,46 @@
|
||||
"connect-source": {
|
||||
"title": "Connect {target}",
|
||||
"body": "One connection, chosen from your answers. Readings then arrive on their own.",
|
||||
"howTo": "The connection is made on the integrations page. Come back here afterwards; this screen notices when it is done.",
|
||||
"howTo": "The connection to {target} is made on the integrations page. Come back here afterwards; this screen notices when it is done.",
|
||||
"cta": "Connect {target}",
|
||||
"checking": "Checking the connection.",
|
||||
"waiting": "Not connected yet.",
|
||||
"done": "{target} is connected.",
|
||||
"syncing": "The first sync is in progress. Readings appear on the dashboard as they arrive."
|
||||
"openSettings": "Open Settings → Integrations",
|
||||
"noDate": "unknown",
|
||||
"needsCredentials": {
|
||||
"title": "This instance has no {target} app on file yet.",
|
||||
"detail": "Enter your own credentials under Settings → Integrations, then the connection can start."
|
||||
},
|
||||
"state": {
|
||||
"pending_first_sync": {
|
||||
"title": "{target} is connected.",
|
||||
"detail": "The first sync is in progress. Readings appear on the dashboard as they arrive."
|
||||
},
|
||||
"fresh": {
|
||||
"title": "{target} is already connected.",
|
||||
"detail": "Last reading {when}. Nothing left to do here."
|
||||
},
|
||||
"stale": {
|
||||
"title": "{target} is connected, but nothing is arriving right now.",
|
||||
"detail": "Last reading {when}. The connection itself is fine."
|
||||
},
|
||||
"stalled": {
|
||||
"title": "{target} is connected, but it has stopped syncing.",
|
||||
"detail": "Last attempt {when}. Check the connection under Settings → Integrations."
|
||||
},
|
||||
"failing": {
|
||||
"title": "{target} is connected, but the sync keeps failing.",
|
||||
"detail": "Nothing has come through since {when}. Check the connection under Settings → Integrations."
|
||||
},
|
||||
"reauth_required": {
|
||||
"title": "The connection to {target} needs renewing.",
|
||||
"detail": "Sign in once more and the readings carry on."
|
||||
},
|
||||
"parked": {
|
||||
"title": "The connection to {target} is paused.",
|
||||
"detail": "Check the connection under Settings → Integrations."
|
||||
}
|
||||
}
|
||||
},
|
||||
"add-medication": {
|
||||
"title": "Add your first medication",
|
||||
@@ -10322,6 +10373,7 @@
|
||||
"invite": {
|
||||
"title": "Invite someone",
|
||||
"description": "Nothing is shared until they accept.",
|
||||
"needsAccount": "The person needs their own HealthLog account on this instance and will find the invitation under Settings → Shared access.",
|
||||
"identifierLabel": "Username or e-mail",
|
||||
"identifierPlaceholder": "The name they sign in with",
|
||||
"identifierHint": "They need an account on this instance. Invitations cannot be sent to an e-mail address that has not registered.",
|
||||
|
||||
+56
-4
@@ -413,6 +413,11 @@
|
||||
"loginFailed": "No se ha podido iniciar sesión",
|
||||
"registerFailed": "No se ha podido completar el registro",
|
||||
"invitedBanner": "Te han invitado: crea tu cuenta aunque el registro esté cerrado.",
|
||||
"alreadySignedIn": {
|
||||
"title": "Ya has iniciado sesión",
|
||||
"body": "Has iniciado sesión como {username}. Una invitación crea una cuenta nueva. Cierra la sesión para aceptarla.",
|
||||
"signOut": "Cerrar sesión y aceptar la invitación"
|
||||
},
|
||||
"createAccount": "Crear cuenta",
|
||||
"newAccount": "Crear cuenta nueva",
|
||||
"backToPasskey": "Volver a passkey",
|
||||
@@ -4553,7 +4558,18 @@
|
||||
"ladderOauthBody": "Conecta una cuenta de ChatGPT — sin coste por token, pero los términos del nivel de consumo pueden usar el contenido para entrenamiento, así que es una elección deliberada, nunca la opción por defecto para tus datos de salud.",
|
||||
"keylessLine": "HealthLog es plenamente útil sin IA. Tus paneles, gráficos, tendencias y exportaciones funcionan — la IA solo añade la lectura en lenguaje claro por encima.",
|
||||
"setupCta": "Configúralo (un minuto aprox.)",
|
||||
"sharedKeyNote": "Esta instancia ya ofrece un proveedor de IA compartido, así que los insights funcionan para ti ahora mismo — sin configuración."
|
||||
"sharedKeyNote": "Esta instancia ya ofrece un proveedor de IA compartido, así que los insights funcionan para ti ahora mismo — sin configuración.",
|
||||
"offer": {
|
||||
"title": "Esta instancia ya puede escribir tu resumen en lenguaje claro.",
|
||||
"body": "El operador ofrece un proveedor que funcionó por última vez en las últimas 24 horas. Con un toque activas el lenguaje claro; tus datos irán entonces a ese único proveedor y a nadie más.",
|
||||
"cta": "Activar el lenguaje claro",
|
||||
"granted": "Activado. El primer resumen aparecerá en tu panel.",
|
||||
"error": "No se pudo activar. Vuelve a intentarlo en un momento."
|
||||
},
|
||||
"unavailable": {
|
||||
"title": "El proveedor de esta instancia no responde en este momento.",
|
||||
"body": "HealthLog es plenamente útil sin el lenguaje claro. Más adelante puedes añadir tu propio proveedor en los ajustes."
|
||||
}
|
||||
},
|
||||
"steps": {
|
||||
"who": "Para quién es este registro",
|
||||
@@ -4661,6 +4677,7 @@
|
||||
"alwaysOn": "Siempre activos: {modules} y las mediciones básicas (peso, presión arterial, pulso).",
|
||||
"everythingElse": "Todo lo demás queda a un clic en",
|
||||
"modulesLink": "Ajustes, Módulos",
|
||||
"switchesOff": "Fuera de la navegación: {modules}. Puedes volver a activarlos cuando quieras en Ajustes, Módulos.",
|
||||
"glucoseUnit": "Glucosa en sangre",
|
||||
"unitPreference": "Unidades",
|
||||
"language": "Idioma",
|
||||
@@ -4677,12 +4694,46 @@
|
||||
"connect-source": {
|
||||
"title": "Conectar {target}",
|
||||
"body": "Una conexión, elegida según tus respuestas. Después las mediciones llegan solas.",
|
||||
"howTo": "La conexión se hace en la página de integraciones. Vuelve aquí después; esta pantalla detecta cuándo está lista.",
|
||||
"howTo": "La conexión con {target} se hace en la página de integraciones. Vuelve aquí después; esta pantalla detecta cuándo está lista.",
|
||||
"cta": "Conectar {target}",
|
||||
"checking": "Comprobando la conexión.",
|
||||
"waiting": "Aún no conectado.",
|
||||
"done": "{target} está conectado.",
|
||||
"syncing": "La primera sincronización está en marcha. Las mediciones aparecen en el panel a medida que llegan."
|
||||
"openSettings": "Abrir Ajustes → Integraciones",
|
||||
"noDate": "desconocido",
|
||||
"needsCredentials": {
|
||||
"title": "Esta instancia aún no tiene una aplicación de {target} registrada.",
|
||||
"detail": "Introduce tus propias credenciales en Ajustes → Integraciones y la conexión podrá empezar."
|
||||
},
|
||||
"state": {
|
||||
"pending_first_sync": {
|
||||
"title": "{target} está conectado.",
|
||||
"detail": "La primera sincronización está en marcha. Los valores aparecerán en el panel en cuanto lleguen."
|
||||
},
|
||||
"fresh": {
|
||||
"title": "{target} ya está conectado.",
|
||||
"detail": "Último valor {when}. Aquí no queda nada por hacer."
|
||||
},
|
||||
"stale": {
|
||||
"title": "{target} está conectado, pero ahora mismo no llega nada.",
|
||||
"detail": "Último valor {when}. La conexión en sí está bien."
|
||||
},
|
||||
"stalled": {
|
||||
"title": "{target} está conectado, pero ha dejado de sincronizar.",
|
||||
"detail": "Último intento {when}. Revisa la conexión en Ajustes → Integraciones."
|
||||
},
|
||||
"failing": {
|
||||
"title": "{target} está conectado, pero la sincronización falla.",
|
||||
"detail": "No ha llegado nada desde {when}. Revisa la conexión en Ajustes → Integraciones."
|
||||
},
|
||||
"reauth_required": {
|
||||
"title": "Hay que renovar la conexión con {target}.",
|
||||
"detail": "Inicia sesión una vez más y los valores seguirán llegando."
|
||||
},
|
||||
"parked": {
|
||||
"title": "La conexión con {target} está en pausa.",
|
||||
"detail": "Revisa la conexión en Ajustes → Integraciones."
|
||||
}
|
||||
}
|
||||
},
|
||||
"add-medication": {
|
||||
"title": "Añade tu primer medicamento",
|
||||
@@ -10322,6 +10373,7 @@
|
||||
"invite": {
|
||||
"title": "Invitar a alguien",
|
||||
"description": "No se comparte nada hasta que acepte.",
|
||||
"needsAccount": "La persona necesita su propia cuenta de HealthLog en esta instancia y encontrará la invitación en Ajustes → Acceso compartido.",
|
||||
"identifierLabel": "Usuario o correo electrónico",
|
||||
"identifierPlaceholder": "El nombre con el que inicia sesión",
|
||||
"identifierHint": "Necesita una cuenta en esta instancia. No se pueden enviar invitaciones a una dirección que no se haya registrado.",
|
||||
|
||||
+56
-4
@@ -413,6 +413,11 @@
|
||||
"loginFailed": "Échec de la connexion",
|
||||
"registerFailed": "Échec de l’inscription",
|
||||
"invitedBanner": "Vous avez été invité — créez votre compte même si les inscriptions sont fermées.",
|
||||
"alreadySignedIn": {
|
||||
"title": "Tu es déjà connecté",
|
||||
"body": "Tu es connecté en tant que {username}. Une invitation crée un nouveau compte. Déconnecte-toi pour l'accepter.",
|
||||
"signOut": "Se déconnecter et accepter l'invitation"
|
||||
},
|
||||
"createAccount": "Créer un compte",
|
||||
"newAccount": "Créer un nouveau compte",
|
||||
"backToPasskey": "Revenir à la passkey",
|
||||
@@ -4553,7 +4558,18 @@
|
||||
"ladderOauthBody": "Reliez un compte ChatGPT — sans coût par jeton, mais les conditions du niveau grand public peuvent utiliser le contenu pour l'entraînement : c'est donc un choix délibéré, jamais l'option par défaut pour vos données de santé.",
|
||||
"keylessLine": "HealthLog est pleinement utile sans IA. Vos tuiles, graphiques, tendances et exports fonctionnent tous — l'IA n'ajoute que la lecture en langage clair par-dessus.",
|
||||
"setupCta": "Configurez-le (environ une minute)",
|
||||
"sharedKeyNote": "Cette instance fournit déjà un fournisseur d'IA partagé : les insights fonctionnent pour vous dès maintenant — aucune configuration nécessaire."
|
||||
"sharedKeyNote": "Cette instance fournit déjà un fournisseur d'IA partagé : les insights fonctionnent pour vous dès maintenant — aucune configuration nécessaire.",
|
||||
"offer": {
|
||||
"title": "Cette instance peut rédiger votre résumé en langage clair.",
|
||||
"body": "L’exploitant met à disposition un fournisseur qui a fonctionné au cours des dernières 24 heures. Une seule touche active le langage clair ; vos données partent alors vers ce seul fournisseur et vers personne d’autre.",
|
||||
"cta": "Activer le langage clair",
|
||||
"granted": "Activé. Le premier résumé apparaîtra sur votre tableau de bord.",
|
||||
"error": "L’activation n’a pas abouti. Réessayez dans un instant."
|
||||
},
|
||||
"unavailable": {
|
||||
"title": "Le fournisseur de cette instance ne répond pas pour le moment.",
|
||||
"body": "HealthLog reste pleinement utile sans le langage clair. Vous pourrez ajouter votre propre fournisseur plus tard dans les paramètres."
|
||||
}
|
||||
},
|
||||
"steps": {
|
||||
"who": "À qui appartient ce dossier",
|
||||
@@ -4661,6 +4677,7 @@
|
||||
"alwaysOn": "Toujours actifs : {modules}, et les mesures de base (poids, tension, pouls).",
|
||||
"everythingElse": "Tout le reste reste à un clic dans",
|
||||
"modulesLink": "Réglages, Modules",
|
||||
"switchesOff": "Hors de la navigation : {modules}. Tu peux les réactiver à tout moment dans Réglages, Modules.",
|
||||
"glucoseUnit": "Glycémie",
|
||||
"unitPreference": "Unités",
|
||||
"language": "Langue",
|
||||
@@ -4677,12 +4694,46 @@
|
||||
"connect-source": {
|
||||
"title": "Connecter {target}",
|
||||
"body": "Une seule connexion, choisie d'après vos réponses. Les mesures arrivent ensuite d'elles-mêmes.",
|
||||
"howTo": "La connexion se fait sur la page des intégrations. Revenez ici ensuite ; cet écran détecte quand elle est établie.",
|
||||
"howTo": "La connexion à {target} se fait sur la page des intégrations. Revenez ici ensuite ; cet écran détecte quand elle est établie.",
|
||||
"cta": "Connecter {target}",
|
||||
"checking": "Vérification de la connexion.",
|
||||
"waiting": "Pas encore connecté.",
|
||||
"done": "{target} est connecté.",
|
||||
"syncing": "La première synchronisation est en cours. Les mesures apparaissent sur le tableau de bord au fur et à mesure."
|
||||
"openSettings": "Ouvrir Paramètres → Intégrations",
|
||||
"noDate": "inconnue",
|
||||
"needsCredentials": {
|
||||
"title": "Cette instance n’a pas encore d’application {target} enregistrée.",
|
||||
"detail": "Saisissez vos propres identifiants dans Paramètres → Intégrations, la connexion pourra alors démarrer."
|
||||
},
|
||||
"state": {
|
||||
"pending_first_sync": {
|
||||
"title": "{target} est connecté.",
|
||||
"detail": "La première synchronisation est en cours. Les valeurs apparaîtront sur le tableau de bord dès leur arrivée."
|
||||
},
|
||||
"fresh": {
|
||||
"title": "{target} est déjà connecté.",
|
||||
"detail": "Dernière valeur {when}. Il n’y a plus rien à faire ici."
|
||||
},
|
||||
"stale": {
|
||||
"title": "{target} est connecté, mais rien n’arrive en ce moment.",
|
||||
"detail": "Dernière valeur {when}. La connexion elle-même est en ordre."
|
||||
},
|
||||
"stalled": {
|
||||
"title": "{target} est connecté, mais la synchronisation ne tourne plus.",
|
||||
"detail": "Dernière tentative {when}. Vérifiez la connexion dans Paramètres → Intégrations."
|
||||
},
|
||||
"failing": {
|
||||
"title": "{target} est connecté, mais la synchronisation échoue.",
|
||||
"detail": "Plus rien n’est arrivé depuis {when}. Vérifiez la connexion dans Paramètres → Intégrations."
|
||||
},
|
||||
"reauth_required": {
|
||||
"title": "La connexion à {target} doit être renouvelée.",
|
||||
"detail": "Reconnectez-vous une fois et les valeurs reprendront."
|
||||
},
|
||||
"parked": {
|
||||
"title": "La connexion à {target} est en pause.",
|
||||
"detail": "Vérifiez la connexion dans Paramètres → Intégrations."
|
||||
}
|
||||
}
|
||||
},
|
||||
"add-medication": {
|
||||
"title": "Ajoutez votre premier médicament",
|
||||
@@ -10322,6 +10373,7 @@
|
||||
"invite": {
|
||||
"title": "Inviter quelqu'un",
|
||||
"description": "Rien n'est partagé avant son acceptation.",
|
||||
"needsAccount": "La personne a besoin de son propre compte HealthLog sur cette instance et trouvera l'invitation dans Paramètres → Accès partagé.",
|
||||
"identifierLabel": "Nom d'utilisateur ou e-mail",
|
||||
"identifierPlaceholder": "Le nom avec lequel elle se connecte",
|
||||
"identifierHint": "Cette personne a besoin d'un compte sur cette instance. Une invitation ne peut pas être envoyée à une adresse non inscrite.",
|
||||
|
||||
+56
-4
@@ -413,6 +413,11 @@
|
||||
"loginFailed": "Accesso non riuscito",
|
||||
"registerFailed": "Registrazione non riuscita",
|
||||
"invitedBanner": "Sei stato invitato — crea il tuo account anche se la registrazione è chiusa.",
|
||||
"alreadySignedIn": {
|
||||
"title": "Hai già effettuato l'accesso",
|
||||
"body": "Hai effettuato l'accesso come {username}. Un invito crea un nuovo account. Esci per accettarlo.",
|
||||
"signOut": "Esci e accetta l'invito"
|
||||
},
|
||||
"createAccount": "Crea account",
|
||||
"newAccount": "Crea un nuovo account",
|
||||
"backToPasskey": "Torna a passkey",
|
||||
@@ -4553,7 +4558,18 @@
|
||||
"ladderOauthBody": "Collega un account ChatGPT — nessun costo per token, ma i termini del piano consumer possono usare i contenuti per l'addestramento, quindi è una scelta deliberata, mai l'impostazione predefinita per i tuoi dati sanitari.",
|
||||
"keylessLine": "HealthLog è pienamente utile anche senza IA. Le tue schede, i grafici, i trend e le esportazioni funzionano tutti — l'IA aggiunge solo la lettura in linguaggio chiaro.",
|
||||
"setupCta": "Configuralo (circa un minuto)",
|
||||
"sharedKeyNote": "Questa istanza fornisce già un provider IA condiviso, quindi gli insight funzionano per te fin da subito — nessuna configurazione necessaria."
|
||||
"sharedKeyNote": "Questa istanza fornisce già un provider IA condiviso, quindi gli insight funzionano per te fin da subito — nessuna configurazione necessaria.",
|
||||
"offer": {
|
||||
"title": "Questa istanza può già scrivere il tuo riepilogo in parole semplici.",
|
||||
"body": "Il gestore mette a disposizione un fornitore che ha funzionato nelle ultime 24 ore. Con un tocco attivi le parole semplici; i tuoi dati andranno allora a quell’unico fornitore e a nessun altro.",
|
||||
"cta": "Attiva le parole semplici",
|
||||
"granted": "Attivato. Il primo riepilogo comparirà sulla tua dashboard.",
|
||||
"error": "L’attivazione non è riuscita. Riprova tra un momento."
|
||||
},
|
||||
"unavailable": {
|
||||
"title": "Il fornitore di questa istanza al momento non risponde.",
|
||||
"body": "HealthLog resta pienamente utile senza le parole semplici. Potrai aggiungere un fornitore tuo più avanti nelle impostazioni."
|
||||
}
|
||||
},
|
||||
"steps": {
|
||||
"who": "A chi appartiene questa cartella",
|
||||
@@ -4661,6 +4677,7 @@
|
||||
"alwaysOn": "Sempre attivi: {modules} e le misurazioni di base (peso, pressione, polso).",
|
||||
"everythingElse": "Tutto il resto resta a un clic in",
|
||||
"modulesLink": "Impostazioni, Moduli",
|
||||
"switchesOff": "Fuori dalla navigazione: {modules}. Puoi riattivarli quando vuoi in Impostazioni, Moduli.",
|
||||
"glucoseUnit": "Glicemia",
|
||||
"unitPreference": "Unità",
|
||||
"language": "Lingua",
|
||||
@@ -4677,12 +4694,46 @@
|
||||
"connect-source": {
|
||||
"title": "Collega {target}",
|
||||
"body": "Una sola connessione, scelta in base alle tue risposte. Poi le misurazioni arrivano da sole.",
|
||||
"howTo": "La connessione si fa nella pagina delle integrazioni. Poi torna qui; questa schermata si accorge quando è pronta.",
|
||||
"howTo": "La connessione a {target} si fa nella pagina delle integrazioni. Poi torna qui; questa schermata si accorge quando è pronta.",
|
||||
"cta": "Collega {target}",
|
||||
"checking": "Verifica della connessione in corso.",
|
||||
"waiting": "Non ancora collegato.",
|
||||
"done": "{target} è collegato.",
|
||||
"syncing": "La prima sincronizzazione è in corso. Le misurazioni compaiono nella dashboard man mano che arrivano."
|
||||
"openSettings": "Apri Impostazioni → Integrazioni",
|
||||
"noDate": "sconosciuto",
|
||||
"needsCredentials": {
|
||||
"title": "Questa istanza non ha ancora un’app {target} registrata.",
|
||||
"detail": "Inserisci le tue credenziali in Impostazioni → Integrazioni e la connessione potrà partire."
|
||||
},
|
||||
"state": {
|
||||
"pending_first_sync": {
|
||||
"title": "{target} è collegato.",
|
||||
"detail": "La prima sincronizzazione è in corso. I valori compaiono sulla dashboard appena arrivano."
|
||||
},
|
||||
"fresh": {
|
||||
"title": "{target} è già collegato.",
|
||||
"detail": "Ultimo valore {when}. Qui non resta nulla da fare."
|
||||
},
|
||||
"stale": {
|
||||
"title": "{target} è collegato, ma al momento non arriva nulla.",
|
||||
"detail": "Ultimo valore {when}. La connessione in sé è a posto."
|
||||
},
|
||||
"stalled": {
|
||||
"title": "{target} è collegato, ma non sincronizza più.",
|
||||
"detail": "Ultimo tentativo {when}. Controlla la connessione in Impostazioni → Integrazioni."
|
||||
},
|
||||
"failing": {
|
||||
"title": "{target} è collegato, ma la sincronizzazione non riesce.",
|
||||
"detail": "Non arriva nulla da {when}. Controlla la connessione in Impostazioni → Integrazioni."
|
||||
},
|
||||
"reauth_required": {
|
||||
"title": "La connessione a {target} va rinnovata.",
|
||||
"detail": "Accedi ancora una volta e i valori riprendono ad arrivare."
|
||||
},
|
||||
"parked": {
|
||||
"title": "La connessione a {target} è in pausa.",
|
||||
"detail": "Controlla la connessione in Impostazioni → Integrazioni."
|
||||
}
|
||||
}
|
||||
},
|
||||
"add-medication": {
|
||||
"title": "Aggiungi il tuo primo farmaco",
|
||||
@@ -10322,6 +10373,7 @@
|
||||
"invite": {
|
||||
"title": "Invita qualcuno",
|
||||
"description": "Nulla viene condiviso finché non accetta.",
|
||||
"needsAccount": "La persona ha bisogno di un proprio account HealthLog su questa istanza e troverà l'invito in Impostazioni → Accesso condiviso.",
|
||||
"identifierLabel": "Nome utente o e-mail",
|
||||
"identifierPlaceholder": "Il nome con cui accede",
|
||||
"identifierHint": "Serve un account su questa istanza. Non è possibile inviare inviti a un indirizzo non registrato.",
|
||||
|
||||
+56
-4
@@ -413,6 +413,11 @@
|
||||
"loginFailed": "로그인 실패",
|
||||
"registerFailed": "회원가입 실패",
|
||||
"invitedBanner": "초대를 받았어요 — 회원가입이 닫혀 있어도 계정을 만들 수 있어요.",
|
||||
"alreadySignedIn": {
|
||||
"title": "이미 로그인되어 있습니다",
|
||||
"body": "{username} 계정으로 로그인되어 있습니다. 초대는 새 계정을 만듭니다. 초대를 수락하려면 로그아웃하세요.",
|
||||
"signOut": "로그아웃하고 초대 수락하기"
|
||||
},
|
||||
"createAccount": "계정 만들기",
|
||||
"newAccount": "새 계정 만들기",
|
||||
"backToPasskey": "패스키로 돌아가기",
|
||||
@@ -4553,7 +4558,18 @@
|
||||
"ladderOauthBody": "ChatGPT 계정을 연결해요. 토큰당 비용은 없지만 소비자 등급 약관은 내용을 학습에 쓸 수 있어서, 건강 데이터에는 기본값이 아니라 신중히 고를 선택이에요.",
|
||||
"keylessLine": "AI 없이도 HealthLog는 충분히 쓸모 있어요. 타일, 차트, 추이, 내보내기 모두 그대로 작동하고 — AI는 그 위에 쉬운 말 해설을 더할 뿐이에요.",
|
||||
"setupCta": "설정하기 (1분 정도)",
|
||||
"sharedKeyNote": "이 인스턴스에는 이미 공용 AI 제공자가 있어서 지금 바로 인사이트를 쓸 수 있어요 — 따로 설정할 게 없어요."
|
||||
"sharedKeyNote": "이 인스턴스에는 이미 공용 AI 제공자가 있어서 지금 바로 인사이트를 쓸 수 있어요 — 따로 설정할 게 없어요.",
|
||||
"offer": {
|
||||
"title": "이 인스턴스가 요약을 쉬운 말로 써 줄 수 있습니다.",
|
||||
"body": "운영자가 제공하는 공급자가 지난 24시간 안에 마지막으로 정상 동작했습니다. 한 번만 누르면 쉬운 말 요약이 켜지며, 그러면 데이터는 그 하나의 공급자에게만 전달되고 다른 어느 곳에도 가지 않습니다.",
|
||||
"cta": "쉬운 말 요약 켜기",
|
||||
"granted": "켜졌습니다. 첫 요약은 대시보드에 표시됩니다.",
|
||||
"error": "켜지지 않았습니다. 잠시 후에 다시 시도해 주세요."
|
||||
},
|
||||
"unavailable": {
|
||||
"title": "이 인스턴스의 공급자가 지금은 응답하지 않습니다.",
|
||||
"body": "쉬운 말 요약 없이도 HealthLog는 완전히 쓸모가 있습니다. 나중에 설정에서 직접 공급자를 등록할 수 있습니다."
|
||||
}
|
||||
},
|
||||
"steps": {
|
||||
"who": "이 기록의 대상",
|
||||
@@ -4661,6 +4677,7 @@
|
||||
"alwaysOn": "항상 켜짐: {modules}, 그리고 기본 측정값(체중, 혈압, 맥박).",
|
||||
"everythingElse": "나머지는 한 번의 클릭으로 열 수 있습니다:",
|
||||
"modulesLink": "설정, 모듈",
|
||||
"switchesOff": "탐색에서 빠집니다: {modules}. 설정, 모듈에서 언제든 다시 켤 수 있습니다.",
|
||||
"glucoseUnit": "혈당",
|
||||
"unitPreference": "단위",
|
||||
"language": "언어",
|
||||
@@ -4677,12 +4694,46 @@
|
||||
"connect-source": {
|
||||
"title": "{target} 연결",
|
||||
"body": "답변을 바탕으로 고른 연결 하나입니다. 이후 측정값은 저절로 들어옵니다.",
|
||||
"howTo": "연결은 연동 페이지에서 합니다. 마친 뒤 이곳으로 돌아오면 이 화면이 연결을 감지합니다.",
|
||||
"howTo": "{target} 연결은 연동 페이지에서 합니다. 마친 뒤 이곳으로 돌아오면 이 화면이 연결을 감지합니다.",
|
||||
"cta": "{target} 연결",
|
||||
"checking": "연결을 확인하는 중입니다.",
|
||||
"waiting": "아직 연결되지 않았습니다.",
|
||||
"done": "{target}이(가) 연결되었습니다.",
|
||||
"syncing": "첫 동기화가 진행 중입니다. 측정값이 도착하는 대로 대시보드에 나타납니다."
|
||||
"openSettings": "설정 → 연동 열기",
|
||||
"noDate": "알 수 없음",
|
||||
"needsCredentials": {
|
||||
"title": "이 인스턴스에는 아직 {target} 앱이 등록되어 있지 않습니다.",
|
||||
"detail": "설정 → 연동에서 직접 발급받은 인증 정보를 입력하면 연결을 시작할 수 있습니다."
|
||||
},
|
||||
"state": {
|
||||
"pending_first_sync": {
|
||||
"title": "{target}이(가) 연결되었습니다.",
|
||||
"detail": "첫 동기화가 진행 중입니다. 값이 도착하는 대로 대시보드에 표시됩니다."
|
||||
},
|
||||
"fresh": {
|
||||
"title": "{target}은(는) 이미 연결되어 있습니다.",
|
||||
"detail": "마지막 값 {when}. 여기서 더 할 일은 없습니다."
|
||||
},
|
||||
"stale": {
|
||||
"title": "{target}은(는) 연결되어 있지만 지금은 값이 들어오지 않습니다.",
|
||||
"detail": "마지막 값 {when}. 연결 자체에는 문제가 없습니다."
|
||||
},
|
||||
"stalled": {
|
||||
"title": "{target}은(는) 연결되어 있지만 동기화가 멈췄습니다.",
|
||||
"detail": "마지막 시도 {when}. 설정 → 연동에서 연결을 확인하세요."
|
||||
},
|
||||
"failing": {
|
||||
"title": "{target}은(는) 연결되어 있지만 동기화가 계속 실패합니다.",
|
||||
"detail": "{when} 이후로 아무것도 들어오지 않았습니다. 설정 → 연동에서 연결을 확인하세요."
|
||||
},
|
||||
"reauth_required": {
|
||||
"title": "{target} 연결을 갱신해야 합니다.",
|
||||
"detail": "한 번 다시 로그인하면 값이 계속 들어옵니다."
|
||||
},
|
||||
"parked": {
|
||||
"title": "{target} 연결이 일시 중지되었습니다.",
|
||||
"detail": "설정 → 연동에서 연결을 확인하세요."
|
||||
}
|
||||
}
|
||||
},
|
||||
"add-medication": {
|
||||
"title": "첫 약 추가하기",
|
||||
@@ -10322,6 +10373,7 @@
|
||||
"invite": {
|
||||
"title": "다른 사람 초대",
|
||||
"description": "상대가 수락하기 전에는 아무것도 공유되지 않아요.",
|
||||
"needsAccount": "상대방에게는 이 인스턴스의 HealthLog 계정이 필요하며, 초대는 설정 → 공유 접근에서 확인할 수 있습니다.",
|
||||
"identifierLabel": "사용자 이름 또는 이메일",
|
||||
"identifierPlaceholder": "로그인에 쓰는 이름",
|
||||
"identifierHint": "이 인스턴스에 계정이 있어야 해요. 가입하지 않은 이메일 주소로는 초대를 보낼 수 없어요.",
|
||||
|
||||
+56
-4
@@ -413,6 +413,11 @@
|
||||
"loginFailed": "Logowanie nie powiodło się",
|
||||
"registerFailed": "Rejestracja nie powiodła się",
|
||||
"invitedBanner": "Otrzymano zaproszenie — utwórz konto, nawet jeśli rejestracja jest zamknięta.",
|
||||
"alreadySignedIn": {
|
||||
"title": "Masz już zalogowaną sesję",
|
||||
"body": "Masz zalogowaną sesję jako {username}. Zaproszenie tworzy nowe konto. Wyloguj się, aby je przyjąć.",
|
||||
"signOut": "Wyloguj się i przyjmij zaproszenie"
|
||||
},
|
||||
"createAccount": "Utwórz konto",
|
||||
"newAccount": "Utwórz nowe konto",
|
||||
"backToPasskey": "Wróć do passkey",
|
||||
@@ -4553,7 +4558,18 @@
|
||||
"ladderOauthBody": "Podłącz konto ChatGPT — bez kosztu za token, ale warunki poziomu konsumenckiego mogą wykorzystywać treści do treningu, więc to świadomy wybór, nigdy domyślny dla danych o zdrowiu.",
|
||||
"keylessLine": "HealthLog jest w pełni użyteczny bez AI. Twoje kafelki, wykresy, trendy i eksporty działają — AI dokłada tylko odczyt prostym językiem.",
|
||||
"setupCta": "Skonfiguruj (około minuty)",
|
||||
"sharedKeyNote": "Ta instancja udostępnia już wspólnego dostawcę AI, więc insighty działają dla Ciebie od razu — bez konfiguracji."
|
||||
"sharedKeyNote": "Ta instancja udostępnia już wspólnego dostawcę AI, więc insighty działają dla Ciebie od razu — bez konfiguracji.",
|
||||
"offer": {
|
||||
"title": "Ta instancja potrafi już napisać twoje podsumowanie zwykłym językiem.",
|
||||
"body": "Operator udostępnia dostawcę, który działał po raz ostatni w ciągu ostatnich 24 godzin. Jedno dotknięcie włącza zwykły język; twoje dane trafią wtedy do tego jednego dostawcy i do nikogo więcej.",
|
||||
"cta": "Włącz zwykły język",
|
||||
"granted": "Włączone. Pierwsze podsumowanie pojawi się na pulpicie.",
|
||||
"error": "Włączenie się nie powiodło. Spróbuj ponownie za chwilę."
|
||||
},
|
||||
"unavailable": {
|
||||
"title": "Dostawca tej instancji w tej chwili nie odpowiada.",
|
||||
"body": "HealthLog jest w pełni użyteczny bez zwykłego języka. Później możesz dodać w ustawieniach własnego dostawcę."
|
||||
}
|
||||
},
|
||||
"steps": {
|
||||
"who": "Dla kogo jest ta kartoteka",
|
||||
@@ -4661,6 +4677,7 @@
|
||||
"alwaysOn": "Zawsze włączone: {modules} oraz podstawowe pomiary (waga, ciśnienie, tętno).",
|
||||
"everythingElse": "Cała reszta jest o jedno kliknięcie w",
|
||||
"modulesLink": "Ustawienia, Moduły",
|
||||
"switchesOff": "Znika z nawigacji: {modules}. W każdej chwili możesz je włączyć w Ustawienia, Moduły.",
|
||||
"glucoseUnit": "Glukoza we krwi",
|
||||
"unitPreference": "Jednostki",
|
||||
"language": "Język",
|
||||
@@ -4677,12 +4694,46 @@
|
||||
"connect-source": {
|
||||
"title": "Połącz {target}",
|
||||
"body": "Jedno połączenie, wybrane na podstawie Twoich odpowiedzi. Potem pomiary przychodzą same.",
|
||||
"howTo": "Połączenie nawiązuje się na stronie integracji. Wróć tutaj później; ten ekran zauważy, kiedy będzie gotowe.",
|
||||
"howTo": "Połączenie z {target} nawiązuje się na stronie integracji. Wróć tutaj później; ten ekran zauważy, kiedy będzie gotowe.",
|
||||
"cta": "Połącz {target}",
|
||||
"checking": "Sprawdzanie połączenia.",
|
||||
"waiting": "Jeszcze nie połączono.",
|
||||
"done": "{target} jest połączone.",
|
||||
"syncing": "Trwa pierwsza synchronizacja. Pomiary pojawią się na panelu, gdy tylko nadejdą."
|
||||
"openSettings": "Otwórz Ustawienia → Integracje",
|
||||
"noDate": "nieznany",
|
||||
"needsCredentials": {
|
||||
"title": "Ta instancja nie ma jeszcze zapisanej aplikacji {target}.",
|
||||
"detail": "Wpisz własne dane dostępowe w Ustawieniach → Integracje, wtedy połączenie będzie mogło ruszyć."
|
||||
},
|
||||
"state": {
|
||||
"pending_first_sync": {
|
||||
"title": "{target} jest połączony.",
|
||||
"detail": "Trwa pierwsza synchronizacja. Wartości pojawią się na pulpicie, gdy tylko dotrą."
|
||||
},
|
||||
"fresh": {
|
||||
"title": "{target} jest już połączony.",
|
||||
"detail": "Ostatnia wartość {when}. Tutaj nie ma już nic do zrobienia."
|
||||
},
|
||||
"stale": {
|
||||
"title": "{target} jest połączony, ale w tej chwili nic nie przychodzi.",
|
||||
"detail": "Ostatnia wartość {when}. Samo połączenie jest w porządku."
|
||||
},
|
||||
"stalled": {
|
||||
"title": "{target} jest połączony, ale synchronizacja się zatrzymała.",
|
||||
"detail": "Ostatnia próba {when}. Sprawdź połączenie w Ustawieniach → Integracje."
|
||||
},
|
||||
"failing": {
|
||||
"title": "{target} jest połączony, ale synchronizacja się nie udaje.",
|
||||
"detail": "Od {when} nic nie dotarło. Sprawdź połączenie w Ustawieniach → Integracje."
|
||||
},
|
||||
"reauth_required": {
|
||||
"title": "Połączenie z {target} wymaga odnowienia.",
|
||||
"detail": "Zaloguj się jeszcze raz, a wartości popłyną dalej."
|
||||
},
|
||||
"parked": {
|
||||
"title": "Połączenie z {target} jest wstrzymane.",
|
||||
"detail": "Sprawdź połączenie w Ustawieniach → Integracje."
|
||||
}
|
||||
}
|
||||
},
|
||||
"add-medication": {
|
||||
"title": "Dodaj swój pierwszy lek",
|
||||
@@ -10322,6 +10373,7 @@
|
||||
"invite": {
|
||||
"title": "Zaproś kogoś",
|
||||
"description": "Nic nie jest udostępniane, dopóki ta osoba nie zaakceptuje.",
|
||||
"needsAccount": "Ta osoba potrzebuje własnego konta HealthLog na tej instancji i znajdzie zaproszenie w Ustawienia → Udostępniony dostęp.",
|
||||
"identifierLabel": "Nazwa użytkownika lub e-mail",
|
||||
"identifierPlaceholder": "Nazwa, którą się loguje",
|
||||
"identifierHint": "Ta osoba potrzebuje konta na tej instancji. Nie można wysłać zaproszenia na niezarejestrowany adres.",
|
||||
|
||||
@@ -232,7 +232,20 @@ export default defineConfig({
|
||||
"setup-flow-visit.spec.ts",
|
||||
"setup-flow-child.spec.ts",
|
||||
"setup-flow-a11y.spec.ts",
|
||||
"setup-flow-connect.spec.ts",
|
||||
"setup-flow-locale-overflow.spec.ts",
|
||||
// The shared-provider offer journey states INSTANCE-wide facts to
|
||||
// reason about: it writes the operator key into `app_settings` and
|
||||
// replaces every `provider_health` row for both shared tags, then
|
||||
// puts both back. Serial mode only orders tests inside one file, so
|
||||
// a second project would hold and clear that state underneath the
|
||||
// first — and underneath any other spec that reads AI availability.
|
||||
"onboarding-ai-offer.spec.ts",
|
||||
// The invitation journey mints its token and registers from a
|
||||
// cookie-less window. Two projects registering from one address
|
||||
// share the five-per-fifteen-minutes budget on `auth:register`, so
|
||||
// the second one meets a rate limit rather than the form.
|
||||
"invite-registration.spec.ts",
|
||||
],
|
||||
use: {
|
||||
// Pixel 5 — Chromium-based mobile profile so CI only needs
|
||||
|
||||
@@ -31,12 +31,19 @@ const SRC = join(process.cwd(), "src");
|
||||
const PROFILE_ROUTES = ["/api/auth/profile", "/api/user/profile"];
|
||||
|
||||
function sourceFiles(): string[] {
|
||||
return walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((p) => !p.startsWith("generated/"))
|
||||
.filter((p) => !p.startsWith("app/api/"))
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
|
||||
.sort();
|
||||
return (
|
||||
walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((p) => !p.startsWith("generated/"))
|
||||
.filter((p) => !p.startsWith("app/api/"))
|
||||
// v1.39 — the edge proxy names routes and methods as DATA
|
||||
// (the demo-mode mutation allowlist is a list of path/method pairs), and
|
||||
// a pair reads to the matcher below exactly like a request expression. It
|
||||
// submits nothing and has no answer to read.
|
||||
.filter((p) => p !== "proxy.ts")
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
|
||||
.sort()
|
||||
);
|
||||
}
|
||||
|
||||
function read(rel: string): string {
|
||||
|
||||
@@ -5,11 +5,16 @@ import { join, sep } from "node:path";
|
||||
|
||||
/**
|
||||
* Demo-mode mutation allowlist. On a `DEMO_MODE=true` deploy the proxy
|
||||
* 403s every non-GET `/api/*` request except a narrow allowlist. Login
|
||||
* is the historical baseline; the two dashboard display-pref writes
|
||||
* (chart-overlay toggles + comparison-baseline selector) were added so
|
||||
* the above-chart toggles work in the demo. They touch nothing but the
|
||||
* caller's own `User.dashboardWidgetsJson` blob.
|
||||
* 403s every non-GET `/api/*` request except a narrow allowlist. Login is
|
||||
* the historical baseline; the rest are the writes the demo has to make to
|
||||
* walk its own flows — the two dashboard display-pref blobs, the setup
|
||||
* flow's closed-enum answers and checkpoints, and the baseline profile
|
||||
* fields. None carries health data or free text.
|
||||
*
|
||||
* The demo is one published account every visitor signs into, so what a
|
||||
* visitor writes is what the next visitor finds. The refusals below are as
|
||||
* much of the contract as the admissions: free text that reaches the Coach
|
||||
* prompt, and anything that creates a second record, stay out.
|
||||
*
|
||||
* This guard locks the allowlist shape in place so a refactor can't
|
||||
* silently widen it (admitting a data-bearing mutation) or narrow it
|
||||
@@ -106,6 +111,49 @@ describe("proxy.ts DEMO_MODE mutation allowlist", () => {
|
||||
expect(missing, "onboarding writes the demo cannot make").toEqual([]);
|
||||
});
|
||||
|
||||
it("permits the profile write the baseline step makes", () => {
|
||||
// v1.39 — a demo visitor who typed a height got a 403 and a generic
|
||||
// toast, because the confirm screen's own write was not on the list.
|
||||
// Date of birth, height and sex are closed, validated fields on the
|
||||
// account's own record and no health data, so the step can complete.
|
||||
expect(
|
||||
proxy(makeRequest("/api/auth/profile", "PUT")).status,
|
||||
"PUT /api/auth/profile is blocked in the demo",
|
||||
).not.toBe(403);
|
||||
});
|
||||
|
||||
it("refuses the free-text self-context write, which the demo shares", () => {
|
||||
// The demo is ONE published account every visitor signs into. Free text
|
||||
// that persists for the next visitor is not a demo affordance, and this
|
||||
// particular text is fed into the Coach prompt — an anonymous write into
|
||||
// an LLM prompt on a public instance, held until the next reseed. The
|
||||
// anamnesis card renders read-only in the demo instead, so nothing here
|
||||
// reaches a visitor as a failed save.
|
||||
expect(proxy(makeRequest("/api/coach/about-me", "PUT")).status).toBe(403);
|
||||
});
|
||||
|
||||
it("refuses the managed-profile create, which needs a factor the demo has not got", () => {
|
||||
// `POST /api/managed-profiles` opens with `requireFreshMfa`. The demo
|
||||
// account has no second factor, so admitting it at the edge buys the
|
||||
// guardian arm nothing: the call is refused a layer later either way,
|
||||
// and the confirm screen already says the profile could not be created
|
||||
// without one.
|
||||
expect(proxy(makeRequest("/api/managed-profiles", "POST")).status).toBe(
|
||||
403,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not drag in a sibling verb on the baseline paths", () => {
|
||||
expect(proxy(makeRequest("/api/auth/profile", "DELETE")).status).toBe(403);
|
||||
expect(proxy(makeRequest("/api/managed-profiles", "DELETE")).status).toBe(
|
||||
403,
|
||||
);
|
||||
// The per-profile routes are a different path and stay closed.
|
||||
expect(
|
||||
proxy(makeRequest("/api/managed-profiles/abc", "DELETE")).status,
|
||||
).toBe(403);
|
||||
});
|
||||
|
||||
it("still blocks a health-data mutation (POST /api/measurements)", () => {
|
||||
const res = proxy(makeRequest("/api/measurements", "POST"));
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
@@ -8,6 +8,16 @@ import { NextRequest } from "next/server";
|
||||
* visitor (scanning the admin QR) must reach it without the auth-gate
|
||||
* bounce to `/auth/login`. This guard locks the allowlist entry in place
|
||||
* so a future refactor of `src/proxy.ts` cannot silently re-gate it.
|
||||
*
|
||||
* v1.38.19 — the redirect moved here, to the edge. It used to be emitted by
|
||||
* `src/app/invite/[token]/page.tsx`, and Next does not answer that with a
|
||||
* server 307: it streams the root layout first and puts the redirect in the
|
||||
* flight payload, so `<AuthShell>` mounted on `/invite/…`, read the pathname
|
||||
* as a protected route and raced its own `router.replace("/auth/login")`
|
||||
* against the redirect. Answering at the edge means there is no HTML, no
|
||||
* hydration and no race — the behaviour the page's doc comment already
|
||||
* claimed. The page stays as the fallback for any path that bypasses the
|
||||
* proxy matcher.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/process-type", () => ({
|
||||
@@ -33,19 +43,18 @@ function makeRequest(
|
||||
describe("proxy.ts /invite public-allowlist (v1.17.0)", () => {
|
||||
it("does not 307 an unauthenticated /invite/<token> to /auth/login", () => {
|
||||
const res = proxy(makeRequest(`/invite/${VALID_TOKEN}`));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("location")).toBeNull();
|
||||
expect(res.headers.get("location") ?? "").not.toMatch(/\/auth\/login/);
|
||||
});
|
||||
|
||||
it("admits a pending-onboarding session onto the invite landing", () => {
|
||||
it("does not bounce a pending-onboarding session off the invite landing", () => {
|
||||
const res = proxy(
|
||||
makeRequest(`/invite/${VALID_TOKEN}`, {
|
||||
healthlog_session: "sess-1",
|
||||
hl_onboarding: "pending",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("location")).toBeNull();
|
||||
expect(res.headers.get("location") ?? "").not.toMatch(/\/onboarding/);
|
||||
expect(res.headers.get("location") ?? "").not.toMatch(/\/auth\/login/);
|
||||
});
|
||||
|
||||
it("hardens the token-in-path surface against cache / index / referrer leaks", () => {
|
||||
@@ -67,3 +76,64 @@ describe("proxy.ts /invite public-allowlist (v1.17.0)", () => {
|
||||
expect(res.headers.get("location")).toMatch(/\/auth\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("proxy.ts /invite edge redirect (v1.38.19)", () => {
|
||||
it("307s a shape-valid token straight onto the registration form", () => {
|
||||
const res = proxy(makeRequest(`/invite/${VALID_TOKEN}`));
|
||||
expect(res.status).toBe(307);
|
||||
expect(res.headers.get("location")).toBe(
|
||||
`http://localhost/auth/register?invite=${VALID_TOKEN}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("307s a malformed segment onto a bare registration form", () => {
|
||||
// Never echo an attacker-controlled segment into the target — a
|
||||
// malformed token behaves exactly like a visitor with no invite.
|
||||
const res = proxy(makeRequest("/invite/not-a-token"));
|
||||
expect(res.status).toBe(307);
|
||||
expect(res.headers.get("location")).toBe("http://localhost/auth/register");
|
||||
});
|
||||
|
||||
it("ignores anything after the token segment", () => {
|
||||
const res = proxy(makeRequest(`/invite/${VALID_TOKEN}/extra`));
|
||||
expect(res.headers.get("location")).toBe(
|
||||
`http://localhost/auth/register?invite=${VALID_TOKEN}`,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an uppercase hex body", `hlv_${"A".repeat(64)}`],
|
||||
["a short body", `hlv_${"a".repeat(63)}`],
|
||||
["a long body", `hlv_${"a".repeat(65)}`],
|
||||
["the wrong prefix", `hlk_${"a".repeat(64)}`],
|
||||
["an empty segment", ""],
|
||||
])("drops %s rather than echoing it into the target", (_label, segment) => {
|
||||
const res = proxy(makeRequest(`/invite/${segment}`));
|
||||
expect(res.headers.get("location")).toBe("http://localhost/auth/register");
|
||||
});
|
||||
|
||||
it("keeps the token hardening on the malformed redirect too", () => {
|
||||
const res = proxy(makeRequest("/invite/not-a-token"));
|
||||
expect(res.headers.get("Cache-Control")).toBe(
|
||||
"no-store, no-cache, must-revalidate",
|
||||
);
|
||||
expect(res.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
|
||||
expect(res.headers.get("Referrer-Policy")).toBe("no-referrer");
|
||||
});
|
||||
|
||||
it("carries the baseline transport headers on the redirect hop", () => {
|
||||
const res = proxy(makeRequest(`/invite/${VALID_TOKEN}`));
|
||||
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
||||
expect(res.headers.get("X-Frame-Options")).toBe("DENY");
|
||||
});
|
||||
|
||||
it("redirects a signed-in caller the same way, with no session read", () => {
|
||||
const res = proxy(
|
||||
makeRequest(`/invite/${VALID_TOKEN}`, { healthlog_session: "sess-1" }),
|
||||
);
|
||||
expect(res.status).toBe(307);
|
||||
expect(res.headers.get("location")).toBe(
|
||||
`http://localhost/auth/register?invite=${VALID_TOKEN}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -171,6 +171,33 @@ describe("record settings contract", () => {
|
||||
).toBe("error_reauth");
|
||||
});
|
||||
|
||||
it("reports a real credential with no ledger row as connected", () => {
|
||||
// The connection map is the credential: this branch is only reached once
|
||||
// it says a pipe exists. `unknown` on top of it means the sync ledger
|
||||
// holds no row yet — an hourly provider connected minutes ago — and this
|
||||
// projection's `state` is documented as "a pipe exists", not as liveness.
|
||||
// Calling it "Not connected" invites a guardian to revoke the grant they
|
||||
// just made.
|
||||
const connected = {
|
||||
withings: true,
|
||||
whoop: false,
|
||||
fitbit: false,
|
||||
nightscout: false,
|
||||
polar: false,
|
||||
oura: false,
|
||||
"google-health": false,
|
||||
strava: false,
|
||||
} as const;
|
||||
|
||||
expect(
|
||||
resolveManagedIntegrationState("unknown", connected, "withings"),
|
||||
).toBe("connected");
|
||||
// No credential still outranks it.
|
||||
expect(resolveManagedIntegrationState("unknown", connected, "oura")).toBe(
|
||||
"disconnected",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a late response for another record in a second tab", () => {
|
||||
expect(() =>
|
||||
assertRecordSettingsResponseForRecord(
|
||||
|
||||
@@ -79,6 +79,12 @@ describe("S1 — the direct session-resolution set is frozen", () => {
|
||||
// the freshness comparison, which `requireAuth` does not project.
|
||||
"app/api/auth/native/complete/route.ts":
|
||||
"reads createdAt for the handoff freshness check",
|
||||
// Anonymous self-registration. Probes for an already-valid session so an
|
||||
// invitation opened from inside one is refused instead of minting a second
|
||||
// account over the caller's own cookie. `requireAuth` is the wrong gate
|
||||
// here twice over: it would throw 401 at the anonymous caller the route
|
||||
// exists for, and this route wants the ABSENCE of a session.
|
||||
"app/api/auth/register/route.ts": "refuses to register over a live session",
|
||||
// Anonymous IdP callback. Probes for an already-valid session so a
|
||||
// replayed callback redirects instead of minting a second identity.
|
||||
"app/api/auth/oidc/callback/route.ts":
|
||||
|
||||
@@ -31,8 +31,19 @@ vi.mock("@/lib/auth/login-alert", () => ({
|
||||
recordSignInDevice: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// The invite ledger. A refused registration must never reach it — the
|
||||
// assertion below is that `uses` is left alone, and the only way this route
|
||||
// can touch it is through `consumeInviteToken`.
|
||||
vi.mock("@/lib/auth/invite-token", () => ({
|
||||
consumeInviteToken: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, inviteId: "inv-1" }),
|
||||
recordInviteConsumer: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
createSession: vi.fn().mockResolvedValue(undefined),
|
||||
getSession: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/password", () => ({
|
||||
@@ -78,7 +89,8 @@ import { POST } from "../route";
|
||||
import { checkRateLimit, checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkPasswordStrength, hashPassword } from "@/lib/auth/password";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { createSession, getSession } from "@/lib/auth/session";
|
||||
import { consumeInviteToken } from "@/lib/auth/invite-token";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
@@ -96,6 +108,7 @@ beforeEach(() => {
|
||||
allowed: true,
|
||||
ip: "1.2.3.4",
|
||||
} as never);
|
||||
vi.mocked(getSession).mockResolvedValue(null as never);
|
||||
});
|
||||
|
||||
describe("POST /api/auth/register — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
@@ -267,3 +280,97 @@ describe("POST /api/auth/register — OIDC_ONLY server-side enforcement", () =>
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.38.19 — an invitation creates a NEW account, so it cannot be accepted
|
||||
* from inside somebody's live session.
|
||||
*
|
||||
* Until this gate the route read no session at all: a request carrying a valid
|
||||
* `healthlog_session` was treated exactly like an anonymous one, and the
|
||||
* unconditional `createSession` at the end overwrote the caller's cookie with
|
||||
* the fresh account's. The admin who clicked his own invite link to check it
|
||||
* was signed out of his admin account and into a stranger's onboarding, with
|
||||
* his previous session row left valid server-side and unreachable.
|
||||
*/
|
||||
describe("POST /api/auth/register — refuses a live session (v1.38.19)", () => {
|
||||
const invitedBody = {
|
||||
email: "new@example.com",
|
||||
username: "newuser",
|
||||
password: "a-very-strong-password-123",
|
||||
inviteToken: `hlv_${"a".repeat(64)}`,
|
||||
};
|
||||
|
||||
// Everything the happy path needs is stubbed, so the ONLY reason this
|
||||
// request can fail is the gate under test. Without it the same request
|
||||
// answers 201 and mints a session over the caller's cookie.
|
||||
beforeEach(() => {
|
||||
vi.mocked(consumeInviteToken).mockResolvedValue({
|
||||
ok: true,
|
||||
inviteId: "inv-1",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(1 as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
|
||||
vi.mocked(checkPasswordStrength).mockReturnValue({
|
||||
isAcceptable: true,
|
||||
feedback: [],
|
||||
} as never);
|
||||
vi.mocked(hashPassword).mockResolvedValue("hashed" as never);
|
||||
vi.mocked(prisma.$transaction).mockImplementation(((
|
||||
fn: (tx: typeof prisma) => unknown,
|
||||
) => fn(prisma)) as never);
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ locked: 1 }] as never);
|
||||
vi.mocked(prisma.user.create).mockResolvedValue({
|
||||
id: "u1",
|
||||
username: "newuser",
|
||||
email: "new@example.com",
|
||||
role: "USER",
|
||||
} as never);
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 1e6) },
|
||||
user: { id: "u-existing", username: "admin", email: "a@example.com" },
|
||||
} as never);
|
||||
});
|
||||
|
||||
it("answers 409 already_authenticated", async () => {
|
||||
const res = await postPathThrough(invitedBody);
|
||||
expect(res.status).toBe(409);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.meta?.errorCode).toBe("auth.already_authenticated");
|
||||
expect(body.error).toMatch(/sign/i);
|
||||
});
|
||||
|
||||
it("creates no account", async () => {
|
||||
await postPathThrough(invitedBody);
|
||||
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not burn a use of the invitation", async () => {
|
||||
await postPathThrough(invitedBody);
|
||||
expect(consumeInviteToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not touch the caller's own session", async () => {
|
||||
await postPathThrough(invitedBody);
|
||||
expect(createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses before the rate-limit bucket is spent", async () => {
|
||||
// The gate is a session read, not an attempt: a signed-in visitor
|
||||
// clicking the link three times must not lock the door for the person
|
||||
// the invitation is actually for, who shares the IP on a home network.
|
||||
await postPathThrough(invitedBody);
|
||||
expect(checkAuthSurfaceRateLimit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still registers an anonymous caller", async () => {
|
||||
// The control. Same request, no session — the invitation works.
|
||||
vi.mocked(getSession).mockResolvedValue(null as never);
|
||||
const res = await postPathThrough(invitedBody);
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from "@/lib/auth/invite-token";
|
||||
import { registerSchema } from "@/lib/validations/auth";
|
||||
import { hashPassword, checkPasswordStrength } from "@/lib/auth/password";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { createSession, getSession } from "@/lib/auth/session";
|
||||
import { recordSignInDevice } from "@/lib/auth/login-alert";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -39,6 +39,31 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
// v1.38.19 — an invitation creates a NEW account, so it can never be
|
||||
// accepted from inside a live session.
|
||||
//
|
||||
// The route used to read no session at all: a request carrying a valid
|
||||
// `healthlog_session` was handled exactly like an anonymous one, and the
|
||||
// unconditional `createSession` at the end overwrote the caller's cookie
|
||||
// with the fresh account's. An admin opening his own invite link to check it
|
||||
// was signed out of his admin account and into a stranger's onboarding, his
|
||||
// previous session row still valid server-side and no longer reachable.
|
||||
//
|
||||
// Refused BEFORE the rate limit on purpose: this is a state, not an attempt,
|
||||
// and spending the 5-per-15-minutes bucket on it would lock the door for the
|
||||
// person the invitation is actually for, who may well share the IP.
|
||||
// `getSession` is the optional resolver — it answers `null` for an anonymous
|
||||
// caller instead of throwing, which `requireAuth` would.
|
||||
const existingSession = await getSession();
|
||||
if (existingSession) {
|
||||
annotate({ action: { name: "auth.register.refused_authenticated" } });
|
||||
return apiError(
|
||||
"Already signed in — sign out first to use an invitation",
|
||||
409,
|
||||
{ errorCode: "auth.already_authenticated" },
|
||||
);
|
||||
}
|
||||
|
||||
// v1.4.43 W13 M-4 — tighten to a global bucket when the trust chain
|
||||
// is misconfigured; otherwise byte-equivalent per-IP semantics.
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
|
||||
@@ -60,6 +60,7 @@ const whoopFind = prisma.whoopConnection.findUnique as ReturnType<typeof vi.fn>;
|
||||
|
||||
type Entry = {
|
||||
integration: string;
|
||||
state?: string;
|
||||
connected?: boolean;
|
||||
configured?: boolean;
|
||||
available?: boolean;
|
||||
@@ -344,3 +345,41 @@ describe("/api/integrations/status — the freshness read failed", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.38.19 — the never-connected provider on the wire.
|
||||
*
|
||||
* The ledger's synthetic "no row" snapshot reads `unknown`, and the envelope
|
||||
* publishes it verbatim. A client that reads `state` must see a value that
|
||||
* cannot be mistaken for a live connection, while `syncHealth.verdict` keeps
|
||||
* saying `disconnected` for the same entry.
|
||||
*/
|
||||
describe("/api/integrations/status — a provider with no ledger row", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
for (const key of Object.keys(perIntegrationLedger)) {
|
||||
delete perIntegrationLedger[key];
|
||||
}
|
||||
polarAvailable.mockResolvedValue(true);
|
||||
ouraAvailable.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it("publishes state unknown beside connected false and a disconnected verdict", async () => {
|
||||
perIntegrationLedger.whoop = {
|
||||
state: "unknown",
|
||||
lastSuccessAt: null,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
failingSince: null,
|
||||
};
|
||||
userFind.mockResolvedValue({});
|
||||
whoopFind.mockResolvedValue(null);
|
||||
|
||||
const whoop = (await fetchEntries()).find(
|
||||
(e) => e.integration === "whoop",
|
||||
)!;
|
||||
expect(whoop.state).toBe("unknown");
|
||||
expect(whoop.connected).toBe(false);
|
||||
expect(whoop.syncHealth?.verdict).toBe("disconnected");
|
||||
});
|
||||
});
|
||||
@@ -43,11 +43,33 @@ vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
// v1.38.19 — the four preconditions the honest offer rides on.
|
||||
vi.mock("@/lib/ai/server-provider-health", () => ({
|
||||
readServerProviderHealth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/feature-flags", () => ({
|
||||
getAssistantFlags: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/sharing/provider-work-authority", () => ({
|
||||
providerWorkAuthorityForRecord: vi.fn(() => ({ origin: "owner" })),
|
||||
providerCredentialPolicy: vi.fn(() => "personal"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
hasActiveConsentForSurface: vi.fn(),
|
||||
}));
|
||||
|
||||
import { GET, PATCH } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveProviderAvailability } from "@/lib/ai/provider";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
import { isLocalAiHostAllowed } from "@/lib/ai/local-host-allowlist";
|
||||
import { readServerProviderHealth } from "@/lib/ai/server-provider-health";
|
||||
import { getAssistantFlags } from "@/lib/feature-flags";
|
||||
import { providerCredentialPolicy } from "@/lib/sharing/provider-work-authority";
|
||||
import { hasActiveConsentForSurface } from "@/lib/ai/consent-guard";
|
||||
|
||||
function patchRequest(body: unknown): Request {
|
||||
return new Request("http://localhost/api/user/ai-provider", {
|
||||
@@ -70,6 +92,18 @@ interface AiProviderResponse {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// The shape every GET needs before a case narrows it: an owning caller on
|
||||
// an instance whose assistant surfaces are on, who has not consented yet.
|
||||
vi.mocked(readServerProviderHealth).mockResolvedValue("unknown");
|
||||
vi.mocked(getAssistantFlags).mockResolvedValue({
|
||||
enabled: true,
|
||||
coach: true,
|
||||
briefing: true,
|
||||
insightStatus: true,
|
||||
correlations: true,
|
||||
});
|
||||
vi.mocked(providerCredentialPolicy).mockReturnValue("personal");
|
||||
vi.mocked(hasActiveConsentForSurface).mockResolvedValue(false);
|
||||
});
|
||||
|
||||
describe("GET /api/user/ai-provider availability", () => {
|
||||
@@ -405,3 +439,150 @@ describe("GET /api/user/ai-provider — the gateway's fields (#470)", () => {
|
||||
expect(JSON.stringify(body)).not.toContain("enc-gateway");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.38.19 — the honest shared-provider offer.
|
||||
*
|
||||
* The setup flow's last screen used to promise that insights "work for you
|
||||
* right away" whenever `managedBy` said `server`. That is a presence read:
|
||||
* it knows a key is configured, never that it works, and it says nothing
|
||||
* about the consent receipt the egress actually needs. These three fields
|
||||
* are what makes the promise checkable — and every one of the five
|
||||
* preconditions below can only ever take the offer AWAY.
|
||||
*/
|
||||
describe("GET /api/user/ai-provider — the shared-provider offer", () => {
|
||||
function serverManaged(): void {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
aiProvider: null,
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: null,
|
||||
managedProfileAt: null,
|
||||
} as never);
|
||||
vi.mocked(resolveProviderAvailability).mockResolvedValue({
|
||||
aiAvailable: true,
|
||||
managedBy: "server",
|
||||
});
|
||||
}
|
||||
|
||||
async function read(): Promise<{
|
||||
serverProviderHealth: string;
|
||||
serverProviderOffer: boolean;
|
||||
serverProviderConsent: boolean;
|
||||
}> {
|
||||
const res = await (GET as () => Promise<Response>)();
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
serverProviderHealth: string;
|
||||
serverProviderOffer: boolean;
|
||||
serverProviderConsent: boolean;
|
||||
};
|
||||
};
|
||||
return body.data;
|
||||
}
|
||||
|
||||
it("offers the shared provider when it is demonstrably healthy and nothing forbids it", async () => {
|
||||
serverManaged();
|
||||
vi.mocked(readServerProviderHealth).mockResolvedValue("healthy");
|
||||
await expect(read()).resolves.toMatchObject({
|
||||
serverProviderHealth: "healthy",
|
||||
serverProviderOffer: true,
|
||||
serverProviderConsent: false,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["unhealthy", "unknown"] as const)(
|
||||
"never offers on %s health — unknown is not a maybe",
|
||||
async (health) => {
|
||||
serverManaged();
|
||||
vi.mocked(readServerProviderHealth).mockResolvedValue(health);
|
||||
await expect(read()).resolves.toMatchObject({
|
||||
serverProviderHealth: health,
|
||||
serverProviderOffer: false,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("never offers when the operator switched the assistant surfaces off", async () => {
|
||||
serverManaged();
|
||||
vi.mocked(readServerProviderHealth).mockResolvedValue("healthy");
|
||||
vi.mocked(getAssistantFlags).mockResolvedValue({
|
||||
enabled: true,
|
||||
coach: false,
|
||||
briefing: true,
|
||||
insightStatus: true,
|
||||
correlations: true,
|
||||
});
|
||||
await expect(read()).resolves.toMatchObject({
|
||||
serverProviderHealth: "healthy",
|
||||
serverProviderOffer: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("never offers to a managed profile — a child's record does not consent for itself", async () => {
|
||||
serverManaged();
|
||||
vi.mocked(readServerProviderHealth).mockResolvedValue("healthy");
|
||||
vi.mocked(providerCredentialPolicy).mockReturnValue("operator-default");
|
||||
await expect(read()).resolves.toMatchObject({ serverProviderOffer: false });
|
||||
});
|
||||
|
||||
it("stops offering once the user holds a receipt, and says the consent is on file", async () => {
|
||||
serverManaged();
|
||||
vi.mocked(readServerProviderHealth).mockResolvedValue("healthy");
|
||||
vi.mocked(hasActiveConsentForSurface).mockResolvedValue(true);
|
||||
await expect(read()).resolves.toMatchObject({
|
||||
serverProviderOffer: false,
|
||||
serverProviderConsent: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("never offers a provider the operator does not manage", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
aiProvider: "OPENAI",
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: "enc-openai",
|
||||
managedProfileAt: null,
|
||||
} as never);
|
||||
vi.mocked(resolveProviderAvailability).mockResolvedValue({
|
||||
aiAvailable: true,
|
||||
managedBy: "user",
|
||||
});
|
||||
vi.mocked(readServerProviderHealth).mockResolvedValue("healthy");
|
||||
await expect(read()).resolves.toMatchObject({ serverProviderOffer: false });
|
||||
});
|
||||
|
||||
it("reads the health without probing anything", async () => {
|
||||
serverManaged();
|
||||
vi.mocked(readServerProviderHealth).mockResolvedValue("healthy");
|
||||
await read();
|
||||
expect(vi.mocked(readServerProviderHealth)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(readServerProviderHealth)).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it("never offers on a demo instance, where the grant cannot be made", async () => {
|
||||
// The one tap posts `POST /api/consent/ai/web`, which the demo's edge
|
||||
// allowlist refuses — and should: the demo is one shared account, so a
|
||||
// receipt minted by one visitor would turn the operator's provider on
|
||||
// for every later one. Offering a button that can only 403 into a
|
||||
// generic toast is the misleading failure this screen exists to remove,
|
||||
// so the offer is withheld and the screen falls to its neutral panel.
|
||||
const previous = process.env.DEMO_MODE;
|
||||
process.env.DEMO_MODE = "true";
|
||||
try {
|
||||
serverManaged();
|
||||
vi.mocked(readServerProviderHealth).mockResolvedValue("healthy");
|
||||
await expect(read()).resolves.toMatchObject({
|
||||
serverProviderHealth: "healthy",
|
||||
serverProviderOffer: false,
|
||||
});
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.DEMO_MODE;
|
||||
else process.env.DEMO_MODE = previous;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,13 @@ import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
import { isLocalAiHostAllowed } from "@/lib/ai/local-host-allowlist";
|
||||
import { encrypt, decrypt } from "@/lib/crypto";
|
||||
import { resolveProviderAvailability } from "@/lib/ai/provider";
|
||||
import { readServerProviderHealth } from "@/lib/ai/server-provider-health";
|
||||
import { hasActiveConsentForSurface } from "@/lib/ai/consent-guard";
|
||||
import { getAssistantFlags } from "@/lib/feature-flags";
|
||||
import {
|
||||
providerCredentialPolicy,
|
||||
providerWorkAuthorityForRecord,
|
||||
} from "@/lib/sharing/provider-work-authority";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { aiProviderPatchSchema } from "@/lib/validations/ai-provider";
|
||||
|
||||
@@ -35,6 +42,9 @@ export const GET = apiHandler(async () => {
|
||||
aiCompatModel: true,
|
||||
// v1.22 (#89)
|
||||
aiResponseTimeoutSeconds: true,
|
||||
// v1.38.19 — a managed profile is never the party that gives
|
||||
// consent for itself; the offer below has to know.
|
||||
managedProfileAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -45,6 +55,54 @@ export const GET = apiHandler(async () => {
|
||||
// `managedBy` reports the origin only; no admin keys/endpoints are leaked.
|
||||
const { aiAvailable, managedBy } = await resolveProviderAvailability(user.id);
|
||||
|
||||
// ── v1.38.19 — the shared provider, honestly ────────────────
|
||||
// `managedBy: "server"` says the operator configured a key. It has never
|
||||
// said the key WORKS, and the setup flow spent three releases promising
|
||||
// that it did. It also says nothing about the consent receipt
|
||||
// `consent-guard.ts` demands before any PHI reaches `admin-openai` /
|
||||
// `admin-codex`, which is the decision a fresh account is actually
|
||||
// missing — the provider was already chosen for them by the default chain.
|
||||
//
|
||||
// So three additive fields, and every one of them can only ever take the
|
||||
// offer away:
|
||||
// serverProviderHealth — the instance-wide tri-state, fail closed.
|
||||
// serverProviderConsent — this user already holds a receipt.
|
||||
// serverProviderOffer — all five preconditions hold at once.
|
||||
//
|
||||
// Nothing here probes a provider. The flow's rule ("nothing in the flow
|
||||
// talks to an AI provider") holds: a probe would bill the OPERATOR for
|
||||
// every registration on the instance.
|
||||
const serverProviderHealth = await readServerProviderHealth();
|
||||
const serverProviderConsent = await hasActiveConsentForSurface(
|
||||
user.id,
|
||||
"coach",
|
||||
);
|
||||
const assistantFlags = await getAssistantFlags();
|
||||
// `personal`, not merely "not deny": a guardian's own record resolves to
|
||||
// `personal`, while a managed profile resolves to `operator-default` —
|
||||
// its provider is the operator's by definition, and a child's record does
|
||||
// not consent to its own PHI egress. Both must be excluded here, and only
|
||||
// the positive form excludes both.
|
||||
const credentialPolicy = providerCredentialPolicy(
|
||||
providerWorkAuthorityForRecord(user.id),
|
||||
u?.managedProfileAt ?? null,
|
||||
);
|
||||
// A demo instance can show the offer but cannot honour it: the one tap
|
||||
// posts `POST /api/consent/ai/web`, which the demo's edge allowlist
|
||||
// refuses — and should, since the demo is one shared account and a receipt
|
||||
// one visitor minted would turn the operator's provider on for every later
|
||||
// one. A button that can only 403 into a generic toast is precisely the
|
||||
// misleading failure this surface exists to remove, so there is no button.
|
||||
const demoInstance = process.env.DEMO_MODE === "true";
|
||||
const serverProviderOffer =
|
||||
!demoInstance &&
|
||||
managedBy === "server" &&
|
||||
serverProviderHealth === "healthy" &&
|
||||
assistantFlags.enabled &&
|
||||
assistantFlags.coach &&
|
||||
credentialPolicy === "personal" &&
|
||||
!serverProviderConsent;
|
||||
|
||||
return apiSuccess({
|
||||
provider: u?.aiProvider ?? null,
|
||||
model: u?.aiModel ?? null,
|
||||
@@ -67,6 +125,11 @@ export const GET = apiHandler(async () => {
|
||||
hasCompatKey: Boolean(u?.aiCompatKeyEncrypted),
|
||||
// v1.22 (#89) — per-user response timeout, in seconds (null = default).
|
||||
responseTimeoutSeconds: u?.aiResponseTimeoutSeconds ?? null,
|
||||
// v1.38.19 — see the block above. A tri-state and two booleans;
|
||||
// no count, no timestamp and no other account is inferable from them.
|
||||
serverProviderHealth,
|
||||
serverProviderOffer,
|
||||
serverProviderConsent,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* An invitation opened from inside a live session.
|
||||
*
|
||||
* `POST /api/auth/register` refuses that with 409 `already_authenticated`
|
||||
* (see `src/app/api/auth/register/__tests__/route.test.ts`). The page must not
|
||||
* make the visitor discover it by filling the form: a signed-in caller gets an
|
||||
* explanation and a way out instead of three inputs that cannot succeed.
|
||||
*
|
||||
* SSR-only, per project convention (node environment, no DOM, no
|
||||
* `@testing-library/react`), so what is asserted is the PAINT — which branch
|
||||
* the page rendered. The sign-out click itself is driven in
|
||||
* `e2e/invite-registration.spec.ts`.
|
||||
*
|
||||
* Mutation check, run: drop the signed-in branch → the two signed-in cases go
|
||||
* red naming the form inputs they found, the anonymous case stays green.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
const authRef = {
|
||||
value: {
|
||||
user: null as { username: string } | null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
},
|
||||
};
|
||||
const inviteRef = { value: null as string | null };
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
|
||||
useSearchParams: () => ({
|
||||
get: (key: string) => (key === "invite" ? inviteRef.value : null),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => authRef.value,
|
||||
clearCachesForSessionEnd: vi.fn(),
|
||||
}));
|
||||
|
||||
import RegisterPage from "../page";
|
||||
|
||||
const TOKEN = `hlv_${"a".repeat(64)}`;
|
||||
|
||||
function render(): string {
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<RegisterPage />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function anonymous(invite: string | null): string {
|
||||
authRef.value = { user: null, isAuthenticated: false, isLoading: false };
|
||||
inviteRef.value = invite;
|
||||
return render();
|
||||
}
|
||||
|
||||
function signedIn(invite: string | null, username = "marc"): string {
|
||||
authRef.value = {
|
||||
user: { username },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
};
|
||||
inviteRef.value = invite;
|
||||
return render();
|
||||
}
|
||||
|
||||
describe("register page — an invitation cannot be accepted from a live session", () => {
|
||||
it("renders the form for an anonymous visitor holding an invitation", () => {
|
||||
const markup = anonymous(TOKEN);
|
||||
expect(markup).toContain('data-testid="register-invite-banner"');
|
||||
expect(markup).toContain('id="username"');
|
||||
expect(markup).not.toContain('data-testid="register-already-signed-in"');
|
||||
});
|
||||
|
||||
it("replaces the form with the explaining panel for a signed-in visitor", () => {
|
||||
const markup = signedIn(TOKEN);
|
||||
expect(markup).toContain('data-testid="register-already-signed-in"');
|
||||
expect(markup).not.toContain('id="username"');
|
||||
expect(markup).not.toContain('id="password"');
|
||||
});
|
||||
|
||||
it("names the account the visitor is signed in as", () => {
|
||||
const markup = signedIn(TOKEN, "grandma");
|
||||
expect(markup).toContain("grandma");
|
||||
});
|
||||
|
||||
it("offers the way out", () => {
|
||||
const markup = signedIn(TOKEN);
|
||||
expect(markup).toContain('data-testid="register-sign-out"');
|
||||
});
|
||||
|
||||
it("explains the same thing without an invitation in the URL", () => {
|
||||
// Reaching /auth/register signed in is the same dead end with or without
|
||||
// a token — the form below could only ever answer 409.
|
||||
const markup = signedIn(null);
|
||||
expect(markup).toContain('data-testid="register-already-signed-in"');
|
||||
});
|
||||
|
||||
it("holds the panel back while the session question is unanswered", () => {
|
||||
// Fail closed the other way round: an unresolved `/api/auth/me` must not
|
||||
// flash "you are already signed in" at somebody who is not.
|
||||
authRef.value = { user: null, isAuthenticated: false, isLoading: true };
|
||||
inviteRef.value = TOKEN;
|
||||
const markup = render();
|
||||
expect(markup).not.toContain('data-testid="register-already-signed-in"');
|
||||
});
|
||||
|
||||
it("renders the form when the auth probe failed and only a stale marker says otherwise", () => {
|
||||
// `useAuth` deliberately holds the LAST-KNOWN state when `/api/auth/me`
|
||||
// fails at the transport level or with a 5xx, so the shell does not sign
|
||||
// people out on a blip. There is no account payload on that path, so the
|
||||
// panel would name nobody ("You are signed in as .") and offer a sign-out
|
||||
// that posts to the endpoint the same failure is affecting. An invited
|
||||
// household member on a flaky connection could not register at all.
|
||||
authRef.value = { user: null, isAuthenticated: true, isLoading: false };
|
||||
inviteRef.value = TOKEN;
|
||||
const markup = render();
|
||||
expect(markup).not.toContain('data-testid="register-already-signed-in"');
|
||||
expect(markup).toContain('id="username"');
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useId, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, MailOpen, UserPlus } from "lucide-react";
|
||||
import { Loader2, LogOut, MailOpen, UserPlus } from "lucide-react";
|
||||
import { Logo } from "@/components/ui/logo";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -13,7 +13,8 @@ import { PasswordStrength } from "@/components/ui/password-strength";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { detectBrowserTimezone } from "@/lib/tz/format";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import { ApiError, apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
|
||||
import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
@@ -23,6 +24,23 @@ export default function RegisterPage() {
|
||||
// admits the invited user; an open instance simply ignores it.
|
||||
const inviteToken = searchParams.get("invite");
|
||||
const queryClient = useQueryClient();
|
||||
// v1.38.19 — an invitation creates a NEW account, so it cannot be accepted
|
||||
// from inside a live session; `POST /api/auth/register` refuses one with
|
||||
// 409 `already_authenticated`. Rendering the form anyway would let the
|
||||
// visitor discover that by filling it in.
|
||||
//
|
||||
// The gate is the resolved account payload, not `isAuthenticated`. That
|
||||
// flag deliberately holds the LAST-KNOWN state off a localStorage marker
|
||||
// whenever `/api/auth/me` fails at the transport level or with a 5xx, so
|
||||
// the shell does not sign people out on a blip — and on that path there is
|
||||
// no `user`. Gating on it would show an invited household member behind a
|
||||
// 502 a panel that names nobody and offers a sign-out posting to the very
|
||||
// endpoint that is failing, with no way to register. A payload in hand is
|
||||
// the only positive evidence of a session, and it is also absent while the
|
||||
// probe is still in flight, so the panel cannot flash at somebody who is
|
||||
// not signed in.
|
||||
const { user } = useAuth();
|
||||
const [signingOut, setSigningOut] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
@@ -69,6 +87,83 @@ export default function RegisterPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSignOut() {
|
||||
setError(null);
|
||||
setSigningOut(true);
|
||||
try {
|
||||
await apiFetchRaw("/api/auth/logout", { method: "POST" });
|
||||
// Same session-end wipe the shell and the menu do: the QueryClient
|
||||
// outlives the navigation on this SPA and the health-data families are
|
||||
// not user-scoped, so nothing may survive into the next account.
|
||||
clearCachesForSessionEnd(queryClient);
|
||||
// Back to this page, invitation intact — NOT to the login screen the
|
||||
// generic logout goes to. The point of the trip is to accept the
|
||||
// invitation, and the token only rides the URL.
|
||||
router.replace(
|
||||
inviteToken
|
||||
? `/auth/register?invite=${encodeURIComponent(inviteToken)}`
|
||||
: "/auth/register",
|
||||
);
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError(t("common.networkError"));
|
||||
} finally {
|
||||
setSigningOut(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (user) {
|
||||
return (
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="border-border bg-card rounded-xl border p-6 shadow-lg shadow-black/20 sm:p-8">
|
||||
<div
|
||||
className="flex flex-col items-center gap-3 text-center"
|
||||
data-testid="register-already-signed-in"
|
||||
>
|
||||
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<Logo className="text-primary" size={28} />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold tracking-tight">
|
||||
{t("auth.alreadySignedIn.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("auth.alreadySignedIn.body", {
|
||||
username: user?.username ?? "",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="bg-destructive/10 text-destructive mt-6 rounded-lg p-3 text-sm"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
className="mt-8 min-h-11 w-full"
|
||||
onClick={handleSignOut}
|
||||
disabled={signingOut}
|
||||
aria-busy={signingOut || undefined}
|
||||
data-testid="register-sign-out"
|
||||
>
|
||||
{signingOut ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<LogOut className="h-4 w-4" />
|
||||
)}
|
||||
{t("auth.alreadySignedIn.signOut")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-sm">
|
||||
{/* v1.4.27 MB7 / CF-61 — drop `p-8` to `p-6 sm:p-8` so the
|
||||
|
||||
@@ -102,7 +102,16 @@ function renderScreen(screen: OnboardingStepId, state: OnboardingStateDto) {
|
||||
return <UnitsScreen state={state} />;
|
||||
}
|
||||
if (screen === "confirm") {
|
||||
return <ConfirmScreen state={state} />;
|
||||
// `DEMO_MODE` is a server-only env var; resolved here and threaded in,
|
||||
// as the root layout does for the shell. The confirm screen's anamnesis
|
||||
// card takes no input on a demo instance, whose account is shared and
|
||||
// whose self-context write is off the edge allowlist.
|
||||
return (
|
||||
<ConfirmScreen
|
||||
state={state}
|
||||
demoMode={process.env.DEMO_MODE === "true"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (screen === "first-result") {
|
||||
return <FirstResultScreen state={state} />;
|
||||
|
||||
@@ -619,7 +619,10 @@ export function InviteTokensSection() {
|
||||
height={176}
|
||||
className="rounded-lg bg-white p-2"
|
||||
/>
|
||||
<p className="bg-muted w-full overflow-x-auto rounded-md p-2 text-center font-mono text-xs break-all select-all">
|
||||
<p
|
||||
className="bg-muted w-full overflow-x-auto rounded-md p-2 text-center font-mono text-xs break-all select-all"
|
||||
data-testid="admin-invites-minted-url"
|
||||
>
|
||||
{minted.url}
|
||||
</p>
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* The edge gate and the client gate must agree about which PAGES are public.
|
||||
*
|
||||
* `src/proxy.ts` decides whether a request reaches the app at all;
|
||||
* `auth-shell.tsx` decides whether the mounted shell treats the pathname as
|
||||
* public or as a protected route that needs `/api/auth/me`. The two lists are
|
||||
* maintained by hand, and when they drift the visitor pays: Next streams the
|
||||
* shell before an RSC redirect resolves, so a route the edge admits but the
|
||||
* shell classifies as protected renders the protected branch and fires
|
||||
* `router.replace("/auth/login")` against the redirect. That is exactly what
|
||||
* happened to `/invite/<token>` — public in `proxy.ts` since v1.17.0, never
|
||||
* added here, and the operator's invite link landed on the login page.
|
||||
*
|
||||
* So this file does not pin one route. It parameterises over every PAGE prefix
|
||||
* the proxy admits and asserts the shell paints the page for each, which makes
|
||||
* the drift itself the thing under test rather than the one instance of it.
|
||||
*
|
||||
* SSR-only, per project convention (node environment, no DOM, no
|
||||
* `@testing-library/react`): effects never run here, so the assertion is the
|
||||
* PAINT. On a public path the shell hands the child through; on a protected
|
||||
* path with auth resolved as unauthenticated it paints the redirect spinner
|
||||
* instead and the child never appears. The production evidence for the defect
|
||||
* was the same signal read off a `curl` — `record-scope-hydration-gate` in the
|
||||
* body of `GET /invite/<token>`.
|
||||
*
|
||||
* Mutation check, run: remove `"/invite/"` from the shell's `PUBLIC_PATHS` →
|
||||
* the `/invite/` case goes red with the child sentinel missing, every other
|
||||
* case stays green.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
const mockPathRef = { value: "/" };
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => mockPathRef.value,
|
||||
useRouter: () => ({ replace: vi.fn(), push: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isAuthUnknown: false,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
clearCachesForSessionEnd: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-record-session-transition", () => ({
|
||||
useRecordSessionTransition: () => ({
|
||||
id: "none",
|
||||
phase: "ready",
|
||||
expectedScope: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Chrome that is not the subject — each pulls its own query cells and portals.
|
||||
vi.mock("../sidebar-nav", () => ({ SidebarNav: () => null }));
|
||||
vi.mock("../bottom-nav", () => ({ BottomNav: () => null }));
|
||||
vi.mock("../top-bar", () => ({ TopBar: () => null }));
|
||||
vi.mock("../offline-banner", () => ({ OfflineBanner: () => null }));
|
||||
vi.mock("../demo-banner", () => ({ DemoBanner: () => null }));
|
||||
vi.mock("../shared-record-banner", () => ({ SharedRecordBanner: () => null }));
|
||||
vi.mock("../shared-record-unavailable", () => ({
|
||||
SharedRecordUnavailable: () => null,
|
||||
}));
|
||||
vi.mock("@/components/i18n/maintainership-banner", () => ({
|
||||
MaintainershipBanner: () => null,
|
||||
}));
|
||||
vi.mock("@/components/gamification/achievement-unlock-notifier", () => ({
|
||||
AchievementUnlockNotifier: () => null,
|
||||
}));
|
||||
vi.mock("@/components/insights/layout-coach-fab", () => ({
|
||||
LayoutCoachFab: () => null,
|
||||
}));
|
||||
vi.mock("@/components/insights/layout-coach-mount", () => ({
|
||||
LayoutCoachMount: () => null,
|
||||
}));
|
||||
vi.mock("@/components/onboarding/tour-launcher", () => ({
|
||||
TourLauncher: () => null,
|
||||
}));
|
||||
|
||||
import { AuthShell } from "../auth-shell";
|
||||
// The proxy's list is the source of truth for what the edge lets through.
|
||||
import { PUBLIC_PATHS as PROXY_PUBLIC_PATHS } from "@/proxy";
|
||||
|
||||
/**
|
||||
* Prefixes the proxy admits that are NOT pages — route handlers with no
|
||||
* React tree, so `<AuthShell>` never mounts on them and has nothing to say
|
||||
* about them. Named explicitly rather than pattern-matched: a new non-page
|
||||
* public prefix should turn this file red once, so its author classifies it
|
||||
* here deliberately instead of a regex swallowing a real page.
|
||||
*/
|
||||
const NON_PAGE_PREFIXES = new Set([
|
||||
"/i18n/", // locale-catalog boot script
|
||||
"/robots.txt", // static text route
|
||||
]);
|
||||
|
||||
const PAGE_PREFIXES = PROXY_PUBLIC_PATHS.filter(
|
||||
(p) =>
|
||||
!p.startsWith("/api/") &&
|
||||
!p.startsWith("/.well-known") &&
|
||||
!NON_PAGE_PREFIXES.has(p),
|
||||
);
|
||||
|
||||
function render(pathname: string): string {
|
||||
mockPathRef.value = pathname;
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<AuthShell>
|
||||
<div data-slot="sentinel-page" />
|
||||
</AuthShell>
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A representative pathname under a prefix. A trailing-slash prefix admits
|
||||
* EVERY path beneath it at the edge, so the sample is a segment that no
|
||||
* narrower shell entry could be covering by accident — otherwise the guard
|
||||
* would pass on the one page that happens to exist there today and miss the
|
||||
* next one.
|
||||
*/
|
||||
function sampleFor(prefix: string): string {
|
||||
return prefix.endsWith("/") ? `${prefix}probe` : prefix;
|
||||
}
|
||||
|
||||
describe("AuthShell public paths agree with proxy.ts", () => {
|
||||
it("has page prefixes to check at all", () => {
|
||||
expect(PAGE_PREFIXES.length).toBeGreaterThan(0);
|
||||
expect(PAGE_PREFIXES).toContain("/invite/");
|
||||
});
|
||||
|
||||
it.each(PAGE_PREFIXES)(
|
||||
"renders the page for %s instead of the sign-in gate",
|
||||
(prefix) => {
|
||||
const markup = render(sampleFor(prefix));
|
||||
expect(markup).toContain('data-slot="sentinel-page"');
|
||||
},
|
||||
);
|
||||
|
||||
it("renders the invite landing for a real token path", () => {
|
||||
// The instance of the class: the operator's link, byte for byte.
|
||||
const markup = render(`/invite/hlv_${"a".repeat(64)}`);
|
||||
expect(markup).toContain('data-slot="sentinel-page"');
|
||||
});
|
||||
|
||||
it("still gates a protected route", () => {
|
||||
// Negative check — confirms the fixture would catch a real regression.
|
||||
const markup = render("/insights");
|
||||
expect(markup).not.toContain('data-slot="sentinel-page"');
|
||||
});
|
||||
});
|
||||
@@ -40,8 +40,12 @@ import { TopBar } from "./top-bar";
|
||||
// both lists must agree or the route round-trips through the
|
||||
// sign-in redirect.
|
||||
const PUBLIC_PATHS = [
|
||||
"/auth/login",
|
||||
"/auth/register",
|
||||
// The whole `/auth/` family, exactly as `proxy.ts` admits it. Listed as the
|
||||
// prefix rather than as `/auth/login` + `/auth/register`: the edge already
|
||||
// lets every path under it through without a session, so a future auth page
|
||||
// that the shell did not know about would mount the protected branch and
|
||||
// bounce its own visitor to the sign-in it is trying to replace.
|
||||
"/auth/",
|
||||
"/privacy",
|
||||
"/about",
|
||||
// v1.11.0 — the public clinician view renders its own standalone chrome
|
||||
@@ -49,6 +53,12 @@ const PUBLIC_PATHS = [
|
||||
// bare; the route is also in `proxy.ts` PUBLIC_PATHS so it never round-trips
|
||||
// the sign-in redirect.
|
||||
"/c/",
|
||||
// v1.17.0 — the invite universal-link landing is a server redirect onto
|
||||
// `/auth/register?invite=…`. It is public in `proxy.ts`, and it must be
|
||||
// public here too: Next streams this shell before the redirect resolves,
|
||||
// so without the entry the shell classifies the route as protected and
|
||||
// `router.replace("/auth/login")` wins the race against the redirect.
|
||||
"/invite/",
|
||||
];
|
||||
|
||||
export function AuthShell({
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* v1.39 — a failed completion leaves the confirm screen usable.
|
||||
*
|
||||
* Two defects, one symptom. `advance()` in the baseline form set its pending
|
||||
* flag and never reset it on the success path, and `finish()` on the confirm
|
||||
* screen caught its own error and returned normally — so a failed
|
||||
* `POST /api/onboarding/complete` (or a 429 on the answer PATCHes before it)
|
||||
* left "Überspringen" and "Bestätigen und weiter" disabled for the rest of the
|
||||
* page's life and only a reload recovered.
|
||||
*
|
||||
* The second defect is the retry itself: `finish()` fires one PATCH per
|
||||
* question the flow never showed, and a failure midway left a partly-passed
|
||||
* ledger that the next attempt walked from the top again.
|
||||
*
|
||||
* Both are pinned at the seam the component drives, because this repo's
|
||||
* component tests render server-side and cannot click.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
defaultOnboardingSteps,
|
||||
emptyOnboardingNeeds,
|
||||
type OnboardingStateDto,
|
||||
} from "@/lib/onboarding/needs";
|
||||
|
||||
import { runBaselineAttempt } from "../baseline-form";
|
||||
import { runConfirmFinish } from "../confirm-screen";
|
||||
|
||||
type PassableStep = Parameters<
|
||||
Parameters<typeof runConfirmFinish>[0]["passStep"]
|
||||
>[0];
|
||||
|
||||
/**
|
||||
* A flow with no unit-bearing area: Q6 is the question the machine never
|
||||
* showed, so the confirm screen has to pass it on the ledger before the
|
||||
* completion.
|
||||
*/
|
||||
function state(
|
||||
overrides: Partial<OnboardingStateDto> = {},
|
||||
): OnboardingStateDto {
|
||||
return {
|
||||
steps: defaultOnboardingSteps().map((step) =>
|
||||
step.id === "who" ? { ...step, status: "done" as const } : step,
|
||||
),
|
||||
needs: { ...emptyOnboardingNeeds(), recordTarget: "me" },
|
||||
completedAt: null,
|
||||
firstResult: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** The ledger as the server would answer it after one step was passed. */
|
||||
function withPassed(
|
||||
base: OnboardingStateDto,
|
||||
ids: readonly string[],
|
||||
): OnboardingStateDto {
|
||||
return {
|
||||
...base,
|
||||
steps: base.steps.map((step) =>
|
||||
ids.includes(step.id) ? { ...step, status: "skipped" as const } : step,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe("runBaselineAttempt", () => {
|
||||
it("hands the buttons back when the completion rejects", async () => {
|
||||
const pending: boolean[] = [];
|
||||
const onError = vi.fn();
|
||||
await runBaselineAttempt(
|
||||
(value) => pending.push(value),
|
||||
async () => {
|
||||
throw new Error("complete failed");
|
||||
},
|
||||
onError,
|
||||
);
|
||||
expect(pending).toEqual([true, false]);
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("hands the buttons back on the success path too", async () => {
|
||||
const pending: boolean[] = [];
|
||||
await runBaselineAttempt(
|
||||
(value) => pending.push(value),
|
||||
async () => {},
|
||||
vi.fn(),
|
||||
);
|
||||
expect(pending).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it("is the form's only way to move the pending flag", () => {
|
||||
// A second, inline `setSaving(false)` is how the flag stopped being
|
||||
// reset on one of the three exits in the first place.
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), "src/components/onboarding/baseline-form.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
expect(source).not.toContain("setSaving(false)");
|
||||
expect(source).not.toContain("setSaving(true)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runConfirmFinish", () => {
|
||||
it("rethrows a failed completion instead of returning normally", async () => {
|
||||
const ledger = { current: state() };
|
||||
const passed: PassableStep[] = [];
|
||||
const navigate = vi.fn();
|
||||
await expect(
|
||||
runConfirmFinish({
|
||||
ledger,
|
||||
// The server answers the written ledger; only the runner may put it
|
||||
// back, which is the whole point of the ref.
|
||||
passStep: async (step) => {
|
||||
passed.push(step);
|
||||
return withPassed(ledger.current, [step]);
|
||||
},
|
||||
complete: async () => {
|
||||
throw new Error("429");
|
||||
},
|
||||
navigate,
|
||||
}),
|
||||
).rejects.toThrow("429");
|
||||
// Q6 was never shown for a flow with no unit-bearing area, so it is
|
||||
// passed on the ledger before the completion — and that write survived.
|
||||
expect(passed).toEqual(["units"]);
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not pass a step the failed attempt already passed", async () => {
|
||||
const ledger = { current: state() };
|
||||
const passed: PassableStep[] = [];
|
||||
const passStep = async (step: PassableStep) => {
|
||||
passed.push(step);
|
||||
return withPassed(ledger.current, [step]);
|
||||
};
|
||||
let attempt = 0;
|
||||
const complete = async () => {
|
||||
attempt += 1;
|
||||
if (attempt === 1) throw new Error("429");
|
||||
return { onboarding: ledger.current };
|
||||
};
|
||||
const navigate = vi.fn();
|
||||
|
||||
await expect(
|
||||
runConfirmFinish({ ledger, passStep, complete, navigate }),
|
||||
).rejects.toThrow("429");
|
||||
await runConfirmFinish({ ledger, passStep, complete, navigate });
|
||||
|
||||
// "units" exactly once across both attempts; "first-result" is the
|
||||
// second attempt's own pass, because these answers offer no task.
|
||||
expect(passed).toEqual(["units", "first-result"]);
|
||||
expect(navigate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* v1.39 — what the confirm screen is allowed to claim.
|
||||
*
|
||||
* Both of the screen's own Settings links are page routes,
|
||||
* and `hl_onboarding=pending` is still set while this screen is on — the
|
||||
* completion is what clears it, and this screen is what calls the completion.
|
||||
* The proxy sends both straight back to the flow, so on a first run they are
|
||||
* dead links under a sentence that is not yet true.
|
||||
*
|
||||
* The screen listed what the answers switch ON and said
|
||||
* everything else "stays one click away under Settings, Modules" — true of
|
||||
* reachability, false of the navigation entry. A module the answers do not
|
||||
* ask for leaves the navigation, and nothing said so.
|
||||
*
|
||||
* Rendered server-side, this repo's component-test convention.
|
||||
*/
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { AuthUser } from "@/hooks/use-auth";
|
||||
import {
|
||||
defaultOnboardingSteps,
|
||||
emptyOnboardingNeeds,
|
||||
type OnboardingStateDto,
|
||||
} from "@/lib/onboarding/needs";
|
||||
|
||||
let currentUser: AuthUser | null = null;
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ user: currentUser }),
|
||||
useAccountOnceMounted: () => currentUser,
|
||||
}));
|
||||
|
||||
const { ConfirmScreen } = await import("../confirm-screen");
|
||||
|
||||
function state(
|
||||
overrides: Partial<OnboardingStateDto> = {},
|
||||
needs: Partial<OnboardingStateDto["needs"]> = {},
|
||||
): OnboardingStateDto {
|
||||
return {
|
||||
steps: defaultOnboardingSteps(),
|
||||
needs: { ...emptyOnboardingNeeds(), recordTarget: "me", ...needs },
|
||||
completedAt: null,
|
||||
firstResult: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function render(
|
||||
flow: OnboardingStateDto,
|
||||
modules: Partial<Record<string, boolean>> = {},
|
||||
demoMode = false,
|
||||
): string {
|
||||
currentUser = {
|
||||
id: "u1",
|
||||
heightCm: null,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
timezone: "Europe/Berlin",
|
||||
unitPreference: "metric",
|
||||
modules,
|
||||
} as unknown as AuthUser;
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<ConfirmScreen state={flow} demoMode={demoMode} />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<ConfirmScreen> — modules leaving the navigation", () => {
|
||||
it("names them when the answers do not ask for what the record shows", () => {
|
||||
const html = render(state({}, { areas: ["sleep"], medication: "no" }), {
|
||||
medications: true,
|
||||
});
|
||||
expect(html).toContain('data-slot="onboarding-confirm-modules-off"');
|
||||
expect(html).toContain("Medications");
|
||||
});
|
||||
|
||||
it("says nothing when the answers take nothing away", () => {
|
||||
const html = render(state({}, { areas: ["sleep"], medication: "yes" }), {
|
||||
medications: true,
|
||||
});
|
||||
expect(html).not.toContain('data-slot="onboarding-confirm-modules-off"');
|
||||
});
|
||||
|
||||
it("says nothing to a guardian, whose own record the answers never touch", () => {
|
||||
// These answers describe the record being set up for somebody else. The
|
||||
// completion applies the derivation to THAT record and stamps the
|
||||
// guardian's own without deriving, so no module of the guardian's is
|
||||
// going anywhere — and the sentence must not say one is.
|
||||
const html = render(
|
||||
state(
|
||||
{},
|
||||
{
|
||||
recordTarget: "someone-else",
|
||||
areas: ["blood-pressure"],
|
||||
medication: "no",
|
||||
},
|
||||
),
|
||||
{ medications: true, workouts: true, documents: true },
|
||||
);
|
||||
expect(html).not.toContain('data-slot="onboarding-confirm-modules-off"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<ConfirmScreen> — Settings links on a first run", () => {
|
||||
it("renders both as plain text while the completion has not run", () => {
|
||||
const html = render(state());
|
||||
expect(html).not.toContain('href="/settings/modules"');
|
||||
expect(html).not.toContain('href="/settings/account"');
|
||||
// The words stay: they say where the thing lives, which is true.
|
||||
expect(html).toContain("Settings, Modules");
|
||||
expect(html).toContain("Settings, Account");
|
||||
});
|
||||
|
||||
it("renders them as links once the flow has been completed once", () => {
|
||||
const html = render(state({ completedAt: "2026-09-10T08:00:00.000Z" }), {});
|
||||
expect(html).toContain('href="/settings/modules"');
|
||||
expect(html).toContain('href="/settings/account"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<ConfirmScreen> — the anamnesis card on a shared demo account", () => {
|
||||
it("takes free text on an ordinary instance", () => {
|
||||
const html = render(state());
|
||||
expect(html).toContain('data-slot="onboarding-anamnesis"');
|
||||
expect(html).not.toContain('data-readonly="true"');
|
||||
});
|
||||
|
||||
it("is read-only in the demo, whose account every visitor shares", () => {
|
||||
// `PUT /api/coach/about-me` is off the demo allowlist: the text persists
|
||||
// for the next visitor and is fed into the Coach prompt. Offering the
|
||||
// box and refusing the save would be the generic-failure toast this
|
||||
// flow exists to remove, so the box does not take input at all.
|
||||
const html = render(state(), {}, true);
|
||||
expect(html).toContain('data-slot="onboarding-anamnesis"');
|
||||
expect(html).toContain('data-readonly="true"');
|
||||
});
|
||||
|
||||
it("takes no display name in the demo either", () => {
|
||||
// `PUT /api/auth/profile` is allowlisted for the demo, but the server
|
||||
// drops every field outside height / date of birth / sex — the display
|
||||
// name would otherwise be the next visitor's. A box that accepts input
|
||||
// and silently discards it is the same dishonesty as a failed save, so
|
||||
// it does not accept input.
|
||||
const html = render(state(), {}, true);
|
||||
expect(html).toMatch(/id="ob-baseline-display-name"[^>]*disabled/);
|
||||
// The three the step exists for still take their answers.
|
||||
expect(html).not.toMatch(/id="ob-baseline-height"[^>]*disabled/);
|
||||
});
|
||||
|
||||
it("takes a display name on an ordinary instance", () => {
|
||||
const html = render(state());
|
||||
expect(html).not.toMatch(/id="ob-baseline-display-name"[^>]*disabled/);
|
||||
});
|
||||
});
|
||||
@@ -12,15 +12,52 @@ import { I18nProvider } from "@/lib/i18n/context";
|
||||
*/
|
||||
|
||||
const aiProviderState = vi.hoisted(() => ({
|
||||
data: undefined as { managedBy?: string | null } | undefined,
|
||||
data: undefined as
|
||||
| {
|
||||
managedBy?: string | null;
|
||||
serverProviderHealth?: string;
|
||||
serverProviderOffer?: boolean;
|
||||
serverProviderConsent?: boolean;
|
||||
}
|
||||
| undefined,
|
||||
}));
|
||||
|
||||
/**
|
||||
* The consent grant is a mutation, and this suite renders to static markup
|
||||
* (the house convention — there is no jsdom in this repo), so the click
|
||||
* itself cannot be dispatched. What IS worth proving is the request the
|
||||
* button would send, so every `useMutation` registration is captured here
|
||||
* and its `mutationFn` invoked directly.
|
||||
*/
|
||||
const mutations = vi.hoisted(
|
||||
() =>
|
||||
[] as Array<{
|
||||
mutationKey?: readonly unknown[];
|
||||
mutationFn?: () => Promise<unknown>;
|
||||
}>,
|
||||
);
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: aiProviderState.data }),
|
||||
useMutation: () => ({ mutate: () => {}, isPending: false }),
|
||||
useMutation: (options?: {
|
||||
mutationKey?: readonly unknown[];
|
||||
mutationFn?: () => Promise<unknown>;
|
||||
}) => {
|
||||
if (options) mutations.push(options);
|
||||
return { mutate: () => {}, mutateAsync: async () => {}, isPending: false };
|
||||
},
|
||||
useQueryClient: () => ({ invalidateQueries: async () => {} }),
|
||||
}));
|
||||
|
||||
const apiFetchRaw = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
apiFetchRaw,
|
||||
}));
|
||||
|
||||
const toastError = vi.hoisted(() => vi.fn());
|
||||
vi.mock("sonner", () => ({ toast: { error: toastError } }));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: () => {}, replace: () => {} }),
|
||||
}));
|
||||
@@ -110,11 +147,224 @@ describe("<DoneScreen> AI panel", () => {
|
||||
expect(render("both")).toContain('href="/settings/access"');
|
||||
expect(render("me")).not.toContain('href="/settings/access"');
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the shared-key note only when the operator key serves the user", () => {
|
||||
aiProviderState.data = { managedBy: "user" };
|
||||
expect(render()).not.toContain('data-slot="onboarding-ai-shared-key"');
|
||||
aiProviderState.data = { managedBy: "server" };
|
||||
expect(render()).toContain('data-slot="onboarding-ai-shared-key"');
|
||||
/**
|
||||
* v1.38.19 — the shared provider, offered only when it works.
|
||||
*
|
||||
* The card used to read `managedBy === "server"` and promise that insights
|
||||
* "work for you right now". That is a presence read: it knew a key was
|
||||
* configured, never that it worked, and it said nothing about the consent
|
||||
* receipt the egress actually needs — so a fresh account followed the
|
||||
* promise straight into a `consent.ai.required` refusal. On 2026-09-11 the
|
||||
* operator's own instance was answering HTTP 500 while the screen kept
|
||||
* making that promise.
|
||||
*
|
||||
* Four variants now, and only one of them has a button.
|
||||
*/
|
||||
function grantMutation() {
|
||||
return mutations.find((m) =>
|
||||
(m.mutationKey ?? []).some(
|
||||
(part) => typeof part === "string" && part.includes("consent"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe("<DoneScreen> shared-provider offer", () => {
|
||||
it("offers one tap when the server says the provider is known to work", () => {
|
||||
aiProviderState.data = {
|
||||
managedBy: "server",
|
||||
serverProviderHealth: "healthy",
|
||||
serverProviderOffer: true,
|
||||
serverProviderConsent: false,
|
||||
};
|
||||
const html = render();
|
||||
expect(html).toContain('data-ai-state="offer"');
|
||||
expect(html).toContain('data-slot="onboarding-ai-offer"');
|
||||
expect(html).toContain('data-slot="onboarding-ai-offer-grant"');
|
||||
// The unproven promise is gone from this branch.
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-shared-key"');
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-unavailable"');
|
||||
});
|
||||
|
||||
it("says the provider is not answering only when a failure earned it", () => {
|
||||
aiProviderState.data = {
|
||||
managedBy: "server",
|
||||
serverProviderHealth: "unhealthy",
|
||||
serverProviderOffer: false,
|
||||
serverProviderConsent: false,
|
||||
};
|
||||
const html = render();
|
||||
expect(html).toContain('data-ai-state="unavailable"');
|
||||
expect(html).toContain('data-slot="onboarding-ai-unavailable"');
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-offer-grant"');
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-shared-key"');
|
||||
// The rest of the panel is untouched in every branch.
|
||||
expect(html).toContain('data-slot="onboarding-ai-keyless"');
|
||||
expect(html).toContain('href="/settings/ai"');
|
||||
});
|
||||
|
||||
it("claims nothing either way when nobody has measured the provider", () => {
|
||||
// `unknown` is an empty `provider_health` table — the state of every
|
||||
// instance until its first AI call, and the most common way through this
|
||||
// screen. Saying "not answering" there is a claim about something nobody
|
||||
// measured, which is the one thing this panel exists not to do.
|
||||
aiProviderState.data = {
|
||||
managedBy: "server",
|
||||
serverProviderHealth: "unknown",
|
||||
serverProviderOffer: false,
|
||||
serverProviderConsent: false,
|
||||
};
|
||||
const html = render();
|
||||
expect(html).toContain('data-ai-state="neutral"');
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-unavailable"');
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-offer-grant"');
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-shared-key"');
|
||||
// The neutral panel is the whole panel, minus any claim about a provider.
|
||||
expect(html).toContain('data-slot="onboarding-ai-keyless"');
|
||||
expect(html).toContain('href="/settings/ai"');
|
||||
});
|
||||
|
||||
it("keeps the shared-key note for the one state it was ever true of", () => {
|
||||
aiProviderState.data = {
|
||||
managedBy: "server",
|
||||
serverProviderHealth: "healthy",
|
||||
serverProviderOffer: false,
|
||||
serverProviderConsent: true,
|
||||
};
|
||||
const html = render();
|
||||
expect(html).toContain('data-ai-state="consent"');
|
||||
expect(html).toContain('data-slot="onboarding-ai-shared-key"');
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-offer-grant"');
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-unavailable"');
|
||||
});
|
||||
|
||||
it("leaves a user with their own provider the setup link and nothing else", () => {
|
||||
aiProviderState.data = {
|
||||
managedBy: "user",
|
||||
serverProviderHealth: "healthy",
|
||||
serverProviderOffer: false,
|
||||
serverProviderConsent: false,
|
||||
};
|
||||
const html = render();
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-offer"');
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-unavailable"');
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-shared-key"');
|
||||
expect(html).toContain('href="/settings/ai"');
|
||||
});
|
||||
|
||||
it("says nothing at all while the status is still in flight", () => {
|
||||
aiProviderState.data = undefined;
|
||||
const html = render();
|
||||
// Not even the settled marker: the question is genuinely unanswered.
|
||||
expect(html).not.toContain("data-ai-state");
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-offer"');
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-unavailable"');
|
||||
});
|
||||
|
||||
/**
|
||||
* The whole decision table, in one place.
|
||||
*
|
||||
* Three arms the screen can be in — the offer is available, the offer is
|
||||
* withheld for a reason that is not health (the assistant surfaces switched
|
||||
* off, a managed profile), a receipt is already on file — crossed with the
|
||||
* three health states. The point of the cross is that HEALTH decides the
|
||||
* claim in every arm: the consent arm used to promise "insights work for
|
||||
* you right now" without reading health at all, which told somebody who had
|
||||
* consented that a dead provider was working. That is the same unmeasured
|
||||
* claim as the `unknown` case at the other end of the panel.
|
||||
*/
|
||||
const TABLE = [
|
||||
{ arm: "offerable", consent: false, health: "healthy", state: "offer" },
|
||||
{
|
||||
arm: "offerable",
|
||||
consent: false,
|
||||
health: "unhealthy",
|
||||
state: "unavailable",
|
||||
},
|
||||
{ arm: "offerable", consent: false, health: "unknown", state: "neutral" },
|
||||
{ arm: "withheld", consent: false, health: "healthy", state: "neutral" },
|
||||
{
|
||||
arm: "withheld",
|
||||
consent: false,
|
||||
health: "unhealthy",
|
||||
state: "unavailable",
|
||||
},
|
||||
{ arm: "withheld", consent: false, health: "unknown", state: "neutral" },
|
||||
{ arm: "consented", consent: true, health: "healthy", state: "consent" },
|
||||
{
|
||||
arm: "consented",
|
||||
consent: true,
|
||||
health: "unhealthy",
|
||||
state: "unavailable",
|
||||
},
|
||||
{ arm: "consented", consent: true, health: "unknown", state: "neutral" },
|
||||
] as const;
|
||||
|
||||
it.each(TABLE)(
|
||||
"settles on $state when the offer is $arm and the provider is $health",
|
||||
({ arm, consent, health, state }) => {
|
||||
aiProviderState.data = {
|
||||
managedBy: "server",
|
||||
serverProviderHealth: health,
|
||||
// The server only ever offers on `healthy`, and never with a receipt
|
||||
// on file; the withheld arm is the flags-off / managed-profile case.
|
||||
serverProviderOffer: arm === "offerable" && health === "healthy",
|
||||
serverProviderConsent: consent,
|
||||
};
|
||||
const html = render();
|
||||
expect(html).toContain(`data-ai-state="${state}"`);
|
||||
// The positive sentence appears in exactly one of the nine cells.
|
||||
expect(html.includes('data-slot="onboarding-ai-shared-key"')).toBe(
|
||||
state === "consent",
|
||||
);
|
||||
expect(html.includes('data-slot="onboarding-ai-unavailable"')).toBe(
|
||||
state === "unavailable",
|
||||
);
|
||||
expect(html.includes('data-slot="onboarding-ai-offer-grant"')).toBe(
|
||||
state === "offer",
|
||||
);
|
||||
// The rest of the panel is untouched in every cell.
|
||||
expect(html).toContain('data-slot="onboarding-ai-keyless"');
|
||||
expect(html).toContain('href="/settings/ai"');
|
||||
},
|
||||
);
|
||||
|
||||
it("grants through the affirmative web intent — the only path that may lift a revocation", async () => {
|
||||
mutations.length = 0;
|
||||
apiFetchRaw.mockReset();
|
||||
apiFetchRaw.mockResolvedValue({ ok: true });
|
||||
aiProviderState.data = {
|
||||
managedBy: "server",
|
||||
serverProviderHealth: "healthy",
|
||||
serverProviderOffer: true,
|
||||
serverProviderConsent: false,
|
||||
};
|
||||
render();
|
||||
const grant = grantMutation();
|
||||
expect(grant).toBeDefined();
|
||||
await grant!.mutationFn!();
|
||||
expect(apiFetchRaw).toHaveBeenCalledWith(
|
||||
"/api/consent/ai/web",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ intent: "affirmative" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("a refused grant throws rather than reporting a consent nobody recorded", async () => {
|
||||
mutations.length = 0;
|
||||
apiFetchRaw.mockReset();
|
||||
apiFetchRaw.mockResolvedValue({ ok: false, status: 429 });
|
||||
aiProviderState.data = {
|
||||
managedBy: "server",
|
||||
serverProviderHealth: "healthy",
|
||||
serverProviderOffer: true,
|
||||
serverProviderConsent: false,
|
||||
};
|
||||
render();
|
||||
const grant = grantMutation();
|
||||
await expect(grant!.mutationFn!()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* v1.38.19 — the connect arm of the one task
|
||||
* the flow ends on.
|
||||
*
|
||||
* This arm is the only place in the flow that renders a claim about real
|
||||
* account state, and it shipped without a test. It decided "connected" from
|
||||
* the ledger's `state` field, whose "no row" default was `connected` — so a
|
||||
* brand-new account that ticked WHOOP on Q4 was told WHOOP was connected and
|
||||
* had the flow's one result stamped as achieved on its behalf. It also had a
|
||||
* single piece of copy, "the first sync is in progress", for every outcome,
|
||||
* including the two that need the person to act.
|
||||
*
|
||||
* Vitest renders without a DOM here (project convention: SSR only), so what
|
||||
* is pinned is the first paint — which tile the screen paints for each of the
|
||||
* eight verdicts and what it says. Whether the result is RECORDED is decided
|
||||
* by `connectSourceView(...).settled` and pinned in
|
||||
* `src/lib/onboarding/__tests__/first-result-config.test.ts`, where the eight
|
||||
* verdicts are enumerated against the decision itself rather than its markup.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { SyncVerdict } from "@/lib/integrations/sync-verdict";
|
||||
import {
|
||||
defaultOnboardingSteps,
|
||||
emptyOnboardingNeeds,
|
||||
type OnboardingStateDto,
|
||||
} from "@/lib/onboarding/needs";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("../use-onboarding-flow", async () => {
|
||||
const actual = await vi.importActual<typeof import("../use-onboarding-flow")>(
|
||||
"../use-onboarding-flow",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useOnboardingAnswer: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue(undefined),
|
||||
isPending: false,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const envelope = vi.hoisted(() => ({
|
||||
value: undefined as { isLoading: boolean; data: unknown } | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/settings/integrations/shared", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/components/settings/integrations/shared")
|
||||
>("@/components/settings/integrations/shared");
|
||||
return {
|
||||
...actual,
|
||||
useIntegrationStatuses: () => envelope.value,
|
||||
};
|
||||
});
|
||||
|
||||
import { FirstResultScreen } from "../first-result-screen";
|
||||
|
||||
interface StatusOverrides {
|
||||
connected?: boolean;
|
||||
configured?: boolean;
|
||||
available?: boolean;
|
||||
state?: string;
|
||||
verdict?: SyncVerdict;
|
||||
since?: string | null;
|
||||
lastSuccessAt?: string | null;
|
||||
}
|
||||
|
||||
function statuses(overrides: StatusOverrides | null) {
|
||||
envelope.value =
|
||||
overrides === null
|
||||
? { isLoading: true, data: undefined }
|
||||
: {
|
||||
isLoading: false,
|
||||
data: {
|
||||
threshold: 3,
|
||||
integrations: [
|
||||
{
|
||||
integration: "whoop",
|
||||
connected: overrides.connected ?? false,
|
||||
configured: overrides.configured ?? true,
|
||||
available: overrides.available,
|
||||
state: overrides.state ?? "unknown",
|
||||
lastSuccessAt: overrides.lastSuccessAt ?? null,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
consecutiveFailuresByKind: null,
|
||||
syncHealth: {
|
||||
verdict: overrides.verdict ?? "disconnected",
|
||||
since: overrides.since ?? null,
|
||||
},
|
||||
metricFreshness: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function state(
|
||||
needs: Partial<OnboardingStateDto["needs"]> = {},
|
||||
): OnboardingStateDto {
|
||||
return {
|
||||
steps: defaultOnboardingSteps(),
|
||||
needs: {
|
||||
...emptyOnboardingNeeds(),
|
||||
recordTarget: "me",
|
||||
sources: ["whoop"],
|
||||
...needs,
|
||||
},
|
||||
completedAt: null,
|
||||
firstResult: null,
|
||||
};
|
||||
}
|
||||
|
||||
function render(
|
||||
overrides: StatusOverrides | null,
|
||||
needs: Partial<OnboardingStateDto["needs"]> = {},
|
||||
) {
|
||||
statuses(overrides);
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<FirstResultScreen state={state(needs)} />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const VERDICT_SLOT = [
|
||||
["disconnected", "connect"],
|
||||
["pending_first_sync", "result"],
|
||||
["fresh", "result"],
|
||||
["stale", "result"],
|
||||
["stalled", "attention"],
|
||||
["failing", "attention"],
|
||||
["reauth_required", "attention"],
|
||||
["parked", "attention"],
|
||||
] as const;
|
||||
|
||||
describe("<FirstResultScreen> — the connect arm", () => {
|
||||
it.each(VERDICT_SLOT)(
|
||||
"paints the %s verdict on the %s tile",
|
||||
(verdict, slot) => {
|
||||
const html = render({
|
||||
connected: verdict !== "disconnected",
|
||||
verdict,
|
||||
since: "2026-09-01T00:00:00.000Z",
|
||||
lastSuccessAt: "2026-09-01T00:00:00.000Z",
|
||||
});
|
||||
expect(html).toContain(`data-connect-slot="${slot}"`);
|
||||
},
|
||||
);
|
||||
|
||||
it("never claims a connection for a fresh account's empty ledger", () => {
|
||||
// The exact envelope a brand-new account publishes: no row for the
|
||||
// provider, so the ledger state is the synthetic `unknown`.
|
||||
const html = render({ connected: false, state: "unknown" });
|
||||
expect(html).toContain('data-connect-slot="connect"');
|
||||
expect(html).not.toContain("is connected");
|
||||
expect(html).not.toContain("The first sync is in progress");
|
||||
});
|
||||
|
||||
it("says the first sync is running only while it actually is", () => {
|
||||
expect(
|
||||
render({ connected: true, verdict: "pending_first_sync" }),
|
||||
).toContain("The first sync is in progress");
|
||||
expect(
|
||||
render({
|
||||
connected: true,
|
||||
verdict: "fresh",
|
||||
lastSuccessAt: "2026-09-01T00:00:00.000Z",
|
||||
}),
|
||||
).not.toContain("The first sync is in progress");
|
||||
});
|
||||
|
||||
it("names the state a connection that needs repairing is in", () => {
|
||||
const html = render({
|
||||
connected: true,
|
||||
verdict: "reauth_required",
|
||||
since: "2026-09-01T00:00:00.000Z",
|
||||
});
|
||||
expect(html).toContain("needs renewing");
|
||||
expect(html).toContain("/settings/integrations#whoop");
|
||||
});
|
||||
|
||||
it("points a source the instance cannot connect at the credentials", () => {
|
||||
const html = render({ connected: false, configured: false });
|
||||
expect(html).toContain('data-connect-slot="credentials"');
|
||||
expect(html).toContain("no WHOOP app on file yet");
|
||||
});
|
||||
|
||||
it("decides nothing while the status envelope is still in flight", () => {
|
||||
const html = render(null);
|
||||
expect(html).not.toContain("data-connect-slot=");
|
||||
expect(html).toContain('data-slot="onboarding-task-checking"');
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.38.19 — the heading is chosen from the answers alone on
|
||||
* the server, because no status is in hand there. Once the envelope has
|
||||
* resolved the screen re-picks: a source that is already delivering is not
|
||||
* a task, and "Connect WHOOP" over a WHOOP that has been feeding the
|
||||
* dashboard for months is the flow talking past the account it can see.
|
||||
*/
|
||||
it("stops offering a source that is already delivering", () => {
|
||||
const html = render(
|
||||
{
|
||||
connected: true,
|
||||
verdict: "fresh",
|
||||
lastSuccessAt: "2026-09-10T06:35:00.000Z",
|
||||
},
|
||||
{ medication: "yes" },
|
||||
);
|
||||
expect(html).toContain('data-task="add-medication"');
|
||||
expect(html).not.toContain("Connect WHOOP");
|
||||
});
|
||||
|
||||
it("acknowledges the connection when there is nothing to fall through to", () => {
|
||||
// The whole answer is one already-connected source. Dropping the step
|
||||
// would end the flow on nothing at all, so the screen keeps it and says
|
||||
// what it found — and that is the result it records.
|
||||
const html = render({
|
||||
connected: true,
|
||||
verdict: "fresh",
|
||||
lastSuccessAt: "2026-09-10T06:35:00.000Z",
|
||||
});
|
||||
expect(html).toContain('data-task="connect-source"');
|
||||
expect(html).toContain('data-connect-state="fresh"');
|
||||
expect(html).toContain("WHOOP is already connected");
|
||||
});
|
||||
|
||||
it("keeps the answer's heading while the envelope is in flight", () => {
|
||||
const html = render(null, { medication: "yes" });
|
||||
expect(html).toContain('data-task="connect-source"');
|
||||
expect(html).toContain("Connect WHOOP");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* v1.39 — the checklist's data queries follow the same rule as
|
||||
* its visibility.
|
||||
*
|
||||
* `shouldShowChecklist` gained `hasEnteredOnboardingFlow` as a third
|
||||
* disjunct; `checklistRelevant` in the component kept restating the older
|
||||
* half of the rule, so a wizard-run account past five readings still saw the
|
||||
* card while every supporting query was `enabled: false` — the medication,
|
||||
* data-source, notification and insights rows read "not done" for things the
|
||||
* account had already done.
|
||||
*
|
||||
* Rendered SSR-only, per this repo's component-test convention. `useQuery` is
|
||||
* mocked to honour `enabled` exactly the way tanstack does — a disabled query
|
||||
* has no data — so what the rows say here is what the dashboard says.
|
||||
*/
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { AuthUser } from "@/hooks/use-auth";
|
||||
import {
|
||||
defaultOnboardingSteps,
|
||||
emptyOnboardingNeeds,
|
||||
type OnboardingStateDto,
|
||||
} from "@/lib/onboarding/needs";
|
||||
|
||||
let currentUser: AuthUser | null = null;
|
||||
/** Every `enabled` flag the component asked for, keyed by its query key. */
|
||||
const enabledByKey = new Map<string, boolean>();
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAccountOnceMounted: () => currentUser,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/queries/use-dashboard-snapshot", () => ({
|
||||
useDashboardSnapshot: () => ({
|
||||
data: { tiles: { summaries: { weight: { count: 12 } } } },
|
||||
}),
|
||||
}));
|
||||
|
||||
const RESPONSES: Record<string, unknown> = {
|
||||
'["medications"]': [{ id: "m1" }, { id: "m2" }, { id: "m3" }],
|
||||
'["user","ai-provider"]': { aiAvailable: true },
|
||||
'["integrations","status"]': {
|
||||
integrations: [{ integration: "whoop", connected: true }],
|
||||
},
|
||||
// Deliberately NOT configured: one open row keeps the card on screen, so
|
||||
// the rows this test is about are actually rendered.
|
||||
'["notifications","preferences"]': { channels: [{ enabled: false }] },
|
||||
};
|
||||
|
||||
vi.mock("@tanstack/react-query", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@tanstack/react-query")>();
|
||||
return {
|
||||
...actual,
|
||||
useQuery: (options: {
|
||||
queryKey: readonly unknown[];
|
||||
enabled?: boolean;
|
||||
}) => {
|
||||
const key = JSON.stringify(options.queryKey);
|
||||
const enabled = options.enabled !== false;
|
||||
enabledByKey.set(key, enabled);
|
||||
return { data: enabled ? RESPONSES[key] : undefined };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { GettingStartedChecklist } =
|
||||
await import("../getting-started-checklist");
|
||||
|
||||
/** A record the flow really finished: Q1 answered, confirmed, task settled. */
|
||||
function settledOnboarding(): OnboardingStateDto {
|
||||
return {
|
||||
steps: defaultOnboardingSteps().map((step) => ({
|
||||
...step,
|
||||
status: "done" as const,
|
||||
})),
|
||||
needs: { ...emptyOnboardingNeeds(), recordTarget: "me" },
|
||||
completedAt: "2026-09-10T08:00:00.000Z",
|
||||
firstResult: {
|
||||
task: "log-reading",
|
||||
target: null,
|
||||
completedAt: "2026-09-10T08:05:00.000Z",
|
||||
},
|
||||
} as OnboardingStateDto;
|
||||
}
|
||||
|
||||
function user(overrides: Partial<AuthUser> = {}): AuthUser {
|
||||
return {
|
||||
id: "u1",
|
||||
email: "a@b.c",
|
||||
username: "a",
|
||||
heightCm: 175,
|
||||
dateOfBirth: "1990-01-01T00:00:00.000Z",
|
||||
gender: "MALE",
|
||||
onboardingCompletedAt: "2026-09-10T08:00:00.000Z",
|
||||
onboarding: settledOnboarding(),
|
||||
...overrides,
|
||||
} as AuthUser;
|
||||
}
|
||||
|
||||
function render(): string {
|
||||
enabledByKey.clear();
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<GettingStartedChecklist />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<GettingStartedChecklist> — rows for an established account", () => {
|
||||
it("reads the real medication, data-source and insights state once the flow is behind the account", () => {
|
||||
currentUser = user();
|
||||
const html = render();
|
||||
|
||||
// The card is on screen: the notifications row is genuinely open. The
|
||||
// list itself is collapsed on a server paint, so the progress meter is
|
||||
// what the rows add up to — profile, measurement, medication, data
|
||||
// source and insights done, notifications not.
|
||||
expect(html).toContain('data-testid="onboarding-card"');
|
||||
expect(html).toContain("5 of 6 done");
|
||||
});
|
||||
|
||||
it("keeps the supporting queries enabled while the card can render", () => {
|
||||
currentUser = user();
|
||||
render();
|
||||
expect(enabledByKey.get('["medications"]')).toBe(true);
|
||||
expect(enabledByKey.get('["integrations","status"]')).toBe(true);
|
||||
expect(enabledByKey.get('["notifications","preferences"]')).toBe(true);
|
||||
expect(enabledByKey.get('["user","ai-provider"]')).toBe(true);
|
||||
});
|
||||
|
||||
it("still fetches nothing for a record that never entered the flow and is long past five readings", () => {
|
||||
currentUser = user({ onboarding: null });
|
||||
render();
|
||||
expect(enabledByKey.get('["medications"]')).toBe(false);
|
||||
expect(enabledByKey.get('["integrations","status"]')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* v1.39 — Q6 cannot be answered with nothing chosen.
|
||||
*
|
||||
* "Weiter" used to be live on an untouched units screen: it wrote
|
||||
* `{ units: {} }`, which marked the step DONE without recording a preference,
|
||||
* so the account carried "asked and answered" for a question nobody answered.
|
||||
* Skip is the way past a question with no opinion, and Skip says so on the
|
||||
* ledger.
|
||||
*
|
||||
* Rendered server-side, this repo's component-test convention.
|
||||
*/
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
defaultOnboardingSteps,
|
||||
emptyOnboardingNeeds,
|
||||
type OnboardingStateDto,
|
||||
} from "@/lib/onboarding/needs";
|
||||
|
||||
import { UnitsScreen } from "../units-screen";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ user: null }),
|
||||
}));
|
||||
|
||||
function state(
|
||||
needs: Partial<OnboardingStateDto["needs"]>,
|
||||
): OnboardingStateDto {
|
||||
return {
|
||||
steps: defaultOnboardingSteps(),
|
||||
needs: { ...emptyOnboardingNeeds(), recordTarget: "me", ...needs },
|
||||
completedAt: null,
|
||||
firstResult: null,
|
||||
};
|
||||
}
|
||||
|
||||
function render(needs: Partial<OnboardingStateDto["needs"]>): string {
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<UnitsScreen state={state(needs)} />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the primary button's own tag disabled? Read off the tag rather than the
|
||||
* whole page, and off the attribute rather than the string "disabled", which
|
||||
* also occurs in the button's `disabled:` utility classes.
|
||||
*/
|
||||
function nextIsDisabled(html: string): boolean {
|
||||
const at = html.indexOf('data-slot="onboarding-next"');
|
||||
expect(at).toBeGreaterThan(-1);
|
||||
const open = html.lastIndexOf("<button", at);
|
||||
return html.slice(open, html.indexOf(">", at) + 1).includes('disabled=""');
|
||||
}
|
||||
|
||||
describe("<UnitsScreen>", () => {
|
||||
it("does not let the person continue without choosing a unit", () => {
|
||||
const html = render({ areas: ["glucose"] });
|
||||
expect(nextIsDisabled(html)).toBe(true);
|
||||
// Skip is still there: it is the honest way past a question with no
|
||||
// opinion, and it says "skipped" on the ledger.
|
||||
expect(html).toContain('data-slot="onboarding-skip"');
|
||||
});
|
||||
|
||||
it("lets the person continue once a unit is chosen", () => {
|
||||
const html = render({
|
||||
areas: ["glucose"],
|
||||
units: { glucoseUnit: "mg/dL", unitPreference: null },
|
||||
});
|
||||
expect(nextIsDisabled(html)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,16 @@ export interface AnamnesisCardProps {
|
||||
value: AnamnesisValue;
|
||||
onChange: (next: AnamnesisValue) => void;
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* v1.39 — the instance cannot persist what is typed here, so nothing may
|
||||
* be typed. True under `DEMO_MODE`: the demo is one published account
|
||||
* every visitor signs into, and `PUT /api/coach/about-me` is off the
|
||||
* demo's edge allowlist because this text persists for the next visitor
|
||||
* and is fed into the Coach prompt. The card still shows what it asks —
|
||||
* the demo walks the same screen — it just does not take an answer, which
|
||||
* is honest where a refused save on Confirm would not be.
|
||||
*/
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
/** Per-field cap mirrors ABOUT_ME_FIELD_MAX_CHARS on the server. */
|
||||
@@ -67,6 +77,7 @@ export function AnamnesisCard({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
readOnly = false,
|
||||
}: AnamnesisCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
@@ -75,7 +86,11 @@ export function AnamnesisCard({
|
||||
const panelId = useId();
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border">
|
||||
<div
|
||||
className="bg-card border-border rounded-xl border"
|
||||
data-slot="onboarding-anamnesis"
|
||||
data-readonly={readOnly ? "true" : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
@@ -122,7 +137,7 @@ export function AnamnesisCard({
|
||||
onChange={(e) =>
|
||||
onChange({ ...value, conditions: e.target.value })
|
||||
}
|
||||
disabled={disabled}
|
||||
disabled={disabled || readOnly}
|
||||
maxLength={FIELD_MAX}
|
||||
rows={2}
|
||||
placeholder={t("onboarding.anamnesis.conditionsPlaceholder")}
|
||||
@@ -142,7 +157,7 @@ export function AnamnesisCard({
|
||||
onChange={(e) =>
|
||||
onChange({ ...value, allergies: e.target.value })
|
||||
}
|
||||
disabled={disabled}
|
||||
disabled={disabled || readOnly}
|
||||
maxLength={FIELD_MAX}
|
||||
rows={2}
|
||||
placeholder={t("onboarding.anamnesis.allergiesPlaceholder")}
|
||||
|
||||
@@ -60,6 +60,7 @@ export function BaselineFields({
|
||||
onChange,
|
||||
heightAdapter,
|
||||
errors,
|
||||
readOnlyDisplayName = false,
|
||||
}: {
|
||||
value: BaselineFieldValues;
|
||||
onChange: <K extends keyof BaselineFieldValues>(
|
||||
@@ -68,6 +69,15 @@ export function BaselineFields({
|
||||
) => void;
|
||||
heightAdapter: HeightUnitAdapter;
|
||||
errors: BaselineFieldErrors;
|
||||
/**
|
||||
* v1.39 — the instance cannot persist a display name, so the box does not
|
||||
* take one. True under `DEMO_MODE`, where `applyProfileUpdate` writes only
|
||||
* height, date of birth and sex: the demo's single published account means
|
||||
* a display name typed here would be the next visitor's. A field that
|
||||
* accepts input and silently discards it is the same dishonesty as a save
|
||||
* that fails, which is why it is disabled rather than merely ignored.
|
||||
*/
|
||||
readOnlyDisplayName?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
@@ -85,6 +95,7 @@ export function BaselineFields({
|
||||
id={IDS.displayName}
|
||||
value={value.displayName}
|
||||
onChange={(e) => onChange("displayName", e.target.value)}
|
||||
disabled={readOnlyDisplayName}
|
||||
autoComplete="nickname"
|
||||
maxLength={50}
|
||||
placeholder={t("onboarding.baseline.displayNamePlaceholder")}
|
||||
|
||||
@@ -71,11 +71,43 @@ interface BaselineFormState {
|
||||
gender: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.39 — one attempt at confirming, with the buttons always
|
||||
* handed back.
|
||||
*
|
||||
* `advance()` used to raise its own pending flag and lower it again on two of
|
||||
* the three exits: the refused-field return and its catch. The third exit —
|
||||
* the success path, `await onConfirmed()` — never lowered it, and
|
||||
* `onConfirmed` is the confirm screen's completion, which caught its own
|
||||
* error and returned normally. So a failed completion disabled "Skip" and
|
||||
* "Confirm and continue" for the rest of the page's life and only a reload
|
||||
* recovered.
|
||||
*
|
||||
* The reset lives in a `finally` here, which is the one place it cannot be
|
||||
* forgotten on a new exit, and the component has no other way to move the
|
||||
* flag.
|
||||
*/
|
||||
export async function runBaselineAttempt(
|
||||
setPending: (pending: boolean) => void,
|
||||
attempt: () => Promise<void>,
|
||||
onError: (err: unknown) => void,
|
||||
): Promise<void> {
|
||||
setPending(true);
|
||||
try {
|
||||
await attempt();
|
||||
} catch (err) {
|
||||
onError(err);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
export function BaselineForm({
|
||||
initial,
|
||||
backHref,
|
||||
onConfirmed,
|
||||
confirming = false,
|
||||
demoMode = false,
|
||||
}: {
|
||||
initial: BaselineInitialValues;
|
||||
backHref?: string;
|
||||
@@ -83,6 +115,14 @@ export function BaselineForm({
|
||||
onConfirmed: () => Promise<void>;
|
||||
/** True while the completion the parent owns is in flight. */
|
||||
confirming?: boolean;
|
||||
/**
|
||||
* True on a `DEMO_MODE` instance, where the shared account's self-context
|
||||
* write is off the edge allowlist and its profile write is narrowed to the
|
||||
* three baseline fields. Makes the anamnesis card and the display-name box
|
||||
* read-only, so neither refusal reaches a visitor as a failed confirm or as
|
||||
* an answer that silently vanished.
|
||||
*/
|
||||
demoMode?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { preference } = useUnitDisplay();
|
||||
@@ -152,50 +192,50 @@ export function BaselineForm({
|
||||
|
||||
async function advance(opts: { saveProfile: boolean }) {
|
||||
if (saving || confirming) return;
|
||||
setSaving(true);
|
||||
setFieldErrors({});
|
||||
try {
|
||||
if (opts.saveProfile) {
|
||||
const profileBody = buildBaselineProfileBody(
|
||||
form,
|
||||
heightAdapter.toCanonicalCm(form.height),
|
||||
);
|
||||
if (Object.keys(profileBody).length > 0) {
|
||||
const outcome = describeBaselineSaveOutcome(
|
||||
await putBaselineProfile(profileBody),
|
||||
t,
|
||||
baselineFieldLabelKeys(heightAdapter.usesFeetInches),
|
||||
await runBaselineAttempt(
|
||||
setSaving,
|
||||
async () => {
|
||||
if (opts.saveProfile) {
|
||||
const profileBody = buildBaselineProfileBody(
|
||||
form,
|
||||
heightAdapter.toCanonicalCm(form.height),
|
||||
);
|
||||
if (outcome.notice) {
|
||||
const show =
|
||||
outcome.notice.tone === "warning" ? toast.warning : toast.error;
|
||||
show(outcome.notice.message);
|
||||
if (Object.keys(profileBody).length > 0) {
|
||||
const outcome = describeBaselineSaveOutcome(
|
||||
await putBaselineProfile(profileBody),
|
||||
t,
|
||||
baselineFieldLabelKeys(heightAdapter.usesFeetInches),
|
||||
);
|
||||
if (outcome.notice) {
|
||||
const show =
|
||||
outcome.notice.tone === "warning" ? toast.warning : toast.error;
|
||||
show(outcome.notice.message);
|
||||
}
|
||||
if (!outcome.advance) {
|
||||
// A field was refused. Staying on the step is the point —
|
||||
// each refused input now says why under itself, the values
|
||||
// the person typed are still in front of them, and the
|
||||
// account is not stamped as set up over a value that never
|
||||
// landed.
|
||||
setFieldErrors(outcome.fieldErrors);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!outcome.advance) {
|
||||
// A field was refused. Staying on the step is the point —
|
||||
// each refused input now says why under itself, the values
|
||||
// the person typed are still in front of them, and the
|
||||
// account is not stamped as set up over a value that never
|
||||
// landed.
|
||||
setFieldErrors(outcome.fieldErrors);
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Anamnesis — only write when the user actually filled a field
|
||||
// (the helper returns null for an untouched card, so a
|
||||
// collapsed card never round-trips).
|
||||
const aboutMeBody = buildAnamnesisAboutMeBody(baseAboutMe, anamnesis);
|
||||
if (aboutMeBody) {
|
||||
await apiPut("/api/coach/about-me", aboutMeBody);
|
||||
// Anamnesis — only write when the user actually filled a field
|
||||
// (the helper returns null for an untouched card, so a
|
||||
// collapsed card never round-trips).
|
||||
const aboutMeBody = buildAnamnesisAboutMeBody(baseAboutMe, anamnesis);
|
||||
if (aboutMeBody) {
|
||||
await apiPut("/api/coach/about-me", aboutMeBody);
|
||||
}
|
||||
}
|
||||
}
|
||||
await onConfirmed();
|
||||
} catch (err) {
|
||||
toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
|
||||
setSaving(false);
|
||||
}
|
||||
await onConfirmed();
|
||||
},
|
||||
(err) =>
|
||||
toast.error(localizedApiError(err, t, "onboarding.errorGeneric")),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -215,12 +255,14 @@ export function BaselineForm({
|
||||
onChange={patch}
|
||||
heightAdapter={heightAdapter}
|
||||
errors={fieldErrors}
|
||||
readOnlyDisplayName={demoMode}
|
||||
/>
|
||||
|
||||
<AnamnesisCard
|
||||
value={anamnesis}
|
||||
onChange={setAnamnesis}
|
||||
disabled={saving}
|
||||
readOnly={demoMode}
|
||||
/>
|
||||
|
||||
<StepActions
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
@@ -20,7 +20,14 @@ import { localeLabels } from "@/lib/i18n/config";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { MODULE_REGISTRY } from "@/lib/modules/registry";
|
||||
import { confirmedModules } from "@/lib/onboarding/confirm-summary";
|
||||
import type { OnboardingStateDto } from "@/lib/onboarding/needs";
|
||||
import {
|
||||
ONBOARDING_SKIPPABLE_STEP_IDS,
|
||||
type OnboardingStateDto,
|
||||
} from "@/lib/onboarding/needs";
|
||||
|
||||
/** A step the confirm screen may pass on the ledger without an answer. */
|
||||
type PassableStepId = (typeof ONBOARDING_SKIPPABLE_STEP_IDS)[number];
|
||||
import { isOnboardingSettled } from "@/lib/onboarding/needs";
|
||||
import {
|
||||
firstResultApplies,
|
||||
nextScreen,
|
||||
@@ -51,17 +58,97 @@ import {
|
||||
* offer, otherwise done. When there is no task, the first-result step is
|
||||
* passed explicitly so the ledger reads as settled rather than owed.
|
||||
*/
|
||||
export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
|
||||
/**
|
||||
* v1.39 — the completion sequence, apart from the component so
|
||||
* a failure partway through it can be pinned without a browser.
|
||||
*
|
||||
* Passes every question the flow never showed (the route derives only once
|
||||
* EVERY question is answered or passed, and a step nobody was asked cannot be
|
||||
* answered), completes, passes the first-result step when there is no task to
|
||||
* offer, and navigates. It reports a failure by THROWING — the caller owns the
|
||||
* buttons and the toast.
|
||||
*/
|
||||
export async function runConfirmFinish(args: {
|
||||
/** The ledger as the last write left it; updated in place as steps pass. */
|
||||
ledger: { current: OnboardingStateDto };
|
||||
managedRecordId?: string;
|
||||
passStep: (step: PassableStepId) => Promise<OnboardingStateDto>;
|
||||
complete: (
|
||||
managedRecordId?: string,
|
||||
) => Promise<{ onboarding?: OnboardingStateDto | null }>;
|
||||
navigate: (href: string) => void;
|
||||
}): Promise<void> {
|
||||
for (const id of questionsToPassBeforeConfirm(args.ledger.current)) {
|
||||
args.ledger.current = await args.passStep(id);
|
||||
}
|
||||
const { onboarding } = await args.complete(args.managedRecordId);
|
||||
let written = onboarding ?? args.ledger.current;
|
||||
if (!firstResultApplies(written)) {
|
||||
// Nothing to offer, so the step is passed rather than left owed: a
|
||||
// pending step would keep the setup reading as unfinished forever.
|
||||
written = await args.passStep("first-result");
|
||||
}
|
||||
args.ledger.current = written;
|
||||
args.navigate(screenHref(nextScreen(written, "confirm") ?? "done"));
|
||||
}
|
||||
|
||||
export function ConfirmScreen({
|
||||
state,
|
||||
demoMode = false,
|
||||
}: {
|
||||
state: OnboardingStateDto;
|
||||
/**
|
||||
* True on a `DEMO_MODE` instance. Resolved by the page (a server
|
||||
* component) from the server-only env var and threaded down, the way the
|
||||
* root layout threads it into the shell — no client-side detection and no
|
||||
* extra request. The baseline step's anamnesis card is read-only there.
|
||||
*/
|
||||
demoMode?: boolean;
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const complete = useOnboardingComplete();
|
||||
const answer = useOnboardingAnswer();
|
||||
const [finishing, setFinishing] = useState(false);
|
||||
/**
|
||||
* v1.39 — the ledger as the last write left it.
|
||||
* `finish()` sends one PATCH per question the flow never showed; a failure
|
||||
* midway used to leave a partly-passed ledger that the next attempt walked
|
||||
* from the top again, because the `state` prop it read was the one this
|
||||
* screen was rendered with. Every mutation answers the written state, so
|
||||
* the ref carries the progress across attempts and
|
||||
* `questionsToPassBeforeConfirm` — which lists pending steps only — then
|
||||
* skips what the failed attempt already passed.
|
||||
*/
|
||||
const ledger = useRef(state);
|
||||
|
||||
const needs = state.needs;
|
||||
/**
|
||||
* v1.39 — is `hl_onboarding=pending` still set?
|
||||
*
|
||||
* Both of this screen's Settings links are page routes, and the cookie is
|
||||
* cleared by the completion — which is what THIS screen calls. So on a
|
||||
* first run the proxy 307s both straight back into the flow: two links
|
||||
* that cannot go anywhere, under a sentence saying where things live. The
|
||||
* words are true, the links are not, so only the links go.
|
||||
*/
|
||||
const settingsReachable =
|
||||
isOnboardingSettled(state) || state.completedAt !== null;
|
||||
const forSomeoneElse = needs.recordTarget === "someone-else";
|
||||
const { chosen, alwaysOn } = confirmedModules(needs);
|
||||
// `user.modules` is the map the navigation reads, so the screen can only
|
||||
// name a module the person actually has today — and only when the answers
|
||||
// are about that record. In the "someone I look after" arm they are not:
|
||||
// `POST /api/onboarding/complete` applies the derivation to the managed
|
||||
// record (or, when none was created, to nothing at all) and stamps the
|
||||
// guardian's own record without deriving. Reading the actor's map there
|
||||
// would name modules that are staying exactly where they are — the same
|
||||
// guardian-derivation confusion v1.38.18 closed in the write, re-entering
|
||||
// through the copy. An empty map makes the list empty.
|
||||
const { chosen, alwaysOn, wouldSwitchOff } = confirmedModules(
|
||||
needs,
|
||||
forSomeoneElse ? {} : (user?.modules ?? {}),
|
||||
);
|
||||
const back = previousScreen(state, "confirm");
|
||||
|
||||
const glucoseUnit = needs.units.glucoseUnit ?? user?.glucoseUnit ?? null;
|
||||
@@ -73,37 +160,39 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
|
||||
* after" just created: the route then applies the derivation to THAT
|
||||
* record and stamps this one complete without deriving, so the guardian's
|
||||
* own modules are never re-ordered around the child's answers.
|
||||
*
|
||||
* v1.39 — this RETHROWS. `<BaselineForm>` owns the two
|
||||
* buttons and releases them in its own `finally`, and it can only do that
|
||||
* for a failure it is told about: the previous version caught, toasted and
|
||||
* returned normally, which left both buttons disabled until a reload.
|
||||
* The two managed-arm callers, which have no form around them, go through
|
||||
* `finishReporting` below.
|
||||
*/
|
||||
async function finish(managedRecordId?: string) {
|
||||
if (finishing) return;
|
||||
setFinishing(true);
|
||||
try {
|
||||
// A question this flow never showed — Q6 for somebody with no
|
||||
// unit-bearing area — is passed on the ledger before the completion,
|
||||
// because the route derives only once EVERY question is answered or
|
||||
// passed, and a step nobody was asked cannot be answered.
|
||||
for (const id of questionsToPassBeforeConfirm(state)) {
|
||||
await answer.mutateAsync({ step: id, status: "skipped" });
|
||||
}
|
||||
const { onboarding } = await complete.mutateAsync(
|
||||
managedRecordId ? { managedRecordId } : undefined,
|
||||
);
|
||||
let written = onboarding ?? state;
|
||||
if (!firstResultApplies(written)) {
|
||||
// Nothing to offer, so the step is passed rather than left owed:
|
||||
// a pending step would keep the setup reading as unfinished forever.
|
||||
written = await answer.mutateAsync({
|
||||
step: "first-result",
|
||||
status: "skipped",
|
||||
});
|
||||
}
|
||||
router.push(screenHref(nextScreen(written, "confirm") ?? "done"));
|
||||
await runConfirmFinish({
|
||||
ledger,
|
||||
managedRecordId,
|
||||
passStep: (step) => answer.mutateAsync({ step, status: "skipped" }),
|
||||
complete: (id) =>
|
||||
complete.mutateAsync(id ? { managedRecordId: id } : undefined),
|
||||
navigate: (href) => router.push(href),
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
|
||||
setFinishing(false);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** The managed arm's two buttons: nothing above them reports a failure. */
|
||||
function finishReporting(managedRecordId?: string) {
|
||||
void finish(managedRecordId).catch((err) => {
|
||||
toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
|
||||
});
|
||||
}
|
||||
|
||||
const moduleNames = (keys: readonly (keyof typeof MODULE_REGISTRY)[]) =>
|
||||
keys.map((key) => t(MODULE_REGISTRY[key].labelKey)).join(", ");
|
||||
|
||||
@@ -129,6 +218,13 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
|
||||
})
|
||||
: t("onboarding.flow.confirm.nothingExtra")}
|
||||
</p>
|
||||
{wouldSwitchOff.length > 0 ? (
|
||||
<p className="text-sm" data-slot="onboarding-confirm-modules-off">
|
||||
{t("onboarding.flow.confirm.switchesOff", {
|
||||
modules: moduleNames(wouldSwitchOff),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("onboarding.flow.confirm.alwaysOn", {
|
||||
modules: moduleNames(alwaysOn),
|
||||
@@ -136,12 +232,18 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{t("onboarding.flow.confirm.everythingElse")}{" "}
|
||||
<Link
|
||||
href="/settings/modules"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
{t("onboarding.flow.confirm.modulesLink")}
|
||||
</Link>
|
||||
{settingsReachable ? (
|
||||
<Link
|
||||
href="/settings/modules"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
{t("onboarding.flow.confirm.modulesLink")}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-medium">
|
||||
{t("onboarding.flow.confirm.modulesLink")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -176,12 +278,18 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
|
||||
) : null}
|
||||
</dl>
|
||||
<p className="text-sm">
|
||||
<Link
|
||||
href="/settings/account"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
{t("onboarding.flow.confirm.changeInSettings")}
|
||||
</Link>
|
||||
{settingsReachable ? (
|
||||
<Link
|
||||
href="/settings/account"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
{t("onboarding.flow.confirm.changeInSettings")}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{t("onboarding.flow.confirm.changeInSettings")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{forSomeoneElse ? (
|
||||
@@ -196,7 +304,7 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
|
||||
</div>
|
||||
<ManagedProfileCreateForm
|
||||
submitLabel={t("onboarding.flow.confirm.managedCreate")}
|
||||
onCreated={(profile) => void finish(profile.id)}
|
||||
onCreated={(profile) => finishReporting(profile.id)}
|
||||
/>
|
||||
<p className="text-sm">{t("onboarding.flow.confirm.managedLater")}</p>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 pt-2">
|
||||
@@ -212,7 +320,7 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void finish()}
|
||||
onClick={() => finishReporting()}
|
||||
disabled={finishing}
|
||||
className="min-h-11"
|
||||
data-slot="onboarding-finish-without-profile"
|
||||
@@ -231,6 +339,7 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
|
||||
}}
|
||||
onConfirmed={finish}
|
||||
confirming={finishing}
|
||||
demoMode={demoMode}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Compass,
|
||||
@@ -24,7 +25,7 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { useAccountSwitch } from "@/hooks/use-account-switch";
|
||||
import { setTourReferrer } from "@/components/onboarding/tour-launcher";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { markChecklistExpanded } from "@/lib/onboarding/checklist-storage";
|
||||
import type { OnboardingStateDto } from "@/lib/onboarding/needs";
|
||||
@@ -33,6 +34,16 @@ import { accountLabel } from "@/lib/sharing/account-access-view";
|
||||
interface AiProviderStatus {
|
||||
/** Origin of the provider that would serve this user, if any. */
|
||||
managedBy?: "user" | "local" | "server" | null;
|
||||
/**
|
||||
* v1.38.19 — the instance-wide tri-state. Fail closed for the
|
||||
* offer: neither `unhealthy` nor `unknown` earns one. Only `unhealthy` is
|
||||
* a verdict this screen may state out loud.
|
||||
*/
|
||||
serverProviderHealth?: "healthy" | "unhealthy" | "unknown";
|
||||
/** Whether the shared provider may honestly be offered in one tap. */
|
||||
serverProviderOffer?: boolean;
|
||||
/** Whether this user already holds an active `ai_full` / `ai_coach` receipt. */
|
||||
serverProviderConsent?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,10 +112,21 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
|
||||
router.push("/");
|
||||
}
|
||||
|
||||
// The shared-key note is the ONLY honest divergence in the panel: on a
|
||||
// deployment that ships an operator key, insights already work for the
|
||||
// user, so we say so plainly instead of pushing a BYOK/local setup they
|
||||
// don't need. Presence-only read; no key material is exposed.
|
||||
// ── v1.38.19 — the shared provider, offered only when it works ──
|
||||
//
|
||||
// This block used to be one line: `managedBy === "server"` painted a note
|
||||
// reading "insights work for you right now — no setup needed". Both halves
|
||||
// were wrong. `managedBy` is a PRESENCE read — it knows the operator
|
||||
// configured a key, never that the key answers — and "no setup needed"
|
||||
// skipped the consent receipt `consent-guard.ts` demands before any health
|
||||
// data reaches `admin-openai` / `admin-codex`, so the person who believed
|
||||
// the note walked into a `consent.ai.required` refusal on their first
|
||||
// briefing. On 2026-09-11 the operator's own instance was answering HTTP
|
||||
// 500 from its OAuth proxy while this note kept promising otherwise.
|
||||
//
|
||||
// The server decides now, and the four branches below only READ it. The
|
||||
// rest of the panel — sample, ladder, the "fully useful without AI" line,
|
||||
// the setup link — is identical in every branch; nothing here is a gate.
|
||||
const { data: aiProvider } = useQuery<AiProviderStatus>({
|
||||
queryKey: queryKeys.userAiProvider(),
|
||||
queryFn: async () => {
|
||||
@@ -112,7 +134,72 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
|
||||
},
|
||||
enabled: !!user,
|
||||
});
|
||||
const sharedKeyServes = aiProvider?.managedBy === "server";
|
||||
|
||||
// The tap's own outcome, held locally so the line changes the moment the
|
||||
// receipt is minted rather than a refetch later.
|
||||
const [justGranted, setJustGranted] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const grantConsent = useMutation({
|
||||
mutationKey: queryKeys.aiConsentReceipt("ai_full"),
|
||||
mutationFn: async () => {
|
||||
// `intent: "affirmative"` marks this as the user's own consent act —
|
||||
// the only web path allowed to supersede an earlier withdrawal. The
|
||||
// silent mount heal posts no body and deliberately cannot.
|
||||
const res = await apiFetchRaw("/api/consent/ai/web", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ intent: "affirmative" }),
|
||||
});
|
||||
if (!res.ok) throw new Error("consent grant failed");
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setJustGranted(true);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.userAiProvider(),
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.aiConsentReceipt("ai_full"),
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
// The button stays. A failed grant is a failed grant; the screen must
|
||||
// not read as though consent were on file.
|
||||
toast.error(t("onboarding.ai.offer.error"));
|
||||
},
|
||||
});
|
||||
|
||||
const operatorProvides = aiProvider?.managedBy === "server";
|
||||
const sharedHealth = aiProvider?.serverProviderHealth;
|
||||
const consentOnFile =
|
||||
justGranted || aiProvider?.serverProviderConsent === true;
|
||||
const offerShared =
|
||||
operatorProvides &&
|
||||
!consentOnFile &&
|
||||
aiProvider?.serverProviderOffer === true;
|
||||
// A receipt on file is a fact about the ACCOUNT; it says nothing about
|
||||
// whether the provider works. This line used to promise "insights work for
|
||||
// you right now" on the strength of the receipt alone, so somebody who had
|
||||
// just consented was told a dead provider was serving them — the same
|
||||
// unmeasured claim as the offer's, made at the other end of the panel. The
|
||||
// positive sentence is now earned by health as well, and a consented
|
||||
// account on a broken provider reads the same honest note as anybody else.
|
||||
const sharedKeyNote =
|
||||
operatorProvides && consentOnFile && sharedHealth === "healthy";
|
||||
// Only a health verdict somebody EARNED paints the "not answering" card —
|
||||
// `unhealthy`, which means the provider was tried and the last thing that
|
||||
// happened was a failure. `unknown` is not that verdict: it is an empty
|
||||
// ledger, which is the state of every instance until its first AI call and
|
||||
// therefore the most common way through this screen. It falls to the
|
||||
// neutral panel, as does every other non-health reason the offer is
|
||||
// withheld (the assistant surfaces switched off, a managed profile whose
|
||||
// provider is the operator's by definition). The neutral panel claims
|
||||
// nothing either way, which is the only honest thing to say about a state
|
||||
// nobody measured.
|
||||
const sharedUnavailable =
|
||||
operatorProvides &&
|
||||
!sharedKeyNote &&
|
||||
!offerShared &&
|
||||
sharedHealth === "unhealthy";
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -148,6 +235,22 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
|
||||
<section
|
||||
aria-labelledby="onboarding-ai-panel-title"
|
||||
data-slot="onboarding-ai-panel"
|
||||
/* Which of the four variants the panel SETTLED on, and absent until
|
||||
it has settled. The panel itself is unconditional and paints
|
||||
immediately, so without this an assertion that a variant is absent
|
||||
passes on a status that has simply not arrived yet. The value is
|
||||
the same tri-state decision made once, above. */
|
||||
data-ai-state={
|
||||
aiProvider === undefined
|
||||
? undefined
|
||||
: sharedKeyNote
|
||||
? "consent"
|
||||
: offerShared
|
||||
? "offer"
|
||||
: sharedUnavailable
|
||||
? "unavailable"
|
||||
: "neutral"
|
||||
}
|
||||
className="border-border bg-card mx-auto flex w-full max-w-md flex-col gap-4 rounded-xl border p-4 text-left md:p-6"
|
||||
>
|
||||
<header className="flex items-start gap-3">
|
||||
@@ -185,15 +288,55 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{sharedKeyServes ? (
|
||||
{sharedKeyNote ? (
|
||||
<p
|
||||
data-slot="onboarding-ai-shared-key"
|
||||
className="text-foreground bg-primary/5 border-primary/20 rounded-lg border px-3 py-2 text-sm leading-relaxed"
|
||||
>
|
||||
{t("onboarding.ai.sharedKeyNote")}
|
||||
{justGranted
|
||||
? t("onboarding.ai.offer.granted")
|
||||
: t("onboarding.ai.sharedKeyNote")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{offerShared ? (
|
||||
<div
|
||||
data-slot="onboarding-ai-offer"
|
||||
className="border-primary/20 bg-primary/5 space-y-2.5 rounded-lg border px-3 py-3"
|
||||
>
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("onboarding.ai.offer.title")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{t("onboarding.ai.offer.body")}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-9 w-full"
|
||||
data-slot="onboarding-ai-offer-grant"
|
||||
disabled={grantConsent.isPending}
|
||||
onClick={() => grantConsent.mutate()}
|
||||
>
|
||||
{t("onboarding.ai.offer.cta")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{sharedUnavailable ? (
|
||||
<div
|
||||
data-slot="onboarding-ai-unavailable"
|
||||
className="border-border bg-muted/40 space-y-1 rounded-lg border px-3 py-3"
|
||||
>
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("onboarding.ai.unavailable.title")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{t("onboarding.ai.unavailable.body")}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("onboarding.ai.ladderTitle")}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -33,7 +33,10 @@ import type { OnboardingAreaKey } from "@/lib/modules/registry";
|
||||
import {
|
||||
AREA_PAGE_HREF,
|
||||
AREA_READING_TARGETS,
|
||||
connectSourceView,
|
||||
deliveringSources,
|
||||
SOURCE_INTEGRATION,
|
||||
type ConnectSourceSlot,
|
||||
} from "@/lib/onboarding/first-result-config";
|
||||
import type { OnboardingStateDto } from "@/lib/onboarding/needs";
|
||||
import { questionOptionLabelKey } from "@/lib/onboarding/question-config";
|
||||
@@ -68,7 +71,23 @@ export function FirstResultScreen({ state }: { state: OnboardingStateDto }) {
|
||||
const { t } = useTranslations();
|
||||
const router = useRouter();
|
||||
const answer = useOnboardingAnswer();
|
||||
const offer = chooseFirstResultTask(state.needs);
|
||||
// The server picked the order from the answers alone — it had no status in
|
||||
// hand. Re-pick here once the envelope resolves: a source that is already
|
||||
// delivering is not a task, and offering to connect it is the flow talking
|
||||
// past an account it can see. The same query key the task body reads, so
|
||||
// this costs no second request.
|
||||
const statuses = useIntegrationStatuses(true);
|
||||
const delivering = useMemo(
|
||||
() => deliveringSources(statuses.data?.integrations),
|
||||
[statuses.data],
|
||||
);
|
||||
// …but only where there is somewhere to fall to. A person whose one answer
|
||||
// is a source that is already delivering still gets an end to the flow: the
|
||||
// screen acknowledges the connection it found rather than dropping the step
|
||||
// and ending on nothing.
|
||||
const offer =
|
||||
chooseFirstResultTask(state.needs, delivering) ??
|
||||
chooseFirstResultTask(state.needs);
|
||||
const back = previousScreen(state, "first-result");
|
||||
|
||||
// Completed only when the LEDGER says so as well: a restart puts the step
|
||||
@@ -193,32 +212,92 @@ function ConnectSourceTask({
|
||||
onComplete: (target: string | null) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { anchor, statusKey } = SOURCE_INTEGRATION[source];
|
||||
const label = t(questionOptionLabelKey("sources", source));
|
||||
// Refetches on window focus, so coming back from the integrations tab
|
||||
// after the handshake picks the fresh verdict up on its own.
|
||||
const statuses = useIntegrationStatuses(true);
|
||||
const status = pickStatus(statuses.data, statusKey as IntegrationKey);
|
||||
const connected = status?.connected === true || status?.state === "connected";
|
||||
// The liveness truth is the server-resolved verdict and nothing else. The
|
||||
// ledger's `state` is what the last ATTEMPT did, and its "no row" default
|
||||
// read as `connected` until v1.38.19 — which is how a brand-new account was
|
||||
// told its wearable was connected, and had this step stamped as achieved on
|
||||
// its behalf. `null` means the envelope has not resolved: no claim, no tile.
|
||||
const view = connectSourceView(source, status);
|
||||
const settled = view?.settled ?? false;
|
||||
|
||||
// Record the result once the connection is there — once. `completed` flips
|
||||
// only after the write resolves, and the status envelope can re-render this
|
||||
// in between, so a ref latches the first call.
|
||||
// Record the result once the connection is really there — once. `completed`
|
||||
// flips only after the write resolves, and the status envelope can re-render
|
||||
// this in between, so a ref latches the first call.
|
||||
const recorded = useRef(completed);
|
||||
useEffect(() => {
|
||||
if (connected && !recorded.current) {
|
||||
if (settled && !recorded.current) {
|
||||
recorded.current = true;
|
||||
void onComplete(source);
|
||||
}
|
||||
}, [connected, onComplete, source]);
|
||||
}, [settled, onComplete, source]);
|
||||
|
||||
if (completed || connected) {
|
||||
const settingsHref = `/settings/integrations#${anchor}`;
|
||||
|
||||
if (!view) {
|
||||
// Nothing is known yet. The heading is the person's own answer, so it
|
||||
// stays; the body says only that the check is running.
|
||||
return (
|
||||
<p
|
||||
className="text-muted-foreground text-sm"
|
||||
role="status"
|
||||
data-slot="onboarding-task-checking"
|
||||
>
|
||||
{t("onboarding.flow.first-result.connect-source.checking")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const key = `onboarding.flow.first-result.connect-source.state.${view.verdict}`;
|
||||
const when = view.when
|
||||
? fmt.dateTime(view.when)
|
||||
: t("onboarding.flow.first-result.connect-source.noDate");
|
||||
|
||||
if (view.slot === "result") {
|
||||
return (
|
||||
<ResultTile
|
||||
title={t("onboarding.flow.first-result.connect-source.done", {
|
||||
target: label,
|
||||
})}
|
||||
detail={t("onboarding.flow.first-result.connect-source.syncing")}
|
||||
title={t(`${key}.title`, { target: label })}
|
||||
detail={t(`${key}.detail`, { when })}
|
||||
connect={{ slot: view.slot, state: view.verdict }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (view.slot === "attention") {
|
||||
return (
|
||||
<ConnectNoticeTile
|
||||
slot={view.slot}
|
||||
state={view.verdict}
|
||||
testSlot="onboarding-task-attention"
|
||||
title={t(`${key}.title`, { target: label })}
|
||||
detail={t(`${key}.detail`, { when })}
|
||||
href={settingsHref}
|
||||
cta={t("onboarding.flow.first-result.connect-source.openSettings")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (view.slot === "credentials") {
|
||||
return (
|
||||
<ConnectNoticeTile
|
||||
slot={view.slot}
|
||||
state={view.verdict}
|
||||
testSlot="onboarding-task-credentials"
|
||||
title={t(
|
||||
"onboarding.flow.first-result.connect-source.needsCredentials.title",
|
||||
{ target: label },
|
||||
)}
|
||||
detail={t(
|
||||
"onboarding.flow.first-result.connect-source.needsCredentials.detail",
|
||||
)}
|
||||
href={settingsHref}
|
||||
cta={t("onboarding.flow.first-result.connect-source.openSettings")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -227,6 +306,8 @@ function ConnectSourceTask({
|
||||
<div
|
||||
className="bg-card border-border space-y-4 rounded-xl border p-4 md:p-6"
|
||||
data-slot="onboarding-task-connect"
|
||||
data-connect-slot={view.slot}
|
||||
data-connect-state={view.verdict}
|
||||
>
|
||||
<p className="text-sm">
|
||||
{t("onboarding.flow.first-result.connect-source.howTo", {
|
||||
@@ -234,21 +315,70 @@ function ConnectSourceTask({
|
||||
})}
|
||||
</p>
|
||||
<Button asChild className="min-h-11">
|
||||
<Link href={`/settings/integrations#${anchor}`}>
|
||||
<Link href={settingsHref}>
|
||||
{t("onboarding.flow.first-result.connect-source.cta", {
|
||||
target: label,
|
||||
})}
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="text-muted-foreground text-xs" aria-live="polite">
|
||||
{statuses.isLoading
|
||||
? t("onboarding.flow.first-result.connect-source.checking")
|
||||
: t("onboarding.flow.first-result.connect-source.waiting")}
|
||||
{t("onboarding.flow.first-result.connect-source.waiting")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A connection the flow cannot treat as its result: one that needs repairing
|
||||
* (`attention`) or one the instance cannot start at all (`credentials`). Both
|
||||
* say which state it is in and link at the one page that can change it —
|
||||
* never a "Connect" button onto a card that has none.
|
||||
*/
|
||||
function ConnectNoticeTile({
|
||||
slot,
|
||||
state,
|
||||
testSlot,
|
||||
title,
|
||||
detail,
|
||||
href,
|
||||
cta,
|
||||
}: {
|
||||
slot: ConnectSourceSlot;
|
||||
state: string;
|
||||
testSlot: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
href: string;
|
||||
cta: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="bg-card border-border space-y-3 rounded-xl border p-4 md:p-6"
|
||||
data-slot={testSlot}
|
||||
data-connect-slot={slot}
|
||||
data-connect-state={state}
|
||||
>
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
<p className="text-muted-foreground text-sm">{detail}</p>
|
||||
{/* This CTA names a PATH — "Settings → Integrations" — which is the
|
||||
longest label shape in the flow and the only button in the tree that
|
||||
carries one. The button base is `whitespace-nowrap` with a fixed
|
||||
height and `shrink-0`, so at 390 px a German or French rendering ran
|
||||
27 px past the viewport instead of shrinking or breaking. It is
|
||||
bounded to the card (`w-full`), allowed to break between the words
|
||||
of the path (`whitespace-normal`) and allowed to grow for the second
|
||||
line (`h-auto`, with the 44 px tap target kept by `min-h-11`). */}
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="h-auto min-h-11 w-full py-2 text-center whitespace-normal"
|
||||
>
|
||||
<Link href={href}>{cta}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── add a medication ──────────────────────────────────────────────────── */
|
||||
|
||||
interface MedicationDetail {
|
||||
@@ -508,11 +638,22 @@ function LatestReadingTile({
|
||||
* itself sits under it in content colour, because it is the reading, the
|
||||
* medication or the connection the person came for.
|
||||
*/
|
||||
function ResultTile({ title, detail }: { title: string; detail?: string }) {
|
||||
function ResultTile({
|
||||
title,
|
||||
detail,
|
||||
connect,
|
||||
}: {
|
||||
title: string;
|
||||
detail?: string;
|
||||
/** Set by the connect arm so a journey can read which verdict it painted. */
|
||||
connect?: { slot: ConnectSourceSlot; state: string };
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="bg-card border-border space-y-2 rounded-xl border p-4 md:p-6"
|
||||
data-slot="onboarding-first-result-done"
|
||||
data-connect-slot={connect?.slot}
|
||||
data-connect-state={connect?.state}
|
||||
>
|
||||
<WrittenOutcomeLine
|
||||
outcome="success"
|
||||
|
||||
@@ -26,6 +26,7 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
buildChecklist,
|
||||
checklistProgress,
|
||||
isStillInSetup,
|
||||
shouldShowChecklist,
|
||||
upcomingVisitCountFrom,
|
||||
visibleChecklist,
|
||||
@@ -239,17 +240,27 @@ export function GettingStartedChecklist() {
|
||||
}, [snapshotData]);
|
||||
|
||||
// v1.15.20 — only fetch the checklist's supporting data while the card
|
||||
// can actually render: the user is still in setup (`shouldShowChecklist`'s
|
||||
// `onboardingCompletedAt == null || measurementCount < 5` rule) and has
|
||||
// not dismissed it. Established users used to pay this medications fetch
|
||||
// (and, pre-v1.5, two more) on every dashboard load for a card that
|
||||
// never mounts. Gated on the snapshot having resolved so the count is
|
||||
// real, not the loading-default 0.
|
||||
// can actually render: the user is still in setup and has not dismissed
|
||||
// it. Established users used to pay this medications fetch (and, pre-v1.5,
|
||||
// two more) on every dashboard load for a card that never mounts. Gated on
|
||||
// the snapshot having resolved so the count is real, not the
|
||||
// loading-default 0.
|
||||
//
|
||||
// v1.39 — the rule is `isStillInSetup`, the same predicate
|
||||
// `shouldShowChecklist` applies, rather than a restatement of half of it.
|
||||
// The two drifted apart once already: the visibility rule learned about
|
||||
// records that ran the setup flow and this gate did not, so the card
|
||||
// rendered for a wizard-run account past five readings with every
|
||||
// network-backed row frozen at "not done".
|
||||
const checklistRelevant =
|
||||
!!user &&
|
||||
!dismissedAll &&
|
||||
snapshotData !== undefined &&
|
||||
(user.onboardingCompletedAt == null || measurementCount < 5);
|
||||
isStillInSetup({
|
||||
onboarding: user.onboarding ?? null,
|
||||
onboardingCompletedAt: user.onboardingCompletedAt ?? null,
|
||||
measurementCount,
|
||||
});
|
||||
|
||||
const { data: medsData } = useQuery<Array<{ id: string }>>({
|
||||
queryKey: queryKeys.medications(),
|
||||
|
||||
@@ -54,9 +54,15 @@ export function UnitsScreen({ state }: { state: OnboardingStateDto }) {
|
||||
|
||||
const back = previousScreen(state, "units");
|
||||
|
||||
const answerBody = unitsAnswerBody({ asked, glucoseUnit, unitPreference });
|
||||
|
||||
async function persist(
|
||||
input:
|
||||
ReturnType<typeof unitsAnswerBody> | { step: "units"; status: "skipped" },
|
||||
| NonNullable<ReturnType<typeof unitsAnswerBody>>
|
||||
| {
|
||||
step: "units";
|
||||
status: "skipped";
|
||||
},
|
||||
) {
|
||||
try {
|
||||
const written = await answer.mutateAsync(input);
|
||||
@@ -121,9 +127,10 @@ export function UnitsScreen({ state }: { state: OnboardingStateDto }) {
|
||||
<StepActions
|
||||
backHref={back ? screenHref(back) : undefined}
|
||||
onSkip={() => void persist({ step: "units", status: "skipped" })}
|
||||
onNext={() =>
|
||||
void persist(unitsAnswerBody({ asked, glucoseUnit, unitPreference }))
|
||||
}
|
||||
onNext={() => {
|
||||
if (answerBody) void persist(answerBody);
|
||||
}}
|
||||
nextDisabled={answerBody === null}
|
||||
pending={answer.isPending}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* The grant card says where the invitation will turn up.
|
||||
*
|
||||
* A sharing invitation carries no link and nothing is e-mailed: it is
|
||||
* addressed to a username or e-mail of an account that already exists here,
|
||||
* and the person accepts it from inside their own session (`POST
|
||||
* /api/account/grants/[id]/accept`). The card said the first half — the input
|
||||
* hint already names the account requirement — and left the second half
|
||||
* unsaid, so an inviter who then goes looking for a link to send has nothing
|
||||
* to find and no reason to stop looking.
|
||||
*
|
||||
* SSR-only, per project convention.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", username: "owner", modules: {} },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { GrantInviteCard } from "../grant-invite-card";
|
||||
import en from "../../../../../messages/en.json";
|
||||
|
||||
function render(): string {
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<GrantInviteCard />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("grant invite card — the invitee needs an account here", () => {
|
||||
it("says where the invited person will find the invitation", () => {
|
||||
const markup = render();
|
||||
expect(markup).toContain(en.recordSharing.invite.needsAccount);
|
||||
});
|
||||
|
||||
it("names Settings → Shared access rather than a link to send", () => {
|
||||
expect(en.recordSharing.invite.needsAccount).toMatch(/Settings/);
|
||||
expect(en.recordSharing.invite.needsAccount).not.toMatch(/https?:/);
|
||||
});
|
||||
});
|
||||
@@ -150,7 +150,14 @@ export function GrantInviteCard() {
|
||||
<SettingsCardHeader
|
||||
icon={UserPlus}
|
||||
title={t("recordSharing.invite.title")}
|
||||
description={t("recordSharing.invite.description")}
|
||||
// A sharing invitation carries no link and nothing is e-mailed: the
|
||||
// person accepts it from inside their own session. Said here, at the
|
||||
// top of the card, so an inviter never goes looking for a link to
|
||||
// send — the invite-link surface is Admin → Invites, and it is a
|
||||
// different thing entirely.
|
||||
description={`${t("recordSharing.invite.description")} ${t(
|
||||
"recordSharing.invite.needsAccount",
|
||||
)}`}
|
||||
/>
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
IntegrationErrorMessage,
|
||||
pillFailurePropsFor,
|
||||
pillStateForVerdict,
|
||||
type IntegrationState,
|
||||
} from "./shared";
|
||||
import {
|
||||
CallbackMismatchNotice,
|
||||
@@ -78,12 +79,12 @@ export interface OAuthProviderStatus {
|
||||
available: boolean;
|
||||
/** Whether the user has stored their own BYO client id/secret pair. */
|
||||
hasOwnCredentials?: boolean;
|
||||
state?:
|
||||
| "connected"
|
||||
| "error_transient"
|
||||
| "error_reauth"
|
||||
| "disconnected"
|
||||
| "parked";
|
||||
/**
|
||||
* The raw ledger state, carried for shape parity with the envelope. The
|
||||
* card paints from `syncHealth.verdict`, never from this — which is why
|
||||
* v1.38.19's `unknown` member needs no arm here.
|
||||
*/
|
||||
state?: IntegrationState;
|
||||
lastSuccessAt?: string | null;
|
||||
lastAttemptAt?: string | null;
|
||||
lastError?: string | null;
|
||||
|
||||
@@ -36,8 +36,16 @@ export type IntegrationKey =
|
||||
// Nightscout folds onto the envelope so the card stops fetching its own
|
||||
// status and inherits the shared verdict.
|
||||
| "nightscout";
|
||||
/** Mirrors `IntegrationState` in `src/lib/integrations/status.ts`. */
|
||||
export type IntegrationState =
|
||||
"connected" | "error_transient" | "error_reauth" | "disconnected" | "parked";
|
||||
| "connected"
|
||||
| "error_transient"
|
||||
| "error_reauth"
|
||||
| "disconnected"
|
||||
| "parked"
|
||||
// v1.38.19 — no ledger row: the ledger has no history for this provider.
|
||||
// Read like `disconnected`; `syncHealth.verdict` owns liveness.
|
||||
| "unknown";
|
||||
|
||||
export interface IntegrationStatusViewModel {
|
||||
integration: IntegrationKey;
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* v1.38.19 — the instance-wide health of the operator's shared
|
||||
* provider, as a tri-state a FRESH account is allowed to read.
|
||||
*
|
||||
* The ledger itself is per-user and fails OPEN (an optimisation, never a
|
||||
* gate). This projection is the opposite on both counts: it folds rows
|
||||
* across every account into one instance-level verdict, and it fails
|
||||
* CLOSED — anything but a demonstrated recent success is not an offer.
|
||||
*
|
||||
* What it must never leak: how many accounts are tracked, when any of them
|
||||
* last succeeded, or which account a row belongs to. The return type is the
|
||||
* whole contract.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
providerHealth: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
SERVER_PROVIDER_FRESH_WINDOW_MS,
|
||||
readServerProviderHealth,
|
||||
} from "../server-provider-health";
|
||||
|
||||
const NOW = new Date("2026-09-11T12:00:00.000Z");
|
||||
|
||||
function ago(ms: number): Date {
|
||||
return new Date(NOW.getTime() - ms);
|
||||
}
|
||||
|
||||
function ahead(ms: number): Date {
|
||||
return new Date(NOW.getTime() + ms);
|
||||
}
|
||||
|
||||
function rows(
|
||||
...list: Array<{
|
||||
lastResult: string;
|
||||
lastOkAt?: Date | null;
|
||||
nextRetryAt?: Date | null;
|
||||
}>
|
||||
) {
|
||||
vi.mocked(prisma.providerHealth.findMany).mockResolvedValue(
|
||||
list.map((row) => ({
|
||||
lastResult: row.lastResult,
|
||||
lastOkAt: row.lastOkAt ?? null,
|
||||
nextRetryAt: row.nextRetryAt ?? null,
|
||||
})) as never,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(prisma.providerHealth.findMany).mockReset();
|
||||
vi.mocked(annotate).mockReset();
|
||||
});
|
||||
|
||||
describe("readServerProviderHealth", () => {
|
||||
it("is healthy when some account saw the shared provider work inside the window", async () => {
|
||||
rows({ lastResult: "ok", lastOkAt: ago(60 * 60 * 1000) });
|
||||
await expect(readServerProviderHealth(NOW)).resolves.toBe("healthy");
|
||||
});
|
||||
|
||||
it("is unknown when the last outcome was a success, just not a recent one", async () => {
|
||||
// A quiet single-person instance nobody asked anything for two days has
|
||||
// not observed a failure. "It is not working" would be a claim about
|
||||
// something that was never measured; the offer is withheld either way.
|
||||
rows({
|
||||
lastResult: "ok",
|
||||
lastOkAt: ago(SERVER_PROVIDER_FRESH_WINDOW_MS + 60_000),
|
||||
});
|
||||
await expect(readServerProviderHealth(NOW)).resolves.toBe("unknown");
|
||||
});
|
||||
|
||||
it("is unhealthy when rows exist but none ever succeeded", async () => {
|
||||
rows({ lastResult: "hard_failed", lastOkAt: null });
|
||||
await expect(readServerProviderHealth(NOW)).resolves.toBe("unhealthy");
|
||||
});
|
||||
|
||||
it("is unhealthy when a stale success is the only thing beside a failure", async () => {
|
||||
// Somebody tried and it failed, and no success has contradicted that
|
||||
// since. That verdict was earned and the card may state it.
|
||||
rows(
|
||||
{ lastResult: "ok", lastOkAt: ago(SERVER_PROVIDER_FRESH_WINDOW_MS * 2) },
|
||||
{ lastResult: "hard_failed", lastOkAt: null },
|
||||
);
|
||||
await expect(readServerProviderHealth(NOW)).resolves.toBe("unhealthy");
|
||||
});
|
||||
|
||||
it("lets a 401 in cooldown beat a fresh ok — a dead key means the same for everyone", async () => {
|
||||
rows(
|
||||
{ lastResult: "ok", lastOkAt: ago(60 * 1000) },
|
||||
{
|
||||
lastResult: "auth_failed",
|
||||
lastOkAt: ago(3 * 60 * 60 * 1000),
|
||||
nextRetryAt: ahead(60 * 60 * 1000),
|
||||
},
|
||||
);
|
||||
await expect(readServerProviderHealth(NOW)).resolves.toBe("unhealthy");
|
||||
});
|
||||
|
||||
it("ignores an auth failure whose cooldown has run out", async () => {
|
||||
rows(
|
||||
{ lastResult: "ok", lastOkAt: ago(60 * 1000) },
|
||||
{
|
||||
lastResult: "auth_failed",
|
||||
lastOkAt: null,
|
||||
nextRetryAt: ago(60 * 1000),
|
||||
},
|
||||
);
|
||||
await expect(readServerProviderHealth(NOW)).resolves.toBe("healthy");
|
||||
});
|
||||
|
||||
it("is unknown when the instance has no rows for either shared provider", async () => {
|
||||
rows();
|
||||
await expect(readServerProviderHealth(NOW)).resolves.toBe("unknown");
|
||||
});
|
||||
|
||||
it("is unknown when the read throws, and says so in the event", async () => {
|
||||
vi.mocked(prisma.providerHealth.findMany).mockRejectedValue(
|
||||
new Error("connection terminated"),
|
||||
);
|
||||
await expect(readServerProviderHealth(NOW)).resolves.toBe("unknown");
|
||||
expect(vi.mocked(annotate)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: { name: "ai.server_health.read_failed" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reads only the operator-funded provider tags, and no per-account column", async () => {
|
||||
rows({ lastResult: "ok", lastOkAt: ago(1000) });
|
||||
await readServerProviderHealth(NOW);
|
||||
const args = vi.mocked(prisma.providerHealth.findMany).mock.calls[0]![0]!;
|
||||
expect(args.where).toEqual({
|
||||
providerType: { in: ["admin-openai", "admin-codex"] },
|
||||
});
|
||||
expect(Object.keys(args.select ?? {}).sort()).toEqual([
|
||||
"lastOkAt",
|
||||
"lastResult",
|
||||
"nextRetryAt",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* v1.38.19 — "does the shared provider of THIS instance work?",
|
||||
* answered for a user who has never made a single AI call.
|
||||
*
|
||||
* The question comes from the end of the setup flow. The done screen used
|
||||
* to claim "insights work for you right away" on the strength of
|
||||
* `managedBy === "server"`, which is a PRESENCE read: it says the operator
|
||||
* configured a key, never that the key works. On 2026-09-11 the operator's
|
||||
* own instance answered HTTP 500 from its OAuth proxy while the screen kept
|
||||
* making that promise. This module is the evidence the promise needs.
|
||||
*
|
||||
* Why it cannot come from the user's own ledger: `provider_health` is unique
|
||||
* on `(user_id, provider_type)`, and an account walking the wizard has NO
|
||||
* row — for any provider. Its own ledger can only answer "unknown", forever.
|
||||
* So the projection folds rows ACROSS accounts. That is also the reason for
|
||||
* the shape of the return value.
|
||||
*
|
||||
* What leaves this module is a tri-state and nothing else: no counts, no
|
||||
* timestamps, no user ids, not even "how many accounts are tracked". "The
|
||||
* provider of this instance last worked" is a statement about the instance;
|
||||
* "seven accounts used it at 06:42Z" is a statement about other people. The
|
||||
* admin fold (`GET /api/admin/provider-health`) keeps the second kind and
|
||||
* stays behind `requireAdmin()`; this one is readable by any signed-in user
|
||||
* precisely because it carries none of it.
|
||||
*
|
||||
* Fail CLOSED, deliberately against the grain of the ledger it reads. The
|
||||
* ledger fails open (`provider-health-ledger.ts`) because there it protects
|
||||
* a generation already under way — contributing nothing is the safe outcome.
|
||||
* Here the output is a promise made to a person, so a read error, a timeout
|
||||
* or an empty table is `unknown`, and `unknown` never buys an offer. It does
|
||||
* NOT buy the opposite claim either: an empty table is the state of every
|
||||
* instance until its first AI call, and telling that operator "the provider
|
||||
* is not answering" is the same unmeasured claim the offer itself avoids.
|
||||
*
|
||||
* No probe. `POST /api/ai/test` exists and would be the direct answer, but it
|
||||
* is a real completion billed to whoever the chain resolves to — usually the
|
||||
* operator. A probe here would charge the operator for every registration,
|
||||
* and a sign-up bot would scale that bill. The design spec's rule holds:
|
||||
* nothing in the flow talks to a provider.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
/**
|
||||
* The whole contract. Three words, no payload.
|
||||
*
|
||||
* healthy — the instance's shared provider demonstrably served somebody
|
||||
* inside the freshness window, and no credential is benched.
|
||||
* unhealthy — somebody tried it and the last thing that happened was a
|
||||
* failure. A verdict a screen is allowed to state out loud.
|
||||
* unknown — nobody has tried it lately on this instance, or the read
|
||||
* failed. Never an offer either, but it is also not a verdict:
|
||||
* a caller may withhold the offer and must not claim an outage.
|
||||
*/
|
||||
export type ServerProviderHealth = "healthy" | "unhealthy" | "unknown";
|
||||
|
||||
/**
|
||||
* How recent a success has to be to still count.
|
||||
*
|
||||
* The ledger's own cooldowns bracket the choice: 6 h for an auth failure,
|
||||
* 5 min for a hard one. A window under those would read `unhealthy` on a
|
||||
* quiet single-person instance simply because nothing ran that hour; a much
|
||||
* larger one would paint a key that died on Monday as green on Thursday.
|
||||
* A day is long enough for an instance with one briefing per morning to hold
|
||||
* the threshold, short enough that a dead key goes grey within a day.
|
||||
*/
|
||||
export const SERVER_PROVIDER_FRESH_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* The provider tags the operator pays for: the instance-wide OpenAI key and
|
||||
* the shared central Codex account. These are exactly the two tags
|
||||
* `consent-guard.ts` gates on, and that is not a coincidence — the offer
|
||||
* this feeds is the consent for these two and for nothing else.
|
||||
*/
|
||||
const SHARED_PROVIDER_TYPES = ["admin-openai", "admin-codex"] as const;
|
||||
|
||||
export async function readServerProviderHealth(
|
||||
now: Date = new Date(),
|
||||
): Promise<ServerProviderHealth> {
|
||||
let rows: Array<{
|
||||
lastResult: string;
|
||||
lastOkAt: Date | null;
|
||||
nextRetryAt: Date | null;
|
||||
}>;
|
||||
try {
|
||||
rows = await prisma.providerHealth.findMany({
|
||||
where: { providerType: { in: [...SHARED_PROVIDER_TYPES] } },
|
||||
// Three columns, none of which identifies an account. The absence of
|
||||
// `userId` here is load-bearing, not tidiness.
|
||||
select: { lastResult: true, lastOkAt: true, nextRetryAt: true },
|
||||
});
|
||||
} catch {
|
||||
annotate({ action: { name: "ai.server_health.read_failed" } });
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
if (rows.length === 0) return "unknown";
|
||||
|
||||
// A benched credential outranks any success. A 401/403 is not one
|
||||
// account's bad luck — the operator's key is one object, and while it is
|
||||
// in its cooldown it is dead for everybody, including whoever is reading
|
||||
// this screen right now.
|
||||
const credentialBenched = rows.some(
|
||||
(row) =>
|
||||
row.lastResult === "auth_failed" &&
|
||||
row.nextRetryAt !== null &&
|
||||
row.nextRetryAt.getTime() > now.getTime(),
|
||||
);
|
||||
if (credentialBenched) return "unhealthy";
|
||||
|
||||
const floor = now.getTime() - SERVER_PROVIDER_FRESH_WINDOW_MS;
|
||||
const workedRecently = rows.some(
|
||||
(row) => row.lastOkAt !== null && row.lastOkAt.getTime() >= floor,
|
||||
);
|
||||
if (workedRecently) return "healthy";
|
||||
|
||||
// Nothing succeeded inside the window, and that alone is not a failure.
|
||||
// `unhealthy` is a statement the screen puts to a person — "it is not
|
||||
// answering" — so it has to be earned by an outcome somebody actually
|
||||
// observed: a row whose LAST outcome was a failure, uncontradicted by a
|
||||
// recent success. When every row's last outcome was a success and it is
|
||||
// merely old, nobody has asked this provider for anything lately, and the
|
||||
// honest answer is that we do not know. Either way there is no offer.
|
||||
const lastOutcomeFailed = rows.some((row) => row.lastResult !== "ok");
|
||||
return lastOutcomeFailed ? "unhealthy" : "unknown";
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { afterEach, describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
@@ -282,3 +282,84 @@ describe("applyProfileUpdate email conflict", () => {
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.39 — what a demo instance may write through this helper.
|
||||
*
|
||||
* `PUT /api/auth/profile` is on the proxy's demo mutation allowlist so the
|
||||
* setup flow's baseline step can complete. That allowlist admits a path and a
|
||||
* method; it cannot see that the route behind the path also writes the
|
||||
* account's email, display name, full name and insurer fields. The demo is
|
||||
* one published account every visitor signs into, so each of those lands on
|
||||
* the record the next visitor meets.
|
||||
*
|
||||
* The narrowing is therefore here, on the server, and not in the client that
|
||||
* happens to send only three fields — the allowlist exists precisely because
|
||||
* the caller is not trusted. `tests/integration/demo-mode-profile-write.test.ts`
|
||||
* proves the same contract against real Postgres by reading the row back; this
|
||||
* pins the written payload so the default suite catches a regression too.
|
||||
*/
|
||||
describe("applyProfileUpdate under DEMO_MODE", () => {
|
||||
const ORIGINAL = process.env.DEMO_MODE;
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL === undefined) delete process.env.DEMO_MODE;
|
||||
else process.env.DEMO_MODE = ORIGINAL;
|
||||
});
|
||||
|
||||
const HOSTILE_AND_BASELINE = {
|
||||
email: "attacker@example.test",
|
||||
displayName: "Owned",
|
||||
fullName: "Attacker",
|
||||
insurerName: "Attacker Insurance",
|
||||
timezone: "America/New_York",
|
||||
heightCm: 181,
|
||||
dateOfBirth: "1988-04-12",
|
||||
gender: "FEMALE",
|
||||
};
|
||||
|
||||
it("writes the three baseline fields and nothing else", async () => {
|
||||
process.env.DEMO_MODE = "true";
|
||||
|
||||
const result = await applyProfileUpdate(USER_ID, HOSTILE_AND_BASELINE);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
const written = vi.mocked(prisma.user.update).mock.calls[0]![0]!.data;
|
||||
expect(Object.keys(written as object).sort()).toEqual([
|
||||
"dateOfBirth",
|
||||
"gender",
|
||||
"heightCm",
|
||||
]);
|
||||
});
|
||||
|
||||
it("never reaches the email conflict check, which is an existence oracle", async () => {
|
||||
// The check 409s when another account holds the address. On a public
|
||||
// demo that answers "does this person have an account here?" to anybody,
|
||||
// so the narrowing has to happen ahead of it rather than after.
|
||||
process.env.DEMO_MODE = "true";
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "someone-else",
|
||||
} as never);
|
||||
|
||||
const result = await applyProfileUpdate(USER_ID, {
|
||||
email: "taken@example.test",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("writes every field on an ordinary instance", async () => {
|
||||
delete process.env.DEMO_MODE;
|
||||
|
||||
await applyProfileUpdate(USER_ID, HOSTILE_AND_BASELINE);
|
||||
|
||||
const written = vi.mocked(prisma.user.update).mock.calls[0]![0]!
|
||||
.data as Record<string, unknown>;
|
||||
expect(written.email).toBe("attacker@example.test");
|
||||
expect(written.displayName).toBe("Owned");
|
||||
expect(written.fullName).toBe("Attacker");
|
||||
expect(written.insurerName).toBe("Attacker Insurance");
|
||||
expect(written.heightCm).toBe(181);
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,28 @@ const extendedProfileSchema = profileSchema.extend({
|
||||
|
||||
type ExtendedProfileInput = z.infer<typeof extendedProfileSchema>;
|
||||
|
||||
/**
|
||||
* The only profile fields a demo instance may write: the three the setup
|
||||
* flow's baseline step collects.
|
||||
*
|
||||
* `PUT /api/auth/profile` sits on the proxy's `DEMO_MUTATION_ALLOWLIST` so
|
||||
* that step can complete. That list is an EDGE control — it admits a path and
|
||||
* a method, and it cannot see that the handler behind the path also writes
|
||||
* this account's contact email, its display name, its full name and its
|
||||
* insurer fields. On an ordinary instance those are the caller's own record;
|
||||
* on the demo, which is ONE published account every visitor signs into, each
|
||||
* of them is the record the next visitor arrives at, until a reseed.
|
||||
*
|
||||
* So the narrowing is here and not in the client. The web form sends only
|
||||
* these three plus a display name, but a form is not a control: the whole
|
||||
* reason the allowlist exists is that the caller is not trusted.
|
||||
*/
|
||||
export const DEMO_WRITABLE_PROFILE_FIELDS = [
|
||||
"heightCm",
|
||||
"dateOfBirth",
|
||||
"gender",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* The profile schema has no cross-field `.refine()` — every field is an
|
||||
* independent scalar column. That means a rejected `gender` has no
|
||||
@@ -202,6 +224,17 @@ export async function applyProfileUpdate(
|
||||
}
|
||||
}
|
||||
|
||||
// Ahead of the conflict check below, deliberately: that check answers 409
|
||||
// when another account holds the address, which on a public demo is an
|
||||
// existence oracle over the instance's accounts. Dropping the field first
|
||||
// closes the write and the question in one move.
|
||||
if (process.env.DEMO_MODE === "true") {
|
||||
const writable = DEMO_WRITABLE_PROFILE_FIELDS as readonly string[];
|
||||
data = Object.fromEntries(
|
||||
Object.entries(data).filter(([key]) => writable.includes(key)),
|
||||
) as Partial<ExtendedProfileInput>;
|
||||
}
|
||||
|
||||
const normalizedEmail = data.email ? data.email.trim().toLowerCase() : null;
|
||||
|
||||
if (data.email !== undefined && normalizedEmail) {
|
||||
|
||||
@@ -48,6 +48,7 @@ vi.mock("@/lib/crypto", () => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
getIntegrationStatus,
|
||||
recordSyncFailure,
|
||||
recordSyncSuccess,
|
||||
markReauthRequired,
|
||||
@@ -1368,3 +1369,25 @@ describe("recordSyncFailure — leg set", () => {
|
||||
expect(writtenUpdate().failingLegs).toEqual(["sleep"]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.38.19 — the synthetic "no row" snapshot.
|
||||
*
|
||||
* `state: "connected"` used to be the value an account with no sync history
|
||||
* read back, which made the ledger field's default mean the opposite of its
|
||||
* name: a provider nobody had ever connected published "connected" on the
|
||||
* envelope, and the setup flow's connect step believed it. The honest value
|
||||
* for "there is no history here" is `unknown`; `syncHealth.verdict` stays the
|
||||
* only liveness truth.
|
||||
*/
|
||||
describe("getIntegrationStatus with no ledger row", () => {
|
||||
it("reads as unknown, not as connected", async () => {
|
||||
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce(
|
||||
null as never,
|
||||
);
|
||||
const snapshot = await getIntegrationStatus("u1", "whoop");
|
||||
expect(snapshot.state).toBe("unknown");
|
||||
expect(snapshot.lastSuccessAt).toBeNull();
|
||||
expect(snapshot.lastAttemptAt).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -159,7 +159,22 @@ export function isSyncFailureRecorded(err: unknown): boolean {
|
||||
* trail.
|
||||
*/
|
||||
export type IntegrationState =
|
||||
"connected" | "error_transient" | "error_reauth" | "disconnected" | "parked";
|
||||
| "connected"
|
||||
| "error_transient"
|
||||
| "error_reauth"
|
||||
| "disconnected"
|
||||
| "parked"
|
||||
/**
|
||||
* v1.38.19 — no ledger row exists for this (user, provider) pair, so the
|
||||
* ledger has nothing to say. It is NOT a claim about the connection either
|
||||
* way: `connected` used to be the synthetic default here, which made the
|
||||
* field's default read as the opposite of the truth for every account that
|
||||
* had never connected anything — the setup flow's connect step believed it
|
||||
* and told a brand-new account its wearable was connected. Consumers treat
|
||||
* `unknown` the way they treat `disconnected`; `syncHealth.verdict` stays
|
||||
* the only liveness truth.
|
||||
*/
|
||||
| "unknown";
|
||||
|
||||
/**
|
||||
* The ladder at which a streak of failures escalates from "user-visible
|
||||
@@ -323,9 +338,11 @@ function zeroBuckets(): ConsecutiveFailuresByKind {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current snapshot. Returns a synthetic "connected, never
|
||||
* attempted" record when no row exists yet — the UI treats this as
|
||||
* "no sync history" without a special case.
|
||||
* Read the current snapshot. Returns a synthetic "unknown, never attempted"
|
||||
* record when no row exists yet — the ledger holds no history, and says so
|
||||
* rather than guessing a state. `resolveSyncVerdict` reads the same absence
|
||||
* off the null timestamps and answers `pending_first_sync` for a live
|
||||
* connection, `disconnected` for one that is not there.
|
||||
*/
|
||||
export async function getIntegrationStatus(
|
||||
userId: string,
|
||||
@@ -337,7 +354,7 @@ export async function getIntegrationStatus(
|
||||
if (!row) {
|
||||
return {
|
||||
integration,
|
||||
state: "connected",
|
||||
state: "unknown",
|
||||
lastSuccessAt: null,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
CHECKLIST_BASE_ITEM_IDS,
|
||||
CHECKLIST_ITEM_IDS,
|
||||
isProfileComplete,
|
||||
isStillInSetup,
|
||||
shouldShowChecklist,
|
||||
trendHintFor,
|
||||
upcomingVisitCountFrom,
|
||||
@@ -608,3 +609,75 @@ describe("shouldShowChecklist and the setup flow", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isStillInSetup", () => {
|
||||
// The one predicate both the visibility rule and the component's query gate
|
||||
// read, so the card can never render with its supporting queries switched
|
||||
// off.
|
||||
it("is true for a record that ran the flow, whatever its reading count", () => {
|
||||
expect(
|
||||
isStillInSetup({
|
||||
onboarding: onboardingState(),
|
||||
onboardingCompletedAt: "2026-01-01T00:00:00Z",
|
||||
measurementCount: 120,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is true for a restarted run nobody finished", () => {
|
||||
const restarted = onboardingState({ completedAt: null });
|
||||
expect(
|
||||
isStillInSetup({
|
||||
onboarding: restarted,
|
||||
// `restart` deliberately leaves the account stamp alone.
|
||||
onboardingCompletedAt: "2026-01-01T00:00:00Z",
|
||||
measurementCount: 120,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the pre-flow rule for a record that never entered the flow", () => {
|
||||
const untouched: OnboardingStateDto = {
|
||||
steps: defaultOnboardingSteps(),
|
||||
needs: emptyOnboardingNeeds(),
|
||||
completedAt: null,
|
||||
firstResult: null,
|
||||
};
|
||||
expect(
|
||||
isStillInSetup({
|
||||
onboarding: untouched,
|
||||
onboardingCompletedAt: "2026-01-01T00:00:00Z",
|
||||
measurementCount: 10,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isStillInSetup({
|
||||
onboarding: untouched,
|
||||
onboardingCompletedAt: "2026-01-01T00:00:00Z",
|
||||
measurementCount: 4,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isStillInSetup({
|
||||
onboarding: null,
|
||||
onboardingCompletedAt: null,
|
||||
measurementCount: 10,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is the rule `shouldShowChecklist` itself applies", () => {
|
||||
const args = {
|
||||
onboarding: onboardingState(),
|
||||
onboardingCompletedAt: "2026-01-01T00:00:00Z",
|
||||
measurementCount: 120,
|
||||
};
|
||||
expect(
|
||||
shouldShowChecklist({
|
||||
...args,
|
||||
dismissedAll: false,
|
||||
items: buildChecklist(inputs({ measurementCount: 120 })),
|
||||
}),
|
||||
).toBe(isStillInSetup(args));
|
||||
});
|
||||
});
|
||||
@@ -66,3 +66,62 @@ describe("confirmedModules", () => {
|
||||
).toContain("vaccinations");
|
||||
});
|
||||
});
|
||||
|
||||
describe("confirmedModules — what leaves the navigation", () => {
|
||||
const noMedication = {
|
||||
...emptyOnboardingNeeds(),
|
||||
recordTarget: "me" as const,
|
||||
areas: ["sleep" as const],
|
||||
medication: "no" as const,
|
||||
visit: "no" as const,
|
||||
};
|
||||
|
||||
it("names a module the answers do not ask for and the record shows today", () => {
|
||||
const { wouldSwitchOff } = confirmedModules(noMedication, {
|
||||
medications: true,
|
||||
doctorReport: true,
|
||||
});
|
||||
expect(wouldSwitchOff).toContain("medications");
|
||||
expect(wouldSwitchOff).toContain("doctorReport");
|
||||
});
|
||||
|
||||
it("never names a module the answers switch on", () => {
|
||||
const { chosen, wouldSwitchOff } = confirmedModules(
|
||||
{ ...noMedication, medication: "yes" },
|
||||
{ medications: true },
|
||||
);
|
||||
expect(chosen).toContain("medications");
|
||||
expect(wouldSwitchOff).not.toContain("medications");
|
||||
});
|
||||
|
||||
it("never names a module the record does not show today", () => {
|
||||
const { wouldSwitchOff } = confirmedModules(noMedication, {
|
||||
medications: false,
|
||||
});
|
||||
expect(wouldSwitchOff).not.toContain("medications");
|
||||
// Nothing is known about a module the payload does not carry, so the
|
||||
// screen says nothing about it.
|
||||
expect(confirmedModules(noMedication, {}).wouldSwitchOff).toEqual([]);
|
||||
});
|
||||
|
||||
it("never names an always-on module, whatever the answers say", () => {
|
||||
const current = Object.fromEntries(
|
||||
ONBOARDING_ALWAYS_ON_MODULES.map((key) => [key, true]),
|
||||
);
|
||||
const { wouldSwitchOff } = confirmedModules(noMedication, current);
|
||||
for (const key of ONBOARDING_ALWAYS_ON_MODULES) {
|
||||
expect(wouldSwitchOff).not.toContain(key);
|
||||
}
|
||||
});
|
||||
|
||||
it("is in registry order, like the list beside it", () => {
|
||||
const current = Object.fromEntries(
|
||||
OWNED_MODULE_KEYS.map((key) => [key, true]),
|
||||
);
|
||||
const { wouldSwitchOff } = confirmedModules(noMedication, current);
|
||||
expect(wouldSwitchOff).toEqual(
|
||||
OWNED_MODULE_KEYS.filter((key) => wouldSwitchOff.includes(key)),
|
||||
);
|
||||
expect(wouldSwitchOff.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,9 @@
|
||||
* on a surface that exists, so the first-result screen can never be handed
|
||||
* a task it cannot render.
|
||||
*/
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ONBOARDING_AREA_KEYS } from "@/lib/modules/registry";
|
||||
@@ -11,6 +14,8 @@ import { NAV_DESTINATIONS } from "@/components/layout/nav-model";
|
||||
import {
|
||||
AREA_PAGE_HREF,
|
||||
AREA_READING_TARGETS,
|
||||
connectSourceView,
|
||||
deliveringSources,
|
||||
SOURCE_INTEGRATION,
|
||||
} from "../first-result-config";
|
||||
import { BROWSER_CONNECTABLE_SOURCES } from "../wizard-steps";
|
||||
@@ -38,3 +43,216 @@ describe("first-result targets", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.38.19 — what the connect step is allowed to say.
|
||||
*
|
||||
* The screen used to decide "connected" from the ledger's `state` field, whose
|
||||
* default meant the opposite of its name, and had one piece of copy for every
|
||||
* outcome. The decision is a pure function over the envelope entry now, so the
|
||||
* eight verdicts can be enumerated here rather than inferred from markup.
|
||||
*/
|
||||
describe("connectSourceView", () => {
|
||||
const cases = [
|
||||
["disconnected", "connect", false],
|
||||
["pending_first_sync", "result", true],
|
||||
["fresh", "result", true],
|
||||
["stale", "result", false],
|
||||
["stalled", "attention", false],
|
||||
["failing", "attention", false],
|
||||
["reauth_required", "attention", false],
|
||||
["parked", "attention", false],
|
||||
] as const;
|
||||
|
||||
it.each(cases)(
|
||||
"verdict %s renders the %s tile and settles=%s",
|
||||
(verdict, slot, settled) => {
|
||||
const view = connectSourceView("whoop", {
|
||||
connected: verdict !== "disconnected",
|
||||
configured: true,
|
||||
syncHealth: { verdict, since: "2026-09-01T00:00:00.000Z" },
|
||||
lastSuccessAt: "2026-09-01T00:00:00.000Z",
|
||||
});
|
||||
expect(view?.slot).toBe(slot);
|
||||
expect(view?.settled).toBe(settled);
|
||||
expect(view?.verdict).toBe(verdict);
|
||||
},
|
||||
);
|
||||
|
||||
it("says nothing at all until the envelope has resolved", () => {
|
||||
expect(connectSourceView("whoop", undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not read a fresh account's empty ledger as a connection", () => {
|
||||
// What a brand-new account publishes: no row anywhere, so `state` is the
|
||||
// synthetic `unknown` and the verdict is `disconnected`.
|
||||
const view = connectSourceView("whoop", {
|
||||
connected: false,
|
||||
configured: true,
|
||||
state: "unknown",
|
||||
syncHealth: { verdict: "disconnected", since: null },
|
||||
lastSuccessAt: null,
|
||||
});
|
||||
expect(view?.slot).toBe("connect");
|
||||
expect(view?.settled).toBe(false);
|
||||
});
|
||||
|
||||
it("points a BYO provider with no credentials at the credentials", () => {
|
||||
const view = connectSourceView("whoop", {
|
||||
connected: false,
|
||||
configured: false,
|
||||
syncHealth: { verdict: "disconnected", since: null },
|
||||
lastSuccessAt: null,
|
||||
});
|
||||
expect(view?.slot).toBe("credentials");
|
||||
});
|
||||
|
||||
it("points a shared-app provider with no usable app at the credentials", () => {
|
||||
const view = connectSourceView("oura", {
|
||||
connected: false,
|
||||
configured: false,
|
||||
available: false,
|
||||
syncHealth: { verdict: "disconnected", since: null },
|
||||
lastSuccessAt: null,
|
||||
});
|
||||
expect(view?.slot).toBe("credentials");
|
||||
});
|
||||
|
||||
it("offers the connect card when the instance's shared app resolves", () => {
|
||||
const view = connectSourceView("oura", {
|
||||
connected: false,
|
||||
configured: false,
|
||||
available: true,
|
||||
syncHealth: { verdict: "disconnected", since: null },
|
||||
lastSuccessAt: null,
|
||||
});
|
||||
expect(view?.slot).toBe("connect");
|
||||
});
|
||||
|
||||
it("never withholds the connect card from a self-hosted URL source", () => {
|
||||
const view = connectSourceView("nightscout", {
|
||||
connected: false,
|
||||
configured: false,
|
||||
syncHealth: { verdict: "disconnected", since: null },
|
||||
lastSuccessAt: null,
|
||||
});
|
||||
expect(view?.slot).toBe("connect");
|
||||
});
|
||||
|
||||
it("dates a stale tile from the last value and a failing one from the streak", () => {
|
||||
expect(
|
||||
connectSourceView("whoop", {
|
||||
connected: true,
|
||||
configured: true,
|
||||
syncHealth: { verdict: "stale", since: "2026-08-01T00:00:00.000Z" },
|
||||
lastSuccessAt: "2026-08-20T00:00:00.000Z",
|
||||
})?.when,
|
||||
).toBe("2026-08-20T00:00:00.000Z");
|
||||
expect(
|
||||
connectSourceView("whoop", {
|
||||
connected: true,
|
||||
configured: true,
|
||||
syncHealth: { verdict: "failing", since: "2026-08-01T00:00:00.000Z" },
|
||||
lastSuccessAt: null,
|
||||
})?.when,
|
||||
).toBe("2026-08-01T00:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The connect step's copy is chosen by a key built at runtime from the
|
||||
* verdict, which the repo's three i18n guards cannot see into: a template
|
||||
* `ns.${x}` counts as covering its whole subtree, so a MEMBER missing from
|
||||
* every bundle is invisible to them. Enumerate the verdicts here instead —
|
||||
* the eight the server can resolve, in all seven locales.
|
||||
*/
|
||||
describe("connect-source state copy", () => {
|
||||
const VERDICTS = [
|
||||
"fresh",
|
||||
"stale",
|
||||
"stalled",
|
||||
"failing",
|
||||
"reauth_required",
|
||||
"parked",
|
||||
"pending_first_sync",
|
||||
] as const;
|
||||
|
||||
const LOCALES = readdirSync(join(__dirname, "../../../../messages"))
|
||||
.filter((file) => file.endsWith(".json"))
|
||||
.map((file) => ({
|
||||
locale: file.replace(/\.json$/, ""),
|
||||
copy: JSON.parse(
|
||||
readFileSync(join(__dirname, "../../../../messages", file), "utf8"),
|
||||
) as Record<string, never>,
|
||||
}));
|
||||
|
||||
it("covers all seven locales", () => {
|
||||
expect(LOCALES.length).toBe(7);
|
||||
});
|
||||
|
||||
it.each(LOCALES)(
|
||||
"names every verdict in $locale, with no English fallback",
|
||||
({ locale, copy }) => {
|
||||
const block = (
|
||||
copy as unknown as {
|
||||
onboarding: {
|
||||
flow: {
|
||||
"first-result": {
|
||||
"connect-source": Record<string, Record<string, unknown>>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
).onboarding.flow["first-result"]["connect-source"];
|
||||
for (const verdict of VERDICTS) {
|
||||
const entry = block.state[verdict] as
|
||||
{ title?: string; detail?: string } | undefined;
|
||||
expect(entry?.title, `${locale}/${verdict}.title`).toBeTruthy();
|
||||
expect(entry?.detail, `${locale}/${verdict}.detail`).toBeTruthy();
|
||||
}
|
||||
// `disconnected` is the one verdict with no state copy: it renders the
|
||||
// connect card or the credentials note, neither of which is a claim
|
||||
// about a connection that exists.
|
||||
expect(block.state.disconnected).toBeUndefined();
|
||||
expect(block.needsCredentials.title).toBeTruthy();
|
||||
expect(block.openSettings).toBeTruthy();
|
||||
expect(block.noDate).toBeTruthy();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.38.19 — which sources the flow must stop offering.
|
||||
*
|
||||
* Only a connection that is actually delivering, or one whose data has merely
|
||||
* gone quiet: both are real connections the person made, and offering to make
|
||||
* them again is the wizard talking past the account it can see. Everything
|
||||
* else — never connected, first sync running, needs repairing — stays in the
|
||||
* priority, because there is still something to do or something to say.
|
||||
*/
|
||||
describe("deliveringSources", () => {
|
||||
function entry(integration: string, verdict: string) {
|
||||
return {
|
||||
integration,
|
||||
connected: verdict !== "disconnected",
|
||||
configured: true,
|
||||
syncHealth: { verdict, since: null },
|
||||
lastSuccessAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
it("names the sources that are delivering or merely quiet", () => {
|
||||
const set = deliveringSources([
|
||||
entry("whoop", "fresh"),
|
||||
entry("oura", "stale"),
|
||||
entry("polar", "pending_first_sync"),
|
||||
entry("fitbit", "reauth_required"),
|
||||
entry("withings", "disconnected"),
|
||||
] as never);
|
||||
expect([...set].sort()).toEqual(["oura", "whoop"]);
|
||||
});
|
||||
|
||||
it("names nothing while the envelope has not resolved", () => {
|
||||
expect(deliveringSources(undefined).size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -80,11 +80,15 @@ describe("questionAnswerBody", () => {
|
||||
expect(questionAnswerBody("visit", [])).toBeNull();
|
||||
});
|
||||
|
||||
it("sends an empty list for a many-answer question with nothing chosen", () => {
|
||||
expect(questionAnswerBody("areas", [])).toEqual({
|
||||
step: "areas",
|
||||
areas: [],
|
||||
});
|
||||
it("is null for a many-answer question with nothing chosen", () => {
|
||||
// An empty tick list is not an opinion. It used to be sent as an
|
||||
// ANSWER, and an answered Q2 with no areas is what switched every
|
||||
// optional module off for somebody who meant "no preference" — Skip is
|
||||
// the way past, and Skip is the conservative derivation.
|
||||
expect(questionAnswerBody("areas", [])).toBeNull();
|
||||
expect(questionAnswerBody("sources", [])).toBeNull();
|
||||
// A list of values none of which the step offers is just as empty.
|
||||
expect(questionAnswerBody("areas", ["no-such-area"])).toBeNull();
|
||||
});
|
||||
|
||||
it("drops a value the step's vocabulary does not contain", () => {
|
||||
@@ -125,13 +129,22 @@ describe("units", () => {
|
||||
expect(onboardingAnswerSchema.safeParse(body).success).toBe(true);
|
||||
});
|
||||
|
||||
it("sends an empty answer when nothing was chosen, which leaves the columns alone", () => {
|
||||
it("is null when nothing was chosen, so Skip is the way past", () => {
|
||||
// An empty units answer marked Q6 done without recording a preference.
|
||||
expect(
|
||||
unitsAnswerBody({
|
||||
asked: { glucose: true, weight: true },
|
||||
glucoseUnit: null,
|
||||
unitPreference: null,
|
||||
}),
|
||||
).toEqual({ step: "units", units: {} });
|
||||
).toBeNull();
|
||||
// One of two asked questions answered is still an answer.
|
||||
expect(
|
||||
unitsAnswerBody({
|
||||
asked: { glucose: true, weight: true },
|
||||
glucoseUnit: "mg/dL",
|
||||
unitPreference: null,
|
||||
}),
|
||||
).toEqual({ step: "units", units: { glucoseUnit: "mg/dL" } });
|
||||
});
|
||||
});
|
||||
@@ -167,6 +167,46 @@ describe("chooseFirstResultTask", () => {
|
||||
).toEqual({ task: "log-reading", target: "weight-body" });
|
||||
expect(chooseFirstResultTask(emptyOnboardingNeeds())).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.38.19 — the choice used to read the answers alone, so an
|
||||
* account whose WHOOP had been delivering for months was told to go and
|
||||
* connect WHOOP. A source that is already delivering is not a task; the
|
||||
* priority falls through to the next one the person's answers name.
|
||||
*/
|
||||
it("passes over a source that is already delivering", () => {
|
||||
const needs: OnboardingNeeds = {
|
||||
...emptyOnboardingNeeds(),
|
||||
sources: ["whoop", "oura"],
|
||||
medication: "yes",
|
||||
areas: ["sleep"],
|
||||
};
|
||||
expect(chooseFirstResultTask(needs, new Set(["whoop"]))).toEqual({
|
||||
task: "connect-source",
|
||||
target: "oura",
|
||||
});
|
||||
expect(chooseFirstResultTask(needs, new Set(["whoop", "oura"]))).toEqual({
|
||||
task: "add-medication",
|
||||
target: null,
|
||||
});
|
||||
expect(
|
||||
chooseFirstResultTask(
|
||||
{ ...needs, medication: "no" },
|
||||
new Set(["whoop", "oura"]),
|
||||
),
|
||||
).toEqual({ task: "log-reading", target: "sleep" });
|
||||
});
|
||||
|
||||
it("is unchanged when nothing is known about the connections", () => {
|
||||
const needs: OnboardingNeeds = {
|
||||
...emptyOnboardingNeeds(),
|
||||
sources: ["whoop"],
|
||||
medication: "yes",
|
||||
};
|
||||
expect(chooseFirstResultTask(needs)).toEqual(
|
||||
chooseFirstResultTask(needs, new Set()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("firstResultApplies", () => {
|
||||
|
||||
@@ -282,11 +282,7 @@ export function checklistProgress(items: ChecklistItem[]): ChecklistProgress {
|
||||
* unfinished, because the one task the flow offered is not one of the six
|
||||
* rows: "every row done" and "the setup finished" are different statements.
|
||||
*/
|
||||
export function shouldShowChecklist(args: {
|
||||
onboardingCompletedAt: string | null;
|
||||
measurementCount: number;
|
||||
dismissedAll: boolean;
|
||||
items: ChecklistItem[];
|
||||
export interface StillInSetupInputs {
|
||||
/**
|
||||
* v1.39 (C1) — the needs-based setup state for this record, or null before
|
||||
* the account payload resolves. A record that never entered the flow reads
|
||||
@@ -295,17 +291,45 @@ export function shouldShowChecklist(args: {
|
||||
* on the rule it already had.
|
||||
*/
|
||||
onboarding?: OnboardingStateDto | null;
|
||||
}): boolean {
|
||||
onboardingCompletedAt: string | null;
|
||||
measurementCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.39 — "is this record still being set up?", as ONE predicate.
|
||||
*
|
||||
* Two places decide this: whether the card renders at all, and whether the
|
||||
* component fetches the data its rows are about. They were two restatements of
|
||||
* the same rule and they drifted — the visibility rule gained
|
||||
* `hasEnteredOnboardingFlow`, the query gate kept the older half, and a
|
||||
* wizard-run account past five readings got a permanently visible card whose
|
||||
* every network-backed row read "not done". A record whose
|
||||
* restarted run was abandoned is in the same state: `restart` clears the
|
||||
* record's own `completedAt` and deliberately not the account stamp, so
|
||||
* `hasEnteredOnboardingFlow` is what keeps its rows live.
|
||||
*
|
||||
* Exported so the component calls it rather than restating it.
|
||||
*/
|
||||
export function isStillInSetup(args: StillInSetupInputs): boolean {
|
||||
return (
|
||||
hasEnteredOnboardingFlow(args.onboarding ?? null) ||
|
||||
args.onboardingCompletedAt == null ||
|
||||
args.measurementCount < 5
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldShowChecklist(
|
||||
args: StillInSetupInputs & {
|
||||
dismissedAll: boolean;
|
||||
items: ChecklistItem[];
|
||||
},
|
||||
): boolean {
|
||||
if (args.dismissedAll) return false;
|
||||
const onboarding = args.onboarding ?? null;
|
||||
const visible = visibleChecklist(args.items);
|
||||
if (visible.length === 0) return false;
|
||||
|
||||
const stillInSetup =
|
||||
hasEnteredOnboardingFlow(onboarding) ||
|
||||
args.onboardingCompletedAt == null ||
|
||||
args.measurementCount < 5;
|
||||
if (!stillInSetup) return false;
|
||||
if (!isStillInSetup(args)) return false;
|
||||
|
||||
if (!isOnboardingSettled(onboarding)) return true;
|
||||
return visible.some((item) => !item.done);
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
import {
|
||||
deriveOnboardingModuleDefaults,
|
||||
mergeDerivedModulePreferences,
|
||||
MODULE_KEYS,
|
||||
ONBOARDING_ALWAYS_ON_MODULES,
|
||||
type ModuleKey,
|
||||
@@ -27,9 +28,23 @@ export interface ConfirmedModules {
|
||||
chosen: ModuleKey[];
|
||||
/** On whatever the answers say. */
|
||||
alwaysOn: ModuleKey[];
|
||||
/**
|
||||
* v1.39 — what the derivation takes OUT of the navigation:
|
||||
* modules this record shows today that the answers do not ask for. Empty
|
||||
* until the account payload has resolved, because a module the payload
|
||||
* says nothing about is a module this screen knows nothing about.
|
||||
*/
|
||||
wouldSwitchOff: ModuleKey[];
|
||||
}
|
||||
|
||||
export function confirmedModules(needs: OnboardingNeeds): ConfirmedModules {
|
||||
/**
|
||||
* @param current the module map the record shows today (`user.modules`), so
|
||||
* the screen names only modules that are actually in the navigation now.
|
||||
*/
|
||||
export function confirmedModules(
|
||||
needs: OnboardingNeeds,
|
||||
current: Partial<Record<ModuleKey, boolean>> = {},
|
||||
): ConfirmedModules {
|
||||
const derived = deriveOnboardingModuleDefaults({
|
||||
// Q1 is required and a confirm screen is never reached without it; the
|
||||
// fallback only keeps the type total.
|
||||
@@ -45,8 +60,27 @@ export function confirmedModules(needs: OnboardingNeeds): ConfirmedModules {
|
||||
}
|
||||
if (derived.cycleTracking) on.add("cycle");
|
||||
const alwaysOn = new Set<ModuleKey>(ONBOARDING_ALWAYS_ON_MODULES);
|
||||
// The registry's own merge rule decides what a derivation puts at `false`,
|
||||
// so this screen never restates it. It is asked with nothing to spare —
|
||||
// no stored preference, no domain holding rows — because that is the
|
||||
// record the confirm screen is looking at on a first run, and it is the
|
||||
// maximum the completion can take away. What the merge would spare on a
|
||||
// re-run (a module switched on by hand, one holding data) it spares
|
||||
// server-side, so this list can only ever be longer than what happens,
|
||||
// never shorter, and the sentence beside it says how to get one back.
|
||||
const afterDerivation = mergeDerivedModulePreferences(
|
||||
{},
|
||||
derived.preferences,
|
||||
new Set<string>(),
|
||||
);
|
||||
return {
|
||||
chosen: MODULE_KEYS.filter((key) => on.has(key) && !alwaysOn.has(key)),
|
||||
alwaysOn: MODULE_KEYS.filter((key) => alwaysOn.has(key)),
|
||||
wouldSwitchOff: MODULE_KEYS.filter(
|
||||
(key) =>
|
||||
!alwaysOn.has(key) &&
|
||||
current[key] === true &&
|
||||
afterDerivation[key] === false,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -7,9 +7,13 @@
|
||||
* which integration a Q4 source is, and which page carries an area the
|
||||
* measurement form cannot log.
|
||||
*/
|
||||
import type { SyncVerdict } from "@/lib/integrations/sync-verdict";
|
||||
import type { OnboardingAreaKey } from "@/lib/modules/registry";
|
||||
|
||||
import type { BrowserConnectableSource } from "./wizard-steps";
|
||||
import {
|
||||
BROWSER_CONNECTABLE_SOURCES,
|
||||
type BrowserConnectableSource,
|
||||
} from "./wizard-steps";
|
||||
|
||||
export interface AreaReadingTarget {
|
||||
/** The `defaultType` the measurement form opens on. */
|
||||
@@ -47,19 +51,158 @@ export const AREA_PAGE_HREF: Readonly<
|
||||
illness: "/illness",
|
||||
});
|
||||
|
||||
/**
|
||||
* How a source's connection is authorised on this instance, which decides
|
||||
* what "cannot be connected yet" means for it:
|
||||
*
|
||||
* - `byo` — the OAuth app is per-user only (`configured` is the stored client
|
||||
* id/secret pair). With none, the provider card renders a credentials note
|
||||
* and no connect button at all, so offering "Connect" walks the person into
|
||||
* a dead end.
|
||||
* - `shared` — an operator env app is the fallback (`available` says whether
|
||||
* any usable credentials resolve, own or shared).
|
||||
* - `self-hosted` — a URL the person pastes; nothing can be missing up front.
|
||||
*/
|
||||
export type SourceCredentialModel = "byo" | "shared" | "self-hosted";
|
||||
|
||||
/**
|
||||
* The connections panel's anchor for each connectable source — the `id` of
|
||||
* the provider card on Settings → Integrations, and the key its status is
|
||||
* published under on `/api/integrations/status`.
|
||||
*/
|
||||
export const SOURCE_INTEGRATION: Readonly<
|
||||
Record<BrowserConnectableSource, { anchor: string; statusKey: string }>
|
||||
Record<
|
||||
BrowserConnectableSource,
|
||||
{ anchor: string; statusKey: string; credentials: SourceCredentialModel }
|
||||
>
|
||||
> = Object.freeze({
|
||||
withings: { anchor: "withings", statusKey: "withings" },
|
||||
oura: { anchor: "oura", statusKey: "oura" },
|
||||
whoop: { anchor: "whoop", statusKey: "whoop" },
|
||||
polar: { anchor: "polar", statusKey: "polar" },
|
||||
fitbit: { anchor: "fitbit", statusKey: "fitbit" },
|
||||
strava: { anchor: "strava", statusKey: "strava" },
|
||||
nightscout: { anchor: "nightscout", statusKey: "nightscout" },
|
||||
withings: { anchor: "withings", statusKey: "withings", credentials: "byo" },
|
||||
oura: { anchor: "oura", statusKey: "oura", credentials: "shared" },
|
||||
whoop: { anchor: "whoop", statusKey: "whoop", credentials: "byo" },
|
||||
polar: { anchor: "polar", statusKey: "polar", credentials: "shared" },
|
||||
fitbit: { anchor: "fitbit", statusKey: "fitbit", credentials: "byo" },
|
||||
strava: { anchor: "strava", statusKey: "strava", credentials: "shared" },
|
||||
nightscout: {
|
||||
anchor: "nightscout",
|
||||
statusKey: "nightscout",
|
||||
credentials: "self-hosted",
|
||||
},
|
||||
});
|
||||
|
||||
/** Which tile the connect step renders. */
|
||||
export type ConnectSourceSlot =
|
||||
/** Nothing is connected and something can be: today's connect card. */
|
||||
| "connect"
|
||||
/** Nothing is connected and nothing CAN be: point at the credentials. */
|
||||
| "credentials"
|
||||
/** A working connection — the flow's result, whether or not it is new. */
|
||||
| "result"
|
||||
/** A connection the person has to repair, named rather than painted green. */
|
||||
| "attention";
|
||||
|
||||
/** The fields of an `/api/integrations/status` entry this decision reads. */
|
||||
export interface ConnectSourceStatus {
|
||||
connected?: boolean;
|
||||
configured?: boolean;
|
||||
available?: boolean;
|
||||
state?: string;
|
||||
syncHealth?: { verdict: SyncVerdict; since: string | null };
|
||||
lastSuccessAt?: string | null;
|
||||
}
|
||||
|
||||
export interface ConnectSourceView {
|
||||
slot: ConnectSourceSlot;
|
||||
verdict: SyncVerdict;
|
||||
/**
|
||||
* Whether the flow may record this as the completed first result. Only a
|
||||
* connection that is delivering, or one whose first sync is under way —
|
||||
* never a connection that needs repairing, and never the mere absence of a
|
||||
* ledger row.
|
||||
*/
|
||||
settled: boolean;
|
||||
/** The instant the copy names, or null when there is none to name. */
|
||||
when: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the connect step may honestly say about one source.
|
||||
*
|
||||
* The liveness truth is `syncHealth.verdict` and nothing else — the ledger's
|
||||
* `state` is what the last ATTEMPT did, and its "no row" default used to read
|
||||
* as `connected`, which is how a brand-new account was told its wearable was
|
||||
* connected and had the flow's one result stamped as achieved on its behalf.
|
||||
*
|
||||
* Returns `null` while the envelope has not resolved: an unloaded status is
|
||||
* not evidence of anything, and the screen says nothing rather than guessing.
|
||||
*/
|
||||
export function connectSourceView(
|
||||
source: BrowserConnectableSource,
|
||||
status: ConnectSourceStatus | undefined,
|
||||
): ConnectSourceView | null {
|
||||
if (!status) return null;
|
||||
const verdict = status.syncHealth?.verdict ?? "disconnected";
|
||||
const settled = verdict === "pending_first_sync" || verdict === "fresh";
|
||||
const when =
|
||||
verdict === "fresh" || verdict === "stale"
|
||||
? (status.lastSuccessAt ?? status.syncHealth?.since ?? null)
|
||||
: (status.syncHealth?.since ?? status.lastSuccessAt ?? null);
|
||||
|
||||
if (settled || verdict === "stale") {
|
||||
return { slot: "result", verdict, settled, when };
|
||||
}
|
||||
if (verdict !== "disconnected") {
|
||||
return { slot: "attention", verdict, settled, when };
|
||||
}
|
||||
return {
|
||||
slot: canStartConnection(source, status) ? "connect" : "credentials",
|
||||
verdict,
|
||||
settled,
|
||||
when,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the connect CTA leads anywhere. A `byo` provider without its client
|
||||
* pair and a `shared` provider without any resolvable app both land on a card
|
||||
* that has no connect button — the dead end the flow used to walk into.
|
||||
*/
|
||||
function canStartConnection(
|
||||
source: BrowserConnectableSource,
|
||||
status: ConnectSourceStatus,
|
||||
): boolean {
|
||||
switch (SOURCE_INTEGRATION[source].credentials) {
|
||||
case "byo":
|
||||
return status.configured !== false;
|
||||
case "shared":
|
||||
return status.available !== false;
|
||||
case "self-hosted":
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The sources the flow must stop offering to connect: the ones whose
|
||||
* connection is already delivering (`fresh`), and the ones whose data has
|
||||
* merely gone quiet (`stale`) — both are connections the person made, and
|
||||
* both would be "connect it" a second time.
|
||||
*
|
||||
* `pending_first_sync` is deliberately NOT in the set: that is a connection
|
||||
* made moments ago, and it is exactly the result this step exists to show.
|
||||
* Neither is anything on the attention arm — there is still something to say.
|
||||
*/
|
||||
export function deliveringSources(
|
||||
entries:
|
||||
ReadonlyArray<ConnectSourceStatus & { integration: string }> | undefined,
|
||||
): ReadonlySet<BrowserConnectableSource> {
|
||||
const delivering = new Set<BrowserConnectableSource>();
|
||||
if (!entries) return delivering;
|
||||
for (const source of BROWSER_CONNECTABLE_SOURCES) {
|
||||
const entry = entries.find(
|
||||
(candidate) =>
|
||||
candidate.integration === SOURCE_INTEGRATION[source].statusKey,
|
||||
);
|
||||
const verdict = connectSourceView(source, entry)?.verdict;
|
||||
if (verdict === "fresh" || verdict === "stale") delivering.add(source);
|
||||
}
|
||||
return delivering;
|
||||
}
|
||||
@@ -110,9 +110,15 @@ function only<T extends string>(
|
||||
|
||||
/**
|
||||
* The body for one answered step, or null when the selection cannot be an
|
||||
* answer (Q1 with nothing chosen). Every arm names its field; a value that is
|
||||
* not in the step's vocabulary is dropped rather than sent, so the strict
|
||||
* server schema never sees a chip this file did not offer.
|
||||
* answer. Every arm names its field; a value that is not in the step's
|
||||
* vocabulary is dropped rather than sent, so the strict server schema never
|
||||
* sees a chip this file did not offer.
|
||||
*
|
||||
* v1.39 — an empty tick list is not an answer either. The many-answer arms
|
||||
* used to send `[]` and mark the step DONE, and an
|
||||
* answered Q2 with no areas is what switched every optional module off for
|
||||
* somebody whose click meant "no preference". Skip is the way past a question
|
||||
* nobody wants to answer, and Skip is the conservative derivation.
|
||||
*/
|
||||
export function questionAnswerBody(
|
||||
step: QuestionStepId,
|
||||
@@ -123,14 +129,18 @@ export function questionAnswerBody(
|
||||
const [recordTarget] = only(ONBOARDING_RECORD_TARGETS, selected);
|
||||
return recordTarget ? { step, recordTarget } : null;
|
||||
}
|
||||
case "areas":
|
||||
return { step, areas: only(ONBOARDING_AREA_KEYS, selected) };
|
||||
case "areas": {
|
||||
const areas = only(ONBOARDING_AREA_KEYS, selected);
|
||||
return areas.length > 0 ? { step, areas } : null;
|
||||
}
|
||||
case "medication": {
|
||||
const [medication] = only(ONBOARDING_MEDICATION_ANSWERS, selected);
|
||||
return medication ? { step, medication } : null;
|
||||
}
|
||||
case "sources":
|
||||
return { step, sources: only(ONBOARDING_SOURCE_KEYS, selected) };
|
||||
case "sources": {
|
||||
const sources = only(ONBOARDING_SOURCE_KEYS, selected);
|
||||
return sources.length > 0 ? { step, sources } : null;
|
||||
}
|
||||
case "visit": {
|
||||
const [visit] = only(ONBOARDING_VISIT_ANSWERS, selected);
|
||||
return visit ? { step, visit } : null;
|
||||
@@ -171,7 +181,7 @@ export function unitsAnswerBody(input: {
|
||||
asked: { glucose: boolean; weight: boolean };
|
||||
glucoseUnit: "mg/dL" | "mmol/L" | null;
|
||||
unitPreference: "metric" | "imperial" | null;
|
||||
}): OnboardingAnswerInput {
|
||||
}): OnboardingAnswerInput | null {
|
||||
const units: {
|
||||
glucoseUnit?: "mg/dL" | "mmol/L";
|
||||
unitPreference?: "metric" | "imperial";
|
||||
@@ -182,5 +192,7 @@ export function unitsAnswerBody(input: {
|
||||
if (input.asked.weight && input.unitPreference) {
|
||||
units.unitPreference = input.unitPreference;
|
||||
}
|
||||
return { step: "units", units };
|
||||
// v1.39 — nothing chosen is not an answer: an empty
|
||||
// units body marked Q6 done while recording no preference at all.
|
||||
return Object.keys(units).length > 0 ? { step: "units", units } : null;
|
||||
}
|
||||
@@ -93,6 +93,8 @@ export function questionScreens(state: FlowState): OnboardingStepId[] {
|
||||
: [...FIXED_QUESTIONS];
|
||||
}
|
||||
|
||||
const EMPTY_CONNECTED: ReadonlySet<string> = new Set<string>();
|
||||
|
||||
export interface FirstResultOffer {
|
||||
task: OnboardingFirstResultTask;
|
||||
/** A source key for a connection, an area key for a reading, else null. */
|
||||
@@ -104,11 +106,21 @@ export interface FirstResultOffer {
|
||||
* the browser can complete → the first medication with a reminder, if the
|
||||
* answer to Q3 was a daily schedule → one reading for the first Q2 area.
|
||||
* Everything the priority passes over goes to the checklist instead.
|
||||
*
|
||||
* `connected` names the sources whose connection is already delivering. It is
|
||||
* optional because the server picks the order from the answers alone, before
|
||||
* any status is in hand; the screen re-picks once the envelope resolves. A
|
||||
* source in the set is not a task — an account whose WHOOP had been feeding
|
||||
* the dashboard for months was told to go and connect WHOOP, which is the
|
||||
* wizard talking past the account it can already see.
|
||||
*/
|
||||
export function chooseFirstResultTask(
|
||||
needs: OnboardingNeeds,
|
||||
connected: ReadonlySet<string> = EMPTY_CONNECTED,
|
||||
): FirstResultOffer | null {
|
||||
const connectable = needs.sources.find(isBrowserConnectableSource);
|
||||
const connectable = needs.sources.find(
|
||||
(source) => isBrowserConnectableSource(source) && !connected.has(source),
|
||||
);
|
||||
if (connectable) return { task: "connect-source", target: connectable };
|
||||
if (needs.medication === "yes")
|
||||
return { task: "add-medication", target: null };
|
||||
|
||||
@@ -46,6 +46,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
],
|
||||
auth: [
|
||||
"auth.admin.required",
|
||||
"auth.already_authenticated",
|
||||
"auth.mfa.code_invalid",
|
||||
"auth.missing",
|
||||
"auth.refresh.invalid",
|
||||
|
||||
@@ -326,9 +326,10 @@ const integrationLedgerState = z
|
||||
"error_reauth",
|
||||
"disconnected",
|
||||
"parked",
|
||||
"unknown",
|
||||
])
|
||||
.describe(
|
||||
"Last recorded ledger state for this provider. `connected` is also the value a user who has never synced sees — the ledger row is created on the first attempt, and its absence reads as `connected`, not as an error.",
|
||||
"Last recorded ledger state for this provider. `unknown` (added v1.38.19) is what a user who has never synced sees: the ledger row is created on the first attempt, and its absence is no claim about the connection in either direction — it used to read as `connected`, which is why a client must never treat this field as liveness. Read `syncHealth.verdict` for that, and treat `unknown` as `disconnected` anywhere a ledger state is painted.",
|
||||
);
|
||||
|
||||
const nightscoutConnectRequest = nightscoutConnectSchema.meta({
|
||||
|
||||
@@ -559,6 +559,26 @@ const aiProviderResponse = z
|
||||
.describe(
|
||||
"v1.22 (#89) — per-user response timeout for AI generation, in seconds (10–600). Null = the built-in comprehensive-briefing default (~120 s). Mainly for slow local/self-hosted backends.",
|
||||
),
|
||||
// v1.38.19 — the setup flow's last screen used to promise
|
||||
// that insights "work for you right away" as soon as `managedBy` read
|
||||
// `server`. That is a presence read; it never knew whether the key
|
||||
// worked, and it said nothing about the consent receipt the egress
|
||||
// actually needs. These three fields make the promise checkable.
|
||||
serverProviderHealth: z
|
||||
.enum(["healthy", "unhealthy", "unknown"])
|
||||
.describe(
|
||||
"Instance-wide health of the operator's shared providers (`admin-openai` / `admin-codex`), folded across all accounts and reduced to a tri-state: `healthy` when one of them demonstrably served somebody within the last 24 h and no credential sits in its auth-failure cooldown, `unhealthy` when the last outcome somebody observed was a failure, `unknown` when nothing has been tried lately on this instance (including a brand-new instance whose ledger is still empty) or the read failed. Deliberately carries no count, no timestamp and no account identity — 'the provider of this instance last worked' is a statement about the instance, not about other people. Fails closed for the offer: neither `unhealthy` nor `unknown` may be offered. They are not interchangeable for copy, though — `unhealthy` is a verdict a client may state, `unknown` is the absence of one and must not be reported as an outage. Reading this never probes a provider.",
|
||||
),
|
||||
serverProviderOffer: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Whether the shared provider may honestly be offered to this caller in one tap. True only when all of: the operator's provider is the one that would serve them (`managedBy: \"server\"`), `serverProviderHealth` is `healthy`, the operator's assistant master + coach flags are on, the acting record holds its own credentials (a managed profile never does), the caller holds no receipt yet, and the instance is not running in demo mode (where the grant the tap makes is refused at the edge). Anything unknown makes it false.",
|
||||
),
|
||||
serverProviderConsent: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Whether this caller already holds an active `ai_full` / `ai_coach` receipt — the grant `POST /api/consent/ai/web` mints. When true there is nothing left to offer, and `serverProviderOffer` is false for that reason rather than for a bad one.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "AiProviderResponse",
|
||||
|
||||
@@ -5,14 +5,27 @@ import type {
|
||||
|
||||
/**
|
||||
* A status-ledger row alone cannot prove a connection: integrations without a
|
||||
* row synthesize a healthy ledger state for the owner Settings dashboard. The
|
||||
* row synthesize a ledger state the owner Settings dashboard reads. The
|
||||
* managed projection must instead report those as disconnected until its
|
||||
* record carries a real credential or connection row.
|
||||
*
|
||||
* This projection's `state` says whether a pipe exists, never whether it is
|
||||
* live — liveness is `syncHealth.verdict`, and the card beside this pill
|
||||
* reads that. So the connection map decides first, and only then does the
|
||||
* ledger state refine the answer.
|
||||
*/
|
||||
export function resolveManagedIntegrationState(
|
||||
state: IntegrationState,
|
||||
connected: Record<IntegrationKey, boolean>,
|
||||
integration: IntegrationKey,
|
||||
): IntegrationState {
|
||||
return connected[integration] ? state : "disconnected";
|
||||
if (!connected[integration]) return "disconnected";
|
||||
// v1.38.19 — `unknown` is the ledger saying it holds no row, and past the
|
||||
// gate above that means a real credential exists whose first sync has not
|
||||
// landed yet. "A pipe exists" is exactly what this field reports, so it
|
||||
// reads `connected`. Reporting "Not connected" over a credential the
|
||||
// guardian created minutes ago invites them to revoke and redo the grant,
|
||||
// and it would contradict the `connected: true` the same row carries. The
|
||||
// closed vocabulary this projection publishes stays closed either way.
|
||||
return state === "unknown" ? "connected" : state;
|
||||
}
|
||||
+84
-8
@@ -13,7 +13,7 @@ import {
|
||||
* compose healthcheck rely on it. `/api/health` stays public for the same
|
||||
* reason.
|
||||
*/
|
||||
const PUBLIC_PATHS = [
|
||||
export const PUBLIC_PATHS = [
|
||||
"/auth/",
|
||||
"/api/auth/login",
|
||||
"/api/auth/register",
|
||||
@@ -142,13 +142,21 @@ function isPublicPath(pathname: string): boolean {
|
||||
* PUT must not drag in the layout-reset DELETE on the same path).
|
||||
*
|
||||
* The login family is the historical baseline — a demo visitor still has
|
||||
* to authenticate. The two dashboard entries are display-only preference
|
||||
* writes: idempotent, user-scoped, Zod-validated, and touching nothing but
|
||||
* the caller's own `User.dashboardWidgetsJson` blob (chart-overlay toggles
|
||||
* + comparison-baseline selector). They carry no health data and are safe
|
||||
* to exercise in the demo so the above-chart toggles work there. This whole
|
||||
* block only runs under `DEMO_MODE=true`, so production (apps01) is
|
||||
* unaffected by construction.
|
||||
* to authenticate. The rest are idempotent, Zod-validated writes that carry
|
||||
* no health data and no free text, and that land only on the acting record:
|
||||
* the two dashboard display-preference blobs (chart-overlay toggles +
|
||||
* comparison-baseline selector), the setup flow's closed-enum answers, its
|
||||
* step ledger and disclaimer/tour checkpoints, and the baseline profile
|
||||
* fields the confirm screen submits.
|
||||
*
|
||||
* The demo is ONE published account that every visitor signs into, so
|
||||
* "user-scoped" is not privacy here — whatever one visitor writes is what
|
||||
* the next one finds until the instance is reseeded. That is tolerable for
|
||||
* a unit toggle and a date of birth, and it is why free text, anything that
|
||||
* reaches an AI prompt, and anything creating a second record are refused.
|
||||
*
|
||||
* This whole block only runs under `DEMO_MODE=true`, so production (apps01)
|
||||
* is unaffected by construction.
|
||||
*/
|
||||
const DEMO_MUTATION_ALLOWLIST: ReadonlyArray<{ path: string; method: string }> =
|
||||
[
|
||||
@@ -174,6 +182,35 @@ const DEMO_MUTATION_ALLOWLIST: ReadonlyArray<{ path: string; method: string }> =
|
||||
// Neither creates health data.
|
||||
{ path: "/api/onboarding/disclaimer", method: "POST" },
|
||||
{ path: "/api/onboarding/tour", method: "POST" },
|
||||
// v1.39 — the baseline write the confirm screen makes. Without it a demo
|
||||
// visitor who typed a height got a 403 and a generic toast, so only the
|
||||
// empty-form/Skip path completed and the demo did not in fact walk the
|
||||
// same flow (design spec §Principles 5).
|
||||
//
|
||||
// Read the HANDLER's field list, not the caller's. This route writes
|
||||
// `email`, `displayName`, `fullName`, `insurerName`, `insurerIkNumber`,
|
||||
// `insuranceNumber`, `locale`, `timezone`, `timeFormat`, `dateFormat` and
|
||||
// `moodReminderEnabled` as well as the three the baseline step collects —
|
||||
// admitting the path admits all of them, and on one shared published
|
||||
// account each is the next visitor's starting state. So the write is
|
||||
// narrowed on the SERVER: `applyProfileUpdate` drops everything outside
|
||||
// `DEMO_WRITABLE_PROFILE_FIELDS` (height, date of birth, sex) while
|
||||
// `DEMO_MODE` is set. What this entry admits is those three fields.
|
||||
//
|
||||
// Two neighbours of it are deliberately NOT here. The demo is one shared,
|
||||
// published account, so "the caller's own" is every visitor at once:
|
||||
// `PUT /api/coach/about-me` is free text that persists for the next
|
||||
// visitor AND is fed into the Coach prompt — an anonymous prompt-
|
||||
// injection surface on a public instance. The flow's anamnesis card
|
||||
// renders read-only under `DEMO_MODE` instead, so no visitor meets it
|
||||
// as a failed save.
|
||||
// `POST /api/managed-profiles` opens with `requireFreshMfa`, and the
|
||||
// demo account has no second factor, so admitting it here would buy
|
||||
// the guardian arm nothing — the call is refused a layer later either
|
||||
// way, and the confirm screen already says so.
|
||||
// The per-profile routes (`/api/managed-profiles/{id}`, its guardians)
|
||||
// are different paths and stay closed too.
|
||||
{ path: "/api/auth/profile", method: "PUT" },
|
||||
];
|
||||
|
||||
// Legacy route redirects (German → English)
|
||||
@@ -330,6 +367,45 @@ export function proxy(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// v1.38.19 — the invite universal-link landing answers at the EDGE.
|
||||
//
|
||||
// `src/app/invite/[token]/page.tsx` has always redirected onto
|
||||
// `/auth/register?invite=…`, but Next does not deliver that as a server 307:
|
||||
// it streams the root layout first and puts the redirect in the RSC flight
|
||||
// payload. `<AuthShell>` therefore mounted on the pathname `/invite/…`, and
|
||||
// before the entry added beside this one it read the route as protected,
|
||||
// took a 401 from `/api/auth/me` and raced its own
|
||||
// `router.replace("/auth/login")` against the redirect — logged out the
|
||||
// login bounce won, and the visitor held a one-time invite on a page that
|
||||
// cannot use it. Answering here means no HTML, no hydration, no race.
|
||||
//
|
||||
// The page stays as the fallback for any path that bypasses the matcher.
|
||||
// The shape gate is the twin of `looksLikeInviteToken` in
|
||||
// `src/lib/auth/invite-token.ts`, restated rather than imported: that module
|
||||
// pulls the Prisma client, which has no business in the proxy bundle. A
|
||||
// segment that fails it is dropped rather than reflected, so the target can
|
||||
// never echo attacker-controlled text.
|
||||
if (pathname.startsWith("/invite/")) {
|
||||
const token = pathname.slice("/invite/".length).split("/")[0] ?? "";
|
||||
const target = new URL("/auth/register", request.url);
|
||||
if (/^hlv_[0-9a-f]{64}$/.test(token)) {
|
||||
target.searchParams.set("invite", token);
|
||||
}
|
||||
const redirected = applyBaselineSecurityHeaders(
|
||||
NextResponse.redirect(target, 307),
|
||||
);
|
||||
// The secret rides in the path of the request that produced this hop, so
|
||||
// the hop itself carries the same defence the landing page earned in
|
||||
// v1.17.0: uncacheable, unindexable, and never leaked as a `Referer`.
|
||||
redirected.headers.set(
|
||||
"Cache-Control",
|
||||
"no-store, no-cache, must-revalidate",
|
||||
);
|
||||
redirected.headers.set("X-Robots-Tag", "noindex, nofollow");
|
||||
redirected.headers.set("Referrer-Policy", "no-referrer");
|
||||
return redirected;
|
||||
}
|
||||
|
||||
// Server-side route protection for pages (not API routes — those have their own getSession checks)
|
||||
const isApiRoute = pathname.startsWith("/api/");
|
||||
const isStaticFile = /\.\w+$/.test(pathname);
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* v1.39 — what `PUT /api/auth/profile` may write on a demo instance.
|
||||
*
|
||||
* The route is on the proxy's `DEMO_MUTATION_ALLOWLIST` so the setup flow's
|
||||
* baseline step can complete in the demo. That allowlist is an EDGE control:
|
||||
* it decides which path and method get through, and once a path is through,
|
||||
* every field that route writes is writable. This route writes the account's
|
||||
* contact email, its display name, its full name and its insurer fields — and
|
||||
* the demo is one published account that every visitor signs into, so any of
|
||||
* them lands on the record the next visitor sees.
|
||||
*
|
||||
* The narrowing therefore cannot live in the client. It is a server-side
|
||||
* field filter in `applyProfileUpdate`, and this file proves it against real
|
||||
* Postgres rather than against a status code: the route answers 200 either
|
||||
* way — it answered 200 while writing the email — so the verdict has to be
|
||||
* read back off the row.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
/** What the shared demo account carries before any visitor touches it. */
|
||||
const SEEDED = {
|
||||
username: "demo",
|
||||
email: "demo@healthlog.test",
|
||||
displayName: "HealthLog Demo",
|
||||
fullName: "Demo Person",
|
||||
insurerName: "Demo Krankenkasse",
|
||||
};
|
||||
|
||||
/** What a visitor could put on the shared record through this one route. */
|
||||
const HOSTILE = {
|
||||
email: "attacker@example.test",
|
||||
displayName: "Owned",
|
||||
fullName: "Attacker",
|
||||
insurerName: "Attacker Insurance",
|
||||
};
|
||||
|
||||
/** The three the baseline step exists to collect, and the only three
|
||||
* the demo may write. */
|
||||
const BASELINE = {
|
||||
heightCm: 181,
|
||||
dateOfBirth: "1988-04-12",
|
||||
gender: "FEMALE" as const,
|
||||
};
|
||||
|
||||
const ORIGINAL_DEMO_MODE = process.env.DEMO_MODE;
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_DEMO_MODE === undefined) delete process.env.DEMO_MODE;
|
||||
else process.env.DEMO_MODE = ORIGINAL_DEMO_MODE;
|
||||
});
|
||||
|
||||
async function seedSignedInAccount(): Promise<string> {
|
||||
const { hashPassword } = await import("@/lib/auth/password");
|
||||
const { createSession } = await import("@/lib/auth/session");
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
...SEEDED,
|
||||
role: "USER",
|
||||
passwordHash: await hashPassword("DemoStrongP@ssw0rd!123abc"),
|
||||
},
|
||||
});
|
||||
await createSession(user.id, false);
|
||||
return user.id;
|
||||
}
|
||||
|
||||
function makeRequest(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/auth/profile", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function putProfile(body: unknown): Promise<number> {
|
||||
const { PUT } = await import("@/app/api/auth/profile/route");
|
||||
const res = await PUT(makeRequest(body));
|
||||
return res.status;
|
||||
}
|
||||
|
||||
describe("PUT /api/auth/profile under DEMO_MODE (real Postgres)", () => {
|
||||
it("writes the baseline fields and drops everything else", async () => {
|
||||
process.env.DEMO_MODE = "true";
|
||||
const userId = await seedSignedInAccount();
|
||||
|
||||
// Everything in one body, exactly as a visitor could send it. The client
|
||||
// never offers these, which is precisely why the client cannot be the
|
||||
// control: this request does not come from the client.
|
||||
const status = await putProfile({ ...HOSTILE, ...BASELINE });
|
||||
expect(status).toBe(200);
|
||||
|
||||
const row = await getPrismaClient().user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
// The record the next visitor sees is the record the reseed left.
|
||||
expect(row.email).toBe(SEEDED.email);
|
||||
expect(row.displayName).toBe(SEEDED.displayName);
|
||||
expect(row.fullName).toBe(SEEDED.fullName);
|
||||
expect(row.insurerName).toBe(SEEDED.insurerName);
|
||||
|
||||
// And the step the allowlist entry exists for still completes.
|
||||
expect(row.heightCm).toBe(BASELINE.heightCm);
|
||||
expect(row.gender).toBe(BASELINE.gender);
|
||||
expect(row.dateOfBirth?.toISOString().slice(0, 10)).toBe(
|
||||
BASELINE.dateOfBirth,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not answer the 'is this email taken?' question either", async () => {
|
||||
// The conflict check runs before the write and 409s on a taken address.
|
||||
// On a public demo that is an existence oracle over the instance's
|
||||
// accounts, so the narrowing has to happen ahead of it, not after.
|
||||
process.env.DEMO_MODE = "true";
|
||||
const prisma = getPrismaClient();
|
||||
await seedSignedInAccount();
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
username: "someone-else",
|
||||
email: "taken@healthlog.test",
|
||||
role: "USER",
|
||||
passwordHash: "x",
|
||||
},
|
||||
});
|
||||
|
||||
expect(await putProfile({ email: "taken@healthlog.test" })).toBe(200);
|
||||
});
|
||||
|
||||
it("writes every field on an ordinary instance — the gate is demo mode, not the route", async () => {
|
||||
delete process.env.DEMO_MODE;
|
||||
const userId = await seedSignedInAccount();
|
||||
|
||||
expect(await putProfile({ ...HOSTILE, ...BASELINE })).toBe(200);
|
||||
|
||||
const row = await getPrismaClient().user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
});
|
||||
expect(row.email).toBe(HOSTILE.email);
|
||||
expect(row.displayName).toBe(HOSTILE.displayName);
|
||||
expect(row.fullName).toBe(HOSTILE.fullName);
|
||||
expect(row.insurerName).toBe(HOSTILE.insurerName);
|
||||
expect(row.heightCm).toBe(BASELINE.heightCm);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user