diff --git a/react-compiler.config.js b/react-compiler.config.js index d63b8f8d0..d609aba16 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/InfoBox.tsx b/src/components/shared/InfoBox.tsx index 6ad2a906f..6f9cf4b04 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/SecretsManagement/components/AddSecretButton.tsx b/src/components/shared/SecretsManagement/components/AddSecretButton.tsx index da7597f29..276c05d1e 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 f243f37e0..5f4df6507 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 3019c11e8..d555451bf 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 bb781a815..65e32e68f 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 771a260d6..b3dc55ff2 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 2e16b802f..4facea2b3 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 000000000..69b73a0e4 --- /dev/null +++ b/src/components/shared/Teams/ActingTeamNotice.test.tsx @@ -0,0 +1,81 @@ +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.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(); + expect(screen.getByTestId("info-box-warning")).toBeInTheDocument(); + }); + + it("says when the backend refuses to act as the team", () => { + renderNotice({ status: "unavailable", team }); + + 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(); + }); + + 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.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(); + }); + + 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 000000000..d54eb49a4 --- /dev/null +++ b/src/components/shared/Teams/ActingTeamNotice.tsx @@ -0,0 +1,92 @@ +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, 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."; + +export function ActingTeamNotice({ + resolution, +}: { + resolution: TeamResolution; +}) { + switch (resolution.status) { + case "not-member": + return ( + + {"You're not in the "} + + {" team"} + + } + variant="warning" + width="full" + > + {`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 ( + + {"Can't act as the "} + + {" team"} + + } + variant="warning" + width="full" + > + {`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 "} + + {". 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 000000000..b7b1ce411 --- /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 000000000..506c776cb --- /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/ListRowIcon.tsx b/src/routes/v2/pages/Tangent/components/ListRowIcon.tsx new file mode 100644 index 000000000..fc9e336f7 --- /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 000000000..9674f052c --- /dev/null +++ b/src/routes/v2/pages/Tangent/components/SecretFormDialog.test.tsx @@ -0,0 +1,115 @@ +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 }); + + 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, + ); + + 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 000000000..821bf4d12 --- /dev/null +++ b/src/routes/v2/pages/Tangent/components/SecretFormDialog.tsx @@ -0,0 +1,83 @@ +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 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 "} + + {" 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 && } + + {dialogTitle({ 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 000000000..377149eb4 --- /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 000000000..6ecbe86b7 --- /dev/null +++ b/src/routes/v2/pages/Tangent/components/SecretsWindowContent.test.tsx @@ -0,0 +1,251 @@ +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("offers to add the first secret when the team has none", async () => { + renderWindow(); + + expect( + await screen.findByRole("button", { name: "Add a secret" }), + ).toBeVisible(); + expect(screen.queryByText(/No team secrets/)).toBeNull(); + }); + }); + + 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.getByTestId("info-box-title")).toHaveTextContent( + "You're not in the ML Research team", + ); + 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 000000000..10fbce24c --- /dev/null +++ b/src/routes/v2/pages/Tangent/components/SecretsWindowContent.tsx @@ -0,0 +1,178 @@ +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 && ( + + {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 7a6891e4c..7475cfd52 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 1bb4e93fb..3bb0e98c7 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 cfa3e7b39..0bc7e7138 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() {