mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
fix(consent): a withdrawn AI consent stays withdrawn, and the web can withdraw
The settings mount fires an idempotent consent heal so an account that predates the gate does not fall into the no-key state. It looked only for an active receipt, so a deliberate withdrawal — which sets revokedAt — read the same as never having consented, and the next visit to the AI settings granted again, writing an audit row for an act the user never performed. GDPR Art. 7 (3) puts withdrawal on the same footing as the grant; one undone by a page load is not a withdrawal. The mint now distinguishes the two callers. The mount heal grants only to an account with no consent history at all and leaves a revocation standing; switching document auto-read on is the consent act itself and may supersede an earlier revocation. The revocation is re-checked inside the transaction so a withdrawal landing mid-mint is not overwritten. The revoke endpoint has existed since v1.4.40 but only the native client called it: on the web, consent could be given and never taken back. The AI settings now carry the standing decision and the control that changes it, in all six languages.
This commit is contained in:
1 parent
851a82cc79
commit
1c2cfffdb8
18 files changed
+473
-18
No files matched your search
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
const receiptKey = ["consent", "ai", "latest", "ai_full"];
|
||||
|
||||
function renderWith(receipt: unknown) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
client.setQueryData(receiptKey, receipt);
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<AiConsentCard isAuthenticated />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
expect(html).toContain('data-slot="ai-consent-withdraw"');
|
||||
expect(html).toContain("Withdraw consent");
|
||||
});
|
||||
|
||||
it("reports a withdrawal and stops offering it again", () => {
|
||||
const html = renderWith({
|
||||
id: "rcpt-1",
|
||||
kind: "ai_full",
|
||||
signedAt: "2026-07-01T08:00:00.000Z",
|
||||
revokedAt: "2026-07-10T08:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(html).toContain("Withdrawn.");
|
||||
expect(html).not.toContain('data-slot="ai-consent-withdraw"');
|
||||
});
|
||||
|
||||
it("treats no receipt at all as consent not given", () => {
|
||||
const html = renderWith(null);
|
||||
|
||||
expect(html).toContain("Withdrawn.");
|
||||
expect(html).not.toContain('data-slot="ai-consent-withdraw"');
|
||||
});
|
||||
|
||||
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("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────
|
||||
* Standing AI consent — the web side of the withdrawal.
|
||||
*
|
||||
* The revoke endpoint (`DELETE /api/consent/ai/latest?kind=ai_full`) has
|
||||
* existed since v1.4.40, but only the native client ever called it: on the
|
||||
* web, consent could be given and never taken back. GDPR Art. 7 (3) puts
|
||||
* 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. 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. Nothing already stored is
|
||||
* deleted by this; that is the export/erase path in Data & Privacy, and the
|
||||
* copy says so rather than implying more than it does.
|
||||
* ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, ShieldCheck, ShieldOff } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
type ConsentReceiptWire = {
|
||||
id: string;
|
||||
kind: string;
|
||||
signedAt: string;
|
||||
revokedAt: string | null;
|
||||
} | null;
|
||||
|
||||
export function AiConsentCard({
|
||||
isAuthenticated,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKeys.aiConsentReceipt("ai_full"),
|
||||
queryFn: async () => {
|
||||
const res = await apiFetchRaw("/api/consent/ai/latest?kind=ai_full");
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
return (json.data?.receipt ?? null) as ConsentReceiptWire;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const revoke = useMutation({
|
||||
mutationKey: queryKeys.aiConsentReceipt("ai_full"),
|
||||
mutationFn: async () => {
|
||||
const res = await apiFetchRaw("/api/consent/ai/latest?kind=ai_full", {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) throw new Error("revoke failed");
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setConfirming(false);
|
||||
// The receipt gates every AI surface, so anything that reads consent
|
||||
// state has to re-resolve — not just this card.
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.aiConsentReceipt("ai_full"),
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.insightsProviderChain(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Say nothing until the state is known: a card that flashes "withdrawn"
|
||||
// before the receipt arrives would misreport the user's own decision.
|
||||
if (!isAuthenticated || isLoading) return null;
|
||||
|
||||
// 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;
|
||||
|
||||
return (
|
||||
<section className="space-y-3" data-slot="ai-consent">
|
||||
<div className="space-y-1">
|
||||
<p className="flex items-center gap-2 text-sm font-medium">
|
||||
{activeReceipt ? (
|
||||
<ShieldCheck className="size-4" aria-hidden />
|
||||
) : (
|
||||
<ShieldOff className="text-muted-foreground size-4" aria-hidden />
|
||||
)}
|
||||
{t("settings.ai.consent.title")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{activeReceipt
|
||||
? t("settings.ai.consent.activeSince", {
|
||||
date: formatDateTime(activeReceipt.signedAt),
|
||||
})
|
||||
: t("settings.ai.consent.withdrawn")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{activeReceipt ? (
|
||||
confirming ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-muted-foreground w-full text-xs">
|
||||
{t("settings.ai.consent.confirmBody")}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
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"
|
||||
onClick={() => setConfirming(false)}
|
||||
disabled={revoke.isPending}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-slot="ai-consent-withdraw"
|
||||
onClick={() => setConfirming(true)}
|
||||
>
|
||||
{t("settings.ai.consent.withdraw")}
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.ai.consent.regrantHint")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{revoke.isError ? (
|
||||
<p className="text-destructive text-xs" role="alert">
|
||||
{t("settings.ai.consent.error")}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { AdminOpenAIProviderForm } from "./admin-openai-provider-form";
|
||||
import { AiConsentCard } from "./ai-consent-card";
|
||||
import { AnthropicProviderForm } from "./anthropic-provider-form";
|
||||
import { AutoReadCard } from "./auto-read-card";
|
||||
import { CentralCodexSwitch } from "./central-codex-switch";
|
||||
@@ -186,6 +187,11 @@ export function AiInsightsCard({
|
||||
renders when the operator has connected it). */}
|
||||
<CentralCodexSwitch settings={insightsSettings} />
|
||||
|
||||
{/* The standing consent and the one control that withdraws it. It
|
||||
sits last because it governs everything above rather than
|
||||
configuring any single provider. */}
|
||||
<AiConsentCard isAuthenticated={isAuthenticated} />
|
||||
|
||||
<RuntimeActionsRow
|
||||
provider={selectedProvider}
|
||||
userProvider={userProvider}
|
||||
|
||||
Reference in new issue
Block a user