Files
HealthLog/src/components/onboarding/done-screen.tsx
T
Marc-André Bombeck e2b7e7350e fix(consent): mint AI consent receipts only on an explicit agreement
The AI settings page posted to /api/consent/ai/web on every mount and the
server minted an ai_full receipt for it, so an account that had agreed to
nothing sent its health data to the operator's provider from background
jobs. Native builds synced a receipt the same way on every launch.

- Both grant routes now require `confirm: true`; anything else writes
  nothing and answers 400 consent.ai.confirmRequired.
- Remove the mount-time heal. A consent card names the operator's service
  and the data that would go there, with one Agree button, in Settings > AI
  and on the Insights overview while AI is closed by the missing consent
  alone.
- Publish the standing consent as `ai.consent` on /api/auth/me (status,
  since, recipients); the settings card renders active-since and the
  withdraw control from it.
- Existing receipts stay valid.
- Guard that no client grants outside a click handler, route tests for the
  confirmation, and an integration test that a fresh operator-key account
  gets no background AI text until it agrees.
2026-10-16 17:11:04 +02:00

480 lines
19 KiB
TypeScript

"use client";
import { useAiProviderState } from "@/hooks/use-ai-capability";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { showErrorToast } from "@/lib/errors/error-toast";
import {
CheckCircle2,
Compass,
FileUp,
PlusCircle,
Plug,
Settings2,
Sparkles,
UserPlus,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { MedicalDisclaimer } from "@/components/common/medical-disclaimer";
import { SampleBriefingCard } from "@/components/onboarding/sample-briefing-card";
import { StepHeading } from "@/components/onboarding/step-heading";
import { useOnboardingAnswer } from "@/components/onboarding/use-onboarding-flow";
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 { useGrantAiConsent } from "@/hooks/use-grant-ai-consent";
import { useTranslations } from "@/lib/i18n/context";
import { markChecklistExpanded } from "@/lib/onboarding/checklist-storage";
import type { OnboardingStateDto } from "@/lib/onboarding/needs";
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;
}
/**
* v1.39 (C2) — the setup flow's exit.
*
* The dashboard is one click away, already ordered, and the checklist opens
* on arrival (design spec §The screens, 5). The tour is an OFFER here rather
* than an automatic launch: pressing "Take a short tour" sets the referrer
* the shell-level `<TourLauncher>` reads, and nothing else does, so somebody
* who wants the dashboard gets the dashboard.
*
* Two record-shaped exits ride the answer to Q1: "both" is offered the
* profile for the person they look after, and "someone I look after" — whose
* profile was created on the confirm screen — is offered that record, through
* the same switch the account menu uses, so their first entry lands in the
* right record.
*
* The step is marked done on arrival, once. Everything else on this screen
* is presentational: the module map was derived on confirm, the cookie
* cleared there too.
*
* The AI panel leads with the payoff (a static, clearly-labelled SAMPLE
* briefing — no model call, no egress, no consent), then the honest
* local-first ladder and the "useful without AI" release valve. Value-first,
* never a gate: every exit below stays, and setup is a single optional
* deep-link.
*/
export function DoneScreen({ state }: { state: OnboardingStateDto }) {
const { t } = useTranslations();
const router = useRouter();
const { user } = useAuth();
const [sampleOpen, setSampleOpen] = useState(false);
const answer = useOnboardingAnswer();
const accountSwitch = useAccountSwitch();
// Mark the step once. The write is idempotent, but a strict-mode double
// mount would still send it twice; the ref keeps it to one.
const marked = useRef(false);
const donePending =
state.steps.find((step) => step.id === "done")?.status === "pending";
useEffect(() => {
if (!donePending || marked.current) return;
marked.current = true;
answer.mutate({ step: "done" });
// `answer` is a fresh object every render; the mutation it wraps is not.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [donePending]);
const recordTarget = state.needs.recordTarget;
const managedRecord =
recordTarget === "someone-else"
? (user?.accountAccess?.accounts.find(
(entry) => entry.recordKind === "managed",
) ?? null)
: null;
function openDashboard() {
markChecklistExpanded();
router.push("/");
}
function takeTour() {
if (user?.id) setTourReferrer(user.id);
markChecklistExpanded();
router.push("/");
}
// ── 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.
// The panel is one optional offer, shown only while this person could set
// AI up here: `provider.canConfigure` is false while the operator has AI
// switched off and inside a record somebody else owns. HealthLog is
// complete without it, so with nothing to offer the screen simply goes
// from "done" to the exits.
const providerState = useAiProviderState();
const showAiPanel = providerState.canConfigure;
const { data: aiProvider } = useQuery<AiProviderStatus>({
queryKey: queryKeys.userAiProvider(),
queryFn: async () => {
return apiGet("/api/user/ai-provider");
},
enabled: !!user && showAiPanel,
});
// 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);
// The tap on the offer is the consent act; the hook sends the explicit
// confirmation the server requires before it records one.
const grantConsent = useGrantAiConsent();
function agreeToSharedProvider() {
grantConsent.mutate(undefined, {
onSuccess: () => setJustGranted(true),
onError: (failure) => {
// The button stays. A failed grant is a failed grant; the screen
// must not read as though consent were on file.
showErrorToast(t("onboarding.ai.offer.error"), { cause: failure });
},
});
}
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
aria-labelledby="onboarding-done-title"
className="flex flex-col items-center gap-6 py-6 text-center"
>
<span
aria-hidden="true"
className="bg-primary/10 text-primary flex size-20 items-center justify-center rounded-full"
>
<CheckCircle2 className="size-10" />
</span>
<div className="mx-auto max-w-md space-y-2">
<StepHeading
id="onboarding-done-title"
title={t("onboarding.done.title")}
description={t("onboarding.done.body")}
/>
<p className="text-muted-foreground text-sm leading-relaxed">
{t("onboarding.done.learning")}
</p>
</div>
{/* v1.28 — the flagship AI value, made reachable at the one screen
every fresh user passes through. The daily briefing / Coach need
a provider the rest of onboarding never provisions, so this
leads with the payoff (a static, clearly-labelled SAMPLE
briefing — no model call, no egress, no consent), then the
honest local-first ladder and the "useful without AI" release
valve. Value-first, never a gate: the three exits below stay,
and setup is a single optional deep-link. */}
{showAiPanel ? (
<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">
<span
aria-hidden="true"
className="from-primary to-brand-pink flex size-9 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
>
<Sparkles className="text-background size-4" />
</span>
<div className="space-y-1">
<h2
id="onboarding-ai-panel-title"
className="text-foreground text-base font-semibold tracking-tight"
>
{t("onboarding.ai.panelTitle")}
</h2>
<p className="text-muted-foreground text-sm leading-relaxed">
{t("onboarding.ai.panelIntro")}
</p>
</div>
</header>
{sampleOpen ? (
<SampleBriefingCard />
) : (
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
data-slot="onboarding-ai-show-sample"
onClick={() => setSampleOpen(true)}
>
{t("onboarding.ai.showSample")}
</Button>
)}
{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"
>
{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={agreeToSharedProvider}
>
{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")}
</p>
<ul className="space-y-2.5">
{/* Local first — the calm, private default — then BYOK, then
the subscription/OAuth path with its training caveat. Same
ordering + vendor-blind framing as the shipped document-
provider governance. */}
<li className="space-y-0.5">
<p className="text-foreground text-sm font-medium">
{t("onboarding.ai.ladderLocalTitle")}
</p>
<p className="text-muted-foreground text-sm leading-relaxed">
{t("onboarding.ai.ladderLocalBody")}
</p>
</li>
<li className="space-y-0.5">
<p className="text-foreground text-sm font-medium">
{t("onboarding.ai.ladderByokTitle")}
</p>
<p className="text-muted-foreground text-sm leading-relaxed">
{t("onboarding.ai.ladderByokBody")}
</p>
</li>
<li className="space-y-0.5">
<p className="text-foreground text-sm font-medium">
{t("onboarding.ai.ladderOauthTitle")}
</p>
<p className="text-muted-foreground text-sm leading-relaxed">
{t("onboarding.ai.ladderOauthBody")}
</p>
</li>
</ul>
</div>
<p
data-slot="onboarding-ai-keyless"
className="text-foreground text-sm leading-relaxed"
>
{t("onboarding.ai.keylessLine")}
</p>
<MedicalDisclaimer variant="dataPosture" />
<Link
href="/settings/ai"
className="text-primary text-sm font-medium underline underline-offset-4"
>
{t("onboarding.ai.setupCta")}
</Link>
</section>
) : null}
<div className="flex w-full max-w-xs flex-col gap-2">
<Button
type="button"
size="lg"
onClick={openDashboard}
className="min-h-11"
data-slot="onboarding-open-dashboard"
>
{t("onboarding.done.returnCta")}
</Button>
<Button
type="button"
size="lg"
variant="outline"
onClick={takeTour}
className="inline-flex min-h-11 items-center gap-2"
data-slot="onboarding-take-tour"
>
<Compass className="size-4" />
{t("onboarding.flow.done.tourCta")}
</Button>
{managedRecord ? (
<Button
type="button"
size="lg"
variant="outline"
onClick={() => accountSwitch.mutate(managedRecord.accountId)}
disabled={accountSwitch.isPending}
className="inline-flex min-h-11 items-center gap-2"
data-slot="onboarding-open-managed-record"
>
<UserPlus className="size-4" />
{t("onboarding.flow.done.openRecord", {
name: accountLabel(managedRecord),
})}
</Button>
) : null}
{recordTarget === "both" ? (
<Button asChild size="lg" variant="outline">
<Link
href="/settings/access"
className="inline-flex min-h-11 items-center gap-2"
data-slot="onboarding-create-managed-profile"
>
<UserPlus className="size-4" />
{t("onboarding.flow.done.createProfile")}
</Link>
</Button>
) : null}
<Button asChild variant="ghost">
<Link
href="/settings/integrations"
className="inline-flex items-center gap-2"
>
<Plug className="size-4" />
{t("onboarding.done.connectCta")}
</Link>
</Button>
<Button asChild variant="ghost">
<Link href="/measurements" className="inline-flex items-center gap-2">
<PlusCircle className="size-4" />
{t("onboarding.done.logCta")}
</Link>
</Button>
</div>
{/* 2026-07-17 UX-onboarding audit M5 — the module system (default-on
set + opt-ins like nutrients / mental health / documents) is
otherwise undiscoverable during first-run: the wizard never
mentions it, and the only pointer used to be one tour stop.
A quiet link here, beside the existing import link, gives every
new account one honest pointer to Settings → Modules before they
leave the wizard. */}
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5">
<Link
href="/settings/export"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-sm underline underline-offset-4"
>
<FileUp className="size-3.5" />
{t("onboarding.done.importCta")}
</Link>
<Link
href="/settings/modules"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-sm underline underline-offset-4"
>
<Settings2 className="size-3.5" />
{t("onboarding.done.modulesCta")}
</Link>
</div>
</section>
);
}