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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 12:36:14 +02:00
1 parent 41cf584a65
commit 6037ebd3d6
16 files changed
+431

No files matched your search

@@ -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",
@@ -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> = {}): 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<UserAIProvider>;
}[] = [
{
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(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<Form userProvider={userProvider} />
</I18nProvider>
</QueryClientProvider>,
);
}
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 });
});
});
}
});
@@ -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({
)}
<SettingsCardActions>
{userProvider?.hasAnthropicKey && (
<RemoveSavedKeyButton field="anthropicKey" />
)}
<Button
type="submit"
size="sm"
@@ -20,6 +20,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 { ReasoningEffortField } from "./reasoning-effort-field";
import {
uiToLegacyProviderEnum,
@@ -152,6 +154,9 @@ export function CompatProviderForm({
)}
<SettingsCardActions>
{userProvider?.hasCompatKey && (
<RemoveSavedKeyButton field="compatKey" />
)}
<Button
type="submit"
size="sm"
@@ -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 { ReasoningEffortField } from "./reasoning-effort-field";
import {
CUSTOM_MODEL_SENTINEL,
@@ -188,6 +190,7 @@ export function LocalProviderForm({
)}
<SettingsCardActions>
{userProvider?.hasLocalKey && <RemoveSavedKeyButton field="localKey" />}
<Button
type="submit"
size="sm"
@@ -21,6 +21,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 {
CUSTOM_MODEL_SENTINEL,
OPENAI_MODEL_PRESETS,
@@ -207,6 +209,9 @@ export function OpenAIProviderForm({
)}
<SettingsCardActions>
{userProvider?.hasOpenaiKey && (
<RemoveSavedKeyButton field="openaiKey" />
)}
<Button
type="submit"
size="sm"
@@ -0,0 +1,68 @@
"use client";
/**
* Removes the stored API key of one personal AI provider.
*
* Every provider form showed a saved key and offered no way to take it back:
* saving with the key field empty keeps the stored key, by design, so a key
* once entered stayed until the account was deleted. `PATCH
* /api/user/ai-provider` has always cleared a key sent as `null`; this is the
* control for it, shared by the four forms that hold a personal key so each
* asks the same question and reports the same way.
*/
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Trash2 } from "lucide-react";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { apiPatch } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
/** The PATCH field that holds each provider's personal key. */
export type SavedKeyField =
"openaiKey" | "anthropicKey" | "localKey" | "compatKey";
/**
* The body that clears exactly one key and touches nothing else: not the
* provider choice, the model or the base URL, which stay as they were.
*/
export function removeSavedKeyBody(
field: SavedKeyField,
): Partial<Record<SavedKeyField, null>> {
return { [field]: null };
}
export function RemoveSavedKeyButton({ field }: { field: SavedKeyField }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const remove = useMutation({
mutationFn: () =>
apiPatch("/api/user/ai-provider", removeSavedKeyBody(field)),
onSuccess: () => {
void invalidateKeys(queryClient, aiInputDependentKeys);
toastWrittenOutcome("success", t("settings.ai.removeKey.done"));
},
onError: (failure) =>
showErrorToast(t("settings.ai.removeKey.failed"), { cause: failure }),
});
return (
<ConfirmButton
slot={`settings-ai-remove-key-${field}`}
destructive
variant="destructive"
size="sm"
className="min-h-11 sm:min-h-9"
icon={<Trash2 className="size-4" aria-hidden />}
label={t("settings.ai.removeKey.label")}
title={t("settings.ai.removeKey.title")}
body={t("settings.ai.removeKey.body")}
confirmLabel={t("settings.ai.removeKey.confirm")}
pending={remove.isPending}
onConfirm={() => remove.mutate()}
/>
);
}