diff --git a/site/src/api/queries/aiProviders.ts b/site/src/api/queries/aiProviders.ts index ad8d722352c..7a3f01cf535 100644 --- a/site/src/api/queries/aiProviders.ts +++ b/site/src/api/queries/aiProviders.ts @@ -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 = () => ({ diff --git a/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx index 9991c3b8f67..005ece6744f 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx @@ -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.`, ); diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/CredentialField.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/CredentialField.tsx index 888818f8596..b584d326cd0 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/CredentialField.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/CredentialField.tsx @@ -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 = ({ placeholder, description, required = false, + onBlur, onFocus, }) => { const inputId = useId(); @@ -62,9 +64,13 @@ export const CredentialField: React.FC = ({ { + helpers.onBlur(event); + onBlur?.(); + }} onFocus={onFocus} autoComplete={autoComplete} placeholder={placeholder} diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx index 181a786a328..49c1e0c866c 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx @@ -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 = { title: "pages/AISettingsPage/ProviderForm", @@ -15,6 +17,88 @@ const meta: Meta = { export default meta; type Story = StoryObj; +const SuccessfulSubmitProviderForm = ({ + args, + deferred, +}: { + args: ComponentProps; + deferred: Deferred; +}) => { + const [isLoading, setIsLoading] = useState(false); + + return ( + { + args.onSubmit?.(values); + setIsLoading(true); + await deferred.promise; + setIsLoading(false); + }} + /> + ); +}; + +const FailedSubmitProviderForm = ({ + args, + deferred, +}: { + args: ComponentProps; + deferred: Deferred; +}) => { + const [isLoading, setIsLoading] = useState(false); + const [submitError, setSubmitError] = useState(); + + return ( + { + args.onSubmit?.(values); + setIsLoading(true); + await deferred.promise; + setSubmitError(new Error(errorSubmitMessage)); + setIsLoading(false); + }} + /> + ); +}; + +const ExternalLoadingProviderForm = ({ + args, + deferred, +}: { + args: ComponentProps; + deferred: Deferred; +}) => { + const [isLoading, setIsLoading] = useState(false); + + return ( + <> + + + + ); +}; + +const errorSubmitMessage = "Failed to update provider."; + +let bedrockSubmitDeferred = createDeferred(); +let apiKeySubmitDeferred = createDeferred(); +let failedSubmitDeferred = createDeferred(); +let externalSaveDeferred = createDeferred(); + export const AddAnthropicDefault: Story = {}; export const AddOpenAI: Story = { @@ -47,6 +131,15 @@ export const AddBedrock: Story = { }; export const EditBedrockKeepCredentials: Story = { + render: (args) => { + bedrockSubmitDeferred = createDeferred(); + return ( + + ); + }, 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,15 @@ export const Submitting: Story = { }; export const CredentialFocusClear: Story = { + render: (args) => { + apiKeySubmitDeferred = createDeferred(); + return ( + + ); + }, args: { editing: true, openAiAnthropicSavedApiKey: true, @@ -154,14 +309,147 @@ export const CredentialFocusClear: Story = { enabled: true, }, }, - play: async ({ canvasElement }) => { + 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(); + return ( + + ); + }, + 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(); + return ( + + ); + }, + 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 }) => { + const canvas = within(canvasElement); + const apiKeyInput = await canvas.findByLabelText(/api key/i); + 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, diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx index 7468d39b861..e2e46f7e206 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx @@ -259,6 +259,21 @@ export const ProviderForm: FC = ({ 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({ initialValues: { ...defaultInitialValues, @@ -266,21 +281,16 @@ export const ProviderForm: FC = ({ // 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 = ({ } }; + // 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 = ({ 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 = ({ required label="Access key" helpers={getFieldHelpers("accessKey")} + onBlur={() => handleCredentialBlur("accessKey")} onFocus={() => handleCredentialFocus("accessKey")} + autoComplete="new-password" /> handleCredentialBlur("accessKeySecret")} onFocus={() => handleCredentialFocus("accessKeySecret")} autoComplete="new-password" /> diff --git a/site/src/pages/AgentsPage/AgentChatPage.test.ts b/site/src/pages/AgentsPage/AgentChatPage.test.ts index 9a20437dfe8..b0acb53af13 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.test.ts +++ b/site/src/pages/AgentsPage/AgentChatPage.test.ts @@ -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 = { - promise: Promise; - resolve: (value: T | PromiseLike) => void; - reject: (reason?: unknown) => void; -}; - -const createDeferred = (): Deferred => { - let resolve!: (value: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((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 = { diff --git a/site/src/pages/AgentsPage/hooks/useChatDraftAttachments.test.ts b/site/src/pages/AgentsPage/hooks/useChatDraftAttachments.test.ts index 72ba18c0d5f..ec94f93ab0e 100644 --- a/site/src/pages/AgentsPage/hooks/useChatDraftAttachments.test.ts +++ b/site/src/pages/AgentsPage/hooks/useChatDraftAttachments.test.ts @@ -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 = { - promise: Promise; - resolve: (value: T | PromiseLike) => void; - reject: (reason?: unknown) => void; -}; - -const createDeferred = (): Deferred => { - let resolve!: (value: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -}; - const orgID = "org-1"; const chatID = "chat-a"; const storageKey = chatDraftAttachmentStorageKey(orgID, chatID); diff --git a/site/src/testHelpers/deferred.ts b/site/src/testHelpers/deferred.ts new file mode 100644 index 00000000000..2ae217ece5a --- /dev/null +++ b/site/src/testHelpers/deferred.ts @@ -0,0 +1,15 @@ +export type Deferred = { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +}; + +export const createDeferred = (): Deferred => { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +};