mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
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:
@@ -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("");
|
||||
});
|
||||
});
|
||||
121
packages/views/settings/components/delete-workspace-dialog.tsx
Normal file
121
packages/views/settings/components/delete-workspace-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user