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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-16 17:11:04 +02:00
1 parent bc5ea5a67b
commit e2b7e7350e
45 files changed
+1707 -678

No files matched your search

@@ -0,0 +1,128 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { AiConsentState } from "@/lib/ai/capabilities/types";
import { I18nProvider } from "@/lib/i18n/context";
/**
* The consent question for the operator's provider. What these pin: it is
* asked only while `ai.consent` says it is needed and the person could set AI
* up here; it names the service the server named and the data that would go
* there; its one button is the consent act, and the request that button
* sends carries the explicit confirmation the server requires.
*
* Static markup (the house convention, no jsdom), so the click itself is not
* dispatched: `useMutation` is captured and its `mutationFn` invoked.
*/
const state = vi.hoisted(() => ({
consent: null as AiConsentState | null,
canConfigure: true,
mounted: true,
}));
const mutations = vi.hoisted(
() => [] as Array<{ mutationFn?: () => Promise<unknown> }>,
);
const apiPost = vi.hoisted(() => vi.fn());
vi.mock("@/hooks/use-ai-capability", () => ({
useAiConsent: () => state.consent,
useAiProviderState: () => ({
configured: true,
managedBy: "server",
canConfigure: state.canConfigure,
responseTimeoutMs: 60_000,
}),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: { id: "u1" } }),
}));
vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => state.mounted }));
vi.mock("@tanstack/react-query", () => ({
useMutation: (options: { mutationFn?: () => Promise<unknown> }) => {
mutations.push(options);
return { mutate: () => {}, isPending: false, isError: false };
},
useQueryClient: () => ({ invalidateQueries: async () => {} }),
}));
vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
apiPost,
}));
import { AiConsentRequest } from "../ai-consent-request";
const NEEDED: AiConsentState = {
status: "needed",
since: null,
recipients: [{ via: "operator_key", name: "OpenAI" }],
};
function render(surface: "settings" | "insights") {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<AiConsentRequest surface={surface} />
</I18nProvider>,
);
}
beforeEach(() => {
state.consent = NEEDED;
state.canConfigure = true;
state.mounted = true;
mutations.length = 0;
apiPost.mockReset();
try {
window.localStorage.clear();
} catch {
/* no window in this environment */
}
});
describe("AiConsentRequest", () => {
it.each(["settings", "insights"] as const)(
"on %s, names the service and the data and offers one Agree",
(surface) => {
const html = render(surface);
expect(html).toContain('data-slot="ai-consent-request"');
expect(html).toContain("OpenAI");
expect(html).toContain("blood pressure");
expect(html).toContain("withdraw");
expect(html.match(/data-slot="ai-consent-request-agree"/g)).toHaveLength(
1,
);
},
);
it.each(["given", "not_needed"] as const)(
"asks nothing while consent is %s",
(status) => {
state.consent = { ...NEEDED, status };
expect(render("settings")).toBe("");
expect(render("insights")).toBe("");
},
);
it("asks nothing before the account payload has answered", () => {
state.consent = null;
expect(render("settings")).toBe("");
});
it("asks nothing of somebody who cannot set AI up for this record", () => {
state.canConfigure = false;
expect(render("settings")).toBe("");
});
it("paints no tile on the overview before hydration", () => {
state.mounted = false;
expect(render("insights")).toBe("");
});
it("sends the explicit confirmation, and only that", async () => {
render("settings");
expect(mutations).toHaveLength(1);
await mutations[0]!.mutationFn!();
expect(apiPost).toHaveBeenCalledWith("/api/consent/ai/web", {
confirm: true,
});
});
});
+168
View File
@@ -0,0 +1,168 @@
"use client";
import { useState } from "react";
import { Loader2, ShieldCheck, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useAiConsent, useAiProviderState } from "@/hooks/use-ai-capability";
import { useAuth } from "@/hooks/use-auth";
import { useGrantAiConsent } from "@/hooks/use-grant-ai-consent";
import { useMounted } from "@/hooks/use-mounted";
import type { AiConsentState } from "@/lib/ai/capabilities/types";
import { useTranslations } from "@/lib/i18n/context";
/**
* The consent question for AI on the operator's provider, asked once and
* only where it opens something.
*
* Shown while `ai.consent.status` is `needed`: the provider chain runs
* through a credential the operator holds (their API key or their shared
* ChatGPT account) and some AI feature is closed by nothing but the missing
* consent. The card says which service the data goes to (named by the server
* in `ai.consent.recipients`), which data, that nothing goes there before the
* person agrees, and that they can withdraw it. The one "Agree" button is the
* consent act; nothing else on any surface mints a receipt.
*
* Two surfaces. In Settings → AI it is a settings card that stays until the
* question is answered there. On the Insights overview, beside the setup
* hint, it is a tile the person can put away; that is remembered per viewer
* in this browser and does not count as an answer.
*/
const DISMISS_STORAGE_PREFIX = "healthlog.ai-consent-request.dismissed";
function readDismissed(viewerId: string): boolean {
try {
return (
window.localStorage.getItem(`${DISMISS_STORAGE_PREFIX}.${viewerId}`) ===
"1"
);
} catch {
return false;
}
}
/** The services the consent would cover, as one readable list. */
function recipientList(consent: AiConsentState): string {
return consent.recipients.map((recipient) => recipient.name).join(", ");
}
export function AiConsentRequest({
surface,
}: {
/**
* `settings`: a Settings card in Settings → AI.
* `insights`: a dismissible tile on the Insights overview.
*/
surface: "settings" | "insights";
}) {
const { t } = useTranslations();
const { user } = useAuth();
const consent = useAiConsent();
const provider = useAiProviderState();
const grant = useGrantAiConsent();
const mounted = useMounted();
const [dismissedNow, setDismissedNow] = useState(false);
const viewerId = user?.id ?? null;
// Asked only of somebody who could set AI up for this record: not inside a
// record somebody else owns, not while the operator has AI switched off.
if (consent?.status !== "needed" || !provider.canConfigure) return null;
if (viewerId === null) return null;
const body = (
<>
<p className="text-foreground text-sm">
{t("settings.ai.consent.request.body", {
providers: recipientList(consent),
})}
</p>
<p className="text-foreground text-sm">
{t("settings.ai.consent.request.data")}
</p>
<p className="text-muted-foreground text-xs">
{t("settings.ai.consent.request.withdrawHint")}
</p>
{grant.isError ? (
<p className="text-destructive text-xs" role="alert">
{t("settings.ai.consent.grantError")}
</p>
) : null}
</>
);
const agree = (
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="ai-consent-request-agree"
disabled={grant.isPending}
onClick={() => grant.mutate()}
>
{grant.isPending ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("settings.ai.consent.request.agree")}
</Button>
);
if (surface === "settings") {
return (
<SettingsCard as="section" data-slot="ai-consent-request">
<SettingsCardHeader
anchor="ai-consent"
icon={ShieldCheck}
title={t("settings.ai.consent.request.title")}
/>
{body}
<SettingsCardActions>{agree}</SettingsCardActions>
</SettingsCard>
);
}
// Storage is browser-only: nothing renders before hydration, so the server
// HTML and the first client render agree.
if (!mounted || dismissedNow || readDismissed(viewerId)) return null;
const dismiss = () => {
setDismissedNow(true);
try {
window.localStorage.setItem(`${DISMISS_STORAGE_PREFIX}.${viewerId}`, "1");
} catch {
// Storage unavailable (private mode): hidden for this visit only.
}
};
return (
<Card data-slot="ai-consent-request" className="gap-2 py-3 md:py-4">
<CardHeader>
<TileHeader
icon={ShieldCheck}
title={t("settings.ai.consent.request.title")}
right={
<Button
type="button"
variant="ghost"
size="icon"
onClick={dismiss}
aria-label={t("settings.ai.consent.request.dismiss")}
title={t("settings.ai.consent.request.dismiss")}
data-slot="ai-consent-request-dismiss"
className="-my-2.5 size-11 shrink-0 sm:-my-2 sm:size-10"
>
<X className="size-4" aria-hidden="true" />
</Button>
}
/>
</CardHeader>
<CardContent className="flex flex-col items-start gap-3">
{body}
{agree}
</CardContent>
</Card>
);
}
@@ -49,10 +49,10 @@ vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({ invalidateQueries: async () => {} }),
}));
const apiFetchRaw = vi.hoisted(() => vi.fn());
const apiPost = vi.hoisted(() => vi.fn());
vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
apiFetchRaw,
apiPost,
}));
const toastError = vi.hoisted(() => vi.fn());
@@ -355,10 +355,10 @@ describe("<DoneScreen> shared-provider offer", () => {
},
);
it("grants through the affirmative web intent — the only path that may lift a revocation", async () => {
it("grants only with the explicit confirmation the server requires", async () => {
mutations.length = 0;
apiFetchRaw.mockReset();
apiFetchRaw.mockResolvedValue({ ok: true });
apiPost.mockReset();
apiPost.mockResolvedValue({ minted: true, kind: "ai_full" });
aiProviderState.data = {
managedBy: "server",
serverProviderHealth: "healthy",
@@ -369,19 +369,15 @@ describe("<DoneScreen> shared-provider offer", () => {
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" }),
}),
);
expect(apiPost).toHaveBeenCalledWith("/api/consent/ai/web", {
confirm: true,
});
});
it("a refused grant throws rather than reporting a consent nobody recorded", async () => {
mutations.length = 0;
apiFetchRaw.mockReset();
apiFetchRaw.mockResolvedValue({ ok: false, status: 429 });
apiPost.mockReset();
apiPost.mockRejectedValue(new Error("429"));
aiProviderState.data = {
managedBy: "server",
serverProviderHealth: "healthy",
+18 -35
View File
@@ -4,7 +4,7 @@ import { useAiProviderState } from "@/hooks/use-ai-capability";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { showErrorToast } from "@/lib/errors/error-toast";
import {
CheckCircle2,
@@ -25,12 +25,9 @@ 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 {
aiInputDependentKeys,
invalidateKeys,
queryKeys,
} from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
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";
@@ -150,33 +147,19 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
// 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 invalidateKeys(queryClient, aiInputDependentKeys);
await queryClient.invalidateQueries({
queryKey: queryKeys.aiConsentReceipt("ai_full"),
});
},
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 });
},
});
// 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;
@@ -327,7 +310,7 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
className="min-h-9 w-full"
data-slot="onboarding-ai-offer-grant"
disabled={grantConsent.isPending}
onClick={() => grantConsent.mutate()}
onClick={agreeToSharedProvider}
>
{t("onboarding.ai.offer.cta")}
</Button>
@@ -56,6 +56,12 @@ vi.mock("@/hooks/use-ai-capability", () => ({
useAiCapabilityMap: () => null,
// v1.43 — no usage level published: the setting stays out of the way.
useAiUsage: () => null,
// v1.43.2 — nothing to ask: the provider is the person's own.
useAiConsent: () => ({
status: "not_needed",
since: null,
recipients: [],
}),
useAiProviderState: () => ({
configured: true,
managedBy: "user",
@@ -56,6 +56,12 @@ vi.mock("@/hooks/use-ai-capability", () => ({
useAiCapabilityMap: () => null,
// v1.43 — no usage level published: the setting stays out of the way.
useAiUsage: () => null,
// v1.43.2 — nothing to ask: the provider is the person's own.
useAiConsent: () => ({
status: "not_needed",
since: null,
recipients: [],
}),
useAiProviderState: () => ({
configured: true,
managedBy: "user",
+6 -1
View File
@@ -18,6 +18,7 @@
* provider-configuration screen.
*/
import { AiConsentRequest } from "@/components/ai/ai-consent-request";
import { AiInsightsCard } from "@/components/settings/ai/ai-insights-card";
import { OperatorOffNotice } from "@/components/settings/ai/operator-off-notice";
import { StoredCoachMemory } from "@/components/settings/coach-memory-section";
@@ -43,7 +44,8 @@ export function AiSection() {
//
// v1.39 — above the provider card: what the operator has switched off (so
// nothing below reads as broken), and the one calm setup hint while no
// provider is configured. Below it: what the Coach stored, while Settings →
// provider is configured, or the consent question while the operator's
// provider waits on it. Below it: what the Coach stored, while Settings →
// Coach is not reachable, so it can always be read and deleted.
const coachSettingsReachable = isSurfaceVisible(
"settings:coach",
@@ -53,6 +55,9 @@ export function AiSection() {
<div className="space-y-6">
<OperatorOffNotice />
<AiSetupHint surface="settings" />
{/* v1.43.2 — the consent question, above the provider card, while the
operator's provider is closed by nothing but the missing consent. */}
<AiConsentRequest surface="settings" />
<AiInsightsCard isAuthenticated={authed} />
{authed && !coachSettingsReachable ? (
<StoredCoachMemory isAuthenticated={authed} />
@@ -2,129 +2,111 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { AiConsentState } from "@/lib/ai/capabilities/types";
import { I18nProvider } from "@/lib/i18n/context";
import { AiConsentCard } from "../ai-consent-card";
/**
* The web withdrawal affordance. Before this card the revoke endpoint was
* reachable only from the native client, so a web-only account could give
* AI consent and never take it back.
*
* These render the card against a pre-seeded query cache (server-side
* markup, so the mutation path is not exercised here — the endpoint it
* calls has its own route tests). What they pin is that the card reports
* the standing decision correctly and offers the withdrawal only when
* there is something to withdraw.
* The standing AI consent in Settings → AI, rendered from `ai.consent` on
* `/api/auth/me`. What these pin: the card says since when and to which
* service consent was given and offers the withdrawal only then; it never
* offers a grant of its own (the question lives in `AiConsentRequest`, at
* the top of the page, and only there); and it never misreports a decision it
* has not been told.
*/
const receiptKey = ["consent", "ai", "latest", "ai_full"];
const consentState = vi.hoisted(() => ({
value: null as AiConsentState | null,
authLoading: false,
}));
function renderWith(receipt: unknown) {
vi.mock("@/hooks/use-ai-capability", () => ({
useAiConsent: () => consentState.value,
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isLoading: consentState.authLoading }),
}));
import { AiConsentCard } from "../ai-consent-card";
function render(isAuthenticated = true) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
defaultOptions: { queries: { retry: false, enabled: false } },
});
client.setQueryData(receiptKey, receipt);
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<AiConsentCard isAuthenticated />
<AiConsentCard isAuthenticated={isAuthenticated} />
</I18nProvider>
</QueryClientProvider>,
);
}
beforeEach(() => {
vi.restoreAllMocks();
consentState.value = null;
consentState.authLoading = false;
});
describe("AiConsentCard", () => {
it("offers the withdrawal while consent stands", () => {
const html = renderWith({
id: "rcpt-1",
kind: "ai_full",
signedAt: "2026-07-01T08:00:00.000Z",
revokedAt: null,
});
it("says since when and to which service consent was given, with the withdrawal", () => {
consentState.value = {
status: "given",
since: "2026-07-01T08:00:00.000Z",
recipients: [{ via: "operator_key", name: "OpenAI" }],
};
const html = render();
expect(html).toContain('data-slot="ai-consent-withdraw"');
expect(html).toContain("Withdraw consent");
expect(html).toContain("Given on");
expect(html).toContain("OpenAI");
});
it("reports a withdrawal, stops offering it again, and offers the grant", () => {
const html = renderWith({
id: "rcpt-1",
kind: "ai_full",
signedAt: "2026-07-01T08:00:00.000Z",
revokedAt: "2026-07-10T08:00:00.000Z",
});
it("falls back to the configured provider when no operator service is in the chain", () => {
consentState.value = {
status: "given",
since: "2026-07-01T08:00:00.000Z",
recipients: [],
};
const html = render();
expect(html).toContain("the configured provider");
expect(html).toContain('data-slot="ai-consent-withdraw"');
});
expect(html).toContain("Withdrawn.");
it.each(["needed", "not_needed"] as const)(
"offers no withdrawal and no grant of its own while consent is %s",
(status) => {
consentState.value = {
status,
since: null,
recipients: [{ via: "operator_key", name: "OpenAI" }],
};
const html = render();
expect(html).not.toContain('data-slot="ai-consent-withdraw"');
expect(html).not.toContain("Agree");
expect(html).not.toContain('data-slot="ai-consent"');
},
);
it("holds its space while the account payload is loading, claiming nothing", () => {
consentState.authLoading = true;
const html = render();
expect(html).toContain('data-slot="ai-consent"');
expect(html).not.toContain('data-slot="ai-consent-withdraw"');
// Withdrawal must be as reversible as it was easy: the same surface
// carries the explicit grant control, and its hint says what granting
// lets the app do.
expect(html).toContain('data-slot="ai-consent-grant"');
expect(html).toContain("Grant consent");
expect(html).not.toContain("Given on");
});
it("treats no receipt at all as consent not given", () => {
const html = renderWith(null);
expect(html).toContain("Withdrawn.");
it("says nothing when the payload carries no consent state", () => {
const html = render();
expect(html).not.toContain('data-slot="ai-consent"');
expect(html).not.toContain('data-slot="ai-consent-withdraw"');
expect(html).toContain('data-slot="ai-consent-grant"');
});
it("never reports a failed read as a withdrawal", () => {
// A refused or failed read (the consent bucket's rate limit, a network
// drop) says nothing about the decision. Reporting it as "Withdrawn" and
// offering a grant would misstate the person's own choice.
const client = new QueryClient({
defaultOptions: { queries: { retry: false, retryOnMount: false } },
});
client
.getQueryCache()
.build(client, { queryKey: receiptKey })
.setState({
status: "error",
error: new Error("read failed"),
data: undefined,
fetchStatus: "idle",
});
const html = renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<AiConsentCard isAuthenticated />
</I18nProvider>
</QueryClientProvider>,
);
expect(html).not.toContain("Withdrawn.");
expect(html).not.toContain('data-slot="ai-consent-grant"');
expect(html).not.toContain('data-slot="ai-consent-withdraw"');
expect(html).toContain('data-slot="ai-consent-load-error"');
});
it("never shows the grant control while consent stands", () => {
const html = renderWith({
id: "rcpt-1",
kind: "ai_full",
signedAt: "2026-07-01T08:00:00.000Z",
revokedAt: null,
});
expect(html).not.toContain('data-slot="ai-consent-grant"');
});
it("renders nothing for a signed-out visitor", () => {
const client = new QueryClient();
const html = renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<AiConsentCard isAuthenticated={false} />
</I18nProvider>
</QueryClientProvider>,
);
expect(html).toBe("");
consentState.value = {
status: "given",
since: "2026-07-01T08:00:00.000Z",
recipients: [],
};
expect(render(false)).toBe("");
});
});
+72 -126
View File
@@ -9,13 +9,17 @@
* withdrawal on the same footing as the grant, so it belongs on the same
* surface, not behind a support request.
*
* The card shows the standing decision in plain words and offers the one
* move that changes it: withdraw while consent stands, grant while it does
* not. Withdrawing takes effect immediately: the consent gate fails closed
* The card renders the standing decision from `ai.consent` on `/api/auth/me`
* and offers the one move that changes it. While consent stands it says
* since when and to which service, with the withdraw control. While it does
* not and the operator's provider needs it, the consent question at the top
* of the page (`AiConsentRequest`) asks it: the provider, the data, and an
* "Agree" button that is the consent act. Nothing here grants on mount;
* until v1.43.2 the page minted a receipt on every visit, which is exactly
* what this replaced.
* Withdrawing takes effect immediately: the consent gate fails closed
* without an active receipt, so every AI surface falls back to its
* no-consent state on the next call. Granting posts an affirmative intent,
* which is the consent act itself and therefore may supersede an earlier
* revocation; the silent mount heal never does.
* no-consent state on the next call.
*
* v1.39 — a withdrawal also deletes what the app can write again: the notes
* a model wrote from the person's data (status notes, the daily briefing,
@@ -31,14 +35,15 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, ShieldCheck, ShieldOff } from "lucide-react";
import { Loader2, ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Skeleton } from "@/components/ui/skeleton";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { useAiConsent } from "@/hooks/use-ai-capability";
import { useAuth } from "@/hooks/use-auth";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
@@ -63,19 +68,11 @@ export function AiConsentCard({
const { t } = useTranslations();
const queryClient = useQueryClient();
const [confirming, setConfirming] = useState(false);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.aiConsentReceipt("ai_full"),
queryFn: async () => {
const res = await apiFetchRaw("/api/consent/ai/latest?kind=ai_full");
// A failed read is not a withdrawal: throw so the card says it could
// not load instead of reporting the person's decision wrongly.
if (!res.ok) throw new Error("consent read failed");
const json = await res.json();
return (json.data?.receipt ?? null) as ConsentReceiptWire;
},
enabled: isAuthenticated,
});
// The standing decision comes off the account payload, resolved on the
// server with the capabilities it governs, so this card and every AI
// surface agree on it. `null` until `/me` has answered.
const consent = useAiConsent();
const { isLoading: authLoading } = useAuth();
const revoke = useMutation({
mutationKey: queryKeys.aiConsentReceipt("ai_full"),
@@ -89,28 +86,8 @@ export function AiConsentCard({
setConfirming(false);
// The receipt gates every AI surface, so anything that reads consent
// state has to re-resolve — not just this card. `/me` carries the
// resolved capabilities every AI surface renders from.
await queryClient.invalidateQueries({
queryKey: queryKeys.aiConsentReceipt("ai_full"),
});
await invalidateKeys(queryClient, aiInputDependentKeys);
},
});
const grant = useMutation({
mutationKey: queryKeys.aiConsentReceipt("ai_full"),
mutationFn: async () => {
// The affirmative intent marks this as the user's own consent act,
// the only web path that may lift a standing revocation. The silent
// mount heal posts no body and 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("grant failed");
},
onSuccess: async () => {
// resolved capabilities and the consent state every AI surface
// renders from.
await queryClient.invalidateQueries({
queryKey: queryKeys.aiConsentReceipt("ai_full"),
});
@@ -120,12 +97,11 @@ export function AiConsentCard({
if (!isAuthenticated) return null;
// Say nothing until the state is known: a card that flashes "withdrawn"
// before the receipt arrives would misreport the user's own decision.
// But hold the block's space with a skeleton mirroring the final shape —
// returning null here made the section pop in and shift everything
// below it once the receipt arrived.
if (isLoading) {
// Say nothing until the state is known: a card that flashes the question
// before the payload arrives would misreport the person's own decision.
// But hold the block's space with a skeleton mirroring the final shape, so
// nothing below moves once it lands.
if (consent === null && authLoading) {
return (
<SettingsCard as="section" data-slot="ai-consent">
<SettingsCardHeader
@@ -139,50 +115,42 @@ export function AiConsentCard({
);
}
if (isError) {
return (
<SettingsCard as="section" data-slot="ai-consent">
<SettingsCardHeader
icon={ShieldCheck}
title={t("settings.ai.consent.title")}
/>
<QueryErrorRow
slot="ai-consent-load-error"
onRetry={() => void refetch()}
/>
</SettingsCard>
);
// Not given: nothing to withdraw. Where agreeing would open something (the
// operator's provider) the question sits at the top of this page
// (`AiConsentRequest`); a person on their own key or a local model has
// nothing to agree to, and reading documents asks its own narrower question
// where a document is read.
if (consent === null || consent.status !== "given") {
return <DocumentReadingConsentRow isAuthenticated={isAuthenticated} />;
}
// Narrow to the receipt itself rather than a boolean, so the branches
// below can read its fields without an assertion.
const activeReceipt = data && data.revokedAt === null ? data : null;
const providers = consent.recipients.map((r) => r.name).join(", ");
const description = consent.since
? providers
? t("settings.ai.consent.activeSinceNamed", {
date: formatDateTime(consent.since),
providers,
})
: t("settings.ai.consent.activeSince", {
date: formatDateTime(consent.since),
})
: t("settings.ai.consent.activeUndated");
return (
<>
<SettingsCard as="section" data-slot="ai-consent">
<SettingsCardHeader
icon={activeReceipt ? ShieldCheck : ShieldOff}
anchor="ai-consent"
icon={ShieldCheck}
title={t("settings.ai.consent.title")}
description={
activeReceipt
? t("settings.ai.consent.activeSince", {
date: formatDateTime(activeReceipt.signedAt),
})
: t("settings.ai.consent.withdrawn")
}
description={description}
/>
{activeReceipt && confirming ? (
<p className="text-muted-foreground text-xs">
{confirming ? (
<p className="text-foreground text-sm">
{t("settings.ai.consent.confirmBody")}
</p>
) : null}
{!activeReceipt ? (
<p className="text-muted-foreground text-xs">
{t("settings.ai.consent.regrantHint")}
</p>
) : null}
{revoke.isError ? (
<p className="text-destructive text-xs" role="alert">
@@ -190,68 +158,46 @@ export function AiConsentCard({
</p>
) : null}
{grant.isError ? (
<p className="text-destructive text-xs" role="alert">
{t("settings.ai.consent.grantError")}
</p>
) : null}
{/* One action row, last in the card; while confirming, Cancel sits
before the destructive confirm (design standards §12). */}
<SettingsCardActions>
{activeReceipt ? (
confirming ? (
<>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setConfirming(false)}
disabled={revoke.isPending}
>
{t("common.cancel")}
</Button>
<Button
type="button"
variant="destructive"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="ai-consent-withdraw-confirm"
onClick={() => revoke.mutate()}
disabled={revoke.isPending}
>
{revoke.isPending ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("settings.ai.consent.confirmWithdraw")}
</Button>
</>
) : (
{confirming ? (
<>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="ai-consent-withdraw"
onClick={() => setConfirming(true)}
onClick={() => setConfirming(false)}
disabled={revoke.isPending}
>
{t("settings.ai.consent.withdraw")}
{t("common.cancel")}
</Button>
)
<Button
type="button"
variant="destructive"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="ai-consent-withdraw-confirm"
onClick={() => revoke.mutate()}
disabled={revoke.isPending}
>
{revoke.isPending ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("settings.ai.consent.confirmWithdraw")}
</Button>
</>
) : (
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="ai-consent-grant"
onClick={() => grant.mutate()}
disabled={grant.isPending}
data-slot="ai-consent-withdraw"
onClick={() => setConfirming(true)}
>
{grant.isPending ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("settings.ai.consent.grant")}
{t("settings.ai.consent.withdraw")}
</Button>
)}
</SettingsCardActions>
@@ -13,7 +13,7 @@
* they were editing.
*/
import { useEffect, useState } from "react";
import { useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Sparkles } from "lucide-react";
@@ -28,11 +28,7 @@ import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import {
aiInputDependentKeys,
invalidateKeys,
queryKeys,
} from "@/lib/query-keys";
import { queryKeys } from "@/lib/query-keys";
import { AdminOpenAIProviderForm } from "./admin-openai-provider-form";
import { AiConsentCard } from "./ai-consent-card";
@@ -132,29 +128,10 @@ export function AiInsightsCard({
void refetchChain();
}
// v1.16.13 — heal the web AI-consent receipt on mount. The server-side
// consent gate (admin-openai egress) requires an active `ai_full`
// receipt; the web client never minted one, so existing web users on a
// shared-key deployment saw the no-key fallback. This mirrors the iOS
// shell-mount heal: idempotent server-side mint (a user with an active
// receipt is a no-op), fire-and-forget so a failure never blocks the
// settings UI. Runs once per mount of the AI-settings surface.
useEffect(() => {
if (!isAuthenticated) return;
// A heal that minted a receipt changed an input of the account's AI
// capability answer, so `/me` re-resolves; a no-op heal leaves it alone.
void apiFetchRaw("/api/consent/ai/web", { method: "POST" })
.then(async (res) => {
if (!res.ok) return;
const body = (await res.json()) as { data?: { minted?: boolean } };
if (body.data?.minted) {
void invalidateKeys(queryClient, aiInputDependentKeys);
}
})
.catch(() => {
/* best-effort heal — the explicit grant path stays available */
});
}, [isAuthenticated, queryClient]);
// No consent is granted from here. Until v1.43.2 this card posted to
// `/api/consent/ai/web` on every mount and minted a receipt nobody had
// agreed to; the question is now asked by `AiConsentRequest` inside the
// consent card below, and only its "Agree" button grants.
// The Select is URL-driven so the SSR test can pick the branch and
// a deep link works. Default = `?provider=…` query param when the