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 && ( + + )}