diff --git a/packages/views/settings/components/delete-workspace-dialog.test.tsx b/packages/views/settings/components/delete-workspace-dialog.test.tsx new file mode 100644 index 000000000..c19144b58 --- /dev/null +++ b/packages/views/settings/components/delete-workspace-dialog.test.tsx @@ -0,0 +1,205 @@ +import type { ReactNode } from "react"; +import { describe, expect, it, beforeEach, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +// The shared Dialog is a Base UI portal that's awkward to test — strip it to +// simple pass-through wrappers. The typed-confirmation logic lives in the +// dialog body, not in Base UI, so this doesn't reduce coverage. +vi.mock("@multica/ui/components/ui/dialog", () => ({ + Dialog: ({ children, open }: { children: ReactNode; open: boolean }) => + open ?
{children}
: null, + DialogContent: ({ children }: { children: ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: ReactNode }) =>

{children}

, + DialogDescription: ({ children }: { children: ReactNode }) =>

{children}

, + DialogFooter: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +import { DeleteWorkspaceDialog } from "./delete-workspace-dialog"; + +describe("DeleteWorkspaceDialog", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("disables Delete when input is empty", () => { + render( + , + ); + expect(screen.getByRole("button", { name: "Delete workspace" })).toBeDisabled(); + }); + + it("keeps Delete disabled when input doesn't match (case-sensitive)", async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.type(screen.getByRole("textbox"), "ACME"); // wrong case + expect(screen.getByRole("button", { name: "Delete workspace" })).toBeDisabled(); + + await user.clear(screen.getByRole("textbox")); + await user.type(screen.getByRole("textbox"), "acme "); // trailing space + expect(screen.getByRole("button", { name: "Delete workspace" })).toBeDisabled(); + }); + + it("enables Delete on exact match and calls onConfirm when clicked", async () => { + const user = userEvent.setup(); + const onConfirm = vi.fn(); + render( + , + ); + + await user.type(screen.getByRole("textbox"), "acme"); + const deleteBtn = screen.getByRole("button", { name: "Delete workspace" }); + expect(deleteBtn).toBeEnabled(); + + await user.click(deleteBtn); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it("submits on Enter when matched; ignores Enter when not matched", async () => { + const user = userEvent.setup(); + const onConfirm = vi.fn(); + render( + , + ); + + const input = screen.getByRole("textbox"); + await user.type(input, "acm{Enter}"); // not yet matched + expect(onConfirm).not.toHaveBeenCalled(); + + await user.type(input, "e{Enter}"); // now matches "acme" + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it("Cancel closes the dialog and does not call onConfirm", async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + const onConfirm = vi.fn(); + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it("shows loading state and disables both buttons while pending", () => { + render( + , + ); + expect(screen.getByRole("button", { name: "Deleting..." })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled(); + }); + + it("matches names with spaces, unicode, and other non-ASCII characters literally", async () => { + const user = userEvent.setup(); + const onConfirm = vi.fn(); + render( + , + ); + const input = screen.getByRole("textbox"); + await user.type(input, "My 团队 🚀"); + expect(screen.getByRole("button", { name: "Delete workspace" })).toBeEnabled(); + await user.click(screen.getByRole("button", { name: "Delete workspace" })); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it("resets the input when the workspace being deleted changes (e.g. rename mid-dialog)", () => { + const { rerender } = render( + , + ); + const input = screen.getByRole("textbox") as HTMLInputElement; + // Simulate user typing (set value directly since userEvent.type would + // lose focus across re-renders). + input.value = "old-name"; + rerender( + , + ); + expect(screen.getByRole("textbox")).toHaveValue(""); + }); + + it("clears the input when reopened so prior attempts don't leak", async () => { + const user = userEvent.setup(); + const { rerender } = render( + , + ); + + await user.type(screen.getByRole("textbox"), "partial"); + expect(screen.getByRole("textbox")).toHaveValue("partial"); + + // Simulate close → reopen (e.g. user canceled, then clicked Delete again) + rerender( + , + ); + rerender( + , + ); + + expect(screen.getByRole("textbox")).toHaveValue(""); + }); +}); diff --git a/packages/views/settings/components/delete-workspace-dialog.tsx b/packages/views/settings/components/delete-workspace-dialog.tsx new file mode 100644 index 000000000..64e0240d6 --- /dev/null +++ b/packages/views/settings/components/delete-workspace-dialog.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@multica/ui/components/ui/dialog"; +import { Input } from "@multica/ui/components/ui/input"; +import { Label } from "@multica/ui/components/ui/label"; +import { Button } from "@multica/ui/components/ui/button"; + +/** + * Typed-confirmation dialog for workspace deletion — GitHub's repo-delete + * pattern. The destructive button stays disabled until the user types + * the workspace name exactly (case-sensitive, no trimming). The friction + * is deliberate: deleting a workspace cascades into every issue, agent, + * skill, and run under it, and the backend has no soft-delete. + * + * Case-sensitive match matches GitHub's pattern and catches the "I + * remember the gist of the name but not the casing" misfire. No trim — + * leading/trailing whitespace indicates a typo, and silently accepting + * it would weaken the whole point of the gate. + * + * Input value resets whenever the dialog closes so reopening doesn't + * leak the previous attempt (which might have been for a different + * workspace after a swap). + */ +export function DeleteWorkspaceDialog({ + workspaceName, + loading = false, + open, + onOpenChange, + onConfirm, +}: { + workspaceName: string; + loading?: boolean; + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; +}) { + const [typed, setTyped] = useState(""); + const matched = typed === workspaceName; + + // Reset on close (so reopening for a different workspace doesn't leak + // the prior attempt) AND on workspaceName change (if another owner + // renames the workspace while the dialog is open, the already-typed + // string stops matching and there'd be no feedback explaining why). + useEffect(() => { + setTyped(""); + }, [open, workspaceName]); + + const submit = () => { + if (!matched || loading) return; + onConfirm(); + }; + + return ( + + + + Delete workspace + + This cannot be undone. All issues, agents, and data will be + permanently removed. + + + +
+ + setTyped(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + submit(); + } + }} + placeholder={workspaceName} + autoFocus + disabled={loading} + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + spellCheck={false} + /> +
+ + + + + +
+
+ ); +} diff --git a/packages/views/settings/components/workspace-tab.tsx b/packages/views/settings/components/workspace-tab.tsx index 57149ac7a..29ddf82a7 100644 --- a/packages/views/settings/components/workspace-tab.tsx +++ b/packages/views/settings/components/workspace-tab.tsx @@ -32,12 +32,13 @@ import { api } from "@multica/core/api"; import { paths } from "@multica/core/paths"; import type { Workspace } from "@multica/core/types"; import { useNavigation } from "../../navigation"; +import { DeleteWorkspaceDialog } from "./delete-workspace-dialog"; export function WorkspaceTab() { const user = useAuthStore((s) => s.user); const workspace = useCurrentWorkspace(); const wsId = useWorkspaceId(); - const { data: members = [] } = useQuery(memberListOptions(wsId)); + const { data: members = [], isFetched: membersFetched } = useQuery(memberListOptions(wsId)); const qc = useQueryClient(); const leaveWorkspace = useLeaveWorkspace(); const deleteWorkspace = useDeleteWorkspace(); @@ -73,10 +74,18 @@ export function WorkspaceTab() { variant?: "destructive"; onConfirm: () => Promise; } | null>(null); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const currentMember = members.find((m) => m.user_id === user?.id) ?? null; const canManageWorkspace = currentMember?.role === "owner" || currentMember?.role === "admin"; const isOwner = currentMember?.role === "owner"; + // Mirror the backend invariant (server/internal/handler/workspace.go:569): + // a workspace must always have at least one owner, so the sole owner can't + // leave. Pre-flight here instead of letting the 400 round-trip become a + // confusing toast — disable Leave and tell the user what they need to do. + const ownerCount = members.filter((m) => m.role === "owner").length; + const isSoleOwner = isOwner && ownerCount <= 1; + const isSoleMember = members.length <= 1; useEffect(() => { setName(workspace?.name ?? ""); @@ -124,24 +133,18 @@ export function WorkspaceTab() { }); }; - const handleDeleteWorkspace = () => { + const handleConfirmDelete = async () => { if (!workspace) return; - setConfirmAction({ - title: "Delete workspace", - description: `Delete ${workspace.name}? This cannot be undone. All issues, agents, and data will be permanently removed.`, - variant: "destructive", - onConfirm: async () => { - setActionId("delete-workspace"); - try { - await deleteWorkspace.mutateAsync(workspace.id); - await navigateAwayFromCurrentWorkspace(); - } catch (e) { - toast.error(e instanceof Error ? e.message : "Failed to delete workspace"); - } finally { - setActionId(null); - } - }, - }); + setActionId("delete-workspace"); + try { + await deleteWorkspace.mutateAsync(workspace.id); + setDeleteDialogOpen(false); + await navigateAwayFromCurrentWorkspace(); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to delete workspace"); + } finally { + setActionId(null); + } }; if (!workspace) return null; @@ -211,7 +214,10 @@ export function WorkspaceTab() { - {/* Danger Zone */} + {/* Danger Zone — gated on the member query settling so the owner-only + Delete button and the sole-owner Leave guidance don't flash in + after mount. */} + {membersFetched && (
@@ -224,14 +230,18 @@ export function WorkspaceTab() {

Leave workspace

- Remove yourself from this workspace. + {isSoleOwner + ? isSoleMember + ? "You're the only member. Delete the workspace to leave." + : "You're the only owner. Promote another member to owner first, or delete the workspace." + : "Remove yourself from this workspace."}

@@ -248,7 +258,7 @@ export function WorkspaceTab() {
+ )} { if (!v) setConfirmAction(null); }}> @@ -279,6 +290,19 @@ export function WorkspaceTab() { + + { + // Ignore close requests while the delete mutation is in flight + // so the user can't accidentally dismiss mid-operation. + if (actionId === "delete-workspace" && !open) return; + setDeleteDialogOpen(open); + }} + onConfirm={handleConfirmDelete} + /> ); }