mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
committed by
GitHub
parent
5ab5e07012
commit
7887cff9d0
@@ -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
@@ -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", {
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user