feat(ui): require typed confirmation for critical entity deletion

Add ConfirmDeleteDialog component that requires typing the entity name
to confirm deletion, preventing accidental misclicks. Applied to agents,
teams, providers, MCP servers, channels, and skills. Remove redundant
agent danger tab (delete button in header is sufficient).
This commit is contained in:
viettranx committed 2026-03-07 19:14:32 +07:00
1 parent 01e40a92b0
commit 2bebe76439
7 files changed
+103 -47

No files matched your search

@@ -0,0 +1,75 @@
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface ConfirmDeleteDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
confirmValue: string;
confirmLabel?: string;
onConfirm: () => void;
loading?: boolean;
}
export function ConfirmDeleteDialog({
open,
onOpenChange,
title,
description,
confirmValue,
confirmLabel = "Delete",
onConfirm,
loading,
}: ConfirmDeleteDialogProps) {
const [inputValue, setInputValue] = useState("");
useEffect(() => {
if (!open) setInputValue("");
}, [open]);
const isMatch = inputValue.toLowerCase() === confirmValue.toLowerCase();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="py-2">
<p className="mb-2 text-sm text-muted-foreground">
Type <span className="font-semibold text-foreground">{confirmValue}</span> to confirm.
</p>
<Input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder={confirmValue}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
Cancel
</Button>
<Button
variant="destructive"
onClick={onConfirm}
disabled={!isMatch || loading}
>
{loading ? "..." : confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -11,10 +11,9 @@ import { AgentFilesTab } from "./agent-files-tab";
import { AgentSharesTab } from "./agent-shares-tab";
import { AgentLinksTab } from "./agent-links-tab";
import { AgentSkillsTab } from "./agent-skills-tab";
import { AgentDangerTab } from "./agent-danger-tab";
import { SummoningModal } from "../summoning-modal";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { DeferredSpinner } from "@/components/shared/loading-skeleton";
import { ConfirmDeleteDialog } from "@/components/shared/confirm-delete-dialog";
import { DetailPageSkeleton } from "@/components/shared/loading-skeleton";
interface AgentDetailPageProps {
agentId: string;
@@ -38,7 +37,7 @@ function agentSubtitle(agent: { display_name?: string; agent_key: string; id: st
}
export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
const { agent, files, loading, updateAgent, getFile, setFile, regenerateAgent, resummonAgent, deleteAgent, refresh } =
const { agent, files, loading, updateAgent, getFile, setFile, regenerateAgent, resummonAgent, refresh } =
useAgentDetail(agentId);
const { deleteAgent: deleteAgentById } = useAgents();
const [summoningOpen, setSummoningOpen] = useState(false);
@@ -62,14 +61,7 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
};
if (loading || !agent) {
return (
<div className="p-4 sm:p-6">
<Button variant="ghost" onClick={onBack} className="mb-4 gap-1">
<ArrowLeft className="h-4 w-4" /> Back
</Button>
<DeferredSpinner />
</div>
);
return <DetailPageSkeleton tabs={6} />;
}
const title = agentDisplayName(agent);
@@ -131,7 +123,6 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
<TabsTrigger value="shares">Shares</TabsTrigger>
<TabsTrigger value="links">Links</TabsTrigger>
<TabsTrigger value="skills">Skills</TabsTrigger>
<TabsTrigger value="danger" className="text-destructive data-[state=active]:text-destructive">Danger</TabsTrigger>
</TabsList>
<TabsContent value="general" className="mt-4">
@@ -165,9 +156,6 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
<AgentSkillsTab agentId={agentId} />
</TabsContent>
<TabsContent value="danger" className="mt-4">
<AgentDangerTab agent={agent} onDelete={deleteAgent} onDeleted={onBack} />
</TabsContent>
</Tabs>
</div>
@@ -180,13 +168,13 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
onResummon={async () => { await resummonAgent(); }}
/>
<ConfirmDialog
<ConfirmDeleteDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
title="Delete Agent"
description={`Are you sure you want to delete "${title}"? This action cannot be undone.`}
description={`Are you sure you want to delete "${title}"? All context files, sessions, and configuration will be permanently removed.`}
confirmValue={agent.display_name || agent.agent_key}
confirmLabel="Delete"
variant="destructive"
onConfirm={async () => {
await deleteAgentById(agentId);
setDeleteOpen(false);
+4 -4
View File
@@ -8,7 +8,7 @@ import { EmptyState } from "@/components/shared/empty-state";
import { TableSkeleton } from "@/components/shared/loading-skeleton";
import { SearchInput } from "@/components/shared/search-input";
import { Pagination } from "@/components/shared/pagination";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { ConfirmDeleteDialog } from "@/components/shared/confirm-delete-dialog";
import { useChannels } from "./hooks/use-channels";
import { useChannelInstances, type ChannelInstanceData, type ChannelInstanceInput } from "./hooks/use-channel-instances";
import { ChannelInstanceFormDialog } from "./channel-instance-form-dialog";
@@ -264,13 +264,13 @@ export function ChannelsPage() {
onUpdate={handleUpdate}
/>
<ConfirmDialog
<ConfirmDeleteDialog
open={!!deleteTarget}
onOpenChange={(v) => !v && setDeleteTarget(null)}
title="Delete Channel Instance"
description={`Are you sure you want to delete "${deleteTarget?.display_name || deleteTarget?.name}"?`}
description={`Are you sure you want to delete "${deleteTarget?.display_name || deleteTarget?.name}"? This action cannot be undone.`}
confirmValue={deleteTarget?.display_name || deleteTarget?.name || ""}
confirmLabel="Delete"
variant="destructive"
onConfirm={handleDelete}
loading={deleteLoading}
/>
+3 -3
View File
@@ -7,7 +7,7 @@ import { EmptyState } from "@/components/shared/empty-state";
import { SearchInput } from "@/components/shared/search-input";
import { Pagination } from "@/components/shared/pagination";
import { TableSkeleton } from "@/components/shared/loading-skeleton";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { ConfirmDeleteDialog } from "@/components/shared/confirm-delete-dialog";
import { useMCP, type MCPServerData, type MCPServerInput } from "./hooks/use-mcp";
import { MCPFormDialog } from "./mcp-form-dialog";
import { MCPGrantsDialog } from "./mcp-grants-dialog";
@@ -226,13 +226,13 @@ export function MCPPage() {
/>
)}
<ConfirmDialog
<ConfirmDeleteDialog
open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(null)}
title="Delete MCP Server"
description={`Are you sure you want to delete "${deleteTarget?.display_name || deleteTarget?.name}"? This will also remove all associated grants.`}
confirmValue={deleteTarget?.display_name || deleteTarget?.name || ""}
confirmLabel="Delete"
variant="destructive"
onConfirm={handleDelete}
loading={deleteLoading}
/>
@@ -7,7 +7,7 @@ import { EmptyState } from "@/components/shared/empty-state";
import { SearchInput } from "@/components/shared/search-input";
import { Pagination } from "@/components/shared/pagination";
import { TableSkeleton } from "@/components/shared/loading-skeleton";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { ConfirmDeleteDialog } from "@/components/shared/confirm-delete-dialog";
import { useProviders, type ProviderData, type ProviderInput } from "./hooks/use-providers";
import { ProviderFormDialog } from "./provider-form-dialog";
import { useMinLoading } from "@/hooks/use-min-loading";
@@ -202,13 +202,13 @@ export function ProvidersPage() {
existingProviders={providers}
/>
<ConfirmDialog
<ConfirmDeleteDialog
open={!!deleteTarget}
onOpenChange={(v) => !v && setDeleteTarget(null)}
title="Delete Provider"
description={`Are you sure you want to delete "${deleteTarget?.display_name || deleteTarget?.name}"?`}
description={`Are you sure you want to delete "${deleteTarget?.display_name || deleteTarget?.name}"? This action cannot be undone.`}
confirmValue={deleteTarget?.display_name || deleteTarget?.name || ""}
confirmLabel="Delete"
variant="destructive"
onConfirm={handleDelete}
loading={deleteLoading}
/>
+3 -3
View File
@@ -7,7 +7,7 @@ import { EmptyState } from "@/components/shared/empty-state";
import { SearchInput } from "@/components/shared/search-input";
import { Pagination } from "@/components/shared/pagination";
import { TableSkeleton } from "@/components/shared/loading-skeleton";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { ConfirmDeleteDialog } from "@/components/shared/confirm-delete-dialog";
import { useSkills, type SkillInfo } from "./hooks/use-skills";
import { SkillDetailDialog } from "./skill-detail-dialog";
import { SkillUploadDialog } from "./skill-upload-dialog";
@@ -176,13 +176,13 @@ export function SkillsPage() {
onUpload={handleUpload}
/>
<ConfirmDialog
<ConfirmDeleteDialog
open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(null)}
title="Delete Skill"
description={`Are you sure you want to delete "${deleteTarget?.name}"? This action cannot be undone.`}
confirmValue={deleteTarget?.name || ""}
confirmLabel="Delete"
variant="destructive"
onConfirm={handleDelete}
loading={deleteLoading}
/>
+7 -14
View File
@@ -3,8 +3,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ArrowLeft, Users, Trash2 } from "lucide-react";
import { DeferredSpinner } from "@/components/shared/loading-skeleton";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { DetailPageSkeleton } from "@/components/shared/loading-skeleton";
import { ConfirmDeleteDialog } from "@/components/shared/confirm-delete-dialog";
import { useTeams } from "./hooks/use-teams";
import { TeamMembersTab } from "./team-members-tab";
import { TeamTasksTab } from "./team-tasks-tab";
@@ -65,14 +65,7 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) {
}, [teamId, removeMember, reload]);
if (loading || !team) {
return (
<div className="p-4 sm:p-6">
<Button variant="ghost" onClick={onBack} className="mb-4 gap-1">
<ArrowLeft className="h-4 w-4" /> Back
</Button>
<DeferredSpinner />
</div>
);
return <DetailPageSkeleton tabs={4} />;
}
return (
@@ -116,7 +109,7 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) {
</div>
{/* Tabs */}
<div className="max-w-4xl">
<div className="max-w-4xl rounded-xl border bg-card p-3 shadow-sm sm:p-4">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="w-full justify-start overflow-x-auto overflow-y-hidden">
<TabsTrigger value="members">Members</TabsTrigger>
@@ -148,13 +141,13 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) {
</Tabs>
</div>
<ConfirmDialog
<ConfirmDeleteDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
title="Delete Team"
description={`Are you sure you want to delete "${team.name}"? This action cannot be undone.`}
description={`Are you sure you want to delete "${team.name}"? All members, tasks, and configuration will be permanently removed.`}
confirmValue={team.name}
confirmLabel="Delete"
variant="destructive"
onConfirm={async () => {
await deleteTeam(teamId);
setDeleteOpen(false);