mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
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:
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user