feat: add user secrets client utilities (#25370)

Add frontend API methods, mocks, and form helpers for user secrets CRUD. The new client methods cover list, get, create, update, and delete requests, including URL encoding for secret names used in route paths.

Add user secret form utilities for create and update payload construction, required create field checks, and structured API validation error mapping back to form fields. User secret name validation now lives in codersdk with tests, and coderd returns field-level validation errors for create, update, and uniqueness conflicts so the frontend can show backend-owned validation results consistently.
This commit is contained in:
dylanhuff-at-coder
2026-05-19 09:30:31 -07:00
committed by GitHub
parent 01492e0e7b
commit 441854daa8
10 changed files with 816 additions and 132 deletions
+100
View File
@@ -326,4 +326,104 @@ describe("api.ts", () => {
expect(axiosInstance.get).toHaveBeenCalledWith(path);
});
});
describe("user secrets endpoints", () => {
const userId = "me";
const secretName = "EXAMPLE_TOKEN";
const secretNameWithPathChars = "foo%2Fbar value";
const userSecret: TypesGen.UserSecret = {
id: "00000000-0000-0000-0000-000000000001",
name: secretName,
description: "Example token for tests",
env_name: secretName,
file_path: "",
created_at: "2026-05-04T00:00:00Z",
updated_at: "2026-05-04T00:00:00Z",
};
it("lists user secrets with the correct method and URL", async () => {
const axiosMockGet = vi.fn().mockResolvedValueOnce({
data: [userSecret],
});
axiosInstance.get = axiosMockGet;
const result = await API.getUserSecrets(userId);
expect(axiosMockGet).toHaveBeenCalledWith("/api/v2/users/me/secrets");
expect(result).toStrictEqual([userSecret]);
});
it("gets a user secret with the correct method and URL", async () => {
const axiosMockGet = vi.fn().mockResolvedValueOnce({
data: userSecret,
});
axiosInstance.get = axiosMockGet;
const result = await API.getUserSecret(userId, secretNameWithPathChars);
expect(axiosMockGet).toHaveBeenCalledWith(
"/api/v2/users/me/secrets/foo%252Fbar%20value",
);
expect(result).toStrictEqual(userSecret);
});
it("creates a user secret with the correct method and URL", async () => {
const request: TypesGen.CreateUserSecretRequest = {
name: secretName,
value: "",
description: "Example token for tests",
env_name: secretName,
};
const axiosMockPost = vi.fn().mockResolvedValueOnce({
data: userSecret,
});
axiosInstance.post = axiosMockPost;
const result = await API.createUserSecret(userId, request);
expect(axiosMockPost).toHaveBeenCalledWith(
"/api/v2/users/me/secrets",
request,
);
expect(result).toStrictEqual(userSecret);
});
it("updates a user secret with the correct method and URL", async () => {
const request: TypesGen.UpdateUserSecretRequest = {
description: "Updated example token for tests",
};
const updatedSecret: TypesGen.UserSecret = {
...userSecret,
description: "Updated example token for tests",
updated_at: "2026-05-04T00:01:00Z",
};
const axiosMockPatch = vi.fn().mockResolvedValueOnce({
data: updatedSecret,
});
axiosInstance.patch = axiosMockPatch;
const result = await API.updateUserSecret(
userId,
secretNameWithPathChars,
request,
);
expect(axiosMockPatch).toHaveBeenCalledWith(
"/api/v2/users/me/secrets/foo%252Fbar%20value",
request,
);
expect(result).toStrictEqual(updatedSecret);
});
it("deletes a user secret with the correct method and URL", async () => {
const axiosMockDelete = vi.fn().mockResolvedValueOnce(undefined);
axiosInstance.delete = axiosMockDelete;
await API.deleteUserSecret(userId, secretNameWithPathChars);
expect(axiosMockDelete).toHaveBeenCalledWith(
"/api/v2/users/me/secrets/foo%252Fbar%20value",
);
});
});
});
+50
View File
@@ -1760,6 +1760,56 @@ class ApiMethods {
return response.data;
};
getUserSecrets = async (userId: string): Promise<TypesGen.UserSecret[]> => {
const response = await this.axios.get<TypesGen.UserSecret[]>(
`/api/v2/users/${encodeURIComponent(userId)}/secrets`,
);
return response.data;
};
getUserSecret = async (
userId: string,
name: string,
): Promise<TypesGen.UserSecret> => {
const response = await this.axios.get<TypesGen.UserSecret>(
`/api/v2/users/${encodeURIComponent(userId)}/secrets/${encodeURIComponent(name)}`,
);
return response.data;
};
createUserSecret = async (
userId: string,
request: TypesGen.CreateUserSecretRequest,
): Promise<TypesGen.UserSecret> => {
const response = await this.axios.post<TypesGen.UserSecret>(
`/api/v2/users/${encodeURIComponent(userId)}/secrets`,
request,
);
return response.data;
};
updateUserSecret = async (
userId: string,
name: string,
request: TypesGen.UpdateUserSecretRequest,
): Promise<TypesGen.UserSecret> => {
const response = await this.axios.patch<TypesGen.UserSecret>(
`/api/v2/users/${encodeURIComponent(userId)}/secrets/${encodeURIComponent(name)}`,
request,
);
return response.data;
};
deleteUserSecret = async (userId: string, name: string): Promise<void> => {
await this.axios.delete(
`/api/v2/users/${encodeURIComponent(userId)}/secrets/${encodeURIComponent(name)}`,
);
};
getWorkspaceBuilds = async (
workspaceId: string,
req?: TypesGen.WorkspaceBuildsRequest,
@@ -0,0 +1,156 @@
import type { UserSecret } from "#/api/typesGenerated";
import { mockApiError } from "#/testHelpers/entities";
import {
buildCreateUserSecretRequest,
buildUpdateUserSecretRequest,
getCreateSecretRequiredFieldErrors,
mapSecretApiErrorToFormErrors,
} from "./secretForm";
const existingSecrets: UserSecret[] = [
{
id: "11111111-1111-1111-1111-111111111111",
name: "github",
description: "GitHub token",
env_name: "GITHUB_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",
description: "",
env_name: "ANTHROPIC_API_KEY",
file_path: "~/.config/anthropic/key",
created_at: "2026-05-04T00:00:00Z",
updated_at: "2026-05-04T00:00:00Z",
},
];
describe("getCreateSecretRequiredFieldErrors", () => {
it("requires name and value on create", () => {
expect(
getCreateSecretRequiredFieldErrors({
name: "",
value: "",
}),
).toEqual({
name: "Name is required.",
value: "Value is required.",
});
});
it("requires a non-whitespace name", () => {
expect(
getCreateSecretRequiredFieldErrors({
name: " ",
value: "some value",
}),
).toEqual({
name: "Name is required.",
});
});
});
describe("payload builders", () => {
it("builds create payloads from form values", () => {
expect(
buildCreateUserSecretRequest({
name: "github",
value: "example-value",
description: "GitHub token",
env_name: "GITHUB_TOKEN",
file_path: "",
}),
).toEqual({
name: "github",
value: "example-value",
description: "GitHub token",
env_name: "GITHUB_TOKEN",
});
});
it("sends only changed update fields", () => {
expect(
buildUpdateUserSecretRequest(existingSecrets[0], {
name: "github",
value: "",
description: "Updated description",
env_name: "GITHUB_TOKEN",
file_path: "~/secrets/github",
}),
).toEqual({
description: "Updated description",
file_path: "~/secrets/github",
});
});
it("includes replacement values only when provided", () => {
expect(
buildUpdateUserSecretRequest(existingSecrets[0], {
name: "github",
value: "replacement-value",
description: "GitHub token",
env_name: "GITHUB_TOKEN",
file_path: "",
}),
).toEqual({
value: "replacement-value",
});
});
});
describe("mapSecretApiErrorToFormErrors", () => {
it("maps structured API validation errors to fields", () => {
expect(
mapSecretApiErrorToFormErrors(
mockApiError({
message: "Validation failed.",
validations: [
{ field: "name", detail: "Name already in use." },
{ field: "env_name", detail: "Use a different variable." },
{ field: "file_path", detail: "Use an absolute path." },
{ field: "unknown", detail: "Ignored." },
],
}),
).fieldErrors,
).toEqual({
name: "Name already in use.",
env_name: "Use a different variable.",
file_path: "Use an absolute path.",
});
});
it("maps unstructured API validation errors to a form error", () => {
expect(
mapSecretApiErrorToFormErrors(
mockApiError({
message: "Invalid environment variable name.",
detail: "Backend detail.",
}),
),
).toEqual({
fieldErrors: {},
formError: "Backend detail.",
});
});
it("maps generic create conflicts to a form error", () => {
expect(
mapSecretApiErrorToFormErrors({
isAxiosError: true,
status: 409,
response: {
status: 409,
data: {
message:
"A secret with that name, environment variable, or file path already exists.",
},
},
}).formError,
).toBe(
"A secret with that name, environment variable, or file path already exists.",
);
});
});
@@ -0,0 +1,147 @@
import {
type ApiErrorResponse,
isApiError,
isApiErrorResponse,
mapApiErrorToFieldErrors,
} from "#/api/errors";
import type {
CreateUserSecretRequest,
UpdateUserSecretRequest,
UserSecret,
} from "#/api/typesGenerated";
interface SecretFormValues {
name: string;
value: string;
description: string;
env_name: string;
file_path: string;
}
type SecretFormField = keyof SecretFormValues;
type SecretFieldErrors = Partial<Record<SecretFormField, string>>;
interface SecretFormErrors {
fieldErrors: SecretFieldErrors;
formError?: string;
}
export const getCreateSecretRequiredFieldErrors = (
values: Pick<SecretFormValues, "name" | "value">,
): SecretFieldErrors => {
const errors: SecretFieldErrors = {};
if (values.name.trim() === "") {
errors.name = "Name is required.";
}
if (values.value === "") {
errors.value = "Value is required.";
}
return errors;
};
export const buildCreateUserSecretRequest = (
values: SecretFormValues,
): CreateUserSecretRequest => {
return stripEmptyOptionalFields({
name: values.name,
value: values.value,
description: values.description,
env_name: values.env_name,
file_path: values.file_path,
});
};
export const buildUpdateUserSecretRequest = (
secret: UserSecret,
values: SecretFormValues,
): UpdateUserSecretRequest => {
return {
...(values.value !== "" ? { value: values.value } : {}),
...(values.description !== secret.description
? { description: values.description }
: {}),
...(values.env_name !== secret.env_name
? { env_name: values.env_name }
: {}),
...(values.file_path !== secret.file_path
? { file_path: values.file_path }
: {}),
};
};
export const mapSecretApiErrorToFormErrors = (
error: unknown,
): SecretFormErrors => {
const apiError = getApiError(error);
if (!apiError) {
return {
fieldErrors: {},
formError: "Something went wrong.",
};
}
const fieldErrors = getSecretFieldErrors(apiError.response);
if (Object.keys(fieldErrors).length > 0) {
return { fieldErrors };
}
return {
fieldErrors: {},
formError: apiError.response.detail ?? apiError.response.message,
};
};
const secretFormFieldLookup: Record<SecretFormField, true> = {
name: true,
value: true,
description: true,
env_name: true,
file_path: true,
};
function getSecretFieldErrors(response: ApiErrorResponse): SecretFieldErrors {
const apiFieldErrors = mapApiErrorToFieldErrors(response);
const fieldErrors: SecretFieldErrors = {};
for (const [field, message] of Object.entries(apiFieldErrors)) {
if (isSecretFormField(field)) {
fieldErrors[field] = message;
}
}
return fieldErrors;
}
function isSecretFormField(field: string): field is SecretFormField {
return Object.hasOwn(secretFormFieldLookup, field);
}
function getApiError(
error: unknown,
): { status?: number; response: ApiErrorResponse } | undefined {
if (isApiError(error)) {
return {
status: error.response.status ?? error.status,
response: error.response.data,
};
}
if (isApiErrorResponse(error)) {
return {
response: error,
};
}
return undefined;
}
function stripEmptyOptionalFields(
request: CreateUserSecretRequest,
): CreateUserSecretRequest {
return {
name: request.name,
value: request.value,
...(request.description ? { description: request.description } : {}),
...(request.env_name ? { env_name: request.env_name } : {}),
...(request.file_path ? { file_path: request.file_path } : {}),
};
}
+48
View File
@@ -568,6 +568,54 @@ export const MockUserAppearanceSettings: TypesGen.UserAppearanceSettings = {
terminal_font: "",
};
export const MockUserSecrets: TypesGen.UserSecret[] = [
{
id: "secret-env-only",
name: "EXAMPLE_TOKEN",
description: "Used by example templates.",
env_name: "EXAMPLE_TOKEN",
file_path: "",
created_at: "2026-04-28T16:30:00Z",
updated_at: "2026-04-30T16:30:00Z",
},
{
id: "secret-file-only",
name: "config-json",
description: "Mounted as a workspace file.",
env_name: "",
file_path: "~/.config/example/config.json",
created_at: "2026-04-29T16:30:00Z",
updated_at: "2026-05-01T16:30:00Z",
},
{
id: "secret-env-and-file",
name: "GITHUB_TOKEN",
description: "Available as an environment variable and file.",
env_name: "GITHUB_TOKEN",
file_path: "/var/run/secrets/github-token",
created_at: "2026-04-30T16:30:00Z",
updated_at: "2026-05-02T16:30:00Z",
},
{
id: "secret-not-injected",
name: "ANTHROPIC_API_KEY",
description: "",
env_name: "",
file_path: "",
created_at: "2026-05-01T16:30:00Z",
updated_at: "2026-05-03T16:30:00Z",
},
{
id: "secret-openai",
name: "OPENAI_API_KEY",
description: "Used to exercise duplicate validation.",
env_name: "OPENAI_API_KEY",
file_path: "",
created_at: "2026-05-01T18:30:00Z",
updated_at: "2026-05-03T18:30:00Z",
},
];
export const MockTasksTabVisible: boolean = false;
export const MockOrganizationMember: TypesGen.OrganizationMemberWithUserData = {
+74 -1
View File
@@ -1,7 +1,12 @@
import fs from "node:fs";
import path from "node:path";
import { HttpResponse, http } from "msw";
import type { CreateWorkspaceBuildRequest } from "#/api/typesGenerated";
import type {
CreateUserSecretRequest,
CreateWorkspaceBuildRequest,
UpdateUserSecretRequest,
UserSecret,
} from "#/api/typesGenerated";
import { permissionChecks } from "#/modules/permissions";
import * as M from "./entities";
import { MockGroup, MockWorkspaceQuota } from "./entities";
@@ -192,6 +197,46 @@ export const handlers = [
http.get("/api/v2/users/:userId/gitsshkey", () => {
return HttpResponse.json(M.MockGitSSHKey);
}),
http.get("/api/v2/users/:userId/secrets", () => {
return HttpResponse.json(M.MockUserSecrets);
}),
http.get("/api/v2/users/:userId/secrets/:name", ({ params }) => {
const secret = M.MockUserSecrets.find(
(secret) => secret.name === params.name,
);
if (!secret) {
return HttpResponse.json(
{ message: "Secret not found." },
{ status: 404 },
);
}
return HttpResponse.json(secret);
}),
http.post("/api/v2/users/:userId/secrets", async ({ request }) => {
const body = (await request.json()) as CreateUserSecretRequest;
return HttpResponse.json(userSecretFromCreateRequest(body), {
status: 201,
});
}),
http.patch(
"/api/v2/users/:userId/secrets/:name",
async ({ request, params }) => {
const body = (await request.json()) as UpdateUserSecretRequest;
const existing = M.MockUserSecrets.find(
(secret) => secret.name === params.name,
);
if (!existing) {
return HttpResponse.json(
{ message: "Secret not found." },
{ status: 404 },
);
}
return HttpResponse.json(userSecretFromUpdateRequest(existing, body));
},
),
http.delete("/api/v2/users/:userId/secrets/:name", () => {
return new HttpResponse(null, { status: 204 });
}),
http.get("/api/v2/users/:userId/workspace/:workspaceName", () => {
return HttpResponse.json(M.MockWorkspace);
}),
@@ -378,3 +423,31 @@ export const handlers = [
return HttpResponse.json(M.MockListeningPortsResponse);
}),
];
function userSecretFromCreateRequest(
request: CreateUserSecretRequest,
): UserSecret {
const now = "2026-05-04T00:00:00Z";
return {
id: `secret-${request.name}`,
name: request.name,
description: request.description ?? "",
env_name: request.env_name ?? "",
file_path: request.file_path ?? "",
created_at: now,
updated_at: now,
};
}
function userSecretFromUpdateRequest(
secret: UserSecret,
request: UpdateUserSecretRequest,
): UserSecret {
return {
...secret,
description: request.description ?? secret.description,
env_name: request.env_name ?? secret.env_name,
file_path: request.file_path ?? secret.file_path,
updated_at: "2026-05-04T00:00:00Z",
};
}