Let the AI-consent card grant again after a withdrawal

POST /api/consent/ai/web now accepts an optional { intent } body. Without
one (the AI-settings mount heal) a standing revocation still blocks the
mint; intent "affirmative" is the user's own consent act and may
supersede an earlier revocation, exactly as a first grant would. The
consent card offers the explicit grant control whenever consent does not
stand, and the audit row records which intent minted the receipt.

The document auto-read opt-in already declared "affirmative" on enable;
the settings-mount heal posts no body. Both stay as they are.
This commit is contained in:
Marc-André Bombeck committed 2026-08-13 02:27:05 +02:00
1 parent fd104dd0ac
commit e83b58c9da
13 files changed
+268 -41

No files matched your search

@@ -50,7 +50,7 @@ describe("AiConsentCard", () => {
expect(html).toContain("Withdraw consent");
});
it("reports a withdrawal and stops offering it again", () => {
it("reports a withdrawal, stops offering it again, and offers the grant", () => {
const html = renderWith({
id: "rcpt-1",
kind: "ai_full",
@@ -60,6 +60,11 @@ describe("AiConsentCard", () => {
expect(html).toContain("Withdrawn.");
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");
});
it("treats no receipt at all as consent not given", () => {
@@ -67,6 +72,18 @@ describe("AiConsentCard", () => {
expect(html).toContain("Withdrawn.");
expect(html).not.toContain('data-slot="ai-consent-withdraw"');
expect(html).toContain('data-slot="ai-consent-grant"');
});
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", () => {
+55 -8
View File
@@ -10,11 +10,14 @@
* 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.
* move that changes it: withdraw while consent stands, grant while it does
* not. 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. Nothing already stored is
* deleted by a withdrawal; 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";
@@ -75,6 +78,29 @@ export function AiConsentCard({
},
});
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 () => {
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;
@@ -144,9 +170,24 @@ export function AiConsentCard({
</Button>
)
) : (
<p className="text-muted-foreground text-xs">
{t("settings.ai.consent.regrantHint")}
</p>
<div className="space-y-2">
<p className="text-muted-foreground text-xs">
{t("settings.ai.consent.regrantHint")}
</p>
<Button
type="button"
variant="outline"
size="sm"
data-slot="ai-consent-grant"
onClick={() => grant.mutate()}
disabled={grant.isPending}
>
{grant.isPending ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("settings.ai.consent.grant")}
</Button>
</div>
)}
{revoke.isError ? (
@@ -154,6 +195,12 @@ export function AiConsentCard({
{t("settings.ai.consent.error")}
</p>
) : null}
{grant.isError ? (
<p className="text-destructive text-xs" role="alert">
{t("settings.ai.consent.grantError")}
</p>
) : null}
</section>
);
}