mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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.
480 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|