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 }) => {