mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 22:17:36 +00:00
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:
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);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user