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