fix(site): CredentialField: mask api key after submit (#25848)

Fixes CODAGT-525

* Re-masks the field after submit
* Sets font to monospaced for legibility
* Extracts `createDeferred` to `testHelpers`
This commit is contained in:
Cian Johnston
2026-06-02 11:55:34 +01:00
committed by GitHub
parent f6a4ed309f
commit 7195be87b1
8 changed files with 379 additions and 51 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ import type {
const aiProvidersListKey = ["ai", "providers"] as const;
const aiProviderKeyFor = (idOrName: string) =>
export const aiProviderKeyFor = (idOrName: string) =>
[...aiProvidersListKey, idOrName] as const;
export const aiProvidersList = () => ({
@@ -7,6 +7,7 @@ import { toast } from "sonner";
import { getErrorMessage } from "#/api/errors";
import {
aiProvider,
aiProviderKeyFor,
deleteAIProviderMutation,
updateAIProviderMutation,
} from "#/api/queries/aiProviders";
@@ -171,6 +172,10 @@ const UpdateProviderPageView: React.FC = () => {
{ enabled: checked },
{
onSuccess: (updated) => {
queryClient.setQueryData(
aiProviderKeyFor(providerId),
updated,
);
toast.success(
`Provider "${updated.display_name || updated.name}" ${checked ? "enabled" : "disabled"}.`,
);
@@ -200,6 +205,7 @@ const UpdateProviderPageView: React.FC = () => {
const request = providerFormValuesToUpdate(values, provider);
try {
const updated = await updateMutation.mutateAsync(request);
queryClient.setQueryData(aiProviderKeyFor(providerId), updated);
toast.success(
`Provider "${updated.display_name || updated.name}" updated.`,
);
@@ -10,6 +10,7 @@ type CredentialFieldProps = {
placeholder?: string;
description?: React.ReactNode;
required?: boolean;
onBlur?: () => void;
onFocus?: () => void;
};
@@ -20,6 +21,7 @@ export const CredentialField: React.FC<CredentialFieldProps> = ({
placeholder,
description,
required = false,
onBlur,
onFocus,
}) => {
const inputId = useId();
@@ -62,9 +64,13 @@ export const CredentialField: React.FC<CredentialFieldProps> = ({
<Input
id={inputId}
name={helpers.name}
className="font-mono text-[13px]"
value={helpers.value}
onChange={helpers.onChange}
onBlur={helpers.onBlur}
onBlur={(event) => {
helpers.onBlur(event);
onBlur?.();
}}
onFocus={onFocus}
autoComplete={autoComplete}
placeholder={placeholder}
@@ -1,6 +1,8 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { type ComponentProps, useState } from "react";
import { expect, fn, screen, userEvent, waitFor, within } from "storybook/test";
import { ProviderForm } from "./ProviderForm";
import { createDeferred, type Deferred } from "#/testHelpers/deferred";
import { ProviderForm, SAVED_CREDENTIAL_MASK } from "./ProviderForm";
const meta: Meta<typeof ProviderForm> = {
title: "pages/AISettingsPage/ProviderForm",
@@ -15,6 +17,88 @@ const meta: Meta<typeof ProviderForm> = {
export default meta;
type Story = StoryObj<typeof ProviderForm>;
const SuccessfulSubmitProviderForm = ({
args,
deferred,
}: {
args: ComponentProps<typeof ProviderForm>;
deferred: Deferred<void>;
}) => {
const [isLoading, setIsLoading] = useState(false);
return (
<ProviderForm
{...args}
isLoading={isLoading}
onSubmit={async (values) => {
args.onSubmit?.(values);
setIsLoading(true);
await deferred.promise;
setIsLoading(false);
}}
/>
);
};
const FailedSubmitProviderForm = ({
args,
deferred,
}: {
args: ComponentProps<typeof ProviderForm>;
deferred: Deferred<void>;
}) => {
const [isLoading, setIsLoading] = useState(false);
const [submitError, setSubmitError] = useState<unknown>();
return (
<ProviderForm
{...args}
isLoading={isLoading}
submitError={submitError}
onSubmit={async (values) => {
args.onSubmit?.(values);
setIsLoading(true);
await deferred.promise;
setSubmitError(new Error(errorSubmitMessage));
setIsLoading(false);
}}
/>
);
};
const ExternalLoadingProviderForm = ({
args,
deferred,
}: {
args: ComponentProps<typeof ProviderForm>;
deferred: Deferred<void>;
}) => {
const [isLoading, setIsLoading] = useState(false);
return (
<>
<ProviderForm {...args} isLoading={isLoading} />
<button
type="button"
onClick={async () => {
setIsLoading(true);
await deferred.promise;
setIsLoading(false);
}}
>
Simulate external save
</button>
</>
);
};
const errorSubmitMessage = "Failed to update provider.";
let bedrockSubmitDeferred = createDeferred<void>();
let apiKeySubmitDeferred = createDeferred<void>();
let failedSubmitDeferred = createDeferred<void>();
let externalSaveDeferred = createDeferred<void>();
export const AddAnthropicDefault: Story = {};
export const AddOpenAI: Story = {
@@ -47,6 +131,15 @@ export const AddBedrock: Story = {
};
export const EditBedrockKeepCredentials: Story = {
render: (args) => {
bedrockSubmitDeferred = createDeferred<void>();
return (
<SuccessfulSubmitProviderForm
args={args}
deferred={bedrockSubmitDeferred}
/>
);
},
args: {
editing: true,
bedrockSavedAccessCredentials: true,
@@ -62,6 +155,59 @@ export const EditBedrockKeepCredentials: Story = {
enabled: true,
},
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const accessKeyInput = await canvas.findByLabelText(/^access key\s*\*?$/i);
const accessKeySecretInput =
await canvas.findByLabelText(/access key secret/i);
expect(accessKeyInput).toHaveProperty("type", "text");
expect(accessKeySecretInput).toHaveProperty("type", "text");
expect(accessKeyInput).toHaveValue(SAVED_CREDENTIAL_MASK);
expect(accessKeySecretInput).toHaveValue(SAVED_CREDENTIAL_MASK);
await userEvent.click(accessKeyInput);
await waitFor(() => expect(accessKeyInput).toHaveValue(""));
await userEvent.click(accessKeySecretInput);
await waitFor(() =>
expect(accessKeyInput).toHaveValue(SAVED_CREDENTIAL_MASK),
);
await userEvent.click(accessKeyInput);
await waitFor(() => expect(accessKeyInput).toHaveValue(""));
await userEvent.type(accessKeyInput, "AKIAI1lO0EXAMPLE");
expect(accessKeyInput).toHaveValue("AKIAI1lO0EXAMPLE");
await userEvent.click(accessKeySecretInput);
await waitFor(() => expect(accessKeySecretInput).toHaveValue(""));
await userEvent.type(accessKeySecretInput, "wJalrI1lO0Secret");
expect(accessKeySecretInput).toHaveValue("wJalrI1lO0Secret");
const displayName = canvas.getByLabelText(/display name/i);
await userEvent.clear(displayName);
await userEvent.type(displayName, "Updated Bedrock");
const submitButton = canvas.getByRole("button", {
name: /update provider/i,
});
await waitFor(() => expect(submitButton).toBeEnabled());
await userEvent.click(submitButton);
await waitFor(() =>
expect(args.onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
accessKey: "AKIAI1lO0EXAMPLE",
accessKeySecret: "wJalrI1lO0Secret",
}),
),
);
await waitFor(() => expect(submitButton).toBeDisabled());
bedrockSubmitDeferred.resolve();
await waitFor(() => {
expect(accessKeyInput).toHaveValue(SAVED_CREDENTIAL_MASK);
expect(accessKeySecretInput).toHaveValue(SAVED_CREDENTIAL_MASK);
});
},
};
export const AddCopilot: Story = {
@@ -141,6 +287,134 @@ export const Submitting: Story = {
};
export const CredentialFocusClear: Story = {
render: (args) => {
apiKeySubmitDeferred = createDeferred<void>();
return (
<SuccessfulSubmitProviderForm
args={args}
deferred={apiKeySubmitDeferred}
/>
);
},
args: {
editing: true,
openAiAnthropicSavedApiKey: true,
openAiAnthropicMaskedApiKey: "sk-ant-***\u2026***ABCD",
initialValues: {
type: "anthropic",
name: "production-anthropic",
displayName: "Production Anthropic",
baseUrl: "https://api.anthropic.com",
apiKey: "",
enabled: true,
},
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const apiKeyInput = await canvas.findByLabelText(/api key/i);
expect(apiKeyInput).toHaveProperty("type", "text");
expect(apiKeyInput).toHaveValue("sk-ant-***\u2026***ABCD");
await userEvent.click(apiKeyInput);
await waitFor(() => expect(apiKeyInput).toHaveValue(""));
const displayName = canvas.getByLabelText(/display name/i);
await userEvent.click(displayName);
await waitFor(() =>
expect(apiKeyInput).toHaveValue("sk-ant-***\u2026***ABCD"),
);
await userEvent.click(apiKeyInput);
await waitFor(() => expect(apiKeyInput).toHaveValue(""));
await userEvent.type(apiKeyInput, "sk-ant-I1lO0-new-secret");
expect(apiKeyInput).toHaveValue("sk-ant-I1lO0-new-secret");
await userEvent.clear(displayName);
await userEvent.type(displayName, "Updated Anthropic");
const submitButton = canvas.getByRole("button", {
name: /update provider/i,
});
await waitFor(() => expect(submitButton).toBeEnabled());
await userEvent.click(submitButton);
await waitFor(() =>
expect(args.onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: "sk-ant-I1lO0-new-secret",
}),
),
);
await waitFor(() => expect(submitButton).toBeDisabled());
apiKeySubmitDeferred.resolve();
await waitFor(() =>
expect(apiKeyInput).toHaveValue("sk-ant-***\u2026***ABCD"),
);
},
};
export const FailedSubmitKeepsCredential: Story = {
render: (args) => {
failedSubmitDeferred = createDeferred<void>();
return (
<FailedSubmitProviderForm args={args} deferred={failedSubmitDeferred} />
);
},
args: {
editing: true,
openAiAnthropicSavedApiKey: true,
openAiAnthropicMaskedApiKey: "sk-ant-***\u2026***ABCD",
initialValues: {
type: "anthropic",
name: "production-anthropic",
displayName: "Production Anthropic",
baseUrl: "https://api.anthropic.com",
apiKey: "",
enabled: true,
},
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const apiKeyInput = await canvas.findByLabelText(/api key/i);
await userEvent.click(apiKeyInput);
await waitFor(() => expect(apiKeyInput).toHaveValue(""));
await userEvent.type(apiKeyInput, "sk-ant-I1lO0-new-secret");
const displayName = canvas.getByLabelText(/display name/i);
await userEvent.clear(displayName);
await userEvent.type(displayName, "Failed Anthropic");
const submitButton = canvas.getByRole("button", {
name: /update provider/i,
});
await waitFor(() => expect(submitButton).toBeEnabled());
await userEvent.click(submitButton);
await waitFor(() =>
expect(args.onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: "sk-ant-I1lO0-new-secret",
}),
),
);
await waitFor(() => expect(submitButton).toBeDisabled());
failedSubmitDeferred.resolve();
await expect(await canvas.findByText(errorSubmitMessage)).toBeVisible();
expect(apiKeyInput).toHaveValue("sk-ant-I1lO0-new-secret");
},
};
export const ExternalLoadingKeepsCredential: Story = {
render: (args) => {
externalSaveDeferred = createDeferred<void>();
return (
<ExternalLoadingProviderForm
args={args}
deferred={externalSaveDeferred}
/>
);
},
args: {
editing: true,
openAiAnthropicSavedApiKey: true,
@@ -157,11 +431,25 @@ export const CredentialFocusClear: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const apiKeyInput = await canvas.findByLabelText(/api key/i);
expect(apiKeyInput).toHaveValue("sk-ant-***\u2026***ABCD");
const submitButton = canvas.getByRole("button", {
name: /update provider/i,
});
await userEvent.click(apiKeyInput);
await waitFor(() => expect(apiKeyInput).toHaveValue(""));
await userEvent.type(apiKeyInput, "sk-ant-I1lO0-new-secret");
await waitFor(() => expect(submitButton).toBeEnabled());
await userEvent.click(
canvas.getByRole("button", { name: /simulate external save/i }),
);
await waitFor(() => expect(submitButton).toBeDisabled());
externalSaveDeferred.resolve();
await waitFor(() => expect(submitButton).toBeEnabled());
expect(apiKeyInput).toHaveValue("sk-ant-I1lO0-new-secret");
},
};
export const UnsavedChangesPrompt: Story = {
args: {
editing: true,
@@ -259,6 +259,21 @@ export const ProviderForm: FC<ProviderFormProps> = ({
const typeDefaults =
providerDefaults[resolvedType as keyof typeof providerDefaults];
// Seed Bedrock credentials with the mask when on file; focus clears it,
// and a re-submitted "" tells the API mapping to keep the value.
const maskedAccessKey = bedrockSavedAccessCredentials
? SAVED_CREDENTIAL_MASK
: "";
const maskedAccessKeySecret = bedrockSavedAccessCredentials
? SAVED_CREDENTIAL_MASK
: "";
// Same pattern for openai/anthropic. Prefer the API-supplied masked
// rendering so the user sees the key's identifying suffix.
const maskedApiKey = openAiAnthropicSavedApiKey
? (openAiAnthropicMaskedApiKey ?? SAVED_CREDENTIAL_MASK)
: "";
const didSubmit = useRef(false);
const form = useFormik<ProviderFormValues>({
initialValues: {
...defaultInitialValues,
@@ -266,21 +281,16 @@ export const ProviderForm: FC<ProviderFormProps> = ({
// Edit overrides prefills with server values; create gets them as-is.
...(typeDefaults ?? {}),
...initialValues,
// Seed Bedrock credentials with the mask when on file; focus clears it,
// and a re-submitted "" tells the API mapping to keep the value.
accessKey: bedrockSavedAccessCredentials ? SAVED_CREDENTIAL_MASK : "",
accessKeySecret: bedrockSavedAccessCredentials
? SAVED_CREDENTIAL_MASK
: "",
// Same pattern for openai/anthropic. Prefer the API-supplied masked
// rendering so the user sees the key's identifying suffix.
apiKey: openAiAnthropicSavedApiKey
? (openAiAnthropicMaskedApiKey ?? SAVED_CREDENTIAL_MASK)
: "",
accessKey: maskedAccessKey,
accessKeySecret: maskedAccessKeySecret,
apiKey: maskedApiKey,
},
validationSchema: getProviderFormSchema(editing),
validateOnMount: true,
onSubmit: onSubmit ?? (() => {}),
onSubmit: (values) => {
didSubmit.current = true;
return onSubmit?.(values);
},
});
const getFieldHelpers = getFormHelpers(form, submitError);
@@ -297,17 +307,46 @@ export const ProviderForm: FC<ProviderFormProps> = ({
}
};
// Restores the mask when the user leaves the field without entering
// a new value, keeping the saved-credential appearance.
const handleCredentialBlur = (
field: "apiKey" | "accessKey" | "accessKeySecret",
) => {
const initial = form.initialValues[field];
if (form.values[field] === "" && initial !== "") {
void form.setFieldValue(field, initial);
}
};
// When the parent's mutation finishes without an error, treat the just-
// submitted values as the new baseline so the unsaved-changes prompt does
// not fire on subsequent navigations. React Query reports a missing error
// as `null`, so a truthy check covers both null and undefined.
const previousIsLoading = useRef(isLoading);
useEffect(() => {
if (previousIsLoading.current && !isLoading && !submitError) {
form.resetForm({ values: form.values });
if (previousIsLoading.current && !isLoading) {
if (didSubmit.current && !submitError) {
// Restore credential fields to their initial masked sentinels so
// the raw key is never left visible after a successful save.
const remaskedValues = {
...form.values,
apiKey: maskedApiKey,
accessKey: maskedAccessKey,
accessKeySecret: maskedAccessKeySecret,
};
form.resetForm({ values: remaskedValues });
}
didSubmit.current = false;
}
previousIsLoading.current = isLoading;
}, [isLoading, submitError, form]);
}, [
isLoading,
submitError,
form,
maskedApiKey,
maskedAccessKey,
maskedAccessKeySecret,
]);
const unsavedChanges = useUnsavedChangesPrompt(
form.dirty && !form.isSubmitting,
@@ -367,6 +406,7 @@ export const ProviderForm: FC<ProviderFormProps> = ({
required
label="API key"
helpers={getFieldHelpers("apiKey")}
onBlur={() => handleCredentialBlur("apiKey")}
onFocus={() => handleCredentialFocus("apiKey")}
autoComplete="new-password"
placeholder={apiKeyPlaceholder(form.values.type)}
@@ -430,12 +470,15 @@ export const ProviderForm: FC<ProviderFormProps> = ({
required
label="Access key"
helpers={getFieldHelpers("accessKey")}
onBlur={() => handleCredentialBlur("accessKey")}
onFocus={() => handleCredentialFocus("accessKey")}
autoComplete="new-password"
/>
<CredentialField
required
label="Access key secret"
helpers={getFieldHelpers("accessKeySecret")}
onBlur={() => handleCredentialBlur("accessKeySecret")}
onFocus={() => handleCredentialFocus("accessKeySecret")}
autoComplete="new-password"
/>
@@ -2,6 +2,7 @@ import { act, renderHook } from "@testing-library/react";
import { createRef } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatQueuedMessage } from "#/api/typesGenerated";
import { createDeferred } from "#/testHelpers/deferred";
import { MockUserOwner, MockWorkspace } from "#/testHelpers/entities";
import {
draftInputStorageKeyPrefix,
@@ -79,22 +80,6 @@ const setMobileViewport = (isMobile: boolean) => {
});
};
type Deferred<T> = {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: unknown) => void;
};
const createDeferred = <T>(): Deferred<T> => {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
describe("getWorkspaceOptionsWithLinkedWorkspace", () => {
it("includes a missing linked workspace only when the current user owns it", () => {
const existingWorkspace = {
@@ -1,28 +1,13 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { API } from "#/api/api";
import { createDeferred } from "#/testHelpers/deferred";
import { chatDraftAttachmentStorageKey } from "../utils/chatDraftAttachmentStorage";
import {
resetChatDraftAttachmentRegistryForTest,
useChatDraftAttachments,
} from "./useChatDraftAttachments";
type Deferred<T> = {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: unknown) => void;
};
const createDeferred = <T>(): Deferred<T> => {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
const orgID = "org-1";
const chatID = "chat-a";
const storageKey = chatDraftAttachmentStorageKey(orgID, chatID);
+15
View File
@@ -0,0 +1,15 @@
export type Deferred<T> = {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: unknown) => void;
};
export const createDeferred = <T>(): Deferred<T> => {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};