From 788c8ad33540838251e5ea62e300a46cd50a1c60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 9 May 2026 17:16:14 +0200 Subject: [PATCH] feat(settings): allow removing saved AI provider key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the v1.4.6 deferred polish item. When the user has saved a key for the active provider (OpenAI, Anthropic, or Local), a "Remove saved key" button now sits next to Save / Test connection. Clicking opens an AlertDialog confirmation, then PATCHes `{ Key: null }` — the existing `/api/user/ai-provider` route already treats null/empty as "clear" (encrypted column set to NULL). Provider, model, and base URL stay untouched so users keep their selection while just rotating the secret. The button hides itself when no key is stored, so the only state where it shows is exactly the one where it's actionable. Insights for the wiped provider fall back to the admin/Codex provider on the next request — surfaced in the confirm-dialog body so the behaviour is explicit. i18n: 4 new keys under `settings.ai.*` (`removeKeyCta`, `keyRemoved`, `removeKeyConfirmTitle`, `removeKeyConfirmBody`), EN canonical, DE mirror. Existing `i18n-locale-integrity.test.ts` enforces parity. Co-Authored-By: Claude Opus 4.7 (1M context) --- messages/de.json | 4 ++ messages/en.json | 4 ++ src/components/settings/ai-section.tsx | 91 ++++++++++++++++++++++++++ 3 files changed, 99 insertions(+) diff --git a/messages/de.json b/messages/de.json index 86a741a62..faa6af6bf 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1069,6 +1069,10 @@ "savedShort": "(gespeichert)", "saveCta": "Speichern", "testCta": "Verbindung testen", + "removeKeyCta": "Gespeicherten Key entfernen", + "keyRemoved": "Gespeicherter Key entfernt", + "removeKeyConfirmTitle": "Gespeicherten API-Key entfernen?", + "removeKeyConfirmBody": "Insights für diesen Provider fallen auf den Admin-/Codex-Provider zurück, bis du einen neuen Key speicherst.", "saved": "Gespeichert", "saveFailed": "Speichern fehlgeschlagen", "testFailed": "Test fehlgeschlagen", diff --git a/messages/en.json b/messages/en.json index f18307680..f542a39f2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1069,6 +1069,10 @@ "savedShort": "(saved)", "saveCta": "Save", "testCta": "Test connection", + "removeKeyCta": "Remove saved key", + "keyRemoved": "Saved key removed", + "removeKeyConfirmTitle": "Remove saved API key?", + "removeKeyConfirmBody": "Insights for this provider will fall back to the admin/Codex provider until you save a new key.", "saved": "Saved", "saveFailed": "Save failed", "testFailed": "Test failed", diff --git a/src/components/settings/ai-section.tsx b/src/components/settings/ai-section.tsx index 6981433ad..043e4afb5 100644 --- a/src/components/settings/ai-section.tsx +++ b/src/components/settings/ai-section.tsx @@ -4,6 +4,17 @@ import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, RefreshCw, Save, Sparkles, Trash2 } from "lucide-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -639,6 +650,48 @@ function UserAIProviderSubsection() { }, }); + // v1.5 phase-5 — "Remove saved key" closes the v1.4.6 deferred polish. + // Sends an explicit `null` for whichever provider key is stored so the + // PATCH route clears it (the route already handles the empty-string / + // null case). Provider/model/baseUrl are left alone so the user can + // keep their selection while just rotating the secret. + const removeKeyMutation = useMutation({ + mutationFn: async (target: "openaiKey" | "anthropicKey" | "localKey") => { + const res = await fetch("/api/user/ai-provider", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ [target]: null }), + }); + const json = await res.json(); + if (!res.ok) throw new Error(json.error || t("settings.ai.saveFailed")); + }, + onSuccess: () => { + setSaveMsg(t("settings.ai.keyRemoved")); + setSaveOk(true); + queryClient.invalidateQueries({ queryKey: ["user", "ai-provider"] }); + queryClient.invalidateQueries({ queryKey: ["insights"] }); + }, + onError: (e) => { + setSaveMsg( + e instanceof Error ? e.message : t("settings.ai.errorGeneric"), + ); + setSaveOk(false); + }, + }); + + /** + * Maps the currently selected provider to its stored-key flag and the + * PATCH-body field name. Returns null when there is no saved key for + * the active provider — the "Remove saved key" button hides itself. + */ + const removableKey: "openaiKey" | "anthropicKey" | "localKey" | null = (() => { + if (provider === "OPENAI" && data?.hasOpenaiKey) return "openaiKey"; + if (provider === "ANTHROPIC" && data?.hasAnthropicKey) + return "anthropicKey"; + if (provider === "LOCAL" && data?.hasLocalKey) return "localKey"; + return null; + })(); + async function runTest() { setTesting(true); setTestMsg(null); @@ -862,6 +915,44 @@ function UserAIProviderSubsection() { )} {t("settings.ai.testCta")} + {removableKey && ( + + + + + + + + {t("settings.ai.removeKeyConfirmTitle")} + + + {t("settings.ai.removeKeyConfirmBody")} + + + + {t("common.cancel")} + removeKeyMutation.mutate(removableKey)} + > + {t("settings.ai.removeKeyCta")} + + + + + )} {saveMsg && (