mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
Every AI surface reads its capability from the ai block on /api/auth/me, but setting up or removing a provider, signing in to or out of Codex, saving the fallback chain, the central-Codex opt-in, the local-OCR switch, a minted consent receipt, the admin's server key and central Codex, and a guardian's save inside a managed record evicted only their own reads. The surfaces stayed on the old answer until a reload. All of these writes, and the ones that already refreshed the payload (consent, modules, Hide Coach, the operator's switches), now evict one bundle, aiInputDependentKeys. A structural test holds every client write to those endpoints to it.
145 lines
5.0 KiB
TypeScript
145 lines
5.0 KiB
TypeScript
"use client";
|
|
|
|
/* ────────────────────────────────────────────────────────────────
|
|
* Use the server's shared AI access (per-user opt-in).
|
|
*
|
|
* Shown ONLY when the operator has connected a shared central Codex
|
|
* (ChatGPT-subscription) account. OFF by default. Turning it ON reveals a
|
|
* once-shown honesty confirm before the write, because the trade — a shared,
|
|
* signed-in AI account bound by the operator's rate limits, on which the
|
|
* provider may use content to improve its models with no data-processing
|
|
* agreement — must be acknowledged, not buried. Turning it OFF is immediate.
|
|
*
|
|
* Opting in does not egress anything on its own: the shared connection is
|
|
* server-managed external egress, so the existing AI-consent gate still applies
|
|
* before any health data leaves for it.
|
|
* ──────────────────────────────────────────────────────────────── */
|
|
|
|
import { useState } from "react";
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { ShieldAlert } from "lucide-react";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { apiPatch } from "@/lib/api/api-fetch";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
|
|
|
|
import type { InsightsSettings } from "./shared";
|
|
|
|
interface UseCentralCodexPref {
|
|
useCentralCodex: boolean;
|
|
}
|
|
|
|
export function CentralCodexSwitch({
|
|
settings,
|
|
}: {
|
|
settings: InsightsSettings | null | undefined;
|
|
}) {
|
|
const { t } = useTranslations();
|
|
const queryClient = useQueryClient();
|
|
|
|
// Off→on reveals the honesty confirm; the write only happens on confirm.
|
|
const [pendingEnable, setPendingEnable] = useState(false);
|
|
|
|
const enabled = settings?.useCentralCodex ?? false;
|
|
|
|
const save = useMutation<UseCentralCodexPref, Error, boolean>({
|
|
mutationFn: (next: boolean) =>
|
|
apiPatch<UseCentralCodexPref>("/api/auth/me/use-central-codex", {
|
|
useCentralCodex: next,
|
|
}),
|
|
onSuccess: () => {
|
|
// The opt-in changes which providers can serve this user — refresh the
|
|
// settings summary (which carries the flag), the account's AI
|
|
// capability answer, and every insight read.
|
|
void invalidateKeys(queryClient, aiInputDependentKeys);
|
|
setPendingEnable(false);
|
|
},
|
|
});
|
|
|
|
// Only meaningful once the operator has connected the shared account.
|
|
if (!settings?.centralCodexAvailable) return null;
|
|
|
|
function onSwitch(next: boolean) {
|
|
if (next) {
|
|
if (!enabled) setPendingEnable(true);
|
|
return;
|
|
}
|
|
setPendingEnable(false);
|
|
if (enabled) save.mutate(false);
|
|
}
|
|
|
|
const busy = save.isPending;
|
|
const checked = enabled || pendingEnable;
|
|
|
|
return (
|
|
<div
|
|
data-slot="central-codex-switch-card"
|
|
className="bg-muted/50 space-y-4 rounded-lg p-4"
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<p className="text-sm font-medium">
|
|
{t("settings.ai.centralCodex.title")}
|
|
</p>
|
|
<p className="text-muted-foreground text-xs">
|
|
{t("settings.ai.centralCodex.subLabel")}
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
checked={checked}
|
|
disabled={busy}
|
|
onCheckedChange={onSwitch}
|
|
aria-label={t("settings.ai.centralCodex.title")}
|
|
data-testid="use-central-codex-enable"
|
|
/>
|
|
</div>
|
|
|
|
{pendingEnable ? (
|
|
<div
|
|
data-slot="central-codex-confirm"
|
|
role="note"
|
|
className="border-border space-y-3 rounded-lg border border-dashed px-3 py-2.5"
|
|
>
|
|
<div className="flex items-start gap-2 text-sm">
|
|
<ShieldAlert
|
|
className="text-muted-foreground mt-0.5 size-3.5 shrink-0"
|
|
aria-hidden
|
|
/>
|
|
<p className="min-w-0">{t("settings.ai.centralCodex.honesty")}</p>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
className="min-h-11 sm:min-h-9"
|
|
disabled={save.isPending}
|
|
onClick={() => save.mutate(true)}
|
|
data-slot="central-codex-confirm-cta"
|
|
>
|
|
{t("settings.ai.centralCodex.confirm")}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="ghost"
|
|
className="min-h-11 sm:min-h-9"
|
|
disabled={save.isPending}
|
|
onClick={() => setPendingEnable(false)}
|
|
>
|
|
{t("settings.ai.centralCodex.cancel")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{save.isError ? (
|
|
<p className="text-destructive text-xs">
|
|
{t("settings.ai.errorGeneric")}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|