From 7de1c03afdcf7436b11153716f9ac716ed03598b Mon Sep 17 00:00:00 2001 From: Camiel van Schoonhoven Date: Thu, 22 Oct 2026 14:35:49 -0700 Subject: [PATCH 1/2] feat(tangent): manage a project's secrets in a Secrets window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tangent's project dock gains a Secrets window when the project's workspace maps to a team. It lists the workspace's secrets, the ones runs in the project use when they act as the team. Your own secrets stay in Settings → Secrets. The copy calls the team the workspace. A team takes its workspace's name, and the workspace is what people know it by. - In a team's project the window is headed "Workspace: " with the team id as its tooltip, and add, replace and delete act as the team. A member whose role can't write sees the list without those actions. Hiding them is the only guard, since the backend doesn't check write access on `/api/secrets/`. - A project whose workspace has no team has no workspace secrets, so it gets no Secrets window. The window opens once the team is known, so it never appears and then vanishes. - When the project has a team but you can't act as it (not a member, refused by the backend, or the lookup failed), the window says the workspace's secrets aren't available and why. It doesn't list your own secrets in their place, where they would pass for the workspace's. The add, replace and delete controls take an optional team id and invalidate that team's cache key. Their failure toasts now show the backend's reason, which matters once a request can be refused for acting as a team. From #2886: the dock registration and its order test, `ListRowIcon`, and the secret row's look. Replace and delete are inline buttons rather than a menu, so the row reuses the confirming delete button. Settings → Secrets stays your own secrets. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../components/AddSecretButton.tsx | 12 +- .../components/AddSecretForm.test.tsx | 137 ++++++++-- .../components/AddSecretForm.tsx | 4 + .../components/RemoveSecretButton.test.tsx | 52 +++- .../components/RemoveSecretButton.tsx | 12 +- .../components/UpdateSecretButton.tsx | 12 +- .../pages/Tangent/components/ListRowIcon.tsx | 14 + .../components/SecretFormDialog.test.tsx | 102 +++++++ .../Tangent/components/SecretFormDialog.tsx | 61 +++++ .../v2/pages/Tangent/components/SecretRow.tsx | 84 ++++++ .../components/SecretsWindowContent.test.tsx | 256 ++++++++++++++++++ .../components/SecretsWindowContent.tsx | 193 +++++++++++++ .../Tangent/components/WindowListRow.tsx | 12 +- .../hooks/tangentProjectWindowOrder.test.ts | 28 +- .../hooks/tangentProjectWindowOrder.ts | 1 + .../hooks/useTangentProjectWindows.test.tsx | 133 +++++++++ .../hooks/useTangentProjectWindows.tsx | 60 +++- 17 files changed, 1113 insertions(+), 60 deletions(-) create mode 100644 src/routes/v2/pages/Tangent/components/ListRowIcon.tsx create mode 100644 src/routes/v2/pages/Tangent/components/SecretFormDialog.test.tsx create mode 100644 src/routes/v2/pages/Tangent/components/SecretFormDialog.tsx create mode 100644 src/routes/v2/pages/Tangent/components/SecretRow.tsx create mode 100644 src/routes/v2/pages/Tangent/components/SecretsWindowContent.test.tsx create mode 100644 src/routes/v2/pages/Tangent/components/SecretsWindowContent.tsx create mode 100644 src/routes/v2/pages/Tangent/hooks/useTangentProjectWindows.test.tsx diff --git a/src/components/shared/SecretsManagement/components/AddSecretButton.tsx b/src/components/shared/SecretsManagement/components/AddSecretButton.tsx index da7597f29b..276c05d1e7 100644 --- a/src/components/shared/SecretsManagement/components/AddSecretButton.tsx +++ b/src/components/shared/SecretsManagement/components/AddSecretButton.tsx @@ -10,12 +10,14 @@ import { SecretsQueryKeys } from "../types"; interface AddSecretButtonProps { secret: Pick; + teamId?: string; disabled?: boolean; onSuccess: () => void; } export function AddSecretButton({ secret, + teamId, disabled, onSuccess, }: AddSecretButtonProps) { @@ -23,13 +25,15 @@ export function AddSecretButton({ const queryClient = useQueryClient(); const { mutate: saveSecret, isPending } = useMutation({ - mutationFn: () => addSecret(secret), + mutationFn: () => addSecret(secret, teamId), onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: SecretsQueryKeys.All() }); + void queryClient.invalidateQueries({ + queryKey: SecretsQueryKeys.All(teamId), + }); onSuccess(); }, - onError: () => { - notify("Failed to add secret", "error"); + onError: (error) => { + notify(error.message, "error"); }, }); diff --git a/src/components/shared/SecretsManagement/components/AddSecretForm.test.tsx b/src/components/shared/SecretsManagement/components/AddSecretForm.test.tsx index c2c677f378..8e2d0d1af4 100644 --- a/src/components/shared/SecretsManagement/components/AddSecretForm.test.tsx +++ b/src/components/shared/SecretsManagement/components/AddSecretForm.test.tsx @@ -4,8 +4,10 @@ import userEvent from "@testing-library/user-event"; import type { ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TeamImpersonationError } from "@/services/teams/errors"; + import { addSecret, updateSecret } from "../secretsStorage"; -import type { Secret } from "../types"; +import { type Secret, SecretsQueryKeys } from "../types"; import { AddSecretForm, lastWholeSecondOfDayUTC } from "./AddSecretForm"; vi.mock("../secretsStorage", () => ({ @@ -13,7 +15,14 @@ vi.mock("../secretsStorage", () => ({ updateSecret: vi.fn(), })); -vi.mock("@/hooks/useToastNotification", () => ({ default: () => vi.fn() })); +const { notify } = vi.hoisted(() => ({ notify: vi.fn() })); + +vi.mock("@/hooks/useToastNotification", () => ({ default: () => notify })); + +const REFUSAL = + "Impersonation error: alice@example.com is not a member of ml-team@example.com"; + +const TEAM_ID = "ml-team@example.com"; const existing: Secret = { id: "API_KEY", @@ -23,14 +32,19 @@ const existing: Secret = { updatedAt: new Date("2026-10-02T09:00:00Z"), }; -function renderForm(existingSecret?: Secret) { +function renderForm({ + existingSecret, + teamId, +}: { existingSecret?: Secret; teamId?: string } = {}) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries"); const onSuccess = vi.fn(); render( , @@ -42,7 +56,7 @@ function renderForm(existingSecret?: Secret) { ), }, ); - return { user: userEvent.setup(), onSuccess }; + return { user: userEvent.setup(), onSuccess, invalidateQueries }; } const field = (label: string) => screen.getByLabelText(label); @@ -68,14 +82,49 @@ describe("AddSecretForm", () => { await user.click(screen.getByRole("button", { name: "Add Secret" })); await waitFor(() => expect(onSuccess).toHaveBeenCalled()); - expect(addSecret).toHaveBeenCalledWith({ - name: "API_KEY", - value: "s3cret", - description: "Model access", - expiresAt: undefined, + expect(addSecret).toHaveBeenCalledWith( + { + name: "API_KEY", + value: "s3cret", + description: "Model access", + expiresAt: undefined, + }, + undefined, + ); + }); + + it("adds a secret to the team it is given", async () => { + const { user, onSuccess, invalidateQueries } = renderForm({ + teamId: TEAM_ID, + }); + + await user.type(field("Secret Name"), "API_KEY"); + await user.type(field("Secret Value"), "s3cret"); + await user.click(screen.getByRole("button", { name: "Add Secret" })); + + await waitFor(() => expect(onSuccess).toHaveBeenCalled()); + expect(addSecret).toHaveBeenCalledWith(expect.anything(), TEAM_ID); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: SecretsQueryKeys.All(TEAM_ID), }); }); + it("shows the backend's reason when adding as the team is refused", async () => { + vi.mocked(addSecret).mockRejectedValue( + new TeamImpersonationError(REFUSAL, 403), + ); + const { user, onSuccess } = renderForm({ teamId: TEAM_ID }); + + await user.type(field("Secret Name"), "API_KEY"); + await user.type(field("Secret Value"), "s3cret"); + await user.click(screen.getByRole("button", { name: "Add Secret" })); + + await waitFor(() => + expect(notify).toHaveBeenCalledWith(REFUSAL, "error"), + ); + expect(onSuccess).not.toHaveBeenCalled(); + }); + it("leaves out a description that was never written", async () => { const { user } = renderForm(); @@ -86,6 +135,7 @@ describe("AddSecretForm", () => { await waitFor(() => expect(addSecret).toHaveBeenCalledWith( expect.objectContaining({ description: undefined }), + undefined, ), ); }); @@ -163,6 +213,7 @@ describe("AddSecretForm", () => { expect.objectContaining({ expiresAt: new Date("2026-11-15T23:59:59.000Z"), }), + undefined, ), ); }); @@ -176,7 +227,7 @@ describe("AddSecretForm", () => { describe("replacing", () => { it("starts from the secret's description", () => { - renderForm(existing); + renderForm({ existingSecret: existing }); expect(field("Secret Name")).toBeDisabled(); expect(field("Description (optional)")).toHaveValue("Model access"); @@ -186,22 +237,46 @@ describe("AddSecretForm", () => { }); it("sends only the new value when nothing else changed", async () => { - const { user, onSuccess } = renderForm(existing); + const { user, onSuccess } = renderForm({ existingSecret: existing }); + + await user.type(field("New Secret Value"), "n3w"); + await user.click(screen.getByRole("button", { name: "Update Secret" })); + + await waitFor(() => expect(onSuccess).toHaveBeenCalled()); + expect(updateSecret).toHaveBeenCalledWith( + "API_KEY", + { + id: "API_KEY", + value: "n3w", + description: undefined, + expiresAt: undefined, + }, + undefined, + ); + }); + + it("replaces a team's secret as the team", async () => { + const { user, onSuccess, invalidateQueries } = renderForm({ + existingSecret: existing, + teamId: TEAM_ID, + }); await user.type(field("New Secret Value"), "n3w"); await user.click(screen.getByRole("button", { name: "Update Secret" })); await waitFor(() => expect(onSuccess).toHaveBeenCalled()); - expect(updateSecret).toHaveBeenCalledWith("API_KEY", { - id: "API_KEY", - value: "n3w", - description: undefined, - expiresAt: undefined, + expect(updateSecret).toHaveBeenCalledWith( + "API_KEY", + expect.anything(), + TEAM_ID, + ); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: SecretsQueryKeys.All(TEAM_ID), }); }); it("sends a description that was changed", async () => { - const { user } = renderForm(existing); + const { user } = renderForm({ existingSecret: existing }); await user.type(field("New Secret Value"), "n3w"); await user.clear(field("Description (optional)")); @@ -212,14 +287,17 @@ describe("AddSecretForm", () => { expect(updateSecret).toHaveBeenCalledWith( "API_KEY", expect.objectContaining({ description: "Agent model access" }), + undefined, ), ); }); it("names the current expiry by its UTC day and keeps it when left empty", async () => { const { user } = renderForm({ - ...existing, - expiresAt: new Date("2027-03-03T02:00:00Z"), + existingSecret: { + ...existing, + expiresAt: new Date("2027-03-03T02:00:00Z"), + }, }); expect(field("Expires (optional)")).toHaveAccessibleDescription( @@ -233,12 +311,31 @@ describe("AddSecretForm", () => { expect(updateSecret).toHaveBeenCalledWith( "API_KEY", expect.objectContaining({ expiresAt: undefined }), + undefined, ), ); }); + it("shows the backend's reason when replacing as the team is refused", async () => { + vi.mocked(updateSecret).mockRejectedValue( + new TeamImpersonationError(REFUSAL, 403), + ); + const { user, onSuccess } = renderForm({ + existingSecret: existing, + teamId: TEAM_ID, + }); + + await user.type(field("New Secret Value"), "n3w"); + await user.click(screen.getByRole("button", { name: "Update Secret" })); + + await waitFor(() => + expect(notify).toHaveBeenCalledWith(REFUSAL, "error"), + ); + expect(onSuccess).not.toHaveBeenCalled(); + }); + it("still needs a new value", () => { - renderForm(existing); + renderForm({ existingSecret: existing }); expect( screen.getByRole("button", { name: "Update Secret" }), diff --git a/src/components/shared/SecretsManagement/components/AddSecretForm.tsx b/src/components/shared/SecretsManagement/components/AddSecretForm.tsx index f751f79474..3327527cfa 100644 --- a/src/components/shared/SecretsManagement/components/AddSecretForm.tsx +++ b/src/components/shared/SecretsManagement/components/AddSecretForm.tsx @@ -100,12 +100,14 @@ function expiryHint(existingSecret?: Secret): string | null { interface AddSecretFormProps { existingSecret?: Secret; + teamId?: string; onSuccess: () => void; onCancel: () => void; } export function AddSecretForm({ existingSecret, + teamId, onSuccess, onCancel, }: AddSecretFormProps) { @@ -260,6 +262,7 @@ export function AddSecretForm({ description, expiresAt, }} + teamId={teamId} disabled={!isFormValid} onSuccess={onSuccess} /> @@ -271,6 +274,7 @@ export function AddSecretForm({ description, expiresAt, }} + teamId={teamId} disabled={!isFormValid} onSuccess={onSuccess} /> diff --git a/src/components/shared/SecretsManagement/components/RemoveSecretButton.test.tsx b/src/components/shared/SecretsManagement/components/RemoveSecretButton.test.tsx index e3feb754a3..cd6ec05b8c 100644 --- a/src/components/shared/SecretsManagement/components/RemoveSecretButton.test.tsx +++ b/src/components/shared/SecretsManagement/components/RemoveSecretButton.test.tsx @@ -4,21 +4,30 @@ import userEvent from "@testing-library/user-event"; import type { ReactNode } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { TeamImpersonationError } from "@/services/teams/errors"; + import { removeSecret } from "../secretsStorage"; +import { SecretsQueryKeys } from "../types"; import { RemoveSecretButton } from "./RemoveSecretButton"; vi.mock("../secretsStorage", () => ({ removeSecret: vi.fn() })); -vi.mock("@/hooks/useToastNotification", () => ({ default: () => vi.fn() })); +const { notify } = vi.hoisted(() => ({ notify: vi.fn() })); + +vi.mock("@/hooks/useToastNotification", () => ({ default: () => notify })); + +const TEAM_ID = "ml-team@example.com"; -function renderButton() { +function renderButton(teamId?: string) { const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } }, }); + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries"); const onSuccess = vi.fn(); render( , { @@ -29,7 +38,7 @@ function renderButton() { ), }, ); - return { user: userEvent.setup(), onSuccess }; + return { user: userEvent.setup(), onSuccess, invalidateQueries }; } describe("RemoveSecretButton", () => { @@ -58,7 +67,42 @@ describe("RemoveSecretButton", () => { await user.click(screen.getByRole("button", { name: "Delete" })); await waitFor(() => expect(onSuccess).toHaveBeenCalled()); - expect(removeSecret).toHaveBeenCalledWith("API_KEY"); + expect(removeSecret).toHaveBeenCalledWith("API_KEY", undefined); + }); + + it("deletes a team's secret as the team", async () => { + vi.mocked(removeSecret).mockResolvedValue(true); + const { user, onSuccess, invalidateQueries } = renderButton(TEAM_ID); + + await user.click(screen.getByRole("button", { name: "Delete API_KEY" })); + await user.click(screen.getByRole("button", { name: "Delete" })); + + await waitFor(() => expect(onSuccess).toHaveBeenCalled()); + expect(removeSecret).toHaveBeenCalledWith("API_KEY", TEAM_ID); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: SecretsQueryKeys.All(TEAM_ID), + }); + }); + + it("shows the backend's reason when acting as the team is refused", async () => { + vi.mocked(removeSecret).mockRejectedValue( + new TeamImpersonationError( + "Impersonation error: alice@example.com is not a member of ml-team@example.com", + 403, + ), + ); + const { user, onSuccess } = renderButton(TEAM_ID); + + await user.click(screen.getByRole("button", { name: "Delete API_KEY" })); + await user.click(screen.getByRole("button", { name: "Delete" })); + + await waitFor(() => + expect(notify).toHaveBeenCalledWith( + "Impersonation error: alice@example.com is not a member of ml-team@example.com", + "error", + ), + ); + expect(onSuccess).not.toHaveBeenCalled(); }); it("keeps the secret when cancelled", async () => { diff --git a/src/components/shared/SecretsManagement/components/RemoveSecretButton.tsx b/src/components/shared/SecretsManagement/components/RemoveSecretButton.tsx index f5a4b01139..6a59a31e5c 100644 --- a/src/components/shared/SecretsManagement/components/RemoveSecretButton.tsx +++ b/src/components/shared/SecretsManagement/components/RemoveSecretButton.tsx @@ -11,24 +11,28 @@ import { SecretsQueryKeys } from "../types"; interface RemoveSecretButtonProps { secret: Pick; + teamId?: string; onSuccess?: () => void; } export function RemoveSecretButton({ secret, + teamId, onSuccess, }: RemoveSecretButtonProps) { const notify = useToastNotification(); const queryClient = useQueryClient(); const { mutate: removeSecretMutation, isPending } = useMutation({ - mutationFn: () => removeSecret(secret.id), + mutationFn: () => removeSecret(secret.id, teamId), onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: SecretsQueryKeys.All() }); + void queryClient.invalidateQueries({ + queryKey: SecretsQueryKeys.All(teamId), + }); onSuccess?.(); }, - onError: () => { - notify("Failed to remove secret", "error"); + onError: (error) => { + notify(error.message, "error"); }, }); diff --git a/src/components/shared/SecretsManagement/components/UpdateSecretButton.tsx b/src/components/shared/SecretsManagement/components/UpdateSecretButton.tsx index 2e16b802f2..4facea2b30 100644 --- a/src/components/shared/SecretsManagement/components/UpdateSecretButton.tsx +++ b/src/components/shared/SecretsManagement/components/UpdateSecretButton.tsx @@ -9,12 +9,14 @@ import { type Secret, SecretsQueryKeys } from "../types"; interface UpdateSecretButtonProps { secret: Pick; + teamId?: string; disabled?: boolean; onSuccess: () => void; } export function UpdateSecretButton({ secret, + teamId, disabled, onSuccess, }: UpdateSecretButtonProps) { @@ -22,13 +24,15 @@ export function UpdateSecretButton({ const queryClient = useQueryClient(); const { mutate: updateSecretMutation, isPending } = useMutation({ - mutationFn: () => updateSecret(secret.id, secret), + mutationFn: () => updateSecret(secret.id, secret, teamId), onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: SecretsQueryKeys.All() }); + void queryClient.invalidateQueries({ + queryKey: SecretsQueryKeys.All(teamId), + }); onSuccess(); }, - onError: () => { - notify("Failed to update secret", "error"); + onError: (error) => { + notify(error.message, "error"); }, }); diff --git a/src/routes/v2/pages/Tangent/components/ListRowIcon.tsx b/src/routes/v2/pages/Tangent/components/ListRowIcon.tsx new file mode 100644 index 0000000000..fc9e336f7f --- /dev/null +++ b/src/routes/v2/pages/Tangent/components/ListRowIcon.tsx @@ -0,0 +1,14 @@ +import { Icon, type IconName } from "@/components/ui/icon"; +import { InlineStack } from "@/components/ui/layout"; + +export function ListRowIcon({ icon }: { icon: IconName }) { + return ( + + + + ); +} diff --git a/src/routes/v2/pages/Tangent/components/SecretFormDialog.test.tsx b/src/routes/v2/pages/Tangent/components/SecretFormDialog.test.tsx new file mode 100644 index 0000000000..1c7e6d7084 --- /dev/null +++ b/src/routes/v2/pages/Tangent/components/SecretFormDialog.test.tsx @@ -0,0 +1,102 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { Secret } from "@/components/shared/SecretsManagement/types"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; + +import { + SecretFormDialog, + type SecretFormDialogProps, +} from "./SecretFormDialog"; + +const { notify, track } = vi.hoisted(() => ({ + notify: vi.fn(), + track: vi.fn(), +})); + +vi.mock( + "@/components/shared/SecretsManagement/components/AddSecretForm", + () => ({ + AddSecretForm: ({ + teamId, + onSuccess, + }: { + teamId?: string; + onSuccess: () => void; + }) => ( + + ), + }), +); + +vi.mock("@/hooks/useToastNotification", () => ({ default: () => notify })); +vi.mock("@/providers/AnalyticsProvider", () => ({ + useAnalytics: () => ({ track }), +})); + +const TEAM = { id: "ml-team@example.com", label: "ML Research" }; + +const apiKey: Secret = { + id: "API_KEY", + name: "API_KEY", + createdAt: new Date("2026-10-01T09:00:00Z"), + updatedAt: new Date("2026-10-02T09:00:00Z"), +}; + +function renderDialog(props: SecretFormDialogProps) { + const close = vi.fn(); + render( + + + + + , + ); + return { user: userEvent.setup(), close }; +} + +describe("SecretFormDialog", () => { + afterEach(() => vi.clearAllMocks()); + + it("adds a team secret for everyone in the team", async () => { + const { user, close } = renderDialog({ team: TEAM }); + + expect(screen.getByRole("dialog", { name: "Add a secret" })).toBeVisible(); + expect( + screen.getByText("Everyone in ML Research can use it in this project."), + ).toBeVisible(); + expect(screen.getByRole("button", { name: "Save" })).toHaveAttribute( + "data-team-id", + TEAM.id, + ); + + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(close).toHaveBeenCalled(); + expect(notify).toHaveBeenCalledWith("Secret added", "success"); + expect(track).toHaveBeenCalledWith("projects.secrets.secret_mutated", { + action: "created", + }); + }); + + it("replaces a secret's value", async () => { + const { user } = renderDialog({ secret: apiKey, team: TEAM }); + + expect( + screen.getByRole("dialog", { name: "Replace API_KEY" }), + ).toBeVisible(); + expect( + screen.getByText("Runs that start after this use the new value."), + ).toBeVisible(); + + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(notify).toHaveBeenCalledWith('Secret "API_KEY" replaced', "success"); + expect(track).toHaveBeenCalledWith("projects.secrets.secret_mutated", { + action: "updated", + }); + }); +}); diff --git a/src/routes/v2/pages/Tangent/components/SecretFormDialog.tsx b/src/routes/v2/pages/Tangent/components/SecretFormDialog.tsx new file mode 100644 index 0000000000..f7342d6144 --- /dev/null +++ b/src/routes/v2/pages/Tangent/components/SecretFormDialog.tsx @@ -0,0 +1,61 @@ +import { AddSecretForm } from "@/components/shared/SecretsManagement/components/AddSecretForm"; +import type { Secret } from "@/components/shared/SecretsManagement/types"; +import { + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import useToastNotification from "@/hooks/useToastNotification"; +import { useAnalytics } from "@/providers/AnalyticsProvider"; +import type { DialogProps } from "@/providers/DialogProvider/types"; +import type { TeamSummary } from "@/services/teams/types"; + +export interface SecretFormDialogProps { + secret?: Secret; + team: TeamSummary; +} + +function dialogDescription({ secret, team }: SecretFormDialogProps): string { + if (secret) return "Runs that start after this use the new value."; + return `Everyone in ${team.label} can use it in this project.`; +} + +export function SecretFormDialog({ + close, + cancel, + secret, + team, +}: DialogProps) { + const notify = useToastNotification(); + const { track } = useAnalytics(); + + const handleSuccess = () => { + track("projects.secrets.secret_mutated", { + action: secret ? "updated" : "created", + }); + notify( + secret ? `Secret "${secret.name}" replaced` : "Secret added", + "success", + ); + close(); + }; + + return ( + <> + + + {secret ? `Replace ${secret.name}` : "Add a secret"} + + + {dialogDescription({ secret, team })} + + + + + ); +} diff --git a/src/routes/v2/pages/Tangent/components/SecretRow.tsx b/src/routes/v2/pages/Tangent/components/SecretRow.tsx new file mode 100644 index 0000000000..f1ba18a278 --- /dev/null +++ b/src/routes/v2/pages/Tangent/components/SecretRow.tsx @@ -0,0 +1,84 @@ +import { RemoveSecretButton } from "@/components/shared/SecretsManagement/components/RemoveSecretButton"; +import type { Secret } from "@/components/shared/SecretsManagement/types"; +import { Button } from "@/components/ui/button"; +import { Icon } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Text } from "@/components/ui/typography"; +import { formatRelativeTime } from "@/utils/date"; +import { tracking } from "@/utils/tracking"; + +import { ListRowIcon } from "./ListRowIcon"; + +interface SecretRowProps { + secret: Secret; + teamId: string; + canWrite: boolean; + onReplace: (secret: Secret) => void; + onRemoved: () => void; +} + +function secretDetail(secret: Secret): string { + return ( + secret.description || `Updated ${formatRelativeTime(secret.updatedAt)}` + ); +} + +export function SecretRow({ + secret, + teamId, + canWrite, + onReplace, + onRemoved, +}: SecretRowProps) { + const detail = secretDetail(secret); + + return ( + + + + + {secret.name} + + + {detail} + + + {canWrite && ( + + + + + )} + + ); +} diff --git a/src/routes/v2/pages/Tangent/components/SecretsWindowContent.test.tsx b/src/routes/v2/pages/Tangent/components/SecretsWindowContent.test.tsx new file mode 100644 index 0000000000..529223c487 --- /dev/null +++ b/src/routes/v2/pages/Tangent/components/SecretsWindowContent.test.tsx @@ -0,0 +1,256 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { Secret } from "@/components/shared/SecretsManagement/types"; +import type { TeamResolution } from "@/services/teams/types"; +import { useTeamForProjects } from "@/services/teams/useTeamForProjects"; + +import { SecretFormDialog } from "./SecretFormDialog"; +import { SecretsWindowContent } from "./SecretsWindowContent"; + +const { fetchSecrets, open } = vi.hoisted(() => ({ + fetchSecrets: vi.fn(), + open: vi.fn(), +})); + +vi.mock("@/routes/v2/pages/Tangent/context/TangentProjectContext", () => ({ + useTangentProject: () => ({ projectId: "project-1" }), +})); + +vi.mock("@/services/teams/useTeamForProjects", () => ({ + useTeamForProjects: vi.fn(), +})); + +vi.mock("@/components/shared/SecretsManagement/secretsStorage", async () => { + const { SecretsQueryKeys } = + await import("@/components/shared/SecretsManagement/types"); + return { + secretsListQuery: (teamId?: string) => ({ + queryKey: SecretsQueryKeys.All(teamId), + queryFn: () => fetchSecrets(teamId), + }), + removeSecret: vi.fn(), + }; +}); + +vi.mock("@/providers/DialogProvider/hooks/useDialog", () => ({ + useDialog: () => ({ open }), +})); + +vi.mock("@/hooks/useToastNotification", () => ({ default: () => vi.fn() })); +vi.mock("@/providers/AnalyticsProvider", () => ({ + useAnalytics: () => ({ track: vi.fn() }), +})); + +const TEAM = { id: "ml-team@example.com", label: "ML Research" }; + +const secret = (name: string, overrides: Partial = {}): Secret => ({ + id: name, + name, + createdAt: new Date("2026-10-01T09:00:00Z"), + updatedAt: new Date("2026-10-02T09:00:00Z"), + ...overrides, +}); + +function givenTeam(resolution: TeamResolution) { + vi.mocked(useTeamForProjects).mockReturnValue(resolution); +} + +function renderWindow() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + render(, { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); + return userEvent.setup(); +} + +describe("SecretsWindowContent", () => { + beforeEach(() => { + fetchSecrets.mockResolvedValue([]); + open.mockResolvedValue(undefined); + givenTeam({ status: "active", team: { ...TEAM, canWrite: true } }); + }); + + afterEach(() => vi.clearAllMocks()); + + it("asks for the team of the Tangent project", () => { + renderWindow(); + + expect(useTeamForProjects).toHaveBeenCalledWith(["project-1"]); + }); + + describe("in a team's project", () => { + it("lists the team's secrets under its name", async () => { + fetchSecrets.mockResolvedValue([ + secret("API_KEY", { description: "Model access" }), + ]); + + renderWindow(); + + expect(await screen.findByText("API_KEY")).toBeVisible(); + expect(screen.getByText("Model access")).toBeVisible(); + expect(screen.getByText("Workspace: ML Research")).toHaveAttribute( + "title", + TEAM.id, + ); + expect(fetchSecrets).toHaveBeenCalledWith(TEAM.id); + }); + + it("adds a secret to the team", async () => { + const user = renderWindow(); + + await user.click( + await screen.findByRole("button", { name: "Add a secret" }), + ); + + expect(open).toHaveBeenCalledWith( + expect.objectContaining({ + component: SecretFormDialog, + props: { + secret: undefined, + team: { ...TEAM, canWrite: true }, + }, + }), + ); + }); + + it("replaces one of the team's secrets", async () => { + const apiKey = secret("API_KEY"); + fetchSecrets.mockResolvedValue([apiKey]); + const user = renderWindow(); + + await user.click( + await screen.findByRole("button", { name: "Replace API_KEY" }), + ); + + expect(open).toHaveBeenCalledWith( + expect.objectContaining({ + props: { secret: apiKey, team: { ...TEAM, canWrite: true } }, + }), + ); + expect( + screen.getByRole("button", { name: "Delete API_KEY" }), + ).toBeVisible(); + }); + + it("says when the team has no secrets", async () => { + renderWindow(); + + expect(await screen.findByText("No workspace secrets yet")).toBeVisible(); + }); + }); + + it("only lists a team's secrets to a member who can't change them", async () => { + givenTeam({ status: "active", team: { ...TEAM, canWrite: false } }); + fetchSecrets.mockResolvedValue([secret("API_KEY")]); + + renderWindow(); + + expect(await screen.findByText("API_KEY")).toBeVisible(); + expect( + screen.getByText(/Your role in the workspace can't change them/), + ).toBeVisible(); + expect(screen.queryByRole("button", { name: "Add a secret" })).toBeNull(); + expect( + screen.queryByRole("button", { name: "Replace API_KEY" }), + ).toBeNull(); + expect(screen.queryByRole("button", { name: "Delete API_KEY" })).toBeNull(); + }); + + describe("when you can't act as the project's workspace", () => { + it.each([ + { status: "not-member", team: TEAM }, + { status: "unavailable", team: TEAM }, + { status: "error", error: new Error("backend down") }, + ])( + "says its secrets aren't available and lists none ($status)", + (resolution) => { + givenTeam(resolution); + + renderWindow(); + + expect(screen.getByTestId("info-box-title")).toHaveTextContent( + "Workspace secrets aren't available", + ); + expect( + screen.queryByRole("button", { name: "Add a secret" }), + ).toBeNull(); + expect(fetchSecrets).not.toHaveBeenCalled(); + }, + ); + + it("tells a non-member to ask for access", () => { + givenTeam({ status: "not-member", team: TEAM }); + + renderWindow(); + + expect(screen.getByTestId("info-box-warning")).toHaveTextContent( + "You're not in the ML Research workspace. Ask an admin to add you.", + ); + }); + + it("says when the backend refuses to act as the workspace", () => { + givenTeam({ status: "unavailable", team: TEAM }); + + renderWindow(); + + expect(screen.getByTestId("info-box-warning")).toHaveTextContent( + "The backend didn't let you act as the ML Research workspace.", + ); + }); + + it("gives the reason the workspace couldn't be checked", () => { + givenTeam({ + status: "error", + error: new Error("Failed to load workspace ws-1"), + }); + + renderWindow(); + + expect( + screen.getByText( + "Couldn't check your access to this project's workspace: Failed to load workspace ws-1", + ), + ).toBeVisible(); + }); + }); + + it("shows nothing in a project whose workspace has no team", () => { + givenTeam({ status: "none" }); + + renderWindow(); + + expect(screen.queryByTestId("info-box-warning")).toBeNull(); + expect(fetchSecrets).not.toHaveBeenCalled(); + }); + + it("waits for the team before listing anything", () => { + givenTeam({ status: "resolving" }); + + renderWindow(); + + expect(screen.getByText("Loading...")).toBeVisible(); + expect(fetchSecrets).not.toHaveBeenCalled(); + }); + + it("says why the secrets couldn't be listed", async () => { + fetchSecrets.mockRejectedValue( + new Error("Failed to fetch secrets: backend down"), + ); + + renderWindow(); + + await waitFor(() => + expect( + screen.getByText("Failed to fetch secrets: backend down"), + ).toBeVisible(), + ); + }); +}); diff --git a/src/routes/v2/pages/Tangent/components/SecretsWindowContent.tsx b/src/routes/v2/pages/Tangent/components/SecretsWindowContent.tsx new file mode 100644 index 0000000000..d7179d03c2 --- /dev/null +++ b/src/routes/v2/pages/Tangent/components/SecretsWindowContent.tsx @@ -0,0 +1,193 @@ +import { useQuery } from "@tanstack/react-query"; + +import { InfoBox } from "@/components/shared/InfoBox"; +import { secretsListQuery } from "@/components/shared/SecretsManagement/secretsStorage"; +import type { Secret } from "@/components/shared/SecretsManagement/types"; +import { Button } from "@/components/ui/button"; +import { Icon } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Spinner } from "@/components/ui/spinner"; +import { Text } from "@/components/ui/typography"; +import useToastNotification from "@/hooks/useToastNotification"; +import { useAnalytics } from "@/providers/AnalyticsProvider"; +import { useDialog } from "@/providers/DialogProvider/hooks/useDialog"; +import { convertCancelErrorTo } from "@/providers/DialogProvider/utils"; +import { useTangentProject } from "@/routes/v2/pages/Tangent/context/TangentProjectContext"; +import type { TeamResolution } from "@/services/teams/types"; +import { useTeamForProjects } from "@/services/teams/useTeamForProjects"; +import { tracking } from "@/utils/tracking"; + +import { + SecretFormDialog, + type SecretFormDialogProps, +} from "./SecretFormDialog"; +import { SecretRow } from "./SecretRow"; + +type ActiveTeam = Extract["team"]; + +type UnavailableTeamResolution = Exclude< + TeamResolution, + { status: "none" | "resolving" | "active" } +>; + +const LoadingSecrets = () => ( + + Loading... + +); + +export function SecretsWindowContent() { + const store = useTangentProject(); + const resolution = useTeamForProjects([store.projectId]); + + return ( + + + + ); +} + +function SecretsWindowBody({ resolution }: { resolution: TeamResolution }) { + switch (resolution.status) { + case "none": + return null; + case "resolving": + return ; + case "active": + return ; + default: + return ; + } +} + +function WorkspaceSecretsUnavailable({ + resolution, +}: { + resolution: UnavailableTeamResolution; +}) { + return ( + + + + ); +} + +function UnavailableReason({ + resolution, +}: { + resolution: UnavailableTeamResolution; +}) { + switch (resolution.status) { + case "not-member": + return `You're not in the ${resolution.team.label} workspace. Ask an admin to add you.`; + case "unavailable": + return `The backend didn't let you act as the ${resolution.team.label} workspace.`; + case "error": + return `Couldn't check your access to this project's workspace: ${resolution.error.message}`; + default: + return null; + } +} + +function WorkspaceHeader({ team }: { team: ActiveTeam }) { + return ( + + + + + {`Workspace: ${team.label}`} + + + + {team.canWrite + ? "Runs in this project use these secrets. Everyone in the workspace can see and change them." + : "Runs in this project use these secrets. Your role in the workspace can't change them."} + + + ); +} + +function WorkspaceSecrets({ team }: { team: ActiveTeam }) { + const { open } = useDialog(); + const notify = useToastNotification(); + const { track } = useAnalytics(); + + const { + data: secrets, + isPending, + error, + } = useQuery(secretsListQuery(team.id)); + + const openSecretDialog = (secret?: Secret) => + void open({ + component: SecretFormDialog, + props: { secret, team }, + routeKey: secret ? "replace-secret" : "add-secret", + size: "md", + }).catch(convertCancelErrorTo(undefined)); + + const handleRemoved = () => { + track("projects.secrets.secret_mutated", { action: "deleted" }); + notify("Secret deleted", "success"); + }; + + return ( + <> + + + {isPending && } + + {error && ( + + {error.message} + + )} + + {secrets && secrets.length === 0 && ( + + No workspace secrets yet + + )} + + {secrets && secrets.length > 0 && ( + + {secrets.map((secret) => ( + + ))} + + )} + + {team.canWrite && ( + + )} + + ); +} diff --git a/src/routes/v2/pages/Tangent/components/WindowListRow.tsx b/src/routes/v2/pages/Tangent/components/WindowListRow.tsx index 7a6891e4c4..7475cfd52b 100644 --- a/src/routes/v2/pages/Tangent/components/WindowListRow.tsx +++ b/src/routes/v2/pages/Tangent/components/WindowListRow.tsx @@ -1,10 +1,12 @@ import type { ComponentProps, MouseEvent, ReactNode } from "react"; import { Button } from "@/components/ui/button"; -import { Icon, type IconName } from "@/components/ui/icon"; +import type { IconName } from "@/components/ui/icon"; import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Text } from "@/components/ui/typography"; +import { ListRowIcon } from "./ListRowIcon"; + interface WindowListRowProps { icon: IconName; title: string; @@ -42,13 +44,7 @@ export function WindowListRow({ {...rest} className="h-auto min-w-0 flex-1 items-start justify-start gap-3 px-2 py-2" > - - - + { it("leaves a saved layout that already knows every window alone", () => { const rearranged = [ "tangent-project-resources", + "tangent-project-secrets", "tangent-project-details", "tangent-project-runs", "tangent-project-sessions", @@ -85,6 +86,31 @@ describe("the Tangent project dock order", () => { expect(store.getDockedWindowOrder("left")).toEqual(rearranged); }); + it("adds the secrets window last to a rearranged layout that predates it", () => { + const rearranged = [ + "tangent-project-resources", + "tangent-project-details", + "tangent-project-runs", + "tangent-project-sessions", + "tangent-project-assets", + "tangent-project-agents", + ]; + const store = new WindowStoreImpl(); + store.enableDockSide("left"); + store.restoreDockArea("left", { + width: 320, + collapsed: false, + windowOrder: rearranged, + }); + + mountProjectWindows(store); + + expect(store.getDockedWindowOrder("left")).toEqual([ + ...rearranged, + "tangent-project-secrets", + ]); + }); + /** A window the user dragged to the right is theirs to keep there. */ it("leaves a window that is no longer docked left alone", () => { const store = new WindowStoreImpl(); diff --git a/src/routes/v2/pages/Tangent/hooks/tangentProjectWindowOrder.ts b/src/routes/v2/pages/Tangent/hooks/tangentProjectWindowOrder.ts index 1bb4e93fb0..3bb0e98c7e 100644 --- a/src/routes/v2/pages/Tangent/hooks/tangentProjectWindowOrder.ts +++ b/src/routes/v2/pages/Tangent/hooks/tangentProjectWindowOrder.ts @@ -9,6 +9,7 @@ export const PROJECT_DOCK_WINDOW_IDS = [ "tangent-project-assets", "tangent-project-resources", "tangent-project-runs", + "tangent-project-secrets", ] as const; export const rememberedDockWindows = ( diff --git a/src/routes/v2/pages/Tangent/hooks/useTangentProjectWindows.test.tsx b/src/routes/v2/pages/Tangent/hooks/useTangentProjectWindows.test.tsx new file mode 100644 index 0000000000..64992960d5 --- /dev/null +++ b/src/routes/v2/pages/Tangent/hooks/useTangentProjectWindows.test.tsx @@ -0,0 +1,133 @@ +import { renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { WindowStoreImpl } from "@/routes/v2/shared/windows/windowStore"; +import type { TeamResolution } from "@/services/teams/types"; +import { useTeamForProjects } from "@/services/teams/useTeamForProjects"; + +import { PROJECT_DOCK_WINDOW_IDS } from "./tangentProjectWindowOrder"; +import { useTangentProjectWindows } from "./useTangentProjectWindows"; + +const stores = vi.hoisted(() => ({ + windows: undefined as WindowStoreImpl | undefined, +})); + +vi.mock("@/routes/v2/shared/store/SharedStoreContext", () => ({ + useSharedStores: () => ({ windows: stores.windows }), +})); + +vi.mock("@/routes/v2/pages/Tangent/context/TangentProjectContext", () => ({ + useTangentProject: () => ({ projectId: "project-1" }), +})); + +vi.mock("@/services/teams/useTeamForProjects", () => ({ + useTeamForProjects: vi.fn(), +})); + +const SECRETS_WINDOW_ID = "tangent-project-secrets"; +const TEAM = { id: "ml-team@example.com", label: "ML Research" }; +const ACTIVE_TEAM: TeamResolution = { + status: "active", + team: { ...TEAM, canWrite: true }, +}; + +function dockedStore() { + const windows = new WindowStoreImpl(); + windows.enableDockSide("left"); + return windows; +} + +function mountWindows(resolution: TeamResolution, windows = dockedStore()) { + stores.windows = windows; + vi.mocked(useTeamForProjects).mockReturnValue(resolution); + const { rerender } = renderHook(() => useTangentProjectWindows()); + + return { + windows, + resolveTo: (next: TeamResolution) => { + vi.mocked(useTeamForProjects).mockReturnValue(next); + rerender(); + }, + }; +} + +describe("useTangentProjectWindows", () => { + afterEach(() => vi.clearAllMocks()); + + it("asks for the team of the Tangent project", () => { + mountWindows({ status: "none" }); + + expect(useTeamForProjects).toHaveBeenCalledWith(["project-1"]); + }); + + it("docks the Secrets window last in a project whose workspace has a team", () => { + const { windows } = mountWindows(ACTIVE_TEAM); + + expect(windows.getDockedWindowOrder("left")).toEqual( + PROJECT_DOCK_WINDOW_IDS, + ); + }); + + it("has no Secrets window in a project whose workspace has no team", () => { + const { windows } = mountWindows({ status: "none" }); + + expect(windows.getWindowById(SECRETS_WINDOW_ID)).toBeUndefined(); + expect(windows.getDockedWindowOrder("left")).toEqual( + PROJECT_DOCK_WINDOW_IDS.filter((id) => id !== SECRETS_WINDOW_ID), + ); + }); + + it.each([ + { status: "not-member", team: TEAM }, + { status: "unavailable", team: TEAM }, + { status: "error", error: new Error("backend down") }, + ])( + "keeps the Secrets window when you can't act as the workspace's team ($status)", + (resolution) => { + const { windows } = mountWindows(resolution); + + expect(windows.getWindowById(SECRETS_WINDOW_ID)).toBeDefined(); + }, + ); + + it("opens the Secrets window once the workspace's team is known", () => { + const { windows, resolveTo } = mountWindows({ status: "resolving" }); + + expect(windows.getWindowById(SECRETS_WINDOW_ID)).toBeUndefined(); + + resolveTo(ACTIVE_TEAM); + + expect(windows.getWindowById(SECRETS_WINDOW_ID)).toBeDefined(); + }); + + it("closes the Secrets window when the project has no team", () => { + const { windows, resolveTo } = mountWindows(ACTIVE_TEAM); + + resolveTo({ status: "none" }); + + expect(windows.getWindowById(SECRETS_WINDOW_ID)).toBeUndefined(); + }); + + it("puts a late Secrets window where the saved layout had it", () => { + const saved = [ + "tangent-project-details", + SECRETS_WINDOW_ID, + "tangent-project-sessions", + "tangent-project-agents", + "tangent-project-assets", + "tangent-project-resources", + "tangent-project-runs", + ]; + const windows = dockedStore(); + windows.restoreDockArea("left", { + width: 320, + collapsed: false, + windowOrder: saved, + }); + + const { resolveTo } = mountWindows({ status: "resolving" }, windows); + resolveTo(ACTIVE_TEAM); + + expect(windows.getDockedWindowOrder("left")).toEqual(saved); + }); +}); diff --git a/src/routes/v2/pages/Tangent/hooks/useTangentProjectWindows.tsx b/src/routes/v2/pages/Tangent/hooks/useTangentProjectWindows.tsx index cfa3e7b392..c8c7cc8770 100644 --- a/src/routes/v2/pages/Tangent/hooks/useTangentProjectWindows.tsx +++ b/src/routes/v2/pages/Tangent/hooks/useTangentProjectWindows.tsx @@ -7,9 +7,13 @@ import { AssetsWindowContent } from "@/routes/v2/pages/Tangent/components/Assets import { ProjectWindowContent } from "@/routes/v2/pages/Tangent/components/ProjectWindowContent"; import { ResourcesWindowContent } from "@/routes/v2/pages/Tangent/components/ResourcesWindowContent"; import { RunsWindowContent } from "@/routes/v2/pages/Tangent/components/RunsWindowContent"; +import { SecretsWindowContent } from "@/routes/v2/pages/Tangent/components/SecretsWindowContent"; import { SessionsWindowContent } from "@/routes/v2/pages/Tangent/components/SessionsWindowContent"; +import { useTangentProject } from "@/routes/v2/pages/Tangent/context/TangentProjectContext"; import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext"; import { WindowMiniButton } from "@/routes/v2/shared/windows/WindowMiniButton"; +import type { WindowStoreImpl } from "@/routes/v2/shared/windows/windowStore"; +import { useTeamForProjects } from "@/services/teams/useTeamForProjects"; import { placeProjectDockWindows, @@ -65,31 +69,57 @@ const PROJECT_DOCK_WINDOWS: ProjectDockWindow[] = [ }, ]; +const SECRETS_DOCK_WINDOW: ProjectDockWindow = { + id: "tangent-project-secrets", + title: "Secrets", + icon: "KeyRound", + content: , +}; + +function openProjectDockWindow( + windows: WindowStoreImpl, + win: ProjectDockWindow, +) { + windows.openWindow(win.content, { + id: win.id, + title: win.title, + persisted: true, + defaultDockState: "left", + startVisible: true, + maxDockedHeight: MAX_PROJECT_WINDOW_HEIGHT, + miniContent: ( + + ), + }); +} + export function useTangentProjectWindows() { const { windows } = useSharedStores(); + const { projectId } = useTangentProject(); + const team = useTeamForProjects([projectId]); + const hasSecretsWindow = + team.status !== "none" && team.status !== "resolving"; useEffect(() => { const remembered = rememberedDockWindows(windows); PROJECT_DOCK_WINDOWS.forEach((win) => { if (windows.getWindowById(win.id)) return; - windows.openWindow(win.content, { - id: win.id, - title: win.title, - persisted: true, - defaultDockState: "left", - startVisible: true, - maxDockedHeight: MAX_PROJECT_WINDOW_HEIGHT, - miniContent: ( - - ), - }); + openProjectDockWindow(windows, win); }); placeProjectDockWindows(windows, remembered); }, [windows]); + + useEffect(() => { + if (!hasSecretsWindow) { + windows.closeWindow(SECRETS_DOCK_WINDOW.id); + return; + } + if (windows.getWindowById(SECRETS_DOCK_WINDOW.id)) return; + + const remembered = rememberedDockWindows(windows); + openProjectDockWindow(windows, SECRETS_DOCK_WINDOW); + placeProjectDockWindows(windows, remembered); + }, [windows, hasSecretsWindow]); } From f3d06a434567e7ac42493679f0addee6e2965c10 Mon Sep 17 00:00:00 2001 From: Camiel van Schoonhoven Date: Fri, 23 Oct 2026 16:42:19 -0700 Subject: [PATCH 2/2] feat(tangent): name workspaces with their icon in the Secrets window Feedback from the first staging pass. - A team's name, which is its workspace's, now carries the workspace icon wherever it's written into copy, with the team id on hover. `TeamName` does this, and the add dialog, the delete confirmation and the window's notice use it. Its 12px icon sits 0.1em below the baseline, which centres it on the capitals of both 12px and 14px text. `bold` sets the name in semibold and the icon at stroke 3, the weight that matches. The React Compiler covers `src/components/shared/Teams`, where it lives. - Deleting a workspace secret asks 'Delete shared secret ""?' and says it's removed for the whole workspace, named in bold: "This removes the secret from the workspace. Runs that use it will fail. This can't be undone." `ConfirmationDialog` now takes any node as its description, so the name carries its icon there too. - Adding a secret is titled "Add a workspace secret". - The replace dialog shows the secret's key icon at its top left, like the secret's row. - The empty-state line under the list is gone. The add button is enough. Co-Authored-By: Claude Opus 5.5 (1M context) --- react-compiler.config.js | 1 + .../shared/Dialogs/ConfirmationDialog.tsx | 2 +- .../components/RemoveSecretButton.test.tsx | 29 ++++++++++---- .../components/RemoveSecretButton.tsx | 30 ++++++++++++-- src/components/shared/Teams/TeamName.test.tsx | 39 +++++++++++++++++++ src/components/shared/Teams/TeamName.tsx | 28 +++++++++++++ .../components/SecretFormDialog.test.tsx | 11 ++++-- .../Tangent/components/SecretFormDialog.tsx | 31 +++++++++++---- .../v2/pages/Tangent/components/SecretRow.tsx | 7 ++-- .../components/SecretsWindowContent.test.tsx | 10 ++++- .../components/SecretsWindowContent.tsx | 25 +++++++----- 11 files changed, 175 insertions(+), 38 deletions(-) create mode 100644 src/components/shared/Teams/TeamName.test.tsx create mode 100644 src/components/shared/Teams/TeamName.tsx diff --git a/react-compiler.config.js b/react-compiler.config.js index 2e22a70e16..3863459ffb 100644 --- a/react-compiler.config.js +++ b/react-compiler.config.js @@ -58,6 +58,7 @@ export const REACT_COMPILER_ENABLED_DIRS = [ "src/services/projects/useRemotePipelineNames.ts", "src/services/projects/useWorkspaceChoice.ts", "src/services/teams", + "src/components/shared/Teams", "src/services/usePipelineSpec.ts", "src/components/shared/FavoriteToggle.tsx", "src/components/shared/LocalPipelineMigration.tsx", diff --git a/src/components/shared/Dialogs/ConfirmationDialog.tsx b/src/components/shared/Dialogs/ConfirmationDialog.tsx index fe46313e1f..20b9c2368e 100644 --- a/src/components/shared/Dialogs/ConfirmationDialog.tsx +++ b/src/components/shared/Dialogs/ConfirmationDialog.tsx @@ -23,7 +23,7 @@ import { InlineStack } from "@/components/ui/layout"; type ConfirmationDialogProps = { title?: string; - description?: string; + description?: ReactNode; content?: ReactNode; confirmLabel?: string; dismissForeverKey?: string; diff --git a/src/components/shared/SecretsManagement/components/RemoveSecretButton.test.tsx b/src/components/shared/SecretsManagement/components/RemoveSecretButton.test.tsx index cd6ec05b8c..0ffaab7582 100644 --- a/src/components/shared/SecretsManagement/components/RemoveSecretButton.test.tsx +++ b/src/components/shared/SecretsManagement/components/RemoveSecretButton.test.tsx @@ -16,9 +16,9 @@ const { notify } = vi.hoisted(() => ({ notify: vi.fn() })); vi.mock("@/hooks/useToastNotification", () => ({ default: () => notify })); -const TEAM_ID = "ml-team@example.com"; +const TEAM = { id: "ml-team@example.com", label: "ML Research" }; -function renderButton(teamId?: string) { +function renderButton(team?: { id: string; label: string }) { const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } }, }); @@ -27,7 +27,7 @@ function renderButton(teamId?: string) { render( , { @@ -59,6 +59,21 @@ describe("RemoveSecretButton", () => { expect(removeSecret).not.toHaveBeenCalled(); }); + it("says a workspace secret is shared and removed from the whole workspace, named in bold", async () => { + const { user } = renderButton(TEAM); + + await user.click(screen.getByRole("button", { name: "Delete API_KEY" })); + + expect( + screen.getByRole("alertdialog", { + name: 'Delete shared secret "API_KEY"?', + }), + ).toHaveAccessibleDescription( + "This removes the secret from the ML Research workspace. Runs that use it will fail. This can't be undone.", + ); + expect(screen.getByTitle(TEAM.id)).toHaveClass("font-semibold"); + }); + it("deletes once confirmed", async () => { vi.mocked(removeSecret).mockResolvedValue(true); const { user, onSuccess } = renderButton(); @@ -72,15 +87,15 @@ describe("RemoveSecretButton", () => { it("deletes a team's secret as the team", async () => { vi.mocked(removeSecret).mockResolvedValue(true); - const { user, onSuccess, invalidateQueries } = renderButton(TEAM_ID); + const { user, onSuccess, invalidateQueries } = renderButton(TEAM); await user.click(screen.getByRole("button", { name: "Delete API_KEY" })); await user.click(screen.getByRole("button", { name: "Delete" })); await waitFor(() => expect(onSuccess).toHaveBeenCalled()); - expect(removeSecret).toHaveBeenCalledWith("API_KEY", TEAM_ID); + expect(removeSecret).toHaveBeenCalledWith("API_KEY", TEAM.id); expect(invalidateQueries).toHaveBeenCalledWith({ - queryKey: SecretsQueryKeys.All(TEAM_ID), + queryKey: SecretsQueryKeys.All(TEAM.id), }); }); @@ -91,7 +106,7 @@ describe("RemoveSecretButton", () => { 403, ), ); - const { user, onSuccess } = renderButton(TEAM_ID); + const { user, onSuccess } = renderButton(TEAM); await user.click(screen.getByRole("button", { name: "Delete API_KEY" })); await user.click(screen.getByRole("button", { name: "Delete" })); diff --git a/src/components/shared/SecretsManagement/components/RemoveSecretButton.tsx b/src/components/shared/SecretsManagement/components/RemoveSecretButton.tsx index 6a59a31e5c..3ee6f96391 100644 --- a/src/components/shared/SecretsManagement/components/RemoveSecretButton.tsx +++ b/src/components/shared/SecretsManagement/components/RemoveSecretButton.tsx @@ -1,9 +1,11 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { ConfirmationDialog } from "@/components/shared/Dialogs"; +import { TeamName } from "@/components/shared/Teams/TeamName"; import { Button } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; import useToastNotification from "@/hooks/useToastNotification"; +import type { TeamSummary } from "@/services/teams/types"; import { removeSecret } from "../secretsStorage"; import type { Secret } from "../types"; @@ -11,17 +13,37 @@ import { SecretsQueryKeys } from "../types"; interface RemoveSecretButtonProps { secret: Pick; - teamId?: string; + team?: TeamSummary; onSuccess?: () => void; } +function deletionTitle(secretName: string, team: TeamSummary | undefined) { + return team + ? `Delete shared secret "${secretName}"?` + : `Delete "${secretName}"?`; +} + +function deletionConsequence(team: TeamSummary | undefined) { + if (!team) { + return "Runs that use it will fail when they start. This can't be undone."; + } + return ( + <> + {"This removes the secret from the "} + + {" workspace. Runs that use it will fail. This can't be undone."} + + ); +} + export function RemoveSecretButton({ secret, - teamId, + team, onSuccess, }: RemoveSecretButtonProps) { const notify = useToastNotification(); const queryClient = useQueryClient(); + const teamId = team?.id; const { mutate: removeSecretMutation, isPending } = useMutation({ mutationFn: () => removeSecret(secret.id, teamId), @@ -50,8 +72,8 @@ export function RemoveSecretButton({ } - title={`Delete "${secret.name}"?`} - description="Runs that use it will fail when they start. This can't be undone." + title={deletionTitle(secret.name, team)} + description={deletionConsequence(team)} confirmLabel="Delete" onConfirm={() => removeSecretMutation()} /> diff --git a/src/components/shared/Teams/TeamName.test.tsx b/src/components/shared/Teams/TeamName.test.tsx new file mode 100644 index 0000000000..dd5e1c6d5e --- /dev/null +++ b/src/components/shared/Teams/TeamName.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { TeamName } from "./TeamName"; + +describe("TeamName", () => { + it("names the team, with its id on hover", () => { + render( + , + ); + + expect(screen.getByTitle("ml-team@example.com")).toHaveTextContent( + "ML Research", + ); + }); + + it("emboldens the name and its icon when asked", () => { + render( + , + ); + + const name = screen.getByTitle("ml-team@example.com"); + expect(name).toHaveClass("font-semibold"); + expect(name.querySelector("svg")).toHaveAttribute("stroke-width", "3"); + }); + + it("uses the icon's regular stroke by default", () => { + render( + , + ); + + const name = screen.getByTitle("ml-team@example.com"); + expect(name).not.toHaveClass("font-semibold"); + expect(name.querySelector("svg")).toHaveAttribute("stroke-width", "2"); + }); +}); diff --git a/src/components/shared/Teams/TeamName.tsx b/src/components/shared/Teams/TeamName.tsx new file mode 100644 index 0000000000..d629e38135 --- /dev/null +++ b/src/components/shared/Teams/TeamName.tsx @@ -0,0 +1,28 @@ +import { Icon } from "@/components/ui/icon"; +import { cn } from "@/lib/utils"; +import type { TeamSummary } from "@/services/teams/types"; + +const BOLD_ICON_STROKE_WIDTH = 3; + +export function TeamName({ + team, + bold = false, +}: { + team: TeamSummary; + bold?: boolean; +}) { + return ( + + + {team.label} + + ); +} diff --git a/src/routes/v2/pages/Tangent/components/SecretFormDialog.test.tsx b/src/routes/v2/pages/Tangent/components/SecretFormDialog.test.tsx index 1c7e6d7084..ca3150d3b0 100644 --- a/src/routes/v2/pages/Tangent/components/SecretFormDialog.test.tsx +++ b/src/routes/v2/pages/Tangent/components/SecretFormDialog.test.tsx @@ -64,10 +64,13 @@ describe("SecretFormDialog", () => { it("adds a team secret for everyone in the team", async () => { const { user, close } = renderDialog({ team: TEAM }); - expect(screen.getByRole("dialog", { name: "Add a secret" })).toBeVisible(); - expect( - screen.getByText("Everyone in ML Research can use it in this project."), - ).toBeVisible(); + const dialog = screen.getByRole("dialog", { + name: "Add a workspace secret", + }); + expect(dialog).toHaveAccessibleDescription( + "Everyone in ML Research can use it in this project.", + ); + expect(screen.getByTitle(TEAM.id)).toHaveTextContent("ML Research"); expect(screen.getByRole("button", { name: "Save" })).toHaveAttribute( "data-team-id", TEAM.id, diff --git a/src/routes/v2/pages/Tangent/components/SecretFormDialog.tsx b/src/routes/v2/pages/Tangent/components/SecretFormDialog.tsx index f7342d6144..12600a092c 100644 --- a/src/routes/v2/pages/Tangent/components/SecretFormDialog.tsx +++ b/src/routes/v2/pages/Tangent/components/SecretFormDialog.tsx @@ -1,23 +1,33 @@ import { AddSecretForm } from "@/components/shared/SecretsManagement/components/AddSecretForm"; import type { Secret } from "@/components/shared/SecretsManagement/types"; +import { TeamName } from "@/components/shared/Teams/TeamName"; import { DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; import useToastNotification from "@/hooks/useToastNotification"; import { useAnalytics } from "@/providers/AnalyticsProvider"; import type { DialogProps } from "@/providers/DialogProvider/types"; import type { TeamSummary } from "@/services/teams/types"; +import { ListRowIcon } from "./ListRowIcon"; + export interface SecretFormDialogProps { secret?: Secret; team: TeamSummary; } -function dialogDescription({ secret, team }: SecretFormDialogProps): string { +function DialogDescriptionText({ secret, team }: SecretFormDialogProps) { if (secret) return "Runs that start after this use the new value."; - return `Everyone in ${team.label} can use it in this project.`; + return ( + <> + {"Everyone in "} + + {" can use it in this project."} + + ); } export function SecretFormDialog({ @@ -43,12 +53,17 @@ export function SecretFormDialog({ return ( <> - - {secret ? `Replace ${secret.name}` : "Add a secret"} - - - {dialogDescription({ secret, team })} - + + {secret && } + + + {secret ? `Replace ${secret.name}` : "Add a workspace secret"} + + + + + + void; onRemoved: () => void; @@ -25,7 +26,7 @@ function secretDetail(secret: Secret): string { export function SecretRow({ secret, - teamId, + team, canWrite, onReplace, onRemoved, @@ -74,7 +75,7 @@ export function SecretRow({ diff --git a/src/routes/v2/pages/Tangent/components/SecretsWindowContent.test.tsx b/src/routes/v2/pages/Tangent/components/SecretsWindowContent.test.tsx index 529223c487..2b2dece067 100644 --- a/src/routes/v2/pages/Tangent/components/SecretsWindowContent.test.tsx +++ b/src/routes/v2/pages/Tangent/components/SecretsWindowContent.test.tsx @@ -140,10 +140,15 @@ describe("SecretsWindowContent", () => { ).toBeVisible(); }); - it("says when the team has no secrets", async () => { + it("offers only the add button when the team has no secrets", async () => { renderWindow(); - expect(await screen.findByText("No workspace secrets yet")).toBeVisible(); + await waitFor(() => expect(screen.queryByText("Loading...")).toBeNull()); + expect(screen.queryAllByTestId("secret-item")).toHaveLength(0); + expect(screen.queryByText(/No workspace secrets/)).toBeNull(); + expect( + screen.getByRole("button", { name: "Add a secret" }), + ).toBeVisible(); }); }); @@ -194,6 +199,7 @@ describe("SecretsWindowContent", () => { expect(screen.getByTestId("info-box-warning")).toHaveTextContent( "You're not in the ML Research workspace. Ask an admin to add you.", ); + expect(screen.getByTitle(TEAM.id)).toHaveTextContent("ML Research"); }); it("says when the backend refuses to act as the workspace", () => { diff --git a/src/routes/v2/pages/Tangent/components/SecretsWindowContent.tsx b/src/routes/v2/pages/Tangent/components/SecretsWindowContent.tsx index d7179d03c2..9e9ff7cdef 100644 --- a/src/routes/v2/pages/Tangent/components/SecretsWindowContent.tsx +++ b/src/routes/v2/pages/Tangent/components/SecretsWindowContent.tsx @@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { InfoBox } from "@/components/shared/InfoBox"; import { secretsListQuery } from "@/components/shared/SecretsManagement/secretsStorage"; import type { Secret } from "@/components/shared/SecretsManagement/types"; +import { TeamName } from "@/components/shared/Teams/TeamName"; import { Button } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; import { BlockStack, InlineStack } from "@/components/ui/layout"; @@ -83,9 +84,21 @@ function UnavailableReason({ }) { switch (resolution.status) { case "not-member": - return `You're not in the ${resolution.team.label} workspace. Ask an admin to add you.`; + return ( + <> + {"You're not in the "} + + {" workspace. Ask an admin to add you."} + + ); case "unavailable": - return `The backend didn't let you act as the ${resolution.team.label} workspace.`; + return ( + <> + {"The backend didn't let you act as the "} + + {" workspace."} + + ); case "error": return `Couldn't check your access to this project's workspace: ${resolution.error.message}`; default: @@ -156,19 +169,13 @@ function WorkspaceSecrets({ team }: { team: ActiveTeam }) { )} - {secrets && secrets.length === 0 && ( - - No workspace secrets yet - - )} - {secrets && secrets.length > 0 && ( {secrets.map((secret) => (