feat: add user secrets management page (#25371)

Adds the account settings UI for managing user secrets, including the
table, add/edit/delete dialog, Storybook coverage, and route/sidebar
entry.

Also updates the shared `FeatureStageBadge` beta variant with
dedicated beta styling, sizing, and label casing for the Secrets
page.

Stacked on #25370.

_This PR was generated by Coder Agents._
This commit is contained in:
dylanhuff-at-coder
2026-05-26 14:51:00 -04:00
committed by GitHub
parent 5ab5e07012
commit 7887cff9d0
17 changed files with 1701 additions and 44 deletions
+3
View File
@@ -79,6 +79,9 @@ distinct paths to avoid the collision.
## Create a secret
You can create, edit, and delete user secrets in the Coder dashboard. Click your
avatar, select **Account**, then select **Secrets**.
Use `coder secret create <name>` to create a user secret. For sensitive values,
provide the value through non-interactive stdin with a pipe or redirect. This
keeps the value out of your shell history and process arguments.
+11 -5
View File
@@ -432,7 +432,7 @@ export const startWorkspaceWithEphemeralParameters = async (
await fillParameters(page, richParameters, buildParameters);
await page.getByRole("button", { name: /update and start/i }).click();
await clickWorkspaceUpdateSubmit(page, /update and start/i);
await page.waitForSelector("text=Workspace status: Running", {
state: "visible",
@@ -1107,6 +1107,12 @@ const fillParameters = async (
}
};
const clickWorkspaceUpdateSubmit = async (page: Page, name: RegExp) => {
const submitButton = page.getByRole("button", { name });
await expect(submitButton).toBeEnabled({ timeout: 30_000 });
await submitButton.click();
};
export const updateTemplate = async (
page: Page,
organization: string,
@@ -1205,11 +1211,11 @@ export const updateWorkspace = async (
await fillParameters(page, richParameters, buildParameters);
if (workspaceStatus === "running") {
await page.getByRole("button", { name: /update and restart/i }).click();
await clickWorkspaceUpdateSubmit(page, /update and restart/i);
// Confirmation dialog.
await page.getByRole("button", { name: /restart/i }).click();
} else {
await page.getByRole("button", { name: /update and start/i }).click();
await clickWorkspaceUpdateSubmit(page, /update and start/i);
}
};
@@ -1228,11 +1234,11 @@ export const updateWorkspaceParameters = async (
await fillParameters(page, richParameters, buildParameters);
if (workspaceStatus === "running") {
await page.getByRole("button", { name: /update and restart/i }).click();
await clickWorkspaceUpdateSubmit(page, /update and restart/i);
// Confirmation dialog.
await page.getByRole("button", { name: /restart/i }).click();
} else {
await page.getByRole("button", { name: /update and start/i }).click();
await clickWorkspaceUpdateSubmit(page, /update and start/i);
}
await page.waitForSelector("text=Workspace status: Running", {
+52
View File
@@ -0,0 +1,52 @@
import type { QueryClient } from "react-query";
import { API } from "#/api/api";
import type * as TypesGen from "#/api/typesGenerated";
const userSecretsKey = (userId: string) => ["users", userId, "secrets"];
export const userSecrets = (userId: string) => {
return {
queryKey: userSecretsKey(userId),
queryFn: () => API.getUserSecrets(userId),
};
};
export const createUserSecret = (queryClient: QueryClient, userId: string) => {
return {
mutationFn: (request: TypesGen.CreateUserSecretRequest) =>
API.createUserSecret(userId, request),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: userSecretsKey(userId),
});
},
};
};
export const updateUserSecret = (queryClient: QueryClient, userId: string) => {
return {
mutationFn: ({
name,
request,
}: {
name: string;
request: TypesGen.UpdateUserSecretRequest;
}) => API.updateUserSecret(userId, name, request),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: userSecretsKey(userId),
});
},
};
};
export const deleteUserSecret = (queryClient: QueryClient, userId: string) => {
return {
mutationFn: (name: string) => API.deleteUserSecret(userId, name),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: userSecretsKey(userId),
});
},
};
};
@@ -1,9 +1,11 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { chromatic } from "#/testHelpers/chromatic";
import { FeatureStageBadge } from "./FeatureStageBadge";
const meta: Meta<typeof FeatureStageBadge> = {
title: "components/FeatureStageBadge",
component: FeatureStageBadge,
parameters: { chromatic },
args: {
contentType: "beta",
},
@@ -19,6 +21,13 @@ export const ExtraSmallBeta: Story = {
},
};
export const ExtraSmallEarlyAccess: Story = {
args: {
size: "xs",
contentType: "early_access",
},
};
export const SmallBeta: Story = {
args: {
size: "sm",
@@ -13,8 +13,8 @@ import { docs } from "#/utils/docs";
* ensure that we can't accidentally make typos when writing the badge text.
*/
const featureStageBadgeTypes = {
early_access: "early access",
beta: "beta",
early_access: "Early Access",
beta: "Beta",
} as const satisfies Record<string, ReactNode>;
type FeatureStageBadgeProps = Readonly<
@@ -26,14 +26,21 @@ type FeatureStageBadgeProps = Readonly<
>;
const badgeColorClasses = {
early_access: "bg-surface-orange text-content-warning",
early_access: "border-border-pending bg-surface-sky text-highlight-sky",
beta: "bg-surface-sky text-highlight-sky",
} as const;
const badgeSizeClasses = {
xs: "text-2xs font-normal px-1.5 py-0.5 h-[18px] rounded border-0",
sm: "text-xs font-medium px-2 py-1",
md: "text-base px-2 py-1",
early_access: {
xs: "rounded-[5px] px-1.5 py-0.5 text-2xs font-normal leading-4",
sm: "rounded-[5px] px-2 py-0.5 text-[10px] font-normal leading-4",
md: "rounded-[5px] px-[7px] py-[3.5px] text-xs font-normal leading-4",
},
beta: {
xs: "text-2xs font-normal px-1.5 py-0.5 h-[18px] rounded border-0",
sm: "text-xs font-medium px-2 py-1",
md: "text-base px-2 py-1",
},
} as const;
export const FeatureStageBadge: FC<FeatureStageBadgeProps> = ({
@@ -44,21 +51,23 @@ export const FeatureStageBadge: FC<FeatureStageBadgeProps> = ({
...delegatedProps
}) => {
const colorClasses = badgeColorClasses[contentType];
const sizeClasses = badgeSizeClasses[size];
const sizeClasses = badgeSizeClasses[contentType][size];
return (
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"block max-w-fit cursor-default flex-shrink-0 leading-none whitespace-nowrap border rounded-md transition-colors duration-200 ease-in-out bg-transparent border-solid border-transparent",
"block max-w-fit cursor-default flex-shrink-0 leading-none whitespace-nowrap rounded-md border border-solid border-transparent transition-colors duration-200 ease-in-out",
sizeClasses,
colorClasses,
className,
)}
{...delegatedProps}
>
<span className="sr-only"> (This is a</span>
<span className="sr-only">
{` (This is ${contentType === "early_access" ? "an" : "a"} `}
</span>
<span className="first-letter:uppercase">
{labelText && `${labelText} `}
{featureStageBadgeTypes[contentType]}
+1 -1
View File
@@ -11,7 +11,7 @@ export const Textarea: React.FC<React.ComponentPropsWithRef<"textarea">> = ({
return (
<textarea
className={cn(
`flex min-h-[60px] w-full px-3 py-2 text-sm shadow-sm text-content-primary
`flex min-h-[60px] w-full px-3 py-2 text-sm font-sans shadow-sm text-content-primary
rounded-md border border-border bg-transparent placeholder:text-content-secondary
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link
disabled:cursor-not-allowed disabled:opacity-50 disabled:text-content-disabled md:text-sm`,
+2
View File
@@ -365,6 +365,8 @@
input:-webkit-autofill:focus,
input:-webkit-autofill:active {
-webkit-box-shadow: 0 0 0 100px hsl(var(--surface-primary)) inset !important;
-webkit-text-fill-color: hsl(var(--content-primary)) !important;
caret-color: hsl(var(--content-primary));
}
::placeholder {
@@ -0,0 +1,463 @@
import { type FormikTouched, useFormik } from "formik";
import { type FC, type ReactNode, useState } from "react";
import type {
CreateUserSecretRequest,
UpdateUserSecretRequest,
UserSecret,
} from "#/api/typesGenerated";
import { Alert, AlertDescription } from "#/components/Alert/Alert";
import { Button } from "#/components/Button/Button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "#/components/Dialog/Dialog";
import { FormField } from "#/components/FormField/FormField";
import { Input } from "#/components/Input/Input";
import { Label } from "#/components/Label/Label";
import { Spinner } from "#/components/Spinner/Spinner";
import { Textarea } from "#/components/Textarea/Textarea";
import { cn } from "#/utils/cn";
import { getFormHelpers } from "#/utils/formUtils";
import {
buildCreateUserSecretRequest,
buildUpdateUserSecretRequest,
getCreateSecretRequiredFieldErrors,
mapSecretApiErrorToFormErrors,
type SecretFieldErrors,
type SecretFormValues,
} from "./secretForm";
type SecretDialogProps = {
open: boolean;
secret?: UserSecret;
isSubmitting: boolean;
returnFocusElement?: HTMLElement | null;
onClose: () => void;
onCreateSecret: (
request: CreateUserSecretRequest,
) => Promise<UserSecret> | UserSecret;
onUpdateSecret: (
name: string,
request: UpdateUserSecretRequest,
) => Promise<UserSecret> | UserSecret;
};
const emptyValues: SecretFormValues = {
name: "",
value: "",
description: "",
env_name: "",
file_path: "",
};
const infoText = "Secret values cannot be retrieved once saved.";
export const SAVED_SECRET_VALUE_DISPLAY = "••••••••••••••••••••";
export const SecretDialog: FC<SecretDialogProps> = ({
open,
secret,
isSubmitting,
returnFocusElement,
onClose,
onCreateSecret,
onUpdateSecret,
}) => {
const isEdit = Boolean(secret);
const initialValues = secret
? {
name: secret.name,
value: "",
description: secret.description,
env_name: secret.env_name,
file_path: secret.file_path,
}
: emptyValues;
const [clearValueRequested, setClearValueRequested] = useState(false);
const form = useFormik<SecretFormValues>({
initialValues,
enableReinitialize: true,
validateOnMount: true,
validate: (values) =>
isEdit ? {} : getCreateSecretRequiredFieldErrors(values),
onSubmit: async (values, helpers) => {
helpers.setStatus(undefined);
try {
if (secret) {
const request = buildUpdateUserSecretRequest(secret, values, {
clearValue: clearValueRequested,
});
await onUpdateSecret(secret.name, request);
} else {
await onCreateSecret(buildCreateUserSecretRequest(values));
}
setClearValueRequested(false);
helpers.resetForm();
onClose();
} catch (error) {
const formErrors = mapSecretApiErrorToFormErrors(error);
helpers.setErrors(formErrors.fieldErrors);
helpers.setTouched(
touchedFromFieldErrors(formErrors.fieldErrors),
false,
);
helpers.setStatus(formErrors.formError);
}
},
});
const closeDialog = () => {
setClearValueRequested(false);
form.resetForm();
onClose();
};
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 confirmDisabled =
isBusy || !form.isValid || (secret ? !hasUpdate : !form.dirty);
const getFieldHelpers = getFormHelpers(form);
const formError = form.status as string | undefined;
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen && !isBusy) {
closeDialog();
}
}}
>
<DialogContent
className="max-h-[90vh] overflow-y-auto"
aria-describedby={undefined}
onCloseAutoFocus={(event) => {
if (returnFocusElement?.isConnected) {
event.preventDefault();
returnFocusElement.focus();
}
}}
>
<DialogHeader>
<DialogTitle>{secret ? "Edit secret" : "Add secret"}</DialogTitle>
</DialogHeader>
<form
onSubmit={form.handleSubmit}
className="flex flex-col gap-5"
autoComplete="off"
>
<Alert severity="info" className="text-content-secondary">
<AlertDescription>{infoText}</AlertDescription>
</Alert>
{formError && (
<Alert severity="error" prominent>
<AlertDescription>{formError}</AlertDescription>
</Alert>
)}
{secret ? (
<>
<SecretFields
getFieldHelpers={getFieldHelpers}
disableName
showValue={false}
/>
<SecretValueField
key={`${secret.name}-${open}`}
field={getFieldHelpers("value", {
helperText: "Leave blank to keep the existing value.",
})}
placeholder="Leave blank to keep existing value"
showSavedValue={open}
clearValueRequested={clearValueRequested}
onClearValue={() => {
setClearValueRequested(true);
void form.setFieldValue("value", "", false);
}}
onUndoClearValue={() => {
setClearValueRequested(false);
void form.setFieldValue("value", "", false);
}}
/>
<SecretDescriptionField field={getFieldHelpers("description")} />
</>
) : (
<>
<SecretFields
getFieldHelpers={getFieldHelpers}
showRequiredLabels
showValue
/>
<SecretDescriptionField field={getFieldHelpers("description")} />
</>
)}
<DialogFooter>
<Button variant="outline" disabled={isBusy} onClick={closeDialog}>
Cancel
</Button>
<Button type="submit" disabled={confirmDisabled}>
<Spinner loading={isSubmitting || form.isSubmitting} />
{secret ? "Update" : "Save"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
type SecretFieldsProps = {
getFieldHelpers: ReturnType<typeof getFormHelpers<SecretFormValues>>;
disableName?: boolean;
showRequiredLabels?: boolean;
showValue: boolean;
};
const SecretFields: FC<SecretFieldsProps> = ({
getFieldHelpers,
disableName,
showRequiredLabels,
showValue,
}) => {
return (
<>
<FormField
field={getFieldHelpers("name")}
label={
showRequiredLabels ? (
<RequiredFieldLabel>Name</RequiredFieldLabel>
) : (
"Name"
)
}
placeholder="Secret name"
autoComplete="off"
className="placeholder:text-content-disabled"
disabled={disableName}
aria-required={showRequiredLabels}
data-lpignore="true"
data-1p-ignore="true"
data-form-type="other"
/>
<FormField
field={getFieldHelpers("env_name", {
helperText:
"Optional. Exposes the secret as an environment variable with this name in your workspace.",
})}
label="Environment variable"
placeholder="SERVICE_TOKEN"
autoComplete="off"
className="placeholder:text-content-disabled"
data-lpignore="true"
data-1p-ignore="true"
data-form-type="other"
/>
<FormField
field={getFieldHelpers("file_path", {
helperText:
"Optional. Exposes the secret as a file at this path in your workspace. Path must start with ~/ or /.",
})}
label="File path"
placeholder="~/api-key.txt"
autoComplete="off"
className="placeholder:text-content-disabled"
data-lpignore="true"
data-1p-ignore="true"
data-form-type="other"
/>
{showValue && (
<SecretValueField
field={getFieldHelpers("value")}
placeholder="Enter secret value"
required={showRequiredLabels}
/>
)}
</>
);
};
type RequiredFieldLabelProps = {
children: ReactNode;
};
const RequiredFieldLabel: FC<RequiredFieldLabelProps> = ({ children }) => {
return (
<span className="after:ml-1 after:text-content-destructive after:content-['*']">
{children}
</span>
);
};
type SecretValueFieldProps = {
field: ReturnType<ReturnType<typeof getFormHelpers<SecretFormValues>>>;
placeholder: string;
required?: boolean;
showSavedValue?: boolean;
clearValueRequested?: boolean;
onClearValue?: () => void;
onUndoClearValue?: () => void;
};
const SecretValueField: FC<SecretValueFieldProps> = ({
field,
placeholder,
required,
showSavedValue = false,
clearValueRequested = false,
onClearValue,
onUndoClearValue,
}) => {
const [hasHiddenSavedValue, setHasHiddenSavedValue] = useState(false);
const isShowingSavedValue =
showSavedValue && !clearValueRequested && !hasHiddenSavedValue;
const value = clearValueRequested
? ""
: isShowingSavedValue
? SAVED_SECRET_VALUE_DISPLAY
: field.value;
const maskTypedValue =
!clearValueRequested &&
!isShowingSavedValue &&
typeof field.value === "string" &&
field.value !== "";
const displayField = clearValueRequested
? {
...field,
helperText: field.error
? field.helperText
: "Saved value will be cleared when you update.",
}
: field;
const errorId = `${field.id}-error`;
const helperId = `${field.id}-helper`;
return (
<div className="flex flex-col gap-2">
<Label htmlFor={field.id}>
{required ? <RequiredFieldLabel>Value</RequiredFieldLabel> : "Value"}
</Label>
<div className="flex flex-col gap-2 sm:flex-row sm:items-start">
<Input
id={field.id}
name={field.name}
type="text"
value={value}
placeholder={placeholder}
autoComplete="off"
aria-required={required}
aria-invalid={displayField.error}
aria-describedby={
displayField.error
? errorId
: displayField.helperText
? helperId
: undefined
}
disabled={clearValueRequested}
className={cn(
"placeholder:text-content-disabled sm:flex-1",
displayField.error && "border-border-destructive",
maskTypedValue && "[-webkit-text-security:circle]",
)}
onFocus={(event) => {
if (isShowingSavedValue) {
event.currentTarget.value = "";
setHasHiddenSavedValue(true);
}
}}
onChange={(event) => {
if (isShowingSavedValue) {
setHasHiddenSavedValue(true);
}
field.onChange(event);
}}
onBlur={(event) => {
field.onBlur(event);
if (showSavedValue && event.currentTarget.value === "") {
setHasHiddenSavedValue(false);
}
}}
/>
{onClearValue && onUndoClearValue && (
<Button
type="button"
variant="outline"
size="sm"
className={cn(
"h-10 w-16 shrink-0",
!clearValueRequested &&
"text-content-secondary hover:border-border-destructive hover:text-content-destructive",
)}
onClick={clearValueRequested ? onUndoClearValue : onClearValue}
>
{clearValueRequested ? "Undo" : "Clear"}
</Button>
)}
</div>
{displayField.error ? (
<span id={errorId} className="text-xs text-content-destructive">
{displayField.helperText}
</span>
) : (
displayField.helperText && (
<span id={helperId} className="text-xs text-content-secondary">
{displayField.helperText}
</span>
)
)}
</div>
);
};
type SecretDescriptionFieldProps = {
field: ReturnType<ReturnType<typeof getFormHelpers<SecretFormValues>>>;
};
const SecretDescriptionField: FC<SecretDescriptionFieldProps> = ({ field }) => {
const errorId = `${field.id}-error`;
return (
<div className="flex flex-col gap-2">
<Label htmlFor={field.id}>Description</Label>
<Textarea
id={field.id}
name={field.name}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="Optional"
aria-invalid={field.error}
aria-describedby={field.error ? errorId : undefined}
className={cn(
"placeholder:text-content-disabled",
field.error && "border-border-destructive",
)}
/>
{field.error && (
<span id={errorId} className="text-xs text-content-destructive">
{field.helperText}
</span>
)}
</div>
);
};
function touchedFromFieldErrors(
fieldErrors: SecretFieldErrors,
): FormikTouched<SecretFormValues> {
return Object.fromEntries(
Object.keys(fieldErrors).map((field) => [field, true]),
) as FormikTouched<SecretFormValues>;
}
@@ -0,0 +1,72 @@
import type { FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { toast } from "sonner";
import { getErrorDetail, getErrorMessage } from "#/api/errors";
import {
createUserSecret,
deleteUserSecret,
updateUserSecret,
userSecrets,
} from "#/api/queries/userSecrets";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { SecretsPageView } from "./SecretsPageView";
const SecretsPage: FC = () => {
const { user: me } = useAuthenticated();
const queryClient = useQueryClient();
const secretsQueryOptions = userSecrets(me.id);
const secretsQuery = useQuery(secretsQueryOptions);
const createSecretMutation = useMutation(
createUserSecret(queryClient, me.id),
);
const updateSecretMutation = useMutation(
updateUserSecret(queryClient, me.id),
);
const deleteSecretMutation = useMutation(
deleteUserSecret(queryClient, me.id),
);
return (
<SecretsPageView
secrets={secretsQuery.data}
isLoading={!secretsQuery.isFetched && secretsQuery.isFetching}
hasLoaded={secretsQuery.isSuccess}
isRefreshing={secretsQuery.isFetching && secretsQuery.isFetched}
isCreating={createSecretMutation.isPending}
isUpdating={updateSecretMutation.isPending}
isDeleting={deleteSecretMutation.isPending}
getSecretsError={secretsQuery.error}
onRefresh={() => {
void queryClient.invalidateQueries({
queryKey: secretsQueryOptions.queryKey,
});
}}
onCreateSecret={async (request) => {
const secret = await createSecretMutation.mutateAsync(request);
toast.success(`Created secret "${secret.name}" successfully.`);
return secret;
}}
onUpdateSecret={async (name, request) => {
const secret = await updateSecretMutation.mutateAsync({
name,
request,
});
toast.success(`Updated secret "${secret.name}" successfully.`);
return secret;
}}
onDeleteSecret={async (secret) => {
try {
await deleteSecretMutation.mutateAsync(secret.name);
toast.success(`Deleted secret "${secret.name}" successfully.`);
} catch (error) {
toast.error(getErrorMessage(error, "Failed to delete secret."), {
description: getErrorDetail(error),
});
throw error;
}
}}
/>
);
};
export default SecretsPage;
@@ -0,0 +1,594 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import type {
CreateUserSecretRequest,
UpdateUserSecretRequest,
UserSecret,
} from "#/api/typesGenerated";
import { MockUserSecrets, mockApiError } from "#/testHelpers/entities";
import { SAVED_SECRET_VALUE_DISPLAY } from "./SecretDialog";
import { SecretsPageView } from "./SecretsPageView";
const visibleSecrets = MockUserSecrets.slice(0, 4);
const PLACEHOLDER_INPUT = "placeholder input";
const meta: Meta<typeof SecretsPageView> = {
title: "pages/UserSettingsPage/SecretsPageView",
component: SecretsPageView,
args: {
secrets: visibleSecrets,
isLoading: false,
hasLoaded: true,
isRefreshing: false,
isCreating: false,
isUpdating: false,
isDeleting: false,
onRefresh: fn(),
onCreateSecret: fn(),
onUpdateSecret: fn(),
onDeleteSecret: fn(),
},
};
export default meta;
type Story = StoryObj<typeof SecretsPageView>;
type CreateSecretMock = ReturnType<
typeof fn<(request: CreateUserSecretRequest) => Promise<UserSecret>>
>;
type UpdateSecretMock = ReturnType<
typeof fn<
(name: string, request: UpdateUserSecretRequest) => Promise<UserSecret>
>
>;
type DeleteSecretMock = ReturnType<
typeof fn<(secret: UserSecret) => Promise<void> | void>
>;
const waitForDialogToClose = async (body: ReturnType<typeof within>) => {
await waitFor(() => {
expect(body.queryByRole("dialog")).not.toBeInTheDocument();
});
};
const expectNoValueField = (body: ReturnType<typeof within>) => {
expect(body.queryByLabelText("Value")).not.toBeInTheDocument();
};
const createSecretFromRequest = (
request: CreateUserSecretRequest,
): UserSecret => ({
id: `created-${request.name}`,
name: request.name,
description: request.description ?? "",
env_name: request.env_name ?? "",
file_path: request.file_path ?? "",
created_at: "2026-05-04T00:00:00Z",
updated_at: "2026-05-04T00:00:00Z",
});
const findVisibleSecretByName = (name: string): UserSecret => {
const secret = visibleSecrets.find((secret) => secret.name === name);
if (!secret) {
throw new Error(`No visible secret named ${name}`);
}
return secret;
};
export const Loaded: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByRole("table", { name: "User secrets" }),
).toBeInTheDocument();
await expect(canvas.getByText("env var")).toBeInTheDocument();
await expect(canvas.getByText("file")).toBeInTheDocument();
await expect(canvas.getByText("env var + file")).toBeInTheDocument();
await expect(canvas.getByText("not injected")).toBeInTheDocument();
const docsLink = canvas.getByRole("link", { name: "View docs" });
await expect(docsLink).toHaveAttribute(
"href",
expect.stringContaining("/user-guides/user-secrets"),
);
},
};
export const Empty: Story = {
args: {
secrets: [],
},
};
export const Loading: Story = {
args: {
secrets: [],
isLoading: true,
hasLoaded: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByRole("button", { name: /Refresh/ }),
).toBeDisabled();
},
};
export const RefreshingWithRows: Story = {
args: {
secrets: visibleSecrets,
isLoading: false,
hasLoaded: true,
isRefreshing: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getAllByText(visibleSecrets[0].name)[0]).toBeVisible();
await expect(
canvas.getByRole("button", { name: /Refresh/ }),
).toBeDisabled();
},
};
export const ListLoadError: Story = {
args: {
secrets: [],
hasLoaded: true,
getSecretsError: mockApiError({
message: "Failed to load secrets.",
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("Failed to load secrets.")).toBeVisible();
await expect(canvas.queryByText("No secrets yet")).not.toBeInTheDocument();
},
};
export const AddDialogOpened: Story = {
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 = await body.findByRole("dialog");
const dialogView = within(dialog);
await expect(
dialogView.getByRole("heading", { name: "Add secret" }),
).toBeInTheDocument();
await expect(dialogView.getByLabelText("Name")).toBeRequired();
await expect(dialogView.getByLabelText("Name")).toHaveAttribute(
"placeholder",
"Secret name",
);
await expect(dialogView.getByLabelText("Value")).toBeRequired();
await expect(dialogView.getByLabelText("Value")).toHaveAttribute(
"placeholder",
"Enter secret value",
);
},
};
export const AddDialogDuplicateEnvValidationError: Story = {
args: {
onCreateSecret: async () => {
throw mockApiError({
message: "Validation failed.",
validations: [
{
field: "env_name",
detail: "Variable already in use. Edit existing variable.",
},
],
});
},
},
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"));
await user.type(dialog.getByLabelText("Name"), "duplicate-env");
await user.type(
dialog.getByLabelText("Environment variable"),
"SERVICE_API_KEY",
);
await user.type(dialog.getByLabelText("Value"), PLACEHOLDER_INPUT);
const saveButton = dialog.getByRole("button", { name: "Save" });
await waitFor(() => expect(saveButton).toBeEnabled());
await user.click(saveButton);
await expect(
await dialog.findByText(
"Variable already in use. Edit existing variable.",
),
).toBeVisible();
await user.click(dialog.getByRole("button", { name: "Cancel" }));
await waitForDialogToClose(body);
},
};
export const AddSecretFormSaveEnabled: Story = {
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 saveButton = dialog.getByRole("button", { name: "Save" });
await user.type(dialog.getByLabelText("Name"), "example-secret");
await expect(saveButton).toBeDisabled();
await user.type(
dialog.getByLabelText("Environment variable"),
"EXAMPLE_SECRET",
);
await user.type(dialog.getByLabelText("Value"), PLACEHOLDER_INPUT);
await expect(saveButton).toBeEnabled();
await user.click(dialog.getByRole("button", { name: "Cancel" }));
await waitForDialogToClose(body);
},
};
export const AddSecretSubmit: Story = {
args: {
onCreateSecret: fn<
(request: CreateUserSecretRequest) => Promise<UserSecret>
>(async (request) => createSecretFromRequest(request)),
},
play: async ({ canvasElement, args }) => {
const onCreateSecret = args.onCreateSecret as CreateSecretMock;
onCreateSecret.mockClear();
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"));
await user.type(dialog.getByLabelText("Name"), "example-secret");
await user.type(
dialog.getByLabelText("Environment variable"),
"EXAMPLE_SECRET",
);
await user.type(dialog.getByLabelText("File path"), "~/secrets/example");
await user.type(dialog.getByLabelText("Value"), PLACEHOLDER_INPUT);
await user.type(dialog.getByLabelText("Description"), "Example secret");
await user.click(dialog.getByRole("button", { name: "Save" }));
await waitFor(() => expect(onCreateSecret).toHaveBeenCalledTimes(1));
expect(onCreateSecret).toHaveBeenCalledWith({
name: "example-secret",
env_name: "EXAMPLE_SECRET",
file_path: "~/secrets/example",
value: PLACEHOLDER_INPUT,
description: "Example secret",
});
await waitForDialogToClose(body);
expectNoValueField(body);
},
};
export const EditDialogOpened: Story = {
play: async ({ canvasElement }) => {
const user = userEvent.setup();
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
const secret = visibleSecrets[2] as UserSecret;
await user.click(
canvas.getByRole("button", {
name: `Open secret actions for ${secret.name}`,
}),
);
await user.click(
await body.findByRole("menuitem", { name: "Edit secret" }),
);
const dialog = await body.findByRole("dialog");
const dialogView = within(dialog);
await expect(
dialogView.getByRole("heading", { name: "Edit secret" }),
).toBeInTheDocument();
await expect(dialogView.getByLabelText("Name")).toHaveValue(secret.name);
await expect(dialogView.getByLabelText("Name")).toBeDisabled();
await expect(dialogView.getByLabelText("Description")).toHaveValue(
secret.description,
);
await expect(dialogView.getByLabelText("Environment variable")).toHaveValue(
secret.env_name,
);
await expect(dialogView.getByLabelText("File path")).toHaveValue(
secret.file_path,
);
const valueField = dialogView.getByLabelText("Value");
await expect(valueField).toHaveValue(SAVED_SECRET_VALUE_DISPLAY);
const clearButton = dialogView.getByRole("button", { name: "Clear" });
await waitFor(() => expect(clearButton).toBeVisible());
await user.click(valueField);
await expect(valueField).toHaveValue("");
await user.tab();
await expect(valueField).toHaveValue(SAVED_SECRET_VALUE_DISPLAY);
await expect(
dialogView.getByRole("button", { name: "Update" }),
).toBeDisabled();
},
};
export const EditSecretSubmit: Story = {
args: {
onUpdateSecret: fn<
(name: string, request: UpdateUserSecretRequest) => Promise<UserSecret>
>(async (name) => findVisibleSecretByName(name)),
},
play: async ({ canvasElement, args }) => {
const onUpdateSecret = args.onUpdateSecret as UpdateSecretMock;
onUpdateSecret.mockClear();
const user = userEvent.setup();
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
const secret = visibleSecrets[0] as UserSecret;
await user.click(
canvas.getByRole("button", {
name: `Open secret actions for ${secret.name}`,
}),
);
await user.click(
await body.findByRole("menuitem", { name: "Edit secret" }),
);
const dialog = within(await body.findByRole("dialog"));
const description = dialog.getByLabelText("Description");
await user.clear(description);
await user.type(description, "Updated example description");
await user.click(dialog.getByRole("button", { name: "Update" }));
await waitFor(() => expect(onUpdateSecret).toHaveBeenCalledTimes(1));
expect(onUpdateSecret).toHaveBeenCalledWith(secret.name, {
description: "Updated example description",
});
await waitForDialogToClose(body);
expectNoValueField(body);
},
};
export const EditSecretClearValue: Story = {
args: {
onUpdateSecret: fn<
(name: string, request: UpdateUserSecretRequest) => Promise<UserSecret>
>(async (name) => findVisibleSecretByName(name)),
},
play: async ({ canvasElement, args }) => {
const onUpdateSecret = args.onUpdateSecret as UpdateSecretMock;
onUpdateSecret.mockClear();
const user = userEvent.setup();
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
const secret = visibleSecrets[0] as UserSecret;
await user.click(
canvas.getByRole("button", {
name: `Open secret actions for ${secret.name}`,
}),
);
await user.click(
await body.findByRole("menuitem", { name: "Edit secret" }),
);
const dialog = within(await body.findByRole("dialog"));
const valueField = dialog.getByLabelText("Value");
const updateButton = dialog.getByRole("button", { name: "Update" });
await expect(updateButton).toBeDisabled();
await user.click(dialog.getByRole("button", { name: "Clear" }));
await expect(valueField).toHaveValue("");
await expect(valueField).toBeDisabled();
await expect(
dialog.getByText("Saved value will be cleared when you update."),
).toBeVisible();
await expect(updateButton).toBeEnabled();
await user.click(dialog.getByRole("button", { name: "Undo" }));
await expect(valueField).toHaveValue(SAVED_SECRET_VALUE_DISPLAY);
await expect(valueField).toBeEnabled();
await expect(updateButton).toBeDisabled();
await user.click(dialog.getByRole("button", { name: "Clear" }));
await user.click(updateButton);
await waitFor(() => expect(onUpdateSecret).toHaveBeenCalledTimes(1));
expect(onUpdateSecret).toHaveBeenCalledWith(secret.name, { value: "" });
await waitForDialogToClose(body);
expectNoValueField(body);
},
};
export const EditSecretMutationErrorDisplay: Story = {
args: {
onUpdateSecret: fn<
(name: string, request: UpdateUserSecretRequest) => Promise<UserSecret>
>(async () => {
throw mockApiError({ message: "Failed to update secret." });
}),
},
play: async ({ canvasElement, args }) => {
const onUpdateSecret = args.onUpdateSecret as UpdateSecretMock;
onUpdateSecret.mockClear();
const user = userEvent.setup();
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
const secret = visibleSecrets[0] as UserSecret;
await user.click(
canvas.getByRole("button", {
name: `Open secret actions for ${secret.name}`,
}),
);
await user.click(
await body.findByRole("menuitem", { name: "Edit secret" }),
);
const dialog = within(await body.findByRole("dialog"));
const description = dialog.getByLabelText("Description");
const value = dialog.getByLabelText("Value");
await user.clear(description);
await user.type(description, "Updated example description");
await user.type(value, PLACEHOLDER_INPUT);
await user.click(dialog.getByRole("button", { name: "Update" }));
await waitFor(() => expect(onUpdateSecret).toHaveBeenCalledTimes(1));
await expect(
await dialog.findByText("Failed to update secret."),
).toBeVisible();
await expect(
dialog.getByRole("heading", { name: "Edit secret" }),
).toBeVisible();
await expect(description).toHaveValue("Updated example description");
await expect(value).toHaveValue(PLACEHOLDER_INPUT);
await user.click(dialog.getByRole("button", { name: "Cancel" }));
await waitForDialogToClose(body);
},
};
export const KebabActionsAndDeleteConfirmation: Story = {
play: async ({ canvasElement }) => {
const user = userEvent.setup();
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
const secret = visibleSecrets[0] as UserSecret;
await user.click(
canvas.getByRole("button", {
name: `Open secret actions for ${secret.name}`,
}),
);
await expect(
await body.findByRole("menuitem", { name: "Edit secret" }),
).toBeInTheDocument();
await user.click(body.getByRole("menuitem", { name: "Delete" }));
const dialog = await body.findByRole("dialog");
const dialogView = within(dialog);
await expect(
dialogView.getByRole("heading", { name: "Delete secret" }),
).toBeInTheDocument();
await user.click(dialogView.getByRole("button", { name: "Cancel" }));
await waitForDialogToClose(body);
},
};
export const DeleteConfirmSubmit: Story = {
args: {
onDeleteSecret: fn<(secret: UserSecret) => void>(),
},
play: async ({ canvasElement, args }) => {
const onDeleteSecret = args.onDeleteSecret as DeleteSecretMock;
onDeleteSecret.mockClear();
const user = userEvent.setup();
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
const secret = visibleSecrets[0] as UserSecret;
await user.click(
canvas.getByRole("button", {
name: `Open secret actions for ${secret.name}`,
}),
);
await user.click(await body.findByRole("menuitem", { name: "Delete" }));
await user.click(await body.findByRole("button", { name: "Delete" }));
await waitFor(() => expect(onDeleteSecret).toHaveBeenCalledTimes(1));
expect(onDeleteSecret).toHaveBeenCalledWith(secret);
await waitForDialogToClose(body);
},
};
export const DeleteSecretMutationErrorDisplay: Story = {
args: {
onDeleteSecret: fn<(secret: UserSecret) => Promise<void>>(async () => {
throw mockApiError({ message: "Failed to delete secret." });
}),
},
play: async ({ canvasElement, args }) => {
const onDeleteSecret = args.onDeleteSecret as DeleteSecretMock;
onDeleteSecret.mockClear();
const user = userEvent.setup();
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
const secret = visibleSecrets[0] as UserSecret;
await user.click(
canvas.getByRole("button", {
name: `Open secret actions for ${secret.name}`,
}),
);
await user.click(await body.findByRole("menuitem", { name: "Delete" }));
const dialog = within(await body.findByRole("dialog"));
await user.click(dialog.getByRole("button", { name: "Delete" }));
await waitFor(() => expect(onDeleteSecret).toHaveBeenCalledTimes(1));
await expect(
dialog.getByRole("heading", { name: "Delete secret" }),
).toBeVisible();
await expect(dialog.getByText(secret.name)).toBeVisible();
await user.click(dialog.getByRole("button", { name: "Cancel" }));
await waitForDialogToClose(body);
},
};
export const DeleteAndCancel: Story = {
play: async ({ canvasElement }) => {
const user = userEvent.setup();
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
const secret = visibleSecrets[0] as UserSecret;
const trigger = canvas.getByRole("button", {
name: `Open secret actions for ${secret.name}`,
});
await user.click(trigger);
await user.click(await body.findByRole("menuitem", { name: "Delete" }));
await user.click(await body.findByRole("button", { name: "Cancel" }));
await waitForDialogToClose(body);
await waitFor(() => expect(trigger).toHaveFocus());
},
};
export const CreateMutationErrorDisplay: Story = {
args: {
onCreateSecret: async () => {
throw mockApiError({
message:
"A secret with that name, environment variable, or file path already exists.",
});
},
},
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"));
await user.type(dialog.getByLabelText("Name"), "conflict-secret");
await user.type(dialog.getByLabelText("Value"), PLACEHOLDER_INPUT);
await user.click(dialog.getByRole("button", { name: "Save" }));
await expect(
await dialog.findByText(
"A secret with that name, environment variable, or file path already exists.",
),
).toBeVisible();
await user.click(dialog.getByRole("button", { name: "Cancel" }));
await waitForDialogToClose(body);
expectNoValueField(body);
},
};
@@ -0,0 +1,155 @@
import { PlusIcon, RefreshCwIcon } from "lucide-react";
import { type FC, useRef, useState } from "react";
import type {
CreateUserSecretRequest,
UpdateUserSecretRequest,
UserSecret,
} from "#/api/typesGenerated";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Button } from "#/components/Button/Button";
import { FeatureStageBadge } from "#/components/FeatureStageBadge/FeatureStageBadge";
import { Link } from "#/components/Link/Link";
import {
SettingsHeader,
SettingsHeaderDescription,
SettingsHeaderTitle,
} from "#/components/SettingsHeader/SettingsHeader";
import { Spinner } from "#/components/Spinner/Spinner";
import { docs } from "#/utils/docs";
import { SecretDialog } from "./SecretDialog";
import { SecretsTable } from "./SecretsTable";
type SecretsPageViewProps = {
secrets?: readonly UserSecret[];
isLoading: boolean;
hasLoaded: boolean;
isRefreshing: boolean;
isCreating: boolean;
isUpdating: boolean;
isDeleting: boolean;
getSecretsError?: unknown;
onRefresh: () => void;
onCreateSecret: (
request: CreateUserSecretRequest,
) => Promise<UserSecret> | UserSecret;
onUpdateSecret: (
name: string,
request: UpdateUserSecretRequest,
) => Promise<UserSecret> | UserSecret;
onDeleteSecret: (secret: UserSecret) => Promise<void> | void;
};
type SecretDialogState =
| { mode: "add"; open: boolean }
| { mode: "edit"; open: boolean; secret: UserSecret };
export const SecretsPageView: FC<SecretsPageViewProps> = ({
secrets = [],
isLoading,
hasLoaded,
isRefreshing,
isCreating,
isUpdating,
isDeleting,
getSecretsError,
onRefresh,
onCreateSecret,
onUpdateSecret,
onDeleteSecret,
}) => {
const [dialogState, setDialogState] = useState<SecretDialogState>({
mode: "add",
open: false,
});
const secretDialogReturnFocusElement = useRef<HTMLElement | null>(null);
const dialogSecret =
dialogState.mode === "edit" ? dialogState.secret : undefined;
const hasLoadedSecrets = hasLoaded && !getSecretsError;
const openAddSecret = (returnFocusElement?: HTMLElement | null) => {
secretDialogReturnFocusElement.current = returnFocusElement ?? null;
setDialogState({ mode: "add", open: true });
};
const openEditSecret = (
secret: UserSecret,
returnFocusElement?: HTMLElement | null,
) => {
secretDialogReturnFocusElement.current = returnFocusElement ?? null;
setDialogState({ mode: "edit", open: true, secret });
};
const closeSecretDialog = () => {
setDialogState((state) => ({ ...state, open: false }));
};
return (
<div className="flex flex-col gap-6">
<SettingsHeader
actions={
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={onRefresh}
disabled={isLoading || isRefreshing}
>
<Spinner loading={isLoading || isRefreshing}>
<RefreshCwIcon />
</Spinner>
Refresh
</Button>
</div>
}
>
<SettingsHeaderTitle
tooltip={<FeatureStageBadge contentType="beta" size="md" />}
>
Secrets
</SettingsHeaderTitle>
<SettingsHeaderDescription>
Secrets with an environment variable or file path are injected into
workspaces you own when they start. Each environment variable and file
path must be unique.{" "}
<Link
href={docs("/user-guides/user-secrets")}
target="_blank"
rel="noreferrer"
showExternalIcon={false}
>
View docs
</Link>
</SettingsHeaderDescription>
</SettingsHeader>
<SecretDialog
open={dialogState.open}
secret={dialogSecret}
isSubmitting={isCreating || isUpdating}
returnFocusElement={secretDialogReturnFocusElement.current}
onClose={closeSecretDialog}
onCreateSecret={onCreateSecret}
onUpdateSecret={onUpdateSecret}
/>
{getSecretsError ? <ErrorAlert error={getSecretsError} /> : undefined}
<section className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h2 className="m-0 text-xl font-semibold">Your secrets</h2>
<Button onClick={(event) => openAddSecret(event.currentTarget)}>
<PlusIcon />
Add secret
</Button>
</div>
<SecretsTable
secrets={secrets}
isLoading={isLoading}
hasLoaded={hasLoadedSecrets}
isDeleting={isDeleting}
onAddSecret={openAddSecret}
onEditSecret={openEditSecret}
onDeleteSecret={onDeleteSecret}
/>
</section>
</div>
);
};
@@ -0,0 +1,250 @@
import { EllipsisVerticalIcon, PencilIcon, TrashIcon } from "lucide-react";
import { type FC, useRef, useState } from "react";
import type { UserSecret } from "#/api/typesGenerated";
import { Badge } from "#/components/Badge/Badge";
import { Button } from "#/components/Button/Button";
import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "#/components/DropdownMenu/DropdownMenu";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "#/components/Table/Table";
import { TableEmpty } from "#/components/TableEmpty/TableEmpty";
import { TableLoader } from "#/components/TableLoader/TableLoader";
import { relativeTime } from "#/utils/time";
type SecretsTableProps = {
secrets?: readonly UserSecret[];
isLoading: boolean;
hasLoaded: boolean;
isDeleting: boolean;
onAddSecret: (returnFocusElement?: HTMLElement | null) => void;
onEditSecret: (
secret: UserSecret,
returnFocusElement?: HTMLElement | null,
) => void;
onDeleteSecret: (secret: UserSecret) => Promise<void> | void;
};
export const SecretsTable: FC<SecretsTableProps> = ({
secrets,
isLoading,
hasLoaded,
isDeleting,
onAddSecret,
onEditSecret,
onDeleteSecret,
}) => {
const [secretToDelete, setSecretToDelete] = useState<UserSecret>();
return (
<>
<DeleteSecretDialog
secret={secretToDelete}
isDeleting={isDeleting}
onCancel={() => setSecretToDelete(undefined)}
onConfirm={(secret) => {
void Promise.resolve()
.then(() => onDeleteSecret(secret))
.then(() => {
setSecretToDelete(undefined);
})
.catch(() => {
// onDeleteSecret reports failures with a toast before rejecting.
// Swallow the rejection here to avoid an unhandled promise rejection warning.
});
}}
/>
<Table aria-label="User secrets">
<TableHeader>
<TableRow>
<TableHead className="w-[16%]">Name</TableHead>
<TableHead className="w-[14%]">Environment variable</TableHead>
<TableHead className="w-[18%]">File path</TableHead>
<TableHead className="w-[11%]">Type</TableHead>
<TableHead className="w-[23%]">Description</TableHead>
<TableHead className="w-[12%]">Updated</TableHead>
<TableHead className="w-[1%]" />
</TableRow>
</TableHeader>
<TableBody>
{isLoading && <TableLoader />}
{hasLoaded && !isLoading && (!secrets || secrets.length === 0) && (
<TableEmpty
message="No secrets yet"
description="Create a secret to inject it into workspaces you own."
cta={
<Button onClick={(event) => onAddSecret(event.currentTarget)}>
Add secret
</Button>
}
/>
)}
{!isLoading &&
secrets?.map((secret) => (
<TableRow key={secret.id}>
<TableCell className="font-semibold text-content-primary">
{secret.name}
</TableCell>
<TableCell>
<OptionalSecretValue value={secret.env_name} />
</TableCell>
<TableCell>
<OptionalSecretValue value={secret.file_path} />
</TableCell>
<TableCell>
<SecretTypeBadge secret={secret} />
</TableCell>
<TableCell>
<OptionalSecretValue
value={secret.description}
fallback="No description"
/>
</TableCell>
<TableCell data-chromatic="ignore">
{relativeTime(secret.updated_at)}
</TableCell>
<TableCell>
<SecretRowActions
secret={secret}
onEditSecret={onEditSecret}
onDeleteSecret={setSecretToDelete}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>
);
};
const OptionalSecretValue: FC<{ value?: string; fallback?: string }> = ({
value,
fallback = "Not set",
}) => {
if (value) {
return value;
}
return <span className="text-content-disabled">{fallback}</span>;
};
const SecretTypeBadge: FC<{ secret: UserSecret }> = ({ secret }) => {
const hasEnv = Boolean(secret.env_name);
const hasFile = Boolean(secret.file_path);
if (hasEnv && hasFile) {
return <Badge>env var + file</Badge>;
}
if (hasEnv) {
return <Badge>env var</Badge>;
}
if (hasFile) {
return <Badge>file</Badge>;
}
return <Badge>not injected</Badge>;
};
type SecretRowActionsProps = {
secret: UserSecret;
onEditSecret: (
secret: UserSecret,
returnFocusElement?: HTMLElement | null,
) => void;
onDeleteSecret: (secret: UserSecret) => void;
};
const SecretRowActions: FC<SecretRowActionsProps> = ({
secret,
onEditSecret,
onDeleteSecret,
}) => {
const label = `Open secret actions for ${secret.name}`;
const triggerRef = useRef<HTMLButtonElement>(null);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
ref={triggerRef}
size="icon"
variant="subtle"
aria-label={label}
>
<EllipsisVerticalIcon aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => onEditSecret(secret, triggerRef.current)}
>
<PencilIcon className="size-icon-xs" />
Edit secret
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onSelect={() => onDeleteSecret(secret)}
>
<TrashIcon className="size-icon-xs" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
type DeleteSecretDialogProps = {
secret?: UserSecret;
isDeleting: boolean;
onCancel: () => void;
onConfirm: (secret: UserSecret) => void;
};
const DeleteSecretDialog: FC<DeleteSecretDialogProps> = ({
secret,
isDeleting,
onCancel,
onConfirm,
}) => {
return (
<ConfirmDialog
type="delete"
open={Boolean(secret)}
confirmLoading={isDeleting}
title="Delete secret"
description={
<p>
Deleting <strong>{secret?.name}</strong> is irreversible. Workspaces
that depend on this secret will no longer receive it on future starts.
</p>
}
onClose={() => {
if (!isDeleting) {
onCancel();
}
}}
onConfirm={() => {
if (secret) {
onConfirm(secret);
}
}}
/>
);
};
@@ -10,19 +10,19 @@ import {
const existingSecrets: UserSecret[] = [
{
id: "11111111-1111-1111-1111-111111111111",
name: "github",
description: "GitHub token",
env_name: "GITHUB_TOKEN",
name: "service-token",
description: "Service token",
env_name: "SERVICE_TOKEN",
file_path: "",
created_at: "2026-05-04T00:00:00Z",
updated_at: "2026-05-04T00:00:00Z",
},
{
id: "22222222-2222-2222-2222-222222222222",
name: "anthropic",
name: "service-key",
description: "",
env_name: "ANTHROPIC_API_KEY",
file_path: "~/.config/anthropic/key",
env_name: "SERVICE_API_KEY",
file_path: "~/.config/service/key",
created_at: "2026-05-04T00:00:00Z",
updated_at: "2026-05-04T00:00:00Z",
},
@@ -57,48 +57,66 @@ describe("payload builders", () => {
it("builds create payloads from form values", () => {
expect(
buildCreateUserSecretRequest({
name: "github",
name: "service-token",
value: "example-value",
description: "GitHub token",
env_name: "GITHUB_TOKEN",
description: "Service token",
env_name: "SERVICE_TOKEN",
file_path: "",
}),
).toEqual({
name: "github",
name: "service-token",
value: "example-value",
description: "GitHub token",
env_name: "GITHUB_TOKEN",
description: "Service token",
env_name: "SERVICE_TOKEN",
});
});
it("sends only changed update fields", () => {
expect(
buildUpdateUserSecretRequest(existingSecrets[0], {
name: "github",
name: "service-token",
value: "",
description: "Updated description",
env_name: "GITHUB_TOKEN",
file_path: "~/secrets/github",
env_name: "SERVICE_TOKEN",
file_path: "~/secrets/service-token",
}),
).toEqual({
description: "Updated description",
file_path: "~/secrets/github",
file_path: "~/secrets/service-token",
});
});
it("includes replacement values only when provided", () => {
expect(
buildUpdateUserSecretRequest(existingSecrets[0], {
name: "github",
name: "service-token",
value: "replacement-value",
description: "GitHub token",
env_name: "GITHUB_TOKEN",
description: "Service token",
env_name: "SERVICE_TOKEN",
file_path: "",
}),
).toEqual({
value: "replacement-value",
});
});
it("sends an empty value when clearing an update", () => {
expect(
buildUpdateUserSecretRequest(
existingSecrets[0],
{
name: "service-token",
value: "",
description: "Service token",
env_name: "SERVICE_TOKEN",
file_path: "",
},
{ clearValue: true },
),
).toEqual({
value: "",
});
});
});
describe("mapSecretApiErrorToFormErrors", () => {
@@ -10,7 +10,7 @@ import type {
UserSecret,
} from "#/api/typesGenerated";
interface SecretFormValues {
export interface SecretFormValues {
name: string;
value: string;
description: string;
@@ -20,7 +20,7 @@ interface SecretFormValues {
type SecretFormField = keyof SecretFormValues;
type SecretFieldErrors = Partial<Record<SecretFormField, string>>;
export type SecretFieldErrors = Partial<Record<SecretFormField, string>>;
interface SecretFormErrors {
fieldErrors: SecretFieldErrors;
@@ -52,12 +52,21 @@ export const buildCreateUserSecretRequest = (
});
};
type BuildUpdateUserSecretRequestOptions = {
clearValue?: boolean;
};
export const buildUpdateUserSecretRequest = (
secret: UserSecret,
values: SecretFormValues,
options: BuildUpdateUserSecretRequestOptions = {},
): UpdateUserSecretRequest => {
return {
...(values.value !== "" ? { value: values.value } : {}),
...(options.clearValue
? { value: "" }
: values.value !== ""
? { value: values.value }
: {}),
...(values.description !== secret.description
? { description: values.description }
: {}),
@@ -1,6 +1,7 @@
import type { FC } from "react";
import type { User } from "#/api/typesGenerated";
import { Avatar } from "#/components/Avatar/Avatar";
import { FeatureStageBadge } from "#/components/FeatureStageBadge/FeatureStageBadge";
import {
Sidebar as BaseSidebar,
SettingsSidebarNavItem,
@@ -52,6 +53,16 @@ export const Sidebar: FC<SidebarProps> = ({ user }) => {
SSH Keys
</SettingsSidebarNavItem>
<SettingsSidebarNavItem href="tokens">Tokens</SettingsSidebarNavItem>
<SettingsSidebarNavItem href="secrets">
<span className="flex min-w-0 items-center gap-2">
<span>Secrets</span>
<FeatureStageBadge
aria-hidden="true"
contentType="beta"
size="sm"
/>
</span>
</SettingsSidebarNavItem>
<SettingsSidebarNavItem href="notifications">
Notifications
</SettingsSidebarNavItem>
+4
View File
@@ -65,6 +65,9 @@ const SSHKeysPage = lazy(
const TokensPage = lazy(
() => import("./pages/UserSettingsPage/TokensPage/TokensPage"),
);
const SecretsPage = lazy(
() => import("./pages/UserSettingsPage/SecretsPage/SecretsPage"),
);
const WorkspaceProxyPage = lazy(
() =>
import("./pages/UserSettingsPage/WorkspaceProxyPage/WorkspaceProxyPage"),
@@ -659,6 +662,7 @@ export const router = createBrowserRouter(
<Route index element={<TokensPage />} />
<Route path="new" element={<CreateTokenPage />} />
</Route>
<Route path="secrets" element={<SecretsPage />} />
<Route path="notifications" element={<UserNotificationsPage />} />
</Route>
+7 -7
View File
@@ -589,16 +589,16 @@ export const MockUserSecrets: TypesGen.UserSecret[] = [
},
{
id: "secret-env-and-file",
name: "GITHUB_TOKEN",
name: "SERVICE_API_KEY",
description: "Available as an environment variable and file.",
env_name: "GITHUB_TOKEN",
file_path: "/var/run/secrets/github-token",
env_name: "SERVICE_API_KEY",
file_path: "/var/run/secrets/service-api-key",
created_at: "2026-04-30T16:30:00Z",
updated_at: "2026-05-02T16:30:00Z",
},
{
id: "secret-not-injected",
name: "ANTHROPIC_API_KEY",
name: "SERVICE_PASSWORD",
description: "",
env_name: "",
file_path: "",
@@ -606,10 +606,10 @@ export const MockUserSecrets: TypesGen.UserSecret[] = [
updated_at: "2026-05-03T16:30:00Z",
},
{
id: "secret-openai",
name: "OPENAI_API_KEY",
id: "secret-duplicate",
name: "DUPLICATE_API_KEY",
description: "Used to exercise duplicate validation.",
env_name: "OPENAI_API_KEY",
env_name: "DUPLICATE_API_KEY",
file_path: "",
created_at: "2026-05-01T18:30:00Z",
updated_at: "2026-05-03T18:30:00Z",