feat(ui): replace API key input with ChatGPT OAuth connect button

Settings now shows "Mit ChatGPT verbinden" button instead of API key
input field. Shows connection status, connected-since date, and
disconnect option. Handles OAuth callback query params with toast.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.6 committed 2026-04-06 11:56:35 +02:00
1 parent d3149170cd
commit 360a11523e
3 files changed
+106 -74

No files matched your search

+3
View File
@@ -756,6 +756,9 @@
"apiKeyStored": "Key hinterlegt — neuen eingeben zum Ersetzen",
"apiKeySaved": "API-Key gespeichert",
"apiKeyRemoved": "API-Key entfernt",
"codexConnected": "ChatGPT erfolgreich verbunden! KI-Insights sind jetzt aktiv.",
"codexDisconnected": "ChatGPT-Verbindung getrennt.",
"codexConnectionFailed": "ChatGPT-Verbindung fehlgeschlagen. Bitte versuche es erneut.",
"rawData": "Rohdaten mitsenden",
"rawDataOnDescription": "Es werden aggregierte Kennzahlen plus anonymisierte Rohdaten der letzten 30 Tage gesendet. Dazu zählen einzelne Messpunkte je Bereich (z. B. Gewicht, Blutdruck, Puls) mit Zeitbezug, damit die KI zeitliche Muster, Ausreißer und Zusammenhänge zuverlässiger erkennen kann. Nicht übertragen werden Name, E-Mail oder direkte Kontodaten.",
"rawDataOffDescription": "Es werden nur zusammengefasste Werte gesendet (z. B. Durchschnitt, Trend, Minimum/Maximum). Keine einzelnen Messpunkte und keine exakten Zeitbezüge. Das ist datensparsamer, die KI kann dafür aber weniger fein auf Muster und kurzfristige Schwankungen eingehen.",
+3
View File
@@ -756,6 +756,9 @@
"apiKeyStored": "Key stored — enter new one to replace",
"apiKeySaved": "API key saved",
"apiKeyRemoved": "API key removed",
"codexConnected": "ChatGPT connected successfully! AI insights are now active.",
"codexDisconnected": "ChatGPT connection removed.",
"codexConnectionFailed": "ChatGPT connection failed. Please try again.",
"rawData": "Send raw data",
"rawDataOnDescription": "Aggregated metrics plus anonymized raw points from the last 30 days are sent. This includes per-metric measurements (for example weight, blood pressure, pulse) with time context so AI can detect patterns, outliers, and correlations more reliably. Name, email, and direct account identifiers are not transmitted.",
"rawDataOffDescription": "Only summarized metrics are sent (for example averages, trends, minimum/maximum). No individual points and no exact time context. This is more privacy-preserving, but AI can be less precise for short-term patterns and fluctuations.",
+100 -74
View File
@@ -2627,8 +2627,7 @@ function InsightsSettingsSection({
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [apiKey, setApiKey] = useState("");
const [saving, setSaving] = useState(false);
const [disconnecting, setDisconnecting] = useState(false);
const [regenerating, setRegenerating] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
@@ -2641,6 +2640,7 @@ function InsightsSettingsSection({
const json = await res.json();
return json.data as {
codexStatus: string;
codexConnectedAt: string | null;
hasAdminKey: boolean;
privacyMode: string;
lastInsightAt: string | null;
@@ -2664,23 +2664,44 @@ function InsightsSettingsSection({
},
});
async function handleSaveKey(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setMsg(null);
setMsgType(null);
try {
await updateSettings.mutateAsync({ apiKey: apiKey.trim() });
setMsg(
apiKey.trim() ? t("settings.apiKeySaved") : t("settings.apiKeyRemoved"),
);
// Handle OAuth callback query params
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get("codex_connected") === "true") {
setMsg(t("settings.codexConnected"));
setMsgType("success");
setApiKey("");
queryClient.invalidateQueries({ queryKey: ["insights"] });
window.history.replaceState({}, "", window.location.pathname);
} else if (params.get("codex_error")) {
setMsg(t("settings.codexConnectionFailed"));
setMsgType("error");
window.history.replaceState({}, "", window.location.pathname);
}
}, [queryClient, t]);
const hasProvider = settings?.codexStatus === "connected" || settings?.hasAdminKey;
async function handleConnect() {
window.location.href = "/api/auth/codex/authorize";
}
async function handleDisconnect() {
setDisconnecting(true);
setMsg(null);
try {
const res = await fetch("/api/auth/codex/disconnect", { method: "DELETE" });
if (!res.ok) {
const json = await res.json();
throw new Error(json.error);
}
setMsg(t("settings.codexDisconnected"));
setMsgType("success");
queryClient.invalidateQueries({ queryKey: ["insights"] });
} catch (err) {
setMsg(err instanceof Error ? err.message : t("settings.savingError"));
setMsgType("error");
} finally {
setSaving(false);
setDisconnecting(false);
}
}
@@ -2731,9 +2752,19 @@ function InsightsSettingsSection({
<h2 className="text-lg font-semibold">{t("settings.kiInsights")}</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
{(settings?.codexStatus === "connected" || settings?.hasAdminKey) && (
{settings?.codexStatus === "connected" && (
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
{t("settings.configured")}
ChatGPT verbunden
</Badge>
)}
{settings?.codexStatus !== "connected" && settings?.hasAdminKey && (
<Badge className="border-dracula-purple/30 bg-dracula-purple/15 text-dracula-purple">
Admin-KI aktiv
</Badge>
)}
{settings?.codexStatus === "expired" && (
<Badge className="border-dracula-orange/30 bg-dracula-orange/15 text-dracula-orange">
Verbindung abgelaufen
</Badge>
)}
{settings?.lastInsightAt && (
@@ -2747,62 +2778,57 @@ function InsightsSettingsSection({
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.kiInsightsDescription")}
</p>
<div className="bg-muted/50 mt-4 rounded-lg p-4">
<p className="text-sm font-medium">
{t("settings.kiInsightsBenefitsTitle")}
</p>
<ul className="text-muted-foreground mt-2 list-disc space-y-1 pl-5 text-xs">
<li>{t("settings.kiInsightsBenefit1")}</li>
<li>{t("settings.kiInsightsBenefit2")}</li>
<li>{t("settings.kiInsightsBenefit3")}</li>
</ul>
<p className="text-muted-foreground mt-3 text-xs">
{t("settings.createApiKeyHint")}{" "}
<a
href="https://platform.openai.com/api-keys"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:text-primary/90 underline"
>
{t("settings.createApiKeyLinkLabel")}
</a>
</p>
</div>
<div className="mt-4 space-y-4">
{/* API Key */}
<form onSubmit={handleSaveKey} className="flex gap-2">
<PasswordInput
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={
(settings?.codexStatus === "connected" || settings?.hasAdminKey) ? t("settings.apiKeyStored") : "sk-..."
}
className="flex-1"
/>
<Button variant="outline" type="submit" disabled={saving}>
{saving ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Key className="mr-1 h-4 w-4" />
)}
{(settings?.codexStatus === "connected" || settings?.hasAdminKey) ? t("common.replace") : t("common.save")}
</Button>
{(settings?.codexStatus === "connected" || settings?.hasAdminKey) && (
<Button
variant="ghost"
type="button"
className="text-destructive"
onClick={async () => {
await updateSettings.mutateAsync({ apiKey: "" });
setMsg(t("settings.apiKeyRemoved"));
setMsgType("success");
}}
>
<Trash2 className="h-4 w-4" />
</Button>
{/* ChatGPT Connection */}
<div className="bg-muted/50 rounded-lg p-4">
{settings?.codexStatus === "connected" ? (
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium">ChatGPT verbunden</p>
<p className="text-muted-foreground text-xs">
Insights werden über dein ChatGPT-Abo generiert — keine zusätzlichen Kosten.
{settings.codexConnectedAt && (
<> Verbunden seit {formatDateTime(settings.codexConnectedAt)}.</>
)}
</p>
</div>
<Button
variant="ghost"
size="sm"
className="text-destructive shrink-0"
onClick={handleDisconnect}
disabled={disconnecting}
>
{disconnecting ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Trash2 className="mr-1 h-4 w-4" />
)}
Trennen
</Button>
</div>
) : (
<div className="space-y-3">
<div>
<p className="text-sm font-medium">Mit ChatGPT verbinden</p>
<p className="text-muted-foreground text-xs">
Verbinde dein ChatGPT Pro/Max-Konto um KI-gestützte Gesundheitsanalysen basierend auf
aktuellen medizinischen Leitlinien zu erhalten. Keine zusätzlichen API-Kosten.
</p>
</div>
<Button variant="outline" onClick={handleConnect} className="w-full sm:w-auto">
<Sparkles className="mr-2 h-4 w-4" />
Mit ChatGPT verbinden
</Button>
{settings?.hasAdminKey && (
<p className="text-muted-foreground text-xs">
Alternativ nutzt HealthLog den vom Administrator konfigurierten KI-Anbieter.
</p>
)}
</div>
)}
</form>
</div>
{msg && (
<p
@@ -2814,25 +2840,25 @@ function InsightsSettingsSection({
)}
{/* Privacy Mode */}
{(settings?.codexStatus === "connected" || settings?.hasAdminKey) && (
{hasProvider && (
<div className="bg-muted/50 rounded-lg p-4">
<div className="flex items-center justify-between gap-4">
<div className="pr-2">
<p className="text-sm font-medium">{t("settings.rawData")}</p>
<p className="text-muted-foreground text-xs">
{settings.privacyMode === "raw"
{settings?.privacyMode === "raw"
? t("settings.rawDataOnDescription")
: t("settings.rawDataOffDescription")}
</p>
</div>
<div className="ml-2 shrink-0">
<Switch
checked={settings.privacyMode === "raw"}
checked={settings?.privacyMode === "raw"}
onCheckedChange={togglePrivacyMode}
/>
</div>
</div>
{settings.privacyMode === "raw" && (
{settings?.privacyMode === "raw" && (
<div className="mt-2 rounded-lg bg-orange-500/10 p-2 text-xs text-orange-400">
{t("settings.rawDataWarning")}
</div>
@@ -2841,7 +2867,7 @@ function InsightsSettingsSection({
)}
{/* Regenerate Reports */}
{(settings?.codexStatus === "connected" || settings?.hasAdminKey) && (
{hasProvider && (
<Button
variant="outline"
onClick={handleRegenerate}