diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx index fe089dcfc0..edf1aabc5e 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx @@ -776,14 +776,15 @@ export const ModelDeleteConfirmation: Story = { const deleteButton = await body.findByRole("button", { name: "Delete" }); await expect(deleteButton).toBeInTheDocument(); - // Click Delete to show the inline confirmation. + // Click Delete to show the confirmation dialog. await userEvent.click(deleteButton); - // The confirmation strip should appear — leave it visible + // The confirmation dialog should appear — leave it visible // so the Chromatic snapshot captures this state. await expect( - await body.findByText(/Are you sure\? This action is irreversible/i), + await body.findByText(/Are you sure you want to delete this model/i), ).toBeInTheDocument(); + await expect(body.getByRole("dialog")).toBeInTheDocument(); await expect( body.getByRole("button", { name: "Delete model" }), ).toBeInTheDocument(); @@ -820,19 +821,16 @@ export const ModelDeleteCancelled: Story = { play: async ({ canvasElement }) => { const body = within(canvasElement.ownerDocument.body); - // Navigate to edit form, trigger confirmation, then cancel. + // Navigate to edit form, open delete dialog, then cancel. await userEvent.click(await body.findByText("GPT-4o")); await userEvent.click(await body.findByRole("button", { name: "Delete" })); await body.findByText(/Are you sure/i); await userEvent.click(body.getByRole("button", { name: "Cancel" })); - // Normal footer should be restored. - await expect( - await body.findByRole("button", { name: "Delete" }), - ).toBeInTheDocument(); - await expect( - await body.findByRole("button", { name: "Save" }), - ).toBeInTheDocument(); + // The dialog should be closed. + await waitFor(() => { + expect(body.queryByRole("dialog")).not.toBeInTheDocument(); + }); }, }; @@ -863,7 +861,7 @@ export const ModelDeleteConfirmed: Story = { play: async ({ canvasElement }) => { const body = within(canvasElement.ownerDocument.body); - // Navigate to edit form, trigger delete confirmation, then confirm. + // Navigate to edit form, open delete dialog, then confirm. await userEvent.click(await body.findByText("GPT-4o")); await userEvent.click(await body.findByRole("button", { name: "Delete" })); await userEvent.click( @@ -903,15 +901,16 @@ export const ProviderDeleteConfirmation: Story = { // Navigate to the provider detail view. await userEvent.click(await body.findByRole("button", { name: /OpenAI/i })); - // Click Delete to show the inline confirmation. + // Click Delete to show the confirmation dialog. const deleteButton = await body.findByRole("button", { name: "Delete" }); await userEvent.click(deleteButton); - // The confirmation strip should appear — leave it visible + // The confirmation dialog should appear — leave it visible // so the Chromatic snapshot captures this state. await expect( - await body.findByText(/Are you sure\? This action is irreversible/i), + await body.findByText(/Are you sure you want to delete this provider/i), ).toBeInTheDocument(); + await expect(body.getByRole("dialog")).toBeInTheDocument(); await expect( body.getByRole("button", { name: "Delete provider" }), ).toBeInTheDocument(); @@ -941,19 +940,16 @@ export const ProviderDeleteCancelled: Story = { play: async ({ canvasElement }) => { const body = within(canvasElement.ownerDocument.body); - // Navigate to provider detail, trigger confirmation, then cancel. + // Navigate to provider detail, open delete dialog, then cancel. await userEvent.click(await body.findByRole("button", { name: /OpenAI/i })); await userEvent.click(await body.findByRole("button", { name: "Delete" })); await body.findByText(/Are you sure/i); await userEvent.click(body.getByRole("button", { name: "Cancel" })); - // Normal footer should be restored. - await expect( - await body.findByRole("button", { name: "Delete" }), - ).toBeInTheDocument(); - await expect( - await body.findByRole("button", { name: "Save changes" }), - ).toBeInTheDocument(); + // The dialog should be closed. + await waitFor(() => { + expect(body.queryByRole("dialog")).not.toBeInTheDocument(); + }); }, }; @@ -977,7 +973,7 @@ export const ProviderDeleteConfirmed: Story = { play: async ({ canvasElement }) => { const body = within(canvasElement.ownerDocument.body); - // Navigate to provider detail, trigger delete, then confirm. + // Navigate to provider detail, open delete dialog, then confirm. await userEvent.click(await body.findByRole("button", { name: /OpenAI/i })); await userEvent.click(await body.findByRole("button", { name: "Delete" })); await userEvent.click( diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelForm.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelForm.tsx index 1ec52feef8..3dd45e9e36 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelForm.tsx +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelForm.tsx @@ -8,6 +8,14 @@ import { type FC, useState } from "react"; import * as Yup from "yup"; import type * as TypesGen from "#/api/typesGenerated"; import { Button } from "#/components/Button/Button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "#/components/Dialog/Dialog"; import { Input } from "#/components/Input/Input"; import { Label } from "#/components/Label/Label"; import { @@ -307,7 +315,6 @@ export const ModelForm: FC = ({ Back - {/* Header — editable display name */}
{selectedProviderState && ( @@ -352,7 +359,6 @@ export const ModelForm: FC = ({ )}

- {/* Form body */}
@@ -539,68 +545,72 @@ export const ModelForm: FC = ({ {/* Footer — pushed to bottom */}

- {confirmingDelete && onDeleteModel && editingModel ? ( -
-

- Are you sure? This action is irreversible. -

-
- - -
-
- ) : ( -
- {isEditing && editingModel && onDeleteModel ? ( - - ) : ( - - )} +
+ {isEditing && editingModel && onDeleteModel ? ( -
- )} + ) : ( + + )} + +
+ {editingModel && onDeleteModel && ( + !open && setConfirmingDelete(false)} + > + + + Delete model + + Are you sure you want to delete this model? This action is + irreversible. + + + + + + + + + )}{" "}
); }; diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProviderForm.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProviderForm.tsx index c79193e372..01594e22c6 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProviderForm.tsx +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProviderForm.tsx @@ -3,6 +3,14 @@ import { type FC, type FormEvent, useId, useState } from "react"; import type * as TypesGen from "#/api/typesGenerated"; import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert"; import { Button } from "#/components/Button/Button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "#/components/Dialog/Dialog"; import { Input } from "#/components/Input/Input"; import { Spinner } from "#/components/Spinner/Spinner"; import { @@ -267,62 +275,67 @@ export const ProviderForm: FC = ({ {/* Footer — pushed to bottom */}

- {confirmingDelete && providerConfig ? ( -
-

- Are you sure? This action is irreversible. -

-
- - -
-
- ) : ( -
- {providerConfig ? ( - - ) : ( -
- )} - -
- )} + ) : ( +
+ )} + +
)} + + {providerConfig && ( + !open && setConfirmingDelete(false)} + > + + + Delete provider + + Are you sure you want to delete this provider? This action is + irreversible. + + + + + + + + + )}
); }; diff --git a/site/src/pages/AgentsPage/components/LimitsTab/GroupLimitsSection.stories.tsx b/site/src/pages/AgentsPage/components/LimitsTab/GroupLimitsSection.stories.tsx index 24548db1f2..fe4b7b6252 100644 --- a/site/src/pages/AgentsPage/components/LimitsTab/GroupLimitsSection.stories.tsx +++ b/site/src/pages/AgentsPage/components/LimitsTab/GroupLimitsSection.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, fn, within } from "storybook/test"; +import { expect, fn, userEvent, waitFor, within } from "storybook/test"; import type { Group } from "#/api/typesGenerated"; import { GroupLimitsSection } from "./GroupLimitsSection"; @@ -131,3 +131,46 @@ export const EditForm: Story = { expect(nameElements.length).toBeGreaterThanOrEqual(2); }, }; + +export const DeleteGroupOverride: Story = { + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + + // Click the Delete button for the first group override. + const deleteButtons = await body.findAllByRole("button", { + name: "Delete", + }); + await userEvent.click(deleteButtons[0]); + + // The confirmation dialog should appear. + const dialog = await body.findByRole("dialog"); + await expect(dialog).toBeInTheDocument(); + await expect( + body.getByText( + /Are you sure you want to delete this group limit override/i, + ), + ).toBeInTheDocument(); + }, +}; + +export const DeleteGroupOverrideCancelled: Story = { + play: async ({ canvasElement, args }) => { + const body = within(canvasElement.ownerDocument.body); + + // Click the Delete button for the first group override. + const deleteButtons = await body.findAllByRole("button", { + name: "Delete", + }); + await userEvent.click(deleteButtons[0]); + + // Cancel the dialog. + await body.findByRole("dialog"); + await userEvent.click(body.getByRole("button", { name: "Cancel" })); + + // The dialog should be closed and the callback should not have been called. + await waitFor(() => { + expect(body.queryByRole("dialog")).not.toBeInTheDocument(); + }); + expect(args.onDeleteGroupOverride).not.toHaveBeenCalled(); + }, +}; diff --git a/site/src/pages/AgentsPage/components/LimitsTab/GroupLimitsSection.tsx b/site/src/pages/AgentsPage/components/LimitsTab/GroupLimitsSection.tsx index 24c770566f..6cd9bb8a57 100644 --- a/site/src/pages/AgentsPage/components/LimitsTab/GroupLimitsSection.tsx +++ b/site/src/pages/AgentsPage/components/LimitsTab/GroupLimitsSection.tsx @@ -1,10 +1,18 @@ import { Check } from "lucide-react"; -import { type FC, useId } from "react"; +import { type FC, useId, useState } from "react"; import { getErrorMessage } from "#/api/errors"; import type { Group } from "#/api/typesGenerated"; import { Autocomplete } from "#/components/Autocomplete/Autocomplete"; import { AvatarData } from "#/components/Avatar/AvatarData"; import { Button } from "#/components/Button/Button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "#/components/Dialog/Dialog"; import { Input } from "#/components/Input/Input"; import { Label } from "#/components/Label/Label"; import { Spinner } from "#/components/Spinner/Spinner"; @@ -84,6 +92,9 @@ export const GroupLimitsSection: FC = ({ const groupAutocompleteId = useId(); const groupAmountId = useId(); const isEditing = editingGroupOverride !== null; + const [pendingDeleteGroupId, setPendingDeleteGroupId] = useState< + string | null + >(null); return (
@@ -91,7 +102,6 @@ export const GroupLimitsSection: FC = ({ label="Group Limits" description="Override the default limit for specific groups. When a user belongs to multiple groups, the lowest group limit applies." /> -
{groupOverrides.length > 0 ? ( @@ -136,7 +146,7 @@ export const GroupLimitsSection: FC = ({ size="sm" type="button" onClick={() => - void onDeleteGroupOverride(override.group_id) + setPendingDeleteGroupId(override.group_id) } disabled={deletePending || upsertPending || isEditing} > @@ -287,6 +297,42 @@ export const GroupLimitsSection: FC = ({

)} + {pendingDeleteGroupId && ( + !open && setPendingDeleteGroupId(null)} + > + + + Delete group override + + Are you sure you want to delete this group limit override? This + action is irreversible. + + + + + + + + + )}{" "} ); }; diff --git a/site/src/pages/AgentsPage/components/LimitsTab/UserOverridesSection.stories.tsx b/site/src/pages/AgentsPage/components/LimitsTab/UserOverridesSection.stories.tsx index 700dca2632..953460c988 100644 --- a/site/src/pages/AgentsPage/components/LimitsTab/UserOverridesSection.stories.tsx +++ b/site/src/pages/AgentsPage/components/LimitsTab/UserOverridesSection.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { QueryClient, QueryClientProvider } from "react-query"; -import { expect, fn, within } from "storybook/test"; +import { expect, fn, userEvent, waitFor, within } from "storybook/test"; import { UserOverridesSection } from "./UserOverridesSection"; const queryClient = new QueryClient({ @@ -108,3 +108,46 @@ export const EditForm: Story = { expect(nameElements.length).toBeGreaterThanOrEqual(2); }, }; + +export const DeleteUserOverride: Story = { + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + + // Click the Delete button for the first user override. + const deleteButtons = await body.findAllByRole("button", { + name: "Delete", + }); + await userEvent.click(deleteButtons[0]); + + // The confirmation dialog should appear. + const dialog = await body.findByRole("dialog"); + await expect(dialog).toBeInTheDocument(); + await expect( + body.getByText( + /Are you sure you want to delete this user limit override/i, + ), + ).toBeInTheDocument(); + }, +}; + +export const DeleteUserOverrideCancelled: Story = { + play: async ({ canvasElement, args }) => { + const body = within(canvasElement.ownerDocument.body); + + // Click the Delete button for the first user override. + const deleteButtons = await body.findAllByRole("button", { + name: "Delete", + }); + await userEvent.click(deleteButtons[0]); + + // Cancel the dialog. + await body.findByRole("dialog"); + await userEvent.click(body.getByRole("button", { name: "Cancel" })); + + // The dialog should be closed and the callback should not have been called. + await waitFor(() => { + expect(body.queryByRole("dialog")).not.toBeInTheDocument(); + }); + expect(args.onDeleteOverride).not.toHaveBeenCalled(); + }, +}; diff --git a/site/src/pages/AgentsPage/components/LimitsTab/UserOverridesSection.tsx b/site/src/pages/AgentsPage/components/LimitsTab/UserOverridesSection.tsx index 23428989d6..f02050090f 100644 --- a/site/src/pages/AgentsPage/components/LimitsTab/UserOverridesSection.tsx +++ b/site/src/pages/AgentsPage/components/LimitsTab/UserOverridesSection.tsx @@ -1,8 +1,16 @@ -import { type FC, useId } from "react"; +import { type FC, useId, useState } from "react"; import { getErrorMessage } from "#/api/errors"; import type { User } from "#/api/typesGenerated"; import { AvatarData } from "#/components/Avatar/AvatarData"; import { Button } from "#/components/Button/Button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "#/components/Dialog/Dialog"; import { Input } from "#/components/Input/Input"; import { Label } from "#/components/Label/Label"; import { Spinner } from "#/components/Spinner/Spinner"; @@ -73,6 +81,9 @@ export const UserOverridesSection: FC = ({ }) => { const userOverrideAmountId = useId(); const isEditing = editingUserOverride !== null; + const [pendingDeleteUserId, setPendingDeleteUserId] = useState( + null, + ); return (
@@ -80,7 +91,6 @@ export const UserOverridesSection: FC = ({ label="Per-User Overrides" description="Override the deployment default spend limit for specific users. User overrides take highest priority, followed by group limits, then the deployment default." /> -
{overrides.length > 0 ? (
@@ -122,7 +132,7 @@ export const UserOverridesSection: FC = ({ variant="outline" size="sm" type="button" - onClick={() => void onDeleteOverride(override.user_id)} + onClick={() => setPendingDeleteUserId(override.user_id)} disabled={deletePending || upsertPending || isEditing} > Delete @@ -246,6 +256,42 @@ export const UserOverridesSection: FC = ({

)} + {pendingDeleteUserId && ( + !open && setPendingDeleteUserId(null)} + > + + + Delete user override + + Are you sure you want to delete this user limit override? This + action is irreversible. + + + + + + + + + )}{" "} ); }; diff --git a/site/src/pages/AgentsPage/components/MCPServerAdminPanel.stories.tsx b/site/src/pages/AgentsPage/components/MCPServerAdminPanel.stories.tsx index a22e9b9936..8456311c1b 100644 --- a/site/src/pages/AgentsPage/components/MCPServerAdminPanel.stories.tsx +++ b/site/src/pages/AgentsPage/components/MCPServerAdminPanel.stories.tsx @@ -480,7 +480,7 @@ export const EditServerWithCustomHeaders: Story = { }, }; -/** Delete a server with confirmation step. */ +/** Delete a server shows confirmation dialog. */ export const DeleteServerConfirmation: Story = { beforeEach: () => { setupMCPSpies({ @@ -501,14 +501,15 @@ export const DeleteServerConfirmation: Story = { // Click Delete. await userEvent.click(await body.findByRole("button", { name: "Delete" })); - // Confirmation should appear. + // Confirmation dialog should appear. await expect( - await body.findByText(/Are you sure\? This action is irreversible/i), + await body.findByText(/Are you sure you want to delete this MCP server/i), ).toBeInTheDocument(); + await expect(body.getByRole("dialog")).toBeInTheDocument(); }, }; -/** Cancel delete returns to normal form footer. */ +/** Cancel delete closes the dialog. */ export const DeleteServerCancelled: Story = { beforeEach: () => { setupMCPSpies({ @@ -526,20 +527,17 @@ export const DeleteServerCancelled: Story = { await userEvent.click(await body.findByRole("button", { name: /Sentry/ })); await userEvent.click(await body.findByRole("button", { name: "Delete" })); - await body.findByText(/Are you sure/i); + await body.findByText(/Are you sure you want to delete this MCP server/i); await userEvent.click(body.getByRole("button", { name: "Cancel" })); - // Normal footer should be restored. - await expect( - await body.findByRole("button", { name: "Delete" }), - ).toBeInTheDocument(); - expect( - body.getByRole("button", { name: /Save changes/i }), - ).toBeInTheDocument(); + // The dialog should be closed. + await waitFor(() => { + expect(body.queryByRole("dialog")).not.toBeInTheDocument(); + }); }, }; -/** Confirm delete calls the API. */ +/** Confirm delete in dialog calls the API. */ export const DeleteServerConfirmed: Story = { beforeEach: () => { setupMCPSpies({ @@ -557,7 +555,6 @@ export const DeleteServerConfirmed: Story = { await userEvent.click(await body.findByRole("button", { name: /Sentry/ })); await userEvent.click(await body.findByRole("button", { name: "Delete" })); - await body.findByText(/Are you sure/i); await userEvent.click(body.getByRole("button", { name: /Delete server/i })); await waitFor(() => { diff --git a/site/src/pages/AgentsPage/components/MCPServerAdminPanel.tsx b/site/src/pages/AgentsPage/components/MCPServerAdminPanel.tsx index f7d9b75c77..180c11704c 100644 --- a/site/src/pages/AgentsPage/components/MCPServerAdminPanel.tsx +++ b/site/src/pages/AgentsPage/components/MCPServerAdminPanel.tsx @@ -20,6 +20,14 @@ import { import type * as TypesGen from "#/api/typesGenerated"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Button } from "#/components/Button/Button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "#/components/Dialog/Dialog"; import { ExternalImage } from "#/components/ExternalImage/ExternalImage"; import { IconField } from "#/components/IconField/IconField"; import { Input } from "#/components/Input/Input"; @@ -361,7 +369,6 @@ const ServerForm: FC = ({ Back - {/* Header with icon + editable name + enabled toggle */}
= ({

-
= ({ {/* Footer — pushed to bottom, matches ProviderForm */}

- {confirmingDelete && server ? ( -
-

- Are you sure? This action is irreversible. -

-
- - -
-
- ) : ( -
- {isEditing ? ( - - ) : ( -
- )} - -
- )} + ) : ( +
+ )} + +
+ {server && ( + !open && setConfirmingDelete(false)} + > + + + Delete server + + Are you sure you want to delete this MCP server? This action is + irreversible. + + + + + + + + + )}{" "}
); };