feat(workspace): typed delete confirm + sole-owner leave preflight (#1238)

* feat(workspace): typed delete confirm + sole-owner leave preflight

Harden the Danger Zone on the Workspace settings tab.

**Delete workspace** now requires typing the workspace name exactly
(case-sensitive, no trimming) before the destructive button enables —
GitHub's repo-delete pattern. Deleting cascades into every issue,
agent, skill, and run under the workspace with no soft-delete, so the
friction is deliberate. Enter submits only when matched; the input
clears on close so reopening for a different workspace doesn't leak
the prior attempt.

**Leave workspace** now preflights the sole-owner case the backend
already blocks (server/internal/handler/workspace.go:569 — "workspace
must have at least one owner"). Previously the user clicked Confirm
and got an opaque 400 toast; now the Leave button is disabled upfront
with inline guidance that distinguishes:
 - sole member: "Delete the workspace to leave."
 - sole owner with other members: "Promote another member to owner
   first, or delete the workspace."

Both changes live in packages/views/, so web and desktop get the same
Danger Zone treatment automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* review: gate Danger Zone on members fetched + reset typed input on rename

Addresses self-review findings on #1238:

- Previously the Danger Zone rendered immediately with `members = []`, so
  the Delete workspace block (gated on `isOwner`, which is derived from
  an empty members list) would flash in once the query settled. Gate the
  whole section on `membersFetched` so it appears once with correct
  controls.
- Reset `typed` on `workspaceName` change too — if another owner renames
  the workspace while the dialog is open, the already-typed string stops
  matching silently; resetting surfaces the mismatch.
- Added two tests: unicode/special-char names match literally; rename
  mid-dialog clears the input.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-04-17 11:15:58 +08:00
committed by GitHub
parent c2f7dc49f8
commit 763c0cd25f
3 changed files with 372 additions and 22 deletions

View File

@@ -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 ? <div>{children}</div> : null,
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: ReactNode }) => <h1>{children}</h1>,
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
DialogFooter: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
import { DeleteWorkspaceDialog } from "./delete-workspace-dialog";
describe("DeleteWorkspaceDialog", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("disables Delete when input is empty", () => {
render(
<DeleteWorkspaceDialog
workspaceName="acme"
open
onOpenChange={vi.fn()}
onConfirm={vi.fn()}
/>,
);
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(
<DeleteWorkspaceDialog
workspaceName="acme"
open
onOpenChange={vi.fn()}
onConfirm={vi.fn()}
/>,
);
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(
<DeleteWorkspaceDialog
workspaceName="acme"
open
onOpenChange={vi.fn()}
onConfirm={onConfirm}
/>,
);
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(
<DeleteWorkspaceDialog
workspaceName="acme"
open
onOpenChange={vi.fn()}
onConfirm={onConfirm}
/>,
);
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(
<DeleteWorkspaceDialog
workspaceName="acme"
open
onOpenChange={onOpenChange}
onConfirm={onConfirm}
/>,
);
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(
<DeleteWorkspaceDialog
workspaceName="acme"
loading
open
onOpenChange={vi.fn()}
onConfirm={vi.fn()}
/>,
);
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(
<DeleteWorkspaceDialog
workspaceName="My 团队 🚀"
open
onOpenChange={vi.fn()}
onConfirm={onConfirm}
/>,
);
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(
<DeleteWorkspaceDialog
workspaceName="old-name"
open
onOpenChange={vi.fn()}
onConfirm={vi.fn()}
/>,
);
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(
<DeleteWorkspaceDialog
workspaceName="new-name"
open
onOpenChange={vi.fn()}
onConfirm={vi.fn()}
/>,
);
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(
<DeleteWorkspaceDialog
workspaceName="acme"
open
onOpenChange={vi.fn()}
onConfirm={vi.fn()}
/>,
);
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(
<DeleteWorkspaceDialog
workspaceName="acme"
open={false}
onOpenChange={vi.fn()}
onConfirm={vi.fn()}
/>,
);
rerender(
<DeleteWorkspaceDialog
workspaceName="acme"
open
onOpenChange={vi.fn()}
onConfirm={vi.fn()}
/>,
);
expect(screen.getByRole("textbox")).toHaveValue("");
});
});

View File

@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete workspace</DialogTitle>
<DialogDescription>
This cannot be undone. All issues, agents, and data will be
permanently removed.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="delete-workspace-confirm" className="text-xs">
To confirm, type{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">
{workspaceName}
</code>{" "}
below.
</Label>
<Input
id="delete-workspace-confirm"
value={typed}
onChange={(e) => 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}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
onClick={submit}
disabled={!matched || loading}
>
{loading ? "Deleting..." : "Delete workspace"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -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<void>;
} | 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() {
</Card>
</section>
{/* 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 && (
<section className="space-y-4">
<div className="flex items-center gap-2">
<LogOut className="h-4 w-4 text-muted-foreground" />
@@ -224,14 +230,18 @@ export function WorkspaceTab() {
<div>
<p className="text-sm font-medium">Leave workspace</p>
<p className="text-xs text-muted-foreground">
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."}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleLeaveWorkspace}
disabled={actionId === "leave"}
disabled={actionId === "leave" || isSoleOwner}
>
{actionId === "leave" ? "Leaving..." : "Leave workspace"}
</Button>
@@ -248,7 +258,7 @@ export function WorkspaceTab() {
<Button
variant="destructive"
size="sm"
onClick={handleDeleteWorkspace}
onClick={() => setDeleteDialogOpen(true)}
disabled={actionId === "delete-workspace"}
>
{actionId === "delete-workspace" ? "Deleting..." : "Delete workspace"}
@@ -258,6 +268,7 @@ export function WorkspaceTab() {
</CardContent>
</Card>
</section>
)}
<AlertDialog open={!!confirmAction} onOpenChange={(v) => { if (!v) setConfirmAction(null); }}>
<AlertDialogContent>
@@ -279,6 +290,19 @@ export function WorkspaceTab() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteWorkspaceDialog
workspaceName={workspace.name}
loading={actionId === "delete-workspace"}
open={deleteDialogOpen}
onOpenChange={(open) => {
// 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}
/>
</div>
);
}