From 2bebe764397f7a87424c696d45833c53ba563084 Mon Sep 17 00:00:00 2001 From: viettranx Date: Sat, 7 Mar 2026 19:14:32 +0700 Subject: [PATCH] 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). --- .../shared/confirm-delete-dialog.tsx | 75 +++++++++++++++++++ .../agents/agent-detail/agent-detail-page.tsx | 26 ++----- ui/web/src/pages/channels/channels-page.tsx | 8 +- ui/web/src/pages/mcp/mcp-page.tsx | 6 +- ui/web/src/pages/providers/providers-page.tsx | 8 +- ui/web/src/pages/skills/skills-page.tsx | 6 +- ui/web/src/pages/teams/team-detail-page.tsx | 21 ++---- 7 files changed, 103 insertions(+), 47 deletions(-) create mode 100644 ui/web/src/components/shared/confirm-delete-dialog.tsx diff --git a/ui/web/src/components/shared/confirm-delete-dialog.tsx b/ui/web/src/components/shared/confirm-delete-dialog.tsx new file mode 100644 index 00000000..0d1a3346 --- /dev/null +++ b/ui/web/src/components/shared/confirm-delete-dialog.tsx @@ -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 ( + + + + {title} + {description} + +
+

+ Type {confirmValue} to confirm. +

+ setInputValue(e.target.value)} + placeholder={confirmValue} + autoFocus + /> +
+ + + + +
+
+ ); +} diff --git a/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx b/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx index 53488368..a7ba76f1 100644 --- a/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx +++ b/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx @@ -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 ( -
- - -
- ); + return ; } const title = agentDisplayName(agent); @@ -131,7 +123,6 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) { Shares Links Skills - Danger @@ -165,9 +156,6 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) { - - - @@ -180,13 +168,13 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) { onResummon={async () => { await resummonAgent(); }} /> - { await deleteAgentById(agentId); setDeleteOpen(false); diff --git a/ui/web/src/pages/channels/channels-page.tsx b/ui/web/src/pages/channels/channels-page.tsx index d3a8a708..149f361d 100644 --- a/ui/web/src/pages/channels/channels-page.tsx +++ b/ui/web/src/pages/channels/channels-page.tsx @@ -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} /> - !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} /> diff --git a/ui/web/src/pages/mcp/mcp-page.tsx b/ui/web/src/pages/mcp/mcp-page.tsx index a244e1c3..c19e3990 100644 --- a/ui/web/src/pages/mcp/mcp-page.tsx +++ b/ui/web/src/pages/mcp/mcp-page.tsx @@ -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() { /> )} - !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} /> diff --git a/ui/web/src/pages/providers/providers-page.tsx b/ui/web/src/pages/providers/providers-page.tsx index b7755fd5..04583872 100644 --- a/ui/web/src/pages/providers/providers-page.tsx +++ b/ui/web/src/pages/providers/providers-page.tsx @@ -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} /> - !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} /> diff --git a/ui/web/src/pages/skills/skills-page.tsx b/ui/web/src/pages/skills/skills-page.tsx index 0c48959f..d36e5b1a 100644 --- a/ui/web/src/pages/skills/skills-page.tsx +++ b/ui/web/src/pages/skills/skills-page.tsx @@ -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} /> - !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} /> diff --git a/ui/web/src/pages/teams/team-detail-page.tsx b/ui/web/src/pages/teams/team-detail-page.tsx index 48f026d5..855fc746 100644 --- a/ui/web/src/pages/teams/team-detail-page.tsx +++ b/ui/web/src/pages/teams/team-detail-page.tsx @@ -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 ( -
- - -
- ); + return ; } return ( @@ -116,7 +109,7 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) { {/* Tabs */} -
+
Members @@ -148,13 +141,13 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) {
- { await deleteTeam(teamId); setDeleteOpen(false);