From 478fc6dd225a2a3e1a0c6fc8e93e14afb60d2d89 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. It lists the secrets that runs submitted in the project use: the team's, when the project's workspace maps to a team you can act as, and your own otherwise. - In a team's project the window is headed "Team: " 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/`. - Everywhere else it is headed "My secrets". - 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 so and lists your own secrets, because that is who your runs there will be submitted as. `ActingTeamNotice` holds that copy so the submit dialog can say the same thing later. - Projects on different teams list nothing and say why; submitting is blocked in that case. 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) --- react-compiler.config.js | 1 + .../components/AddSecretButton.tsx | 12 +- .../components/AddSecretForm.test.tsx | 94 +++++-- .../components/AddSecretForm.tsx | 4 + .../components/RemoveSecretButton.test.tsx | 25 +- .../components/RemoveSecretButton.tsx | 12 +- .../components/UpdateSecretButton.tsx | 12 +- .../shared/Teams/ActingTeamNotice.test.tsx | 73 ++++++ .../shared/Teams/ActingTeamNotice.tsx | 65 +++++ .../pages/Tangent/components/ListRowIcon.tsx | 14 + .../components/SecretFormDialog.test.tsx | 114 ++++++++ .../Tangent/components/SecretFormDialog.tsx | 63 +++++ .../v2/pages/Tangent/components/SecretRow.tsx | 84 ++++++ .../components/SecretsWindowContent.test.tsx | 248 ++++++++++++++++++ .../components/SecretsWindowContent.tsx | 184 +++++++++++++ .../Tangent/components/WindowListRow.tsx | 12 +- .../hooks/tangentProjectWindowOrder.test.ts | 28 +- .../hooks/tangentProjectWindowOrder.ts | 1 + .../hooks/useTangentProjectWindows.tsx | 7 + 19 files changed, 1011 insertions(+), 42 deletions(-) create mode 100644 src/components/shared/Teams/ActingTeamNotice.test.tsx create mode 100644 src/components/shared/Teams/ActingTeamNotice.tsx 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 diff --git a/react-compiler.config.js b/react-compiler.config.js index d63b8f8d00..d609aba161 100644 --- a/react-compiler.config.js +++ b/react-compiler.config.js @@ -57,6 +57,7 @@ export const REACT_COMPILER_ENABLED_DIRS = [ "src/services/projects/useProjectRuns.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/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 f243f37e03..5f4df65074 100644 --- a/src/components/shared/SecretsManagement/components/AddSecretForm.test.tsx +++ b/src/components/shared/SecretsManagement/components/AddSecretForm.test.tsx @@ -5,7 +5,7 @@ import type { ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { addSecret, updateSecret } from "../secretsStorage"; -import type { Secret } from "../types"; +import { type Secret, SecretsQueryKeys } from "../types"; import { AddSecretForm } from "./AddSecretForm"; vi.mock("../secretsStorage", () => ({ @@ -22,6 +22,8 @@ class ResizeObserverMock { } vi.stubGlobal("ResizeObserver", ResizeObserverMock); +const TEAM_ID = "ml-team@example.com"; + const existing: Secret = { id: "API_KEY", name: "API_KEY", @@ -30,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( , @@ -49,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); @@ -74,11 +81,30 @@ 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), }); }); @@ -92,6 +118,7 @@ describe("AddSecretForm", () => { await waitFor(() => expect(addSecret).toHaveBeenCalledWith( expect.objectContaining({ description: undefined }), + undefined, ), ); }); @@ -143,6 +170,7 @@ describe("AddSecretForm", () => { ), ), }), + undefined, ), ); }); @@ -156,7 +184,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"); @@ -166,22 +194,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, + 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", + 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)")); @@ -192,12 +244,18 @@ describe("AddSecretForm", () => { expect(updateSecret).toHaveBeenCalledWith( "API_KEY", expect.objectContaining({ description: "Agent model access" }), + undefined, ), ); }); it("names the current expiry and keeps it when left empty", () => { - renderForm({ ...existing, expiresAt: new Date("2027-03-03T23:59:59Z") }); + renderForm({ + existingSecret: { + ...existing, + expiresAt: new Date("2027-03-03T23:59:59Z"), + }, + }); expect(field("Expires (optional)")).toHaveAccessibleDescription( "Currently Mar 3, 2027. Leave empty to keep that date.", @@ -205,7 +263,7 @@ describe("AddSecretForm", () => { }); 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 3019c11e89..d555451bfa 100644 --- a/src/components/shared/SecretsManagement/components/AddSecretForm.tsx +++ b/src/components/shared/SecretsManagement/components/AddSecretForm.tsx @@ -85,12 +85,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) { @@ -233,6 +235,7 @@ export function AddSecretForm({ description, expiresAt, }} + teamId={teamId} disabled={!isFormValid} onSuccess={onSuccess} /> @@ -244,6 +247,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 bb781a8153..65e32e68fa 100644 --- a/src/components/shared/SecretsManagement/components/RemoveSecretButton.test.tsx +++ b/src/components/shared/SecretsManagement/components/RemoveSecretButton.test.tsx @@ -5,20 +5,25 @@ import type { ReactNode } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; 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() })); -function renderButton() { +const TEAM_ID = "ml-team@example.com"; + +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 +34,7 @@ function renderButton() { ), }, ); - return { user: userEvent.setup(), onSuccess }; + return { user: userEvent.setup(), onSuccess, invalidateQueries }; } describe("RemoveSecretButton", () => { @@ -58,7 +63,21 @@ describe("RemoveSecretButton", () => { await user.click(screen.getByRole("button", { name: "Continue" })); 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: "Continue" })); + + await waitFor(() => expect(onSuccess).toHaveBeenCalled()); + expect(removeSecret).toHaveBeenCalledWith("API_KEY", TEAM_ID); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: SecretsQueryKeys.All(TEAM_ID), + }); }); 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 771a260d63..b3dc55ff24 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/components/shared/Teams/ActingTeamNotice.test.tsx b/src/components/shared/Teams/ActingTeamNotice.test.tsx new file mode 100644 index 0000000000..6184c2881f --- /dev/null +++ b/src/components/shared/Teams/ActingTeamNotice.test.tsx @@ -0,0 +1,73 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import type { TeamResolution } from "@/services/teams/types"; + +import { ActingTeamNotice } from "./ActingTeamNotice"; + +const team = { id: "ml-team@example.com", label: "ML Research" }; + +function renderNotice(resolution: TeamResolution) { + return render(); +} + +describe("ActingTeamNotice", () => { + it("tells a non-member their runs fall back to them", () => { + renderNotice({ status: "not-member", team }); + + expect( + screen.getByText("You're not in the ML Research team"), + ).toBeVisible(); + expect( + screen.getByText(/run as you instead, with your own secrets/), + ).toBeVisible(); + expect(screen.getByTestId("info-box-warning")).toBeInTheDocument(); + }); + + it("says when the backend refuses to act as the team", () => { + renderNotice({ status: "unavailable", team }); + + expect(screen.getByText("Can't act as the ML Research team")).toBeVisible(); + expect( + screen.getByText(/run as you instead, with your own secrets/), + ).toBeVisible(); + }); + + it("gives the reason a team couldn't be checked", () => { + renderNotice({ + status: "error", + error: new Error("Failed to load workspace ws-1"), + }); + + expect( + screen.getByText("Couldn't check this project's team"), + ).toBeVisible(); + expect(screen.getByText("Failed to load workspace ws-1")).toBeVisible(); + expect( + screen.getByText(/Until it's checked, runs you submit/), + ).toBeVisible(); + }); + + it("names the teams of projects that disagree", () => { + renderNotice({ + status: "conflict", + teams: [team, { id: "data-team@example.com", label: "Data" }], + }); + + expect( + screen.getByText("These projects belong to different teams"), + ).toBeVisible(); + expect(screen.getByText(/use ML Research and Data/)).toBeVisible(); + expect(screen.getByTestId("info-box-error")).toBeInTheDocument(); + }); + + it.each([ + { status: "none" }, + { status: "resolving" }, + { status: "active", team: { ...team, canWrite: true } }, + ])("says nothing when the runs act as expected: $status", (resolution) => { + const { container } = renderNotice(resolution); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/src/components/shared/Teams/ActingTeamNotice.tsx b/src/components/shared/Teams/ActingTeamNotice.tsx new file mode 100644 index 0000000000..e758ae429d --- /dev/null +++ b/src/components/shared/Teams/ActingTeamNotice.tsx @@ -0,0 +1,65 @@ +import { InfoBox } from "@/components/shared/InfoBox"; +import { BlockStack } from "@/components/ui/layout"; +import { Text } from "@/components/ui/typography"; +import type { TeamResolution } from "@/services/teams/types"; + +const RUNS_FALL_BACK_TO_YOU = + "Runs you submit for this project run as you instead, with your own secrets."; + +export function ActingTeamNotice({ + resolution, +}: { + resolution: TeamResolution; +}) { + switch (resolution.status) { + case "not-member": + return ( + + {`This project's runs use the team's secrets. ${RUNS_FALL_BACK_TO_YOU} Ask an admin to add you to the team.`} + + ); + case "unavailable": + return ( + + {`The backend didn't let you act as this project's team. ${RUNS_FALL_BACK_TO_YOU}`} + + ); + case "error": + return ( + + + {resolution.error.message} + + { + "Until it's checked, runs you submit for this project run as you, with your own secrets." + } + + + + ); + case "conflict": + return ( + + {`A run can only act as one team, and these projects use ${resolution.teams.map((team) => team.label).join(" and ")}. File the run under projects on one team.`} + + ); + default: + return null; + } +} 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..1317f9ffdb --- /dev/null +++ b/src/routes/v2/pages/Tangent/components/SecretFormDialog.test.tsx @@ -0,0 +1,114 @@ +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", + owner: "team", + }); + }); + + it("adds a secret of your own without a team", () => { + renderDialog({}); + + expect(screen.getByText("Only runs you submit can use it.")).toBeVisible(); + expect(screen.getByRole("button", { name: "Save" })).toHaveAttribute( + "data-team-id", + "", + ); + }); + + 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", + owner: "team", + }); + }); +}); 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..e8e007f9c0 --- /dev/null +++ b/src/routes/v2/pages/Tangent/components/SecretFormDialog.tsx @@ -0,0 +1,63 @@ +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."; + if (team) return `Everyone in ${team.label} can use it in this project.`; + return "Only runs you submit can use it."; +} + +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", + owner: team ? "team" : "user", + }); + 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..377149eb44 --- /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..be9f8114e8 --- /dev/null +++ b/src/routes/v2/pages/Tangent/components/SecretsWindowContent.test.tsx @@ -0,0 +1,248 @@ +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 { useBackend } from "@/providers/BackendProvider"; +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("@/providers/BackendProvider", () => ({ useBackend: vi.fn() })); + +vi.mock("@/components/shared/SecretsManagement/secretsStorage", () => ({ + secretsListQuery: (teamId?: string) => ({ + queryKey: ["secrets", teamId ?? "user"], + 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(() => { + vi.mocked(useBackend).mockReturnValue({ + available: true, + } as ReturnType); + fetchSecrets.mockResolvedValue([]); + open.mockResolvedValue(undefined); + givenTeam({ status: "none" }); + }); + + afterEach(() => vi.clearAllMocks()); + + it("asks for the team of the Tangent project", () => { + renderWindow(); + + expect(useTeamForProjects).toHaveBeenCalledWith(["project-1"]); + }); + + describe("in a team's project", () => { + beforeEach(() => { + givenTeam({ status: "active", team: { ...TEAM, canWrite: true } }); + }); + + 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("Team: 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 team 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 team 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(); + }); + + it("lists your own secrets in a project without a team", async () => { + fetchSecrets.mockResolvedValue([secret("MY_TOKEN")]); + + renderWindow(); + + expect(await screen.findByText("MY_TOKEN")).toBeVisible(); + expect(screen.getByText("My secrets")).toBeVisible(); + expect( + screen.getByText("Runs you submit in this project use your own secrets."), + ).toBeVisible(); + expect(fetchSecrets).toHaveBeenCalledWith(undefined); + }); + + it("explains a fallback to your own secrets when you're not in the team", async () => { + givenTeam({ status: "not-member", team: TEAM }); + fetchSecrets.mockResolvedValue([secret("MY_TOKEN")]); + + renderWindow(); + + expect(await screen.findByText("MY_TOKEN")).toBeVisible(); + expect( + screen.getByText("You're not in the ML Research team"), + ).toBeVisible(); + expect(screen.getByText("My secrets")).toBeVisible(); + expect(fetchSecrets).toHaveBeenCalledWith(undefined); + }); + + it("lists nothing for projects on different teams", () => { + givenTeam({ + status: "conflict", + teams: [TEAM, { id: "data-team@example.com", label: "Data" }], + }); + + renderWindow(); + + expect( + screen.getByText("These projects belong to different teams"), + ).toBeVisible(); + expect(screen.queryByRole("button", { name: "Add a secret" })).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(), + ); + }); + + it("asks for a backend when none is connected", () => { + vi.mocked(useBackend).mockReturnValue({ + available: false, + } as ReturnType); + + renderWindow(); + + expect(screen.getByTestId("secrets-backend-unavailable")).toBeVisible(); + expect(fetchSecrets).not.toHaveBeenCalled(); + }); +}); 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..ab7ed84ae5 --- /dev/null +++ b/src/routes/v2/pages/Tangent/components/SecretsWindowContent.tsx @@ -0,0 +1,184 @@ +import { useQuery } from "@tanstack/react-query"; + +import { InfoBox } from "@/components/shared/InfoBox"; +import { SecretsBackendUnavailable } from "@/components/shared/SecretsManagement/components/SecretsBackendUnavailable"; +import { secretsListQuery } from "@/components/shared/SecretsManagement/secretsStorage"; +import type { Secret } from "@/components/shared/SecretsManagement/types"; +import { ActingTeamNotice } from "@/components/shared/Teams/ActingTeamNotice"; +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 { useBackend } from "@/providers/BackendProvider"; +import { useDialog } from "@/providers/DialogProvider/hooks/useDialog"; +import { convertCancelErrorTo } from "@/providers/DialogProvider/utils"; +import { useTangentProject } from "@/routes/v2/pages/Tangent/context/TangentProjectContext"; +import { actingTeamId } from "@/services/teams/teamResolution"; +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 SettledTeamResolution = Exclude; + +const LoadingSecrets = () => ( + + Loading... + +); + +export function SecretsWindowContent() { + const store = useTangentProject(); + const { available } = useBackend(); + const resolution = useTeamForProjects([store.projectId]); + + return ( + + {!available && } + {available && resolution.status === "resolving" && } + {available && resolution.status !== "resolving" && ( + + )} + + ); +} + +function SecretsOwner({ resolution }: { resolution: SettledTeamResolution }) { + if (resolution.status === "active") { + return ( + + + + + {`Team: ${resolution.team.label}`} + + + + {resolution.team.canWrite + ? "Runs in this project use these secrets. Everyone in the team can see and change them." + : "Runs in this project use these secrets. Your role in the team can't change them."} + + + ); + } + + return ( + + + + + My secrets + + + {resolution.status === "none" && ( + + Runs you submit in this project use your own secrets. + + )} + + ); +} + +function ProjectSecrets({ resolution }: { resolution: SettledTeamResolution }) { + const { open } = useDialog(); + const notify = useToastNotification(); + const { track } = useAnalytics(); + + const teamId = actingTeamId(resolution); + const team = resolution.status === "active" ? resolution.team : undefined; + const canWrite = team?.canWrite ?? true; + const blocked = resolution.status === "conflict"; + + const { + data: secrets, + isPending, + error, + } = useQuery({ ...secretsListQuery(teamId), enabled: !blocked }); + + 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", + owner: team ? "team" : "user", + }); + notify("Secret deleted", "success"); + }; + + if (blocked) return ; + + return ( + <> + + + + {isPending && } + + {error && ( + + {error.message} + + )} + + {secrets && secrets.length === 0 && ( + + {team ? "No team secrets yet" : "No secrets yet"} + + )} + + {secrets && secrets.length > 0 && ( + + {secrets.map((secret) => ( + + ))} + + )} + + {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.tsx b/src/routes/v2/pages/Tangent/hooks/useTangentProjectWindows.tsx index cfa3e7b392..0bc7e7138b 100644 --- a/src/routes/v2/pages/Tangent/hooks/useTangentProjectWindows.tsx +++ b/src/routes/v2/pages/Tangent/hooks/useTangentProjectWindows.tsx @@ -7,6 +7,7 @@ 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 { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext"; import { WindowMiniButton } from "@/routes/v2/shared/windows/WindowMiniButton"; @@ -63,6 +64,12 @@ const PROJECT_DOCK_WINDOWS: ProjectDockWindow[] = [ icon: "ListChecks", content: , }, + { + id: "tangent-project-secrets", + title: "Secrets", + icon: "KeyRound", + content: , + }, ]; export function useTangentProjectWindows() { From 49949ebdd3d5ea017e88f826f90e07c62f227c29 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 teams with their icon in the Secrets window Feedback from the first staging pass. - A team's name now carries the team icon wherever it's written into copy, with the team id on hover. `TeamName` does this, and the add dialog and the team notices use it. `InfoBox` titles accept rich content so the notices can include it. - Adding to a team is titled "Add a team 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) --- src/components/shared/InfoBox.tsx | 2 +- .../shared/Teams/ActingTeamNotice.test.tsx | 18 +++++++--- .../shared/Teams/ActingTeamNotice.tsx | 35 +++++++++++++++--- src/components/shared/Teams/TeamName.test.tsx | 16 +++++++++ src/components/shared/Teams/TeamName.tsx | 15 ++++++++ .../components/SecretFormDialog.test.tsx | 9 ++--- .../Tangent/components/SecretFormDialog.tsx | 36 ++++++++++++++----- .../components/SecretsWindowContent.test.tsx | 13 ++++--- .../components/SecretsWindowContent.tsx | 6 ---- 9 files changed, 117 insertions(+), 33 deletions(-) create mode 100644 src/components/shared/Teams/TeamName.test.tsx create mode 100644 src/components/shared/Teams/TeamName.tsx diff --git a/src/components/shared/InfoBox.tsx b/src/components/shared/InfoBox.tsx index 6ad2a906f5..6f9cf4b04d 100644 --- a/src/components/shared/InfoBox.tsx +++ b/src/components/shared/InfoBox.tsx @@ -7,7 +7,7 @@ import { Text } from "@/components/ui/typography"; import { cn } from "@/lib/utils"; interface InfoBoxProps { - title: string; + title: ReactNode; width?: "full" | "fit" | "auto"; className?: string; children: ReactNode; diff --git a/src/components/shared/Teams/ActingTeamNotice.test.tsx b/src/components/shared/Teams/ActingTeamNotice.test.tsx index 6184c2881f..69b73a0e48 100644 --- a/src/components/shared/Teams/ActingTeamNotice.test.tsx +++ b/src/components/shared/Teams/ActingTeamNotice.test.tsx @@ -15,9 +15,10 @@ describe("ActingTeamNotice", () => { it("tells a non-member their runs fall back to them", () => { renderNotice({ status: "not-member", team }); - expect( - screen.getByText("You're not in the ML Research team"), - ).toBeVisible(); + expect(screen.getByTestId("info-box-title")).toHaveTextContent( + "You're not in the ML Research team", + ); + expect(screen.getByTitle(team.id)).toHaveTextContent("ML Research"); expect( screen.getByText(/run as you instead, with your own secrets/), ).toBeVisible(); @@ -27,7 +28,9 @@ describe("ActingTeamNotice", () => { it("says when the backend refuses to act as the team", () => { renderNotice({ status: "unavailable", team }); - expect(screen.getByText("Can't act as the ML Research team")).toBeVisible(); + expect(screen.getByTestId("info-box-title")).toHaveTextContent( + "Can't act as the ML Research team", + ); expect( screen.getByText(/run as you instead, with your own secrets/), ).toBeVisible(); @@ -57,7 +60,12 @@ describe("ActingTeamNotice", () => { expect( screen.getByText("These projects belong to different teams"), ).toBeVisible(); - expect(screen.getByText(/use ML Research and Data/)).toBeVisible(); + expect(screen.getByTestId("info-box-error")).toHaveTextContent( + "these projects use ML Research and Data.", + ); + expect(screen.getByTitle("data-team@example.com")).toHaveTextContent( + "Data", + ); expect(screen.getByTestId("info-box-error")).toBeInTheDocument(); }); diff --git a/src/components/shared/Teams/ActingTeamNotice.tsx b/src/components/shared/Teams/ActingTeamNotice.tsx index e758ae429d..d54eb49a41 100644 --- a/src/components/shared/Teams/ActingTeamNotice.tsx +++ b/src/components/shared/Teams/ActingTeamNotice.tsx @@ -1,7 +1,11 @@ +import { Fragment } from "react"; + import { InfoBox } from "@/components/shared/InfoBox"; import { BlockStack } from "@/components/ui/layout"; import { Text } from "@/components/ui/typography"; -import type { TeamResolution } from "@/services/teams/types"; +import type { TeamResolution, TeamSummary } from "@/services/teams/types"; + +import { TeamName } from "./TeamName"; const RUNS_FALL_BACK_TO_YOU = "Runs you submit for this project run as you instead, with your own secrets."; @@ -15,7 +19,13 @@ export function ActingTeamNotice({ case "not-member": return ( + {"You're not in the "} + + {" team"} + + } variant="warning" width="full" > @@ -25,7 +35,13 @@ export function ActingTeamNotice({ case "unavailable": return ( + {"Can't act as the "} + + {" team"} + + } variant="warning" width="full" > @@ -56,10 +72,21 @@ export function ActingTeamNotice({ variant="error" width="full" > - {`A run can only act as one team, and these projects use ${resolution.teams.map((team) => team.label).join(" and ")}. File the run under projects on one team.`} + {"A run can only act as one team, and these projects use "} + + {". File the run under projects on one team."} ); default: return null; } } + +function TeamNames({ teams }: { teams: TeamSummary[] }) { + return teams.map((team, index) => ( + + {index > 0 && " and "} + + + )); +} diff --git a/src/components/shared/Teams/TeamName.test.tsx b/src/components/shared/Teams/TeamName.test.tsx new file mode 100644 index 0000000000..b7b1ce411e --- /dev/null +++ b/src/components/shared/Teams/TeamName.test.tsx @@ -0,0 +1,16 @@ +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", + ); + }); +}); diff --git a/src/components/shared/Teams/TeamName.tsx b/src/components/shared/Teams/TeamName.tsx new file mode 100644 index 0000000000..506c776cb8 --- /dev/null +++ b/src/components/shared/Teams/TeamName.tsx @@ -0,0 +1,15 @@ +import { Icon } from "@/components/ui/icon"; +import type { TeamSummary } from "@/services/teams/types"; + +export function TeamName({ team }: { team: TeamSummary }) { + 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 1317f9ffdb..9674f052c9 100644 --- a/src/routes/v2/pages/Tangent/components/SecretFormDialog.test.tsx +++ b/src/routes/v2/pages/Tangent/components/SecretFormDialog.test.tsx @@ -64,10 +64,11 @@ 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 team 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 e8e007f9c0..821bf4d120 100644 --- a/src/routes/v2/pages/Tangent/components/SecretFormDialog.tsx +++ b/src/routes/v2/pages/Tangent/components/SecretFormDialog.tsx @@ -1,23 +1,40 @@ 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 dialogTitle({ secret, team }: SecretFormDialogProps): string { + if (secret) return `Replace ${secret.name}`; + return team ? "Add a team secret" : "Add a secret"; +} + +function DialogDescriptionText({ secret, team }: SecretFormDialogProps) { if (secret) return "Runs that start after this use the new value."; - if (team) return `Everyone in ${team.label} can use it in this project.`; + if (team) { + return ( + <> + {"Everyone in "} + + {" can use it in this project."} + + ); + } return "Only runs you submit can use it."; } @@ -45,12 +62,15 @@ export function SecretFormDialog({ return ( <> - - {secret ? `Replace ${secret.name}` : "Add a secret"} - - - {dialogDescription({ secret, team })} - + + {secret && } + + {dialogTitle({ secret, team })} + + + + + { ).toBeVisible(); }); - it("says when the team has no secrets", async () => { + it("offers to add the first secret when the team has none", async () => { renderWindow(); - expect(await screen.findByText("No team secrets yet")).toBeVisible(); + expect( + await screen.findByRole("button", { name: "Add a secret" }), + ).toBeVisible(); + expect(screen.queryByText(/No team secrets/)).toBeNull(); }); }); @@ -190,9 +193,9 @@ describe("SecretsWindowContent", () => { renderWindow(); expect(await screen.findByText("MY_TOKEN")).toBeVisible(); - expect( - screen.getByText("You're not in the ML Research team"), - ).toBeVisible(); + expect(screen.getByTestId("info-box-title")).toHaveTextContent( + "You're not in the ML Research team", + ); expect(screen.getByText("My secrets")).toBeVisible(); expect(fetchSecrets).toHaveBeenCalledWith(undefined); }); diff --git a/src/routes/v2/pages/Tangent/components/SecretsWindowContent.tsx b/src/routes/v2/pages/Tangent/components/SecretsWindowContent.tsx index ab7ed84ae5..10fbce24c1 100644 --- a/src/routes/v2/pages/Tangent/components/SecretsWindowContent.tsx +++ b/src/routes/v2/pages/Tangent/components/SecretsWindowContent.tsx @@ -147,12 +147,6 @@ function ProjectSecrets({ resolution }: { resolution: SettledTeamResolution }) { )} - {secrets && secrets.length === 0 && ( - - {team ? "No team secrets yet" : "No secrets yet"} - - )} - {secrets && secrets.length > 0 && ( {secrets.map((secret) => (