From efbf802319a30c062397c8f9fbba6c43458afadc Mon Sep 17 00:00:00 2001 From: dylanhuff-at-coder Date: Tue, 28 Jul 2026 18:30:15 -0400 Subject: [PATCH] feat: add bulk secret import upload to Add secret dialog (PLAT-240) (#26725) Adds a file dropzone to the create branch of the Add secret dialog (final PR in the PLAT-240 stack, after #26723 and #26724). The browser reads the file, derives the format from the extension (`.env`/`.json`/`.yaml`/`.yml`), and imports via `POST /secrets/batch`; per-entry backend errors surface in an alert and the success toast flags secrets imported without an env name. Storybook play stories and vitests cover the flow. Also documents the upload flow in `docs/user-guides/user-secrets.md`. Closes https://linear.app/codercom/issue/PLAT-240 > Reviewed and updated by Coder Agents on behalf of @dylanhuff-at-coder. --- docs/user-guides/user-secrets.md | 36 +++ site/src/api/api.ts | 12 + site/src/api/queries/userSecrets.ts | 12 + site/src/components/FileUpload/FileUpload.tsx | 17 +- .../SecretsPage/SecretDialog.tsx | 139 ++++++++- .../SecretsPage/SecretsPage.tsx | 10 + .../SecretsPage/SecretsPageView.stories.tsx | 285 +++++++++++++++++- .../SecretsPage/SecretsPageView.tsx | 4 + .../SecretsPage/secretForm.test.ts | 81 ++++- .../SecretsPage/secretForm.ts | 36 +++ site/src/testHelpers/entities.ts | 21 ++ site/src/testHelpers/handlers.ts | 3 + 12 files changed, 636 insertions(+), 20 deletions(-) diff --git a/docs/user-guides/user-secrets.md b/docs/user-guides/user-secrets.md index 5673629322..ff2c862854 100644 --- a/docs/user-guides/user-secrets.md +++ b/docs/user-guides/user-secrets.md @@ -299,5 +299,41 @@ can see which secrets are currently injected. See [How your secrets reach a workspace](#how-your-secrets-reach-a-workspace) for what happens to running workspaces when you delete a secret. +## Import secrets from a file + +If you keep secrets in a dotenv file, a flat JSON object, or a flat YAML +mapping, you can import the whole file instead of creating each secret +individually: + +1. Go to the [**Secrets** page](#manage-secrets-from-the-dashboard) and select + **Add secret**. +1. Drop or select a `.env`, `.json`, `.yaml`, or `.yml` file in the upload + area. Coder imports the file as soon as you choose it. + +Every key in the file becomes a secret. For example, this dotenv file creates +two secrets, `API_KEY` and `DATABASE_URL`, each injected as an environment +variable of the same name: + +```sh +API_KEY=abc123 +DATABASE_URL=postgres://user:pass@db.internal/app +``` + +In JSON and YAML files, every value must be a string. Quote numeric and +boolean values, for example `"PORT": "8080"`. + +The import is all or nothing. If any entry fails validation, conflicts with +an existing secret, or exceeds a [limit](#limits), Coder cancels the import +and creates no secrets. The file must also be 1 MiB or smaller and contain no +more than 50 keys. + +Keys that are not valid environment variable names, such as `MY-TOKEN` or the +reserved name `PATH`, are imported without an environment variable target. +They are not injected into workspaces until you add a valid environment +variable or file target. + +To import secrets programmatically, use the +[Secrets API](../reference/api/secrets.md#import-user-secrets-from-a-file). + For full command details, see [`coder secret`](../reference/cli/secret.md) and the [Secrets API reference](../reference/api/secrets.md). diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 284edf485c..2cd93083fd 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1788,6 +1788,18 @@ class ApiMethods { ); }; + importUserSecrets = async ( + userId: string, + request: TypesGen.ImportUserSecretsRequest, + ): Promise => { + const response = await this.axios.post( + `/api/v2/users/${encodeURIComponent(userId)}/secrets/batch`, + request, + ); + + return response.data; + }; + getWorkspaceBuilds = async ( workspaceId: string, req?: TypesGen.WorkspaceBuildsRequest, diff --git a/site/src/api/queries/userSecrets.ts b/site/src/api/queries/userSecrets.ts index 40463d7851..340e791c91 100644 --- a/site/src/api/queries/userSecrets.ts +++ b/site/src/api/queries/userSecrets.ts @@ -50,3 +50,15 @@ export const deleteUserSecret = (queryClient: QueryClient, userId: string) => { }, }; }; + +export const importUserSecrets = (queryClient: QueryClient, userId: string) => { + return { + mutationFn: (request: TypesGen.ImportUserSecretsRequest) => + API.importUserSecrets(userId, request), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: userSecretsKey(userId), + }); + }, + }; +}; diff --git a/site/src/components/FileUpload/FileUpload.tsx b/site/src/components/FileUpload/FileUpload.tsx index b35a89e76d..2e6cd3b252 100644 --- a/site/src/components/FileUpload/FileUpload.tsx +++ b/site/src/components/FileUpload/FileUpload.tsx @@ -8,6 +8,7 @@ import { Spinner } from "../Spinner/Spinner"; interface FileUploadProps { isUploading: boolean; onUpload: (file: File) => void; + onUnsupportedFile?: (file: File) => void; onRemove?: () => void; file?: File; removeLabel: string; @@ -19,6 +20,7 @@ interface FileUploadProps { export const FileUpload: FC = ({ isUploading, onUpload, + onUnsupportedFile, onRemove, file, removeLabel, @@ -26,7 +28,7 @@ export const FileUpload: FC = ({ description, extensions, }) => { - const fileDrop = useFileDrop(onUpload, extensions); + const fileDrop = useFileDrop(onUpload, extensions, onUnsupportedFile); const inputRef = useRef(null); const clickable = useClickable(() => inputRef.current?.click(), @@ -87,6 +89,7 @@ export const FileUpload: FC = ({ data-testid="file-upload" ref={inputRef} className="hidden" + disabled={isUploading} accept={extensions?.map((ext) => `.${ext}`).join(",")} onChange={(event) => { const file = event.currentTarget.files?.[0]; @@ -102,6 +105,7 @@ export const FileUpload: FC = ({ const useFileDrop = ( callback: (file: File) => void, extensions?: string[], + onUnsupportedFile?: (file: File) => void, ): { onDragOver: (e: DragEvent) => void; onDrop: (e: DragEvent) => void; @@ -123,15 +127,14 @@ const useFileDrop = ( return; } - const extension = file.name.split(".").pop(); + const extension = file.name.split(".").pop()?.toLowerCase(); - if (!extension) { - throw new Error(`File has no extension to compare with ${extensions}`); - } - - if (extensions.includes(extension)) { + if (extension && extensions.includes(extension)) { callback(file); + return; } + + onUnsupportedFile?.(file); }; return { diff --git a/site/src/pages/UserSettingsPage/SecretsPage/SecretDialog.tsx b/site/src/pages/UserSettingsPage/SecretsPage/SecretDialog.tsx index 4e6709ed71..b33420c5b7 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/SecretDialog.tsx +++ b/site/src/pages/UserSettingsPage/SecretsPage/SecretDialog.tsx @@ -1,11 +1,20 @@ import { type FormikTouched, useFormik } from "formik"; import { type FC, type ReactNode, useState } from "react"; -import type { - CreateUserSecretRequest, - UpdateUserSecretRequest, - UserSecret, +import { + type FieldError, + getErrorMessage, + isApiError, + isApiErrorResponse, +} from "#/api/errors"; +import { + type CreateUserSecretRequest, + type ImportUserSecretsRequest, + MaxSecretsFileBytes, + type UpdateUserSecretRequest, + type UserSecret, } from "#/api/typesGenerated"; -import { Alert, AlertDescription } from "#/components/Alert/Alert"; +import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert"; +import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Button } from "#/components/Button/Button"; import { Dialog, @@ -14,9 +23,11 @@ import { DialogHeader, DialogTitle, } from "#/components/Dialog/Dialog"; +import { FileUpload } from "#/components/FileUpload/FileUpload"; import { FormField } from "#/components/FormField/FormField"; import { Input } from "#/components/Input/Input"; import { Label } from "#/components/Label/Label"; +import { Separator } from "#/components/Separator/Separator"; import { Spinner } from "#/components/Spinner/Spinner"; import { Textarea } from "#/components/Textarea/Textarea"; import { cn } from "#/utils/cn"; @@ -28,6 +39,7 @@ import { mapSecretApiErrorToFormErrors, type SecretFieldErrors, type SecretFormValues, + secretsFileFormatFromFilename, } from "./secretForm"; type SecretDialogProps = { @@ -43,6 +55,7 @@ type SecretDialogProps = { name: string, request: UpdateUserSecretRequest, ) => Promise | UserSecret; + onImportSecrets: (request: ImportUserSecretsRequest) => Promise; }; const emptyValues: SecretFormValues = { @@ -64,6 +77,7 @@ export const SecretDialog: FC = ({ onClose, onCreateSecret, onUpdateSecret, + onImportSecrets, }) => { const isEdit = Boolean(secret); const initialValues = secret @@ -76,6 +90,9 @@ export const SecretDialog: FC = ({ } : emptyValues; const [clearValueRequested, setClearValueRequested] = useState(false); + const [importFile, setImportFile] = useState(undefined); + const [isImporting, setIsImporting] = useState(false); + const [importError, setImportError] = useState(undefined); const form = useFormik({ initialValues, @@ -111,17 +128,62 @@ export const SecretDialog: FC = ({ const closeDialog = () => { setClearValueRequested(false); + setImportFile(undefined); + setImportError(undefined); + setIsImporting(false); form.resetForm(); onClose(); }; + const handleImportFile = (file: File) => { + if (isImporting) { + return; + } + setImportError(undefined); + setImportFile(file); + + const format = secretsFileFormatFromFilename(file.name); + if (!format) { + setImportError({ + message: + "Unsupported file type. Import a .env, .json, .yaml, or .yml file.", + }); + return; + } + if (file.size > MaxSecretsFileBytes) { + setImportError({ + message: "File is too large. Import a file of 1 MiB or smaller.", + }); + return; + } + + setIsImporting(true); + const reader = new FileReader(); + reader.onload = async () => { + const content = typeof reader.result === "string" ? reader.result : ""; + try { + await onImportSecrets({ format, content }); + closeDialog(); + } catch (error) { + setImportError(error); + } finally { + setIsImporting(false); + } + }; + reader.onerror = () => { + setImportError({ message: "Failed to read the selected file." }); + setIsImporting(false); + }; + reader.readAsText(file); + }; + const request = secret ? buildUpdateUserSecretRequest(secret, form.values, { clearValue: clearValueRequested, }) : undefined; const hasUpdate = request ? Object.keys(request).length > 0 : false; - const isBusy = isSubmitting || form.isSubmitting; + const isBusy = isSubmitting || form.isSubmitting || isImporting; const confirmDisabled = isBusy || !form.isValid || (secret ? !hasUpdate : !form.dirty); const getFieldHelpers = getFormHelpers(form); @@ -193,6 +255,32 @@ export const SecretDialog: FC = ({ ) : ( <> +
+ { + setImportFile(undefined); + setImportError(undefined); + }} + removeLabel="Remove file" + title="Import secrets from a file" + description="Import a single or multiple secrets at once with a .env, .json, .yaml, or .yml file." + extensions={["env", "json", "yaml", "yml"]} + /> + {importError !== undefined && ( + + )} +
+
+ + + or add individually + + +
[field, true]), ) as FormikTouched; } + +type ImportSecretsErrorProps = { + error: unknown; +}; + +const ImportSecretsError: FC = ({ error }) => { + const validations = getImportSecretValidations(error); + if (validations.length === 0) { + return ; + } + + return ( + + + {getErrorMessage(error, "Failed to import secrets.")} + + +
    + {validations.map((validation) => ( +
  • + {validation.field} + {validation.detail} +
  • + ))} +
+
+
+ ); +}; + +function getImportSecretValidations(error: unknown): FieldError[] { + if (isApiError(error)) { + return error.response.data.validations ?? []; + } + if (isApiErrorResponse(error)) { + return error.validations ?? []; + } + return []; +} diff --git a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPage.tsx b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPage.tsx index 43b7463884..3f72521c33 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPage.tsx +++ b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPage.tsx @@ -5,11 +5,13 @@ import { getErrorDetail, getErrorMessage } from "#/api/errors"; import { createUserSecret, deleteUserSecret, + importUserSecrets, updateUserSecret, userSecrets, } from "#/api/queries/userSecrets"; import { useAuthenticated } from "#/hooks/useAuthenticated"; import { SecretsPageView } from "./SecretsPageView"; +import { buildImportSuccessMessage } from "./secretForm"; const SecretsPage: FC = () => { const { user: me } = useAuthenticated(); @@ -25,6 +27,9 @@ const SecretsPage: FC = () => { const deleteSecretMutation = useMutation( deleteUserSecret(queryClient, me.id), ); + const importSecretsMutation = useMutation( + importUserSecrets(queryClient, me.id), + ); return ( { toast.success(`Updated secret "${secret.name}" successfully.`); return secret; }} + onImportSecrets={async (request) => { + const secrets = await importSecretsMutation.mutateAsync(request); + toast.success(buildImportSuccessMessage(secrets)); + return secrets; + }} onDeleteSecret={async (secret) => { try { await deleteSecretMutation.mutateAsync(secret.name); diff --git a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx index 0988c7172b..91a632afb5 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx +++ b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx @@ -1,11 +1,18 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, fn, userEvent, waitFor, within } from "storybook/test"; -import type { - CreateUserSecretRequest, - UpdateUserSecretRequest, - UserSecret, +import { expect, fn, spyOn, userEvent, waitFor, within } from "storybook/test"; +import { + type CreateUserSecretRequest, + type ImportUserSecretsRequest, + MaxSecretsFileBytes, + type UpdateUserSecretRequest, + type UserSecret, } from "#/api/typesGenerated"; -import { MockUserSecrets, mockApiError } from "#/testHelpers/entities"; +import { createDeferred } from "#/testHelpers/deferred"; +import { + MockImportedUserSecrets, + MockUserSecrets, + mockApiError, +} from "#/testHelpers/entities"; import { SAVED_SECRET_VALUE_DISPLAY } from "./SecretDialog"; import { SecretsPageView } from "./SecretsPageView"; @@ -28,6 +35,7 @@ const meta: Meta = { onRefresh: fn(), onCreateSecret: fn(), onUpdateSecret: fn(), + onImportSecrets: fn(), onDeleteSecret: fn(), onToggleSecretEnabled: fn(), }, @@ -56,6 +64,16 @@ const waitForDialogToClose = async (body: ReturnType) => { }); }; +const uploadImportFile = async (canvasElement: HTMLElement, file: File) => { + const user = userEvent.setup({ applyAccept: false }); + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + await user.click(canvas.getByRole("button", { name: "Add secret" })); + const dialog = within(await body.findByRole("dialog")); + await user.upload(dialog.getByTestId("file-upload"), file); + return { user, dialog, body }; +}; + const expectNoValueField = (body: ReturnType) => { expect(body.queryByLabelText("Value")).not.toBeInTheDocument(); }; @@ -603,6 +621,261 @@ export const CreateMutationErrorDisplay: Story = { }, }; +const importSecretsSuccess = fn< + (request: ImportUserSecretsRequest) => Promise +>(async () => MockImportedUserSecrets); + +export const ImportSecretsFromFileSubmit: Story = { + args: { + onImportSecrets: importSecretsSuccess, + }, + beforeEach: () => { + importSecretsSuccess.mockClear(); + }, + play: async ({ canvasElement }) => { + const { body } = await uploadImportFile( + canvasElement, + new File(["A=1\nB=2"], "secrets.ENV", { type: "text/plain" }), + ); + + await waitFor(() => expect(importSecretsSuccess).toHaveBeenCalledTimes(1)); + expect(importSecretsSuccess).toHaveBeenCalledWith({ + format: "env", + content: "A=1\nB=2", + }); + await waitForDialogToClose(body); + }, +}; + +const importSecretsValidationError = fn< + (request: ImportUserSecretsRequest) => Promise +>(async () => { + throw mockApiError({ + message: "Validation failed.", + validations: [ + { + field: "secrets[1].value", + detail: "Value is required.", + }, + ], + }); +}); + +export const ImportSecretsValidationError: Story = { + args: { + onImportSecrets: importSecretsValidationError, + }, + beforeEach: () => { + importSecretsValidationError.mockClear(); + }, + play: async ({ canvasElement }) => { + const { dialog } = await uploadImportFile( + canvasElement, + new File(["A=1\nB="], "secrets.env", { type: "text/plain" }), + ); + + await waitFor(() => + expect(importSecretsValidationError).toHaveBeenCalledTimes(1), + ); + await waitFor(() => + expect(dialog.getByText("secrets[1].value")).toBeVisible(), + ); + expect(dialog.getByText("Value is required.")).toBeVisible(); + expect(dialog.getByRole("heading", { name: "Add secret" })).toBeVisible(); + }, +}; + +const importSecretsUnsupportedFile = fn< + (request: ImportUserSecretsRequest) => Promise +>(async () => MockImportedUserSecrets); + +export const ImportSecretsUnsupportedFile: Story = { + args: { + onImportSecrets: importSecretsUnsupportedFile, + }, + beforeEach: () => { + importSecretsUnsupportedFile.mockClear(); + }, + play: async ({ canvasElement }) => { + const user = userEvent.setup(); + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + await user.click(canvas.getByRole("button", { name: "Add secret" })); + const dialog = within(await body.findByRole("dialog")); + const dropZone = dialog.getByRole("button", { + name: /Import secrets from a file/, + }); + const dataTransfer = new DataTransfer(); + dataTransfer.items.add( + new File(["not a secret"], "bad.txt", { type: "text/plain" }), + ); + dropZone.dispatchEvent( + new DragEvent("drop", { + bubbles: true, + cancelable: true, + dataTransfer, + }), + ); + + await waitFor(() => + expect( + dialog.getByText( + "Unsupported file type. Import a .env, .json, .yaml, or .yml file.", + ), + ).toBeVisible(), + ); + expect(importSecretsUnsupportedFile).not.toHaveBeenCalled(); + }, +}; + +const importSecretsTooLarge = fn< + (request: ImportUserSecretsRequest) => Promise +>(async () => MockImportedUserSecrets); + +export const ImportSecretsTooLarge: Story = { + args: { + onImportSecrets: importSecretsTooLarge, + }, + beforeEach: () => { + importSecretsTooLarge.mockClear(); + }, + play: async ({ canvasElement }) => { + const { dialog } = await uploadImportFile( + canvasElement, + new File([new Uint8Array(MaxSecretsFileBytes + 1)], "too-large.env"), + ); + + await waitFor(() => + expect( + dialog.getByText( + "File is too large. Import a file of 1 MiB or smaller.", + ), + ).toBeVisible(), + ); + expect(importSecretsTooLarge).not.toHaveBeenCalled(); + }, +}; + +const importSecretsParseError = fn< + (request: ImportUserSecretsRequest) => Promise +>(async () => { + throw mockApiError({ + message: "Failed to parse secrets file.", + detail: "Line 2 must contain KEY=VALUE.", + }); +}); + +export const ImportSecretsParseError: Story = { + args: { + onImportSecrets: importSecretsParseError, + }, + beforeEach: () => { + importSecretsParseError.mockClear(); + }, + play: async ({ canvasElement }) => { + const { dialog } = await uploadImportFile( + canvasElement, + new File(["GOOD=1\nbad line"], "secrets.env"), + ); + + await expect( + await dialog.findByText("Failed to parse secrets file."), + ).toBeVisible(); + expect(dialog.getByText("Line 2 must contain KEY=VALUE.")).toBeVisible(); + expect(dialog.queryByText("Response data")).not.toBeInTheDocument(); + expect(dialog.queryByText("Stack Trace")).not.toBeInTheDocument(); + }, +}; + +export const ImportSecretsFileReadError: Story = { + beforeEach: () => { + const readAsText = spyOn(FileReader.prototype, "readAsText"); + readAsText.mockImplementation(function (this: FileReader) { + this.dispatchEvent(new ProgressEvent("error")); + }); + return () => readAsText.mockRestore(); + }, + play: async ({ canvasElement }) => { + const { dialog } = await uploadImportFile( + canvasElement, + new File(["A=1"], "secrets.env"), + ); + + await expect( + await dialog.findByText("Failed to read the selected file."), + ).toBeVisible(); + }, +}; + +const pendingImport = createDeferred(); +const importSecretsPending = fn< + (request: ImportUserSecretsRequest) => Promise +>(() => pendingImport.promise); + +export const ImportSecretsPending: Story = { + args: { + onImportSecrets: importSecretsPending, + }, + beforeEach: () => { + importSecretsPending.mockClear(); + }, + play: async ({ canvasElement }) => { + const user = userEvent.setup(); + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + await user.click(canvas.getByRole("button", { name: "Add secret" })); + const dialog = within(await body.findByRole("dialog")); + const input = dialog.getByTestId("file-upload"); + await user.upload(input, new File(["A=1"], "secrets.env")); + + await waitFor(() => expect(importSecretsPending).toHaveBeenCalledTimes(1)); + expect(dialog.getByRole("button", { name: "Cancel" })).toBeDisabled(); + expect(dialog.getByRole("button", { name: "Save" })).toBeDisabled(); + expect(input).toBeDisabled(); + await user.upload(input, new File(["B=2"], "second.env")); + expect(importSecretsPending).toHaveBeenCalledTimes(1); + }, +}; + +const importSecretsAfterRemoval = fn< + (request: ImportUserSecretsRequest) => Promise +>(async () => MockImportedUserSecrets); + +export const ImportSecretsRemoveAndRetry: Story = { + args: { + onImportSecrets: importSecretsAfterRemoval, + }, + beforeEach: () => { + importSecretsAfterRemoval.mockClear(); + }, + play: async ({ canvasElement }) => { + const { user, dialog, body } = await uploadImportFile( + canvasElement, + new File(["not a secret"], "bad.txt"), + ); + + await expect( + await dialog.findByText( + "Unsupported file type. Import a .env, .json, .yaml, or .yml file.", + ), + ).toBeVisible(); + await user.click(dialog.getByRole("button", { name: "Remove file" })); + expect( + dialog.queryByText( + "Unsupported file type. Import a .env, .json, .yaml, or .yml file.", + ), + ).not.toBeInTheDocument(); + await user.upload( + dialog.getByTestId("file-upload"), + new File(["A=1"], "secrets.env"), + ); + await waitFor(() => + expect(importSecretsAfterRemoval).toHaveBeenCalledTimes(1), + ); + await waitForDialogToClose(body); + }, +}; + export const ToggleEnabledSubmit: Story = { args: { onToggleSecretEnabled: fn< diff --git a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.tsx b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.tsx index 26543a988f..1e44db1b23 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.tsx +++ b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.tsx @@ -2,6 +2,7 @@ import { PlusIcon, RefreshCwIcon } from "lucide-react"; import { type FC, useRef, useState } from "react"; import type { CreateUserSecretRequest, + ImportUserSecretsRequest, UpdateUserSecretRequest, UserSecret, } from "#/api/typesGenerated"; @@ -35,6 +36,7 @@ type SecretsPageViewProps = { name: string, request: UpdateUserSecretRequest, ) => Promise | UserSecret; + onImportSecrets: (request: ImportUserSecretsRequest) => Promise; onDeleteSecret: (secret: UserSecret) => Promise | void; onToggleSecretEnabled: ( secret: UserSecret, @@ -58,6 +60,7 @@ export const SecretsPageView: FC = ({ onRefresh, onCreateSecret, onUpdateSecret, + onImportSecrets, onDeleteSecret, onToggleSecretEnabled, }) => { @@ -127,6 +130,7 @@ export const SecretsPageView: FC = ({ onClose={closeSecretDialog} onCreateSecret={onCreateSecret} onUpdateSecret={onUpdateSecret} + onImportSecrets={onImportSecrets} /> {getSecretsError ? : undefined} diff --git a/site/src/pages/UserSettingsPage/SecretsPage/secretForm.test.ts b/site/src/pages/UserSettingsPage/SecretsPage/secretForm.test.ts index a449272857..cb34711570 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/secretForm.test.ts +++ b/site/src/pages/UserSettingsPage/SecretsPage/secretForm.test.ts @@ -1,10 +1,12 @@ import type { UserSecret } from "#/api/typesGenerated"; -import { mockApiError } from "#/testHelpers/entities"; +import { MockImportedUserSecret, mockApiError } from "#/testHelpers/entities"; import { buildCreateUserSecretRequest, + buildImportSuccessMessage, buildUpdateUserSecretRequest, getCreateSecretRequiredFieldErrors, mapSecretApiErrorToFormErrors, + secretsFileFormatFromFilename, } from "./secretForm"; const existingSecrets: UserSecret[] = [ @@ -30,6 +32,58 @@ const existingSecrets: UserSecret[] = [ }, ]; +describe("buildImportSuccessMessage", () => { + it("reports a single secret imported successfully", () => { + expect(buildImportSuccessMessage([MockImportedUserSecret])).toBe( + "Imported 1 secret successfully.", + ); + }); + + it("reports multiple secrets imported successfully", () => { + expect( + buildImportSuccessMessage([ + MockImportedUserSecret, + { ...MockImportedUserSecret, id: "second-secret" }, + ]), + ).toBe("Imported 2 secrets successfully."); + }); + + it("reports one secret imported without an env name", () => { + expect( + buildImportSuccessMessage([ + MockImportedUserSecret, + { + ...MockImportedUserSecret, + id: "without-env-name", + env_name: "", + }, + ]), + ).toBe( + "Imported 2 secrets. " + + "1 was imported without an environment variable name " + + "because its key is not a valid environment variable name. Edit it to set one.", + ); + }); + + it("reports multiple secrets imported without env names", () => { + expect( + buildImportSuccessMessage([ + { ...MockImportedUserSecret, env_name: "" }, + { + ...MockImportedUserSecret, + id: "second-without-env-name", + env_name: "", + }, + MockImportedUserSecret, + ]), + ).toBe( + "Imported 3 secrets. " + + "2 were imported without an environment variable name " + + "because their keys are not valid environment variable names. Edit them to set one.", + ); + }); +}); + describe("getCreateSecretRequiredFieldErrors", () => { it("requires name and value on create", () => { expect( @@ -121,6 +175,31 @@ describe("payload builders", () => { }); }); +describe("secretsFileFormatFromFilename", () => { + it.each([ + ["a.env", "env"], + [".env", "env"], + ["prod.env", "env"], + ["config.json", "json"], + ["values.yaml", "yaml"], + ["values.yml", "yaml"], + ["CONFIG.JSON", "json"], + ["Values.YML", "yaml"], + ["secrets.ENV", "env"], + ])("maps %s to the %s format", (filename, format) => { + expect(secretsFileFormatFromFilename(filename)).toBe(format); + }); + + it.each([ + ["foo.txt"], + ["noextension"], + ["archive.tar.gz"], + [""], + ])("returns undefined for unsupported filename %s", (filename) => { + expect(secretsFileFormatFromFilename(filename)).toBeUndefined(); + }); +}); + describe("mapSecretApiErrorToFormErrors", () => { it("maps structured API validation errors to fields", () => { expect( diff --git a/site/src/pages/UserSettingsPage/SecretsPage/secretForm.ts b/site/src/pages/UserSettingsPage/SecretsPage/secretForm.ts index 4b242ea4a0..af951c4641 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/secretForm.ts +++ b/site/src/pages/UserSettingsPage/SecretsPage/secretForm.ts @@ -6,6 +6,7 @@ import { } from "#/api/errors"; import type { CreateUserSecretRequest, + SecretsFileFormat, UpdateUserSecretRequest, UserSecret, } from "#/api/typesGenerated"; @@ -27,6 +28,41 @@ interface SecretFormErrors { formError?: string; } +export const buildImportSuccessMessage = (secrets: UserSecret[]): string => { + const total = secrets.length; + const noEnvName = secrets.filter((s) => s.env_name === "").length; + const secretWord = total === 1 ? "secret" : "secrets"; + if (noEnvName === 0) { + return `Imported ${total} ${secretWord} successfully.`; + } + const wasWere = noEnvName === 1 ? "was" : "were"; + const keyPhrase = + noEnvName === 1 + ? "its key is not a valid environment variable name. Edit it to set one." + : "their keys are not valid environment variable names. Edit them to set one."; + return ( + `Imported ${total} ${secretWord}. ` + + `${noEnvName} ${wasWere} imported without an environment variable name ` + + `because ${keyPhrase}` + ); +}; + +export const secretsFileFormatFromFilename = ( + filename: string, +): SecretsFileFormat | undefined => { + const lowerName = filename.toLowerCase(); + if (lowerName.endsWith(".env")) { + return "env"; + } + if (lowerName.endsWith(".json")) { + return "json"; + } + if (lowerName.endsWith(".yaml") || lowerName.endsWith(".yml")) { + return "yaml"; + } + return undefined; +}; + export const getCreateSecretRequiredFieldErrors = ( values: Pick, ): SecretFieldErrors => { diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 20e693e494..17a296a812 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -627,6 +627,27 @@ export const MockUserSecrets: TypesGen.UserSecret[] = [ }, ]; +export const MockImportedUserSecret: TypesGen.UserSecret = { + id: "imported-database-url", + name: "DATABASE_URL", + description: "", + env_name: "DATABASE_URL", + file_path: "", + enabled: true, + created_at: "2026-05-04T00:00:00Z", + updated_at: "2026-05-04T00:00:00Z", +}; + +export const MockImportedUserSecrets: TypesGen.UserSecret[] = [ + MockImportedUserSecret, + { + ...MockImportedUserSecret, + id: "imported-api-token", + name: "API_TOKEN", + env_name: "API_TOKEN", + }, +]; + export const MockTasksTabVisible: boolean = false; export const MockAIGatewayEnabled: boolean = true; diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 82833ff688..a615681ff1 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -228,6 +228,9 @@ export const handlers = [ status: 201, }); }), + http.post("/api/v2/users/:userId/secrets/batch", () => { + return HttpResponse.json(M.MockImportedUserSecrets, { status: 201 }); + }), http.patch( "/api/v2/users/:userId/secrets/:name", async ({ request, params }) => {