mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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.
172 lines
6.2 KiB
TypeScript
172 lines
6.2 KiB
TypeScript
"use client";
|
|
|
|
import { useAuth } from "@/hooks/use-auth";
|
|
import { useQueryClientMounted } from "@/hooks/_internal/use-query-client-safe";
|
|
import type {
|
|
AiCapabilityKey,
|
|
AiCapabilityState,
|
|
AiConsentState,
|
|
AiProviderState,
|
|
} from "@/lib/ai/capabilities/types";
|
|
import { PROVIDER_DEFAULT_TIMEOUT_MS } from "@/lib/ai/effective-timeout";
|
|
import type { CoachReasoningState } from "@/lib/ai/reasoning/resolve";
|
|
import type { AiUsageState } from "@/lib/ai/usage-level";
|
|
|
|
/**
|
|
* The web reader for the `ai` block on `GET /api/auth/me`.
|
|
*
|
|
* The server resolves every AI capability for the record this browser is
|
|
* inside, from every layer that can say no, and publishes the answer with the
|
|
* reason. This hook hands that answer to a surface and adds exactly one rule
|
|
* of its own: until the answer is known, a capability is unavailable. While
|
|
* `/me` is loading, when no query client is mounted (an isolated
|
|
* presentational render), or when the payload carries no well-formed block,
|
|
* the hook answers `check_failed`, so no AI surface paints and no model
|
|
* request fires on first render only to be refused. Failing open was how AI
|
|
* chrome used to flash and fire refusals before the switch set arrived.
|
|
*
|
|
* Never recompute a capability here or in a component; branch on
|
|
* `available`, and on `reason` only to say why.
|
|
*/
|
|
const UNKNOWN: AiCapabilityState = Object.freeze({
|
|
available: false,
|
|
reason: "check_failed",
|
|
onDeviceAllowed: false,
|
|
});
|
|
|
|
const NO_PROVIDER_STATE: AiProviderState = Object.freeze({
|
|
configured: false,
|
|
managedBy: null,
|
|
canConfigure: false,
|
|
responseTimeoutMs: PROVIDER_DEFAULT_TIMEOUT_MS,
|
|
});
|
|
|
|
/** One capability, for the record this browser is inside. */
|
|
export function useAiCapability(key: AiCapabilityKey): AiCapabilityState {
|
|
const hasClient = useQueryClientMounted();
|
|
if (!hasClient) return UNKNOWN;
|
|
// The branch is stable for the component's lifetime (a provider is either
|
|
// mounted around it or not), so the conditional hook call is safe.
|
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
return useAiCapabilityInner(key);
|
|
}
|
|
|
|
function useAiCapabilityInner(key: AiCapabilityKey): AiCapabilityState {
|
|
const { user } = useAuth();
|
|
return user?.ai?.capabilities?.[key] ?? UNKNOWN;
|
|
}
|
|
|
|
/** The account's provider state: configured, managed by whom, configurable here. */
|
|
export function useAiProviderState(): AiProviderState {
|
|
const hasClient = useQueryClientMounted();
|
|
if (!hasClient) return NO_PROVIDER_STATE;
|
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
return useAiProviderStateInner();
|
|
}
|
|
|
|
function useAiProviderStateInner(): AiProviderState {
|
|
const { user } = useAuth();
|
|
return user?.ai?.provider ?? NO_PROVIDER_STATE;
|
|
}
|
|
|
|
/**
|
|
* v1.41 — the Coach's thinking depth, resolved on the server (`coachReasoning`
|
|
* on `GET /api/auth/me`), or `null` while it is not known: `/me` loading, no
|
|
* query client, or a server that does not publish it. Render the settings
|
|
* from it; never recompute the cap or the switch.
|
|
*/
|
|
export function useCoachReasoning(): CoachReasoningState | null {
|
|
const hasClient = useQueryClientMounted();
|
|
if (!hasClient) return null;
|
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
return useCoachReasoningInner();
|
|
}
|
|
|
|
function useCoachReasoningInner(): CoachReasoningState | null {
|
|
const { user } = useAuth();
|
|
return user?.coachReasoning ?? null;
|
|
}
|
|
|
|
/**
|
|
* v1.43 (#1217) — the record's AI usage level (`ai.usage` on
|
|
* `GET /api/auth/me`): the level in effect, the person's choice, the
|
|
* operator's bounds, the three options with their estimates. `null` while it
|
|
* is not known or on a server that does not publish it; the setting is then
|
|
* not shown. Render from it; never compute an estimate or an allowance here.
|
|
*/
|
|
export function useAiUsage(): AiUsageState | null {
|
|
const hasClient = useQueryClientMounted();
|
|
if (!hasClient) return null;
|
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
return useAiUsageInner();
|
|
}
|
|
|
|
function useAiUsageInner(): AiUsageState | null {
|
|
const { user } = useAuth();
|
|
return user?.ai?.usage ?? null;
|
|
}
|
|
|
|
/**
|
|
* v1.43.2 — the standing AI consent (`ai.consent` on `GET /api/auth/me`):
|
|
* given (and since when), needed (and for which services), or not needed.
|
|
* `null` while it is not known; a consent surface then says nothing rather
|
|
* than misreport the person's decision. Render from it; never decide here
|
|
* whether consent is needed.
|
|
*/
|
|
export function useAiConsent(): AiConsentState | null {
|
|
const hasClient = useQueryClientMounted();
|
|
if (!hasClient) return null;
|
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
return useAiConsentInner();
|
|
}
|
|
|
|
function useAiConsentInner(): AiConsentState | null {
|
|
const { user } = useAuth();
|
|
return user?.ai?.consent ?? null;
|
|
}
|
|
|
|
/**
|
|
* The server's answer for one capability, or `null` while there is none yet
|
|
* (`/me` loading, no signed-in account, no query client). For the few places
|
|
* that act on an unavailable capability rather than just hiding: a Coach page
|
|
* that sends its visitor elsewhere must not do so on the loading frame, when
|
|
* `useAiCapability` already reads unavailable.
|
|
*/
|
|
export function useAiCapabilityAnswer(
|
|
key: AiCapabilityKey,
|
|
): AiCapabilityState | null {
|
|
const hasClient = useQueryClientMounted();
|
|
if (!hasClient) return null;
|
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
return useAiCapabilityAnswerInner(key);
|
|
}
|
|
|
|
function useAiCapabilityAnswerInner(
|
|
key: AiCapabilityKey,
|
|
): AiCapabilityState | null {
|
|
const { user, isLoading } = useAuth();
|
|
if (isLoading || !user) return null;
|
|
return user.ai?.capabilities?.[key] ?? UNKNOWN;
|
|
}
|
|
|
|
/**
|
|
* Every capability at once, or `null` until `/me` has answered. For the one
|
|
* surface that explains the whole set (the operator notice in Settings → AI);
|
|
* a surface that shows or hides one feature reads `useAiCapability`.
|
|
*/
|
|
export function useAiCapabilityMap(): Partial<
|
|
Record<AiCapabilityKey, AiCapabilityState>
|
|
> | null {
|
|
const hasClient = useQueryClientMounted();
|
|
if (!hasClient) return null;
|
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
return useAiCapabilityMapInner();
|
|
}
|
|
|
|
function useAiCapabilityMapInner(): Partial<
|
|
Record<AiCapabilityKey, AiCapabilityState>
|
|
> | null {
|
|
const { user } = useAuth();
|
|
return user?.ai?.capabilities ?? null;
|
|
}
|