Files
HealthLog/src/components/admin/ai-server-key-section.tsx
T
Marc-André Bombeck 5445bfc1f1 refactor(ui): give Settings and Admin one card rhythm and one page header
The two consoles used the same primitives and still read as two systems,
because everything without a primitive had drifted: the header-to-body
step existed in three competing techniques at once (`mt-4` on the first
body child, `space-y-4` on the card, `mb-4` on the header), the body
started either at the card edge or 28 px in, and both shells hand-rolled
the page title instead of calling `PageHeader`.

Move the decisions into the primitives so a call site cannot re-open them:

- `SettingsCard` is gap-based like `Card` (`flex flex-col gap-4`). The
  header-to-body step is the container's, not a margin somebody remembers.
  A dense card still overrides the gap on the card itself.
- The body starts at the card edge. `pl-7` is gone — off the spacing scale,
  and it mis-aligned action rows against their own forms wherever two
  neighbouring cards disagreed about it.
- The card header's description slot loses its `space-y-1`. That was the
  licence call sites used to stack explainer paragraphs into a muted
  `text-xs` slot; the slot holds one sentence.
- New `SettingsCardActions`: one action row, trailing edge, last thing in
  the card. `align="start"` spells the one exception, a card whose only
  actions are links out.
- New `StatTile` replaces four hand-rolled label/value boxes in Admin.
- `SectionHeading` moves from `insights/` to `ui/`. It is the heading above
  a group of cards, which is what Settings needs; Settings had been using a
  card header with no card, which reads as a card that failed to paint.

Both shells now render their heading through `PageHeader`, which gained
`headingAs="div"` for the second copy each shell paints (mobile above the
nav strip, desktop inside the grid) so only one is a real `<h1>`. The
page explainer is muted everywhere: the primitive said foreground, the
standards and every hand-rolled copy said muted, so pages disagreed with
each other. Medications, Vorsorge, the labs / custom-metric / illness
detail pages and the clinician view drop their hand-rolled title blocks
too, including the mid-word truncation on the medications subtitle.

Tests pin the parts that can be checked exactly: the card's gap, the
description slot's missing rhythm, the action row's two alignments, the
`<h1>`-free shells, and a sweep asserting `pl-7` stays gone.
2026-08-07 19:05:15 +02:00

190 lines
6.3 KiB
TypeScript

"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { KeyRound, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import { PasswordInput } from "./_shared";
/**
* v1.16.6 — operator panel for the server-wide AI key.
*
* The backing endpoint (`/api/admin/ai-settings`, admin-cookie-only) has
* existed since the `admin-openai` chain fallback shipped, but it never had
* an operator UI — configuring the shared key took a manual API call. This
* card is its home, beside the assistant feature flags.
*
* Semantics worth keeping visible to the operator:
* - A user's own key (BYOK / Codex OAuth / local) always wins; the server
* key sits at the END of the provider chain as the shared fallback.
* - Egress over the server key is consent-gated per user
* (`ConsentRequiredError` in `src/lib/ai/consent-guard.ts`) — setting a
* key here never silently forwards anyone's health data.
* - The base URL is locked server-side to HTTPS + a hostname allowlist
* (`ADMIN_AI_BASE_URL_ALLOWLIST` extends it).
*/
interface AiServerKeyResponse {
hasKey: boolean;
keyPreview: string | null;
model: string;
baseUrl: string;
}
export function AiServerKeySection() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { data: settings } = useQuery({
queryKey: queryKeys.adminAiServerKey(),
queryFn: () => apiGet<AiServerKeyResponse>("/api/admin/ai-settings"),
});
const [keyDraft, setKeyDraft] = useState("");
const [modelDraft, setModelDraft] = useState<string | null>(null);
const [baseUrlDraft, setBaseUrlDraft] = useState<string | null>(null);
const modelValue = modelDraft ?? settings?.model ?? "";
const baseUrlValue = baseUrlDraft ?? settings?.baseUrl ?? "";
const configured = settings?.hasKey ?? false;
const save = useMutation({
mutationFn: (payload: Record<string, unknown>) =>
apiPut<AiServerKeyResponse>("/api/admin/ai-settings", payload),
onSuccess: () => {
setKeyDraft("");
setModelDraft(null);
setBaseUrlDraft(null);
queryClient.invalidateQueries({
queryKey: queryKeys.adminAiServerKey(),
});
toast.success(t("admin.aiServerKey.saved"));
},
onError: (error: unknown) => {
toast.error(
error instanceof Error ? error.message : t("admin.aiServerKey.error"),
);
},
});
function handleSave() {
const payload: Record<string, unknown> = {
model: modelValue,
baseUrl: baseUrlValue,
};
if (keyDraft.trim().length > 0) {
payload.apiKey = keyDraft.trim();
}
save.mutate(payload);
}
function handleRemove() {
save.mutate({ apiKey: "" });
}
return (
<SettingsCard data-slot="admin-ai-server-key">
<SettingsCardHeader
icon={KeyRound}
title={t("admin.aiServerKey.title")}
description={
<>
<p>{t("admin.aiServerKey.description")}</p>
<p>{t("admin.aiServerKey.byokHint")}</p>
<p>{t("admin.aiServerKey.consentHint")}</p>
</>
}
status={
<Badge variant={configured ? "default" : "outline"}>
{configured
? t("admin.aiServerKey.statusConfigured")
: t("admin.aiServerKey.statusNotConfigured")}
</Badge>
}
/>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="admin-ai-server-key-input">
{t("admin.aiServerKey.keyLabel")}
</Label>
<PasswordInput
id="admin-ai-server-key-input"
value={keyDraft}
onChange={(e) => setKeyDraft(e.target.value)}
placeholder={
configured && settings?.keyPreview
? t("admin.aiServerKey.keyPlaceholderSet", {
preview: settings.keyPreview,
})
: t("admin.aiServerKey.keyPlaceholder")
}
autoComplete="off"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="admin-ai-server-key-model">
{t("admin.aiServerKey.modelLabel")}
</Label>
<Input
id="admin-ai-server-key-model"
value={modelValue}
onChange={(e) => setModelDraft(e.target.value)}
placeholder="gpt-4o"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="admin-ai-server-key-base-url">
{t("admin.aiServerKey.baseUrlLabel")}
</Label>
<Input
id="admin-ai-server-key-base-url"
value={baseUrlValue}
onChange={(e) => setBaseUrlDraft(e.target.value)}
placeholder="https://api.openai.com/v1"
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={handleSave}
disabled={save.isPending}
data-slot="admin-ai-server-key-save"
>
{save.isPending && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("admin.aiServerKey.save")}
</Button>
{configured && (
<ConfirmButton
slot="admin-ai-server-key-remove"
variant="outline"
label={t("admin.aiServerKey.remove")}
title={t("admin.aiServerKey.removeTitle")}
body={t("admin.aiServerKey.removeBody")}
confirmLabel={t("admin.aiServerKey.removeConfirm")}
pending={save.isPending}
onConfirm={handleRemove}
/>
)}
</div>
</div>
</SettingsCard>
);
}