From 6037ebd3d6580b68e3b0a9f5de80f5eef6a64ddd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 12:35:24 +0200 Subject: [PATCH] fix(settings): let a saved AI API key be removed Every personal provider form showed a stored key and offered no way to take it back, since saving with an empty key field keeps the stored one. The OpenAI, Anthropic, local model and gateway forms now carry a confirmed "Remove API key" while a key is stored. It sends the existing PATCH /api/user/ai-provider with that one key as null, which clears the column and leaves the provider choice, model and the other keys alone. --- messages/de.json | 8 + messages/en.json | 8 + messages/es.json | 8 + messages/fr.json | 8 + messages/it.json | 8 + messages/ko.json | 8 + messages/nl.json | 8 + messages/pl.json | 8 + .../destructive-control-guard.test.ts | 15 ++ .../ai/__tests__/remove-saved-key.test.tsx | 121 +++++++++++++++ .../settings/ai/anthropic-provider-form.tsx | 5 + .../settings/ai/compat-provider-form.tsx | 5 + .../settings/ai/local-provider-form.tsx | 3 + .../settings/ai/openai-provider-form.tsx | 5 + .../settings/ai/remove-saved-key-button.tsx | 68 ++++++++ .../ai-provider-key-removal.test.ts | 145 ++++++++++++++++++ 16 files changed, 431 insertions(+) create mode 100644 src/components/settings/ai/__tests__/remove-saved-key.test.tsx create mode 100644 src/components/settings/ai/remove-saved-key-button.tsx create mode 100644 tests/integration/ai-provider-key-removal.test.ts diff --git a/messages/de.json b/messages/de.json index 8900b5641..3ddf4e6cb 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6440,6 +6440,14 @@ "followsDefault": "Du folgst dem Standard des Servers ({level}).", "useDefault": "Standard des Servers verwenden ({level})", "saveError": "Die Stufe konnte nicht gespeichert werden. Bitte versuche es erneut." + }, + "removeKey": { + "label": "API-Schlüssel entfernen", + "title": "Gespeicherten API-Schlüssel entfernen?", + "body": "HealthLog löscht den gespeicherten Schlüssel und kann ihn nicht wiederherstellen. Gib ihn erneut ein, um ihn weiter zu nutzen.", + "confirm": "API-Schlüssel entfernen", + "done": "API-Schlüssel entfernt.", + "failed": "Der API-Schlüssel konnte nicht entfernt werden." } }, "dashboard": { diff --git a/messages/en.json b/messages/en.json index aaf2689f9..07385af4c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6440,6 +6440,14 @@ "followsDefault": "You follow the server default ({level}).", "useDefault": "Use the server default ({level})", "saveError": "The level could not be saved. Please try again." + }, + "removeKey": { + "label": "Remove API key", + "title": "Remove the saved API key?", + "body": "HealthLog deletes the stored key and cannot recover it. Enter it again to keep using it.", + "confirm": "Remove API key", + "done": "API key removed.", + "failed": "Could not remove the API key." } }, "dashboard": { diff --git a/messages/es.json b/messages/es.json index a304a8819..7dba85540 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6440,6 +6440,14 @@ "followsDefault": "Sigues el valor predeterminado del servidor ({level}).", "useDefault": "Usar el valor predeterminado del servidor ({level})", "saveError": "No se pudo guardar el nivel. Inténtalo de nuevo." + }, + "removeKey": { + "label": "Quitar clave de API", + "title": "¿Quitar la clave de API guardada?", + "body": "HealthLog borra la clave guardada y no puede recuperarla. Vuelve a introducirla para seguir usándola.", + "confirm": "Quitar clave de API", + "done": "Clave de API quitada.", + "failed": "No se pudo quitar la clave de API." } }, "dashboard": { diff --git a/messages/fr.json b/messages/fr.json index 0249cfcf9..ae30d241a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6440,6 +6440,14 @@ "followsDefault": "Vous suivez le réglage par défaut du serveur ({level}).", "useDefault": "Utiliser le réglage par défaut du serveur ({level})", "saveError": "Le niveau n'a pas pu être enregistré. Réessaie." + }, + "removeKey": { + "label": "Supprimer la clé API", + "title": "Supprimer la clé API enregistrée ?", + "body": "HealthLog efface la clé enregistrée et ne peut pas la récupérer. Saisissez-la de nouveau pour continuer à l’utiliser.", + "confirm": "Supprimer la clé API", + "done": "Clé API supprimée.", + "failed": "Impossible de supprimer la clé API." } }, "dashboard": { diff --git a/messages/it.json b/messages/it.json index f3ff85750..ef9466176 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6440,6 +6440,14 @@ "followsDefault": "Segui l'impostazione predefinita del server ({level}).", "useDefault": "Usa l'impostazione predefinita del server ({level})", "saveError": "Non è stato possibile salvare il livello. Riprova." + }, + "removeKey": { + "label": "Rimuovi chiave API", + "title": "Rimuovere la chiave API salvata?", + "body": "HealthLog elimina la chiave salvata e non può recuperarla. Inseriscila di nuovo per continuare a usarla.", + "confirm": "Rimuovi chiave API", + "done": "Chiave API rimossa.", + "failed": "Impossibile rimuovere la chiave API." } }, "dashboard": { diff --git a/messages/ko.json b/messages/ko.json index 2952f87cb..dc5fd3c26 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6440,6 +6440,14 @@ "followsDefault": "서버 기본값({level})을 따르고 있어요.", "useDefault": "서버 기본값 사용({level})", "saveError": "단계를 저장하지 못했어요. 다시 시도해 주세요." + }, + "removeKey": { + "label": "API 키 삭제", + "title": "저장된 API 키를 삭제할까요?", + "body": "HealthLog가 저장된 키를 삭제하며 되돌릴 수 없어요. 계속 쓰려면 키를 다시 입력하세요.", + "confirm": "API 키 삭제", + "done": "API 키를 삭제했어요.", + "failed": "API 키를 삭제하지 못했어요." } }, "dashboard": { diff --git a/messages/nl.json b/messages/nl.json index 4eac566f5..809029015 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -6440,6 +6440,14 @@ "followsDefault": "Je volgt de standaardinstelling van de server ({level}).", "useDefault": "Standaardinstelling van de server gebruiken ({level})", "saveError": "Het niveau kon niet worden opgeslagen. Probeer het opnieuw." + }, + "removeKey": { + "label": "API-sleutel verwijderen", + "title": "Opgeslagen API-sleutel verwijderen?", + "body": "HealthLog wist de opgeslagen sleutel en kan hem niet herstellen. Voer hem opnieuw in om hem te blijven gebruiken.", + "confirm": "API-sleutel verwijderen", + "done": "API-sleutel verwijderd.", + "failed": "De API-sleutel kon niet worden verwijderd." } }, "dashboard": { diff --git a/messages/pl.json b/messages/pl.json index 78cf7bb4a..ad4b463a9 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6440,6 +6440,14 @@ "followsDefault": "Korzystasz z ustawienia domyślnego serwera ({level}).", "useDefault": "Użyj ustawienia domyślnego serwera ({level})", "saveError": "Nie udało się zapisać poziomu. Spróbuj ponownie." + }, + "removeKey": { + "label": "Usuń klucz API", + "title": "Usunąć zapisany klucz API?", + "body": "HealthLog usuwa zapisany klucz i nie może go odzyskać. Wpisz go ponownie, aby dalej z niego korzystać.", + "confirm": "Usuń klucz API", + "done": "Usunięto klucz API.", + "failed": "Nie udało się usunąć klucza API." } }, "dashboard": { diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index 7cd537ce7..6fe80d772 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -137,6 +137,21 @@ const REGISTRY: DestructiveEntry[] = [ recovery: "permanent", confirm: ["AlertDialog"], }, + { + file: "components/settings/ai/remove-saved-key-button.tsx", + destroys: + "one stored personal AI provider key (OpenAI, Anthropic, the local model or the gateway); the provider choice and model stay", + triggers: [ + "components/settings/ai/openai-provider-form.tsx", + "components/settings/ai/anthropic-provider-form.tsx", + "components/settings/ai/local-provider-form.tsx", + "components/settings/ai/compat-provider-form.tsx", + ], + recovery: "permanent", + confirm: ["ConfirmButton"], + undetected: + "No detector sees it. The body comes from `removeSavedKeyBody` with a computed key (`{ [field]: null }`), which the D3 literal pattern does not match, and `/api/user/ai-provider` names no act. Registered by hand.", + }, { file: "components/settings/api-section.tsx", destroys: "an API token — revoked, and the secret is never shown again", diff --git a/src/components/settings/ai/__tests__/remove-saved-key.test.tsx b/src/components/settings/ai/__tests__/remove-saved-key.test.tsx new file mode 100644 index 000000000..bda1376bb --- /dev/null +++ b/src/components/settings/ai/__tests__/remove-saved-key.test.tsx @@ -0,0 +1,121 @@ +/** + * A saved API key can be removed from every provider form that holds one. + * + * The forms showed "saved" beside the key field and nothing to take the key + * back: an empty field on Save keeps the stored key by design, so a key once + * entered stayed. The remove control renders on each of the four personal-key + * forms exactly when that form's key is stored, and the body it sends clears + * that one key and nothing beside it. The route's side (the column is cleared, + * the other keys and the provider choice survive) is pinned against Postgres + * in `tests/integration/ai-provider-key-removal.test.ts`. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ComponentType } from "react"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { aiProviderPatchSchema } from "@/lib/validations/ai-provider"; + +import { AnthropicProviderForm } from "../anthropic-provider-form"; +import { CompatProviderForm } from "../compat-provider-form"; +import { LocalProviderForm } from "../local-provider-form"; +import { OpenAIProviderForm } from "../openai-provider-form"; +import { + removeSavedKeyBody, + type SavedKeyField, +} from "../remove-saved-key-button"; +import type { UserAIProvider } from "../shared"; + +function provider(overrides: Partial = {}): UserAIProvider { + return { + provider: null, + model: null, + baseUrl: null, + hasAnthropicKey: false, + anthropicKeyPreview: null, + hasLocalKey: false, + hasOpenaiKey: false, + openaiKeyPreview: null, + compatBaseUrl: null, + compatModel: null, + hasCompatKey: false, + responseTimeoutSeconds: null, + ...overrides, + }; +} + +type Form = ComponentType<{ userProvider: UserAIProvider | null | undefined }>; + +const FORMS: { + name: string; + Form: Form; + field: SavedKeyField; + saved: Partial; +}[] = [ + { + name: "OpenAI", + Form: OpenAIProviderForm, + field: "openaiKey", + saved: { hasOpenaiKey: true, openaiKeyPreview: "...abcd" }, + }, + { + name: "Anthropic", + Form: AnthropicProviderForm, + field: "anthropicKey", + saved: { hasAnthropicKey: true, anthropicKeyPreview: "...abcd" }, + }, + { + name: "Local", + Form: LocalProviderForm, + field: "localKey", + saved: { hasLocalKey: true }, + }, + { + name: "OpenAI-compatible gateway", + Form: CompatProviderForm, + field: "compatKey", + saved: { hasCompatKey: true }, + }, +]; + +function render(Form: Form, userProvider: UserAIProvider) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return renderToStaticMarkup( + + +
+ + , + ); +} + +describe("remove a saved API key", () => { + for (const { name, Form, field, saved } of FORMS) { + describe(name, () => { + const slot = `data-slot="settings-ai-remove-key-${field}"`; + + it("offers the remove control while a key is stored", () => { + const html = render(Form, provider(saved)); + expect(html).toContain(slot); + expect(html).toContain("Remove API key"); + }); + + it("offers nothing to remove when no key is stored", () => { + expect(render(Form, provider())).not.toContain(slot); + }); + + it("sends a body that clears that key and nothing else", () => { + const body = removeSavedKeyBody(field); + expect(body).toEqual({ [field]: null }); + // The route reads the body through this schema; a field it did not + // know would be dropped and the request answered "No valid fields". + const parsed = aiProviderPatchSchema.safeParse(body); + expect(parsed.success).toBe(true); + expect(parsed.data).toEqual({ [field]: null }); + }); + }); + } +}); diff --git a/src/components/settings/ai/anthropic-provider-form.tsx b/src/components/settings/ai/anthropic-provider-form.tsx index 33ded234f..7aa31d06b 100644 --- a/src/components/settings/ai/anthropic-provider-form.tsx +++ b/src/components/settings/ai/anthropic-provider-form.tsx @@ -18,6 +18,8 @@ import { apiPatch } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; +import { RemoveSavedKeyButton } from "./remove-saved-key-button"; + import { ANTHROPIC_MODEL_PRESETS, CUSTOM_MODEL_SENTINEL, @@ -169,6 +171,9 @@ export function AnthropicProviderForm({ )} + {userProvider?.hasAnthropicKey && ( + + )}