mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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.
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -1788,6 +1788,18 @@ class ApiMethods {
|
||||
);
|
||||
};
|
||||
|
||||
importUserSecrets = async (
|
||||
userId: string,
|
||||
request: TypesGen.ImportUserSecretsRequest,
|
||||
): Promise<TypesGen.UserSecret[]> => {
|
||||
const response = await this.axios.post<TypesGen.UserSecret[]>(
|
||||
`/api/v2/users/${encodeURIComponent(userId)}/secrets/batch`,
|
||||
request,
|
||||
);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
getWorkspaceBuilds = async (
|
||||
workspaceId: string,
|
||||
req?: TypesGen.WorkspaceBuildsRequest,
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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<FileUploadProps> = ({
|
||||
isUploading,
|
||||
onUpload,
|
||||
onUnsupportedFile,
|
||||
onRemove,
|
||||
file,
|
||||
removeLabel,
|
||||
@@ -26,7 +28,7 @@ export const FileUpload: FC<FileUploadProps> = ({
|
||||
description,
|
||||
extensions,
|
||||
}) => {
|
||||
const fileDrop = useFileDrop(onUpload, extensions);
|
||||
const fileDrop = useFileDrop(onUpload, extensions, onUnsupportedFile);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const clickable = useClickable<HTMLDivElement>(() =>
|
||||
inputRef.current?.click(),
|
||||
@@ -87,6 +89,7 @@ export const FileUpload: FC<FileUploadProps> = ({
|
||||
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<FileUploadProps> = ({
|
||||
const useFileDrop = (
|
||||
callback: (file: File) => void,
|
||||
extensions?: string[],
|
||||
onUnsupportedFile?: (file: File) => void,
|
||||
): {
|
||||
onDragOver: (e: DragEvent<HTMLDivElement>) => void;
|
||||
onDrop: (e: DragEvent<HTMLDivElement>) => 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 {
|
||||
|
||||
@@ -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> | UserSecret;
|
||||
onImportSecrets: (request: ImportUserSecretsRequest) => Promise<UserSecret[]>;
|
||||
};
|
||||
|
||||
const emptyValues: SecretFormValues = {
|
||||
@@ -64,6 +77,7 @@ export const SecretDialog: FC<SecretDialogProps> = ({
|
||||
onClose,
|
||||
onCreateSecret,
|
||||
onUpdateSecret,
|
||||
onImportSecrets,
|
||||
}) => {
|
||||
const isEdit = Boolean(secret);
|
||||
const initialValues = secret
|
||||
@@ -76,6 +90,9 @@ export const SecretDialog: FC<SecretDialogProps> = ({
|
||||
}
|
||||
: emptyValues;
|
||||
const [clearValueRequested, setClearValueRequested] = useState(false);
|
||||
const [importFile, setImportFile] = useState<File | undefined>(undefined);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [importError, setImportError] = useState<unknown>(undefined);
|
||||
|
||||
const form = useFormik<SecretFormValues>({
|
||||
initialValues,
|
||||
@@ -111,17 +128,62 @@ export const SecretDialog: FC<SecretDialogProps> = ({
|
||||
|
||||
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<SecretDialogProps> = ({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
<FileUpload
|
||||
isUploading={isImporting}
|
||||
file={importFile}
|
||||
onUpload={handleImportFile}
|
||||
onUnsupportedFile={handleImportFile}
|
||||
onRemove={() => {
|
||||
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 && (
|
||||
<ImportSecretsError error={importError} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Separator />
|
||||
<span className="whitespace-nowrap px-3 text-xs text-content-secondary">
|
||||
or add individually
|
||||
</span>
|
||||
<Separator />
|
||||
</div>
|
||||
<SecretFields
|
||||
getFieldHelpers={getFieldHelpers}
|
||||
showRequiredLabels
|
||||
@@ -465,3 +553,42 @@ function touchedFromFieldErrors(
|
||||
Object.keys(fieldErrors).map((field) => [field, true]),
|
||||
) as FormikTouched<SecretFormValues>;
|
||||
}
|
||||
|
||||
type ImportSecretsErrorProps = {
|
||||
error: unknown;
|
||||
};
|
||||
|
||||
const ImportSecretsError: FC<ImportSecretsErrorProps> = ({ error }) => {
|
||||
const validations = getImportSecretValidations(error);
|
||||
if (validations.length === 0) {
|
||||
return <ErrorAlert error={error} showDebugDetail={false} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert severity="error" prominent>
|
||||
<AlertTitle>
|
||||
{getErrorMessage(error, "Failed to import secrets.")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="m-0 flex list-disc flex-col gap-1 pl-5">
|
||||
{validations.map((validation) => (
|
||||
<li key={validation.field}>
|
||||
<span className="font-semibold">{validation.field}</span>
|
||||
<span className="block">{validation.detail}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
function getImportSecretValidations(error: unknown): FieldError[] {
|
||||
if (isApiError(error)) {
|
||||
return error.response.data.validations ?? [];
|
||||
}
|
||||
if (isApiErrorResponse(error)) {
|
||||
return error.validations ?? [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<SecretsPageView
|
||||
@@ -54,6 +59,11 @@ const SecretsPage: FC = () => {
|
||||
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);
|
||||
|
||||
@@ -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<typeof SecretsPageView> = {
|
||||
onRefresh: fn(),
|
||||
onCreateSecret: fn(),
|
||||
onUpdateSecret: fn(),
|
||||
onImportSecrets: fn(),
|
||||
onDeleteSecret: fn(),
|
||||
onToggleSecretEnabled: fn(),
|
||||
},
|
||||
@@ -56,6 +64,16 @@ const waitForDialogToClose = async (body: ReturnType<typeof within>) => {
|
||||
});
|
||||
};
|
||||
|
||||
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<typeof within>) => {
|
||||
expect(body.queryByLabelText("Value")).not.toBeInTheDocument();
|
||||
};
|
||||
@@ -603,6 +621,261 @@ export const CreateMutationErrorDisplay: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
const importSecretsSuccess = fn<
|
||||
(request: ImportUserSecretsRequest) => Promise<UserSecret[]>
|
||||
>(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<UserSecret[]>
|
||||
>(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<UserSecret[]>
|
||||
>(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<UserSecret[]>
|
||||
>(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<UserSecret[]>
|
||||
>(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<UserSecret[]>();
|
||||
const importSecretsPending = fn<
|
||||
(request: ImportUserSecretsRequest) => Promise<UserSecret[]>
|
||||
>(() => 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<UserSecret[]>
|
||||
>(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<
|
||||
|
||||
@@ -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> | UserSecret;
|
||||
onImportSecrets: (request: ImportUserSecretsRequest) => Promise<UserSecret[]>;
|
||||
onDeleteSecret: (secret: UserSecret) => Promise<void> | void;
|
||||
onToggleSecretEnabled: (
|
||||
secret: UserSecret,
|
||||
@@ -58,6 +60,7 @@ export const SecretsPageView: FC<SecretsPageViewProps> = ({
|
||||
onRefresh,
|
||||
onCreateSecret,
|
||||
onUpdateSecret,
|
||||
onImportSecrets,
|
||||
onDeleteSecret,
|
||||
onToggleSecretEnabled,
|
||||
}) => {
|
||||
@@ -127,6 +130,7 @@ export const SecretsPageView: FC<SecretsPageViewProps> = ({
|
||||
onClose={closeSecretDialog}
|
||||
onCreateSecret={onCreateSecret}
|
||||
onUpdateSecret={onUpdateSecret}
|
||||
onImportSecrets={onImportSecrets}
|
||||
/>
|
||||
|
||||
{getSecretsError ? <ErrorAlert error={getSecretsError} /> : undefined}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<SecretFormValues, "name" | "value">,
|
||||
): SecretFieldErrors => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user