diff --git a/docs/user-guides/user-secrets.md b/docs/user-guides/user-secrets.md
index d3b6cc45b6..70e2f2bc78 100644
--- a/docs/user-guides/user-secrets.md
+++ b/docs/user-guides/user-secrets.md
@@ -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 ` 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.
diff --git a/site/e2e/helpers.ts b/site/e2e/helpers.ts
index 38205be20d..dc68cba15f 100644
--- a/site/e2e/helpers.ts
+++ b/site/e2e/helpers.ts
@@ -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", {
diff --git a/site/src/api/queries/userSecrets.ts b/site/src/api/queries/userSecrets.ts
new file mode 100644
index 0000000000..40463d7851
--- /dev/null
+++ b/site/src/api/queries/userSecrets.ts
@@ -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),
+ });
+ },
+ };
+};
diff --git a/site/src/components/FeatureStageBadge/FeatureStageBadge.stories.tsx b/site/src/components/FeatureStageBadge/FeatureStageBadge.stories.tsx
index 520970cd54..fe1c848988 100644
--- a/site/src/components/FeatureStageBadge/FeatureStageBadge.stories.tsx
+++ b/site/src/components/FeatureStageBadge/FeatureStageBadge.stories.tsx
@@ -1,9 +1,11 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
+import { chromatic } from "#/testHelpers/chromatic";
import { FeatureStageBadge } from "./FeatureStageBadge";
const meta: Meta = {
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",
diff --git a/site/src/components/FeatureStageBadge/FeatureStageBadge.tsx b/site/src/components/FeatureStageBadge/FeatureStageBadge.tsx
index 5c76219696..bedfd6222c 100644
--- a/site/src/components/FeatureStageBadge/FeatureStageBadge.tsx
+++ b/site/src/components/FeatureStageBadge/FeatureStageBadge.tsx
@@ -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;
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 = ({
@@ -44,21 +51,23 @@ export const FeatureStageBadge: FC = ({
...delegatedProps
}) => {
const colorClasses = badgeColorClasses[contentType];
- const sizeClasses = badgeSizeClasses[size];
+ const sizeClasses = badgeSizeClasses[contentType][size];
return (
- (This is a
+
+ {` (This is ${contentType === "early_access" ? "an" : "a"} `}
+
{labelText && `${labelText} `}
{featureStageBadgeTypes[contentType]}
diff --git a/site/src/components/Textarea/Textarea.tsx b/site/src/components/Textarea/Textarea.tsx
index 51a248e5ad..f735b73d4c 100644
--- a/site/src/components/Textarea/Textarea.tsx
+++ b/site/src/components/Textarea/Textarea.tsx
@@ -11,7 +11,7 @@ export const Textarea: React.FC> = ({
return (
+ }
+ onClose={() => {
+ if (!isDeleting) {
+ onCancel();
+ }
+ }}
+ onConfirm={() => {
+ if (secret) {
+ onConfirm(secret);
+ }
+ }}
+ />
+ );
+};
diff --git a/site/src/pages/UserSettingsPage/SecretsPage/secretForm.test.ts b/site/src/pages/UserSettingsPage/SecretsPage/secretForm.test.ts
index 26f8a1dd19..f45415bdfa 100644
--- a/site/src/pages/UserSettingsPage/SecretsPage/secretForm.test.ts
+++ b/site/src/pages/UserSettingsPage/SecretsPage/secretForm.test.ts
@@ -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", () => {
diff --git a/site/src/pages/UserSettingsPage/SecretsPage/secretForm.ts b/site/src/pages/UserSettingsPage/SecretsPage/secretForm.ts
index 09bf8551fa..4b242ea4a0 100644
--- a/site/src/pages/UserSettingsPage/SecretsPage/secretForm.ts
+++ b/site/src/pages/UserSettingsPage/SecretsPage/secretForm.ts
@@ -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>;
+export type SecretFieldErrors = Partial>;
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 }
: {}),
diff --git a/site/src/pages/UserSettingsPage/Sidebar.tsx b/site/src/pages/UserSettingsPage/Sidebar.tsx
index 9017a37416..a96541a3fb 100644
--- a/site/src/pages/UserSettingsPage/Sidebar.tsx
+++ b/site/src/pages/UserSettingsPage/Sidebar.tsx
@@ -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 = ({ user }) => {
SSH Keys
Tokens
+
+
+ Secrets
+
+
+
Notifications
diff --git a/site/src/router.tsx b/site/src/router.tsx
index e549fa6bcf..96871176c6 100644
--- a/site/src/router.tsx
+++ b/site/src/router.tsx
@@ -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(
} />
} />
+ } />
} />
diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts
index c8b8a083ba..07bd064129 100644
--- a/site/src/testHelpers/entities.ts
+++ b/site/src/testHelpers/entities.ts
@@ -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",