diff --git a/backend/src/routes/repositoryRoutes.js b/backend/src/routes/repositoryRoutes.js index 6763d18..a1c0b10 100644 --- a/backend/src/routes/repositoryRoutes.js +++ b/backend/src/routes/repositoryRoutes.js @@ -289,6 +289,77 @@ router.get( }, ); +// Delete a specific repository (admin only) +router.delete( + "/:repositoryId", + authenticateToken, + requireManageHosts, + async (req, res) => { + try { + const { repositoryId } = req.params; + + // Check if repository exists first + const existingRepository = await prisma.repositories.findUnique({ + where: { id: repositoryId }, + select: { + id: true, + name: true, + url: true, + _count: { + select: { + host_repositories: true, + }, + }, + }, + }); + + if (!existingRepository) { + return res.status(404).json({ + error: "Repository not found", + details: "The repository may have been deleted or does not exist", + }); + } + + // Delete repository and all related data (cascade will handle host_repositories) + await prisma.repositories.delete({ + where: { id: repositoryId }, + }); + + res.json({ + message: "Repository deleted successfully", + deletedRepository: { + id: existingRepository.id, + name: existingRepository.name, + url: existingRepository.url, + hostCount: existingRepository._count.host_repositories, + }, + }); + } catch (error) { + console.error("Repository deletion error:", error); + + // Handle specific Prisma errors + if (error.code === "P2025") { + return res.status(404).json({ + error: "Repository not found", + details: "The repository may have been deleted or does not exist", + }); + } + + if (error.code === "P2003") { + return res.status(400).json({ + error: "Cannot delete repository due to foreign key constraints", + details: "The repository has related data that prevents deletion", + }); + } + + res.status(500).json({ + error: "Failed to delete repository", + details: error.message || "An unexpected error occurred", + }); + } + }, +); + // Cleanup orphaned repositories (admin only) router.delete( "/cleanup/orphaned", diff --git a/frontend/src/pages/Repositories.jsx b/frontend/src/pages/Repositories.jsx index 88b0fd5..812b2f0 100644 --- a/frontend/src/pages/Repositories.jsx +++ b/frontend/src/pages/Repositories.jsx @@ -1,4 +1,4 @@ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, ArrowDown, @@ -7,7 +7,6 @@ import { Check, Columns, Database, - Eye, GripVertical, Lock, RefreshCw, @@ -15,20 +14,24 @@ import { Server, Shield, ShieldCheck, + Trash2, Unlock, X, } from "lucide-react"; import { useMemo, useState } from "react"; -import { Link } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { repositoryAPI } from "../utils/api"; const Repositories = () => { + const queryClient = useQueryClient(); + const navigate = useNavigate(); const [searchTerm, setSearchTerm] = useState(""); const [filterType, setFilterType] = useState("all"); // all, secure, insecure const [filterStatus, setFilterStatus] = useState("all"); // all, active, inactive const [sortField, setSortField] = useState("name"); const [sortDirection, setSortDirection] = useState("asc"); const [showColumnSettings, setShowColumnSettings] = useState(false); + const [deleteModalData, setDeleteModalData] = useState(null); // Column configuration const [columnConfig, setColumnConfig] = useState(() => { @@ -79,6 +82,15 @@ const Repositories = () => { queryFn: () => repositoryAPI.getStats().then((res) => res.data), }); + // Delete repository mutation + const deleteRepositoryMutation = useMutation({ + mutationFn: (repositoryId) => repositoryAPI.delete(repositoryId), + onSuccess: () => { + queryClient.invalidateQueries(["repositories"]); + queryClient.invalidateQueries(["repository-stats"]); + }, + }); + // Get visible columns in order const visibleColumns = columnConfig .filter((col) => col.visible) @@ -137,6 +149,32 @@ const Repositories = () => { updateColumnConfig(defaultConfig); }; + const handleDeleteRepository = (repo, e) => { + e.preventDefault(); + e.stopPropagation(); + + setDeleteModalData({ + id: repo.id, + name: repo.name, + hostCount: repo.hostCount || 0, + }); + }; + + const handleRowClick = (repo) => { + navigate(`/repositories/${repo.id}`); + }; + + const confirmDelete = () => { + if (deleteModalData) { + deleteRepositoryMutation.mutate(deleteModalData.id); + setDeleteModalData(null); + } + }; + + const cancelDelete = () => { + setDeleteModalData(null); + }; + // Filter and sort repositories const filteredAndSortedRepositories = useMemo(() => { if (!repositories) return []; @@ -224,6 +262,56 @@ const Repositories = () => { return (
+ {/* Delete Confirmation Modal */} + {deleteModalData && ( +
+
+
+ +

+ Delete Repository +

+
+
+

+ Are you sure you want to delete{" "} + "{deleteModalData.name}"? +

+ {deleteModalData.hostCount > 0 && ( +

+ ⚠️ This repository is currently assigned to{" "} + {deleteModalData.hostCount} host + {deleteModalData.hostCount !== 1 ? "s" : ""}. +

+ )} +

+ This action cannot be undone. +

+
+
+ + +
+
+
+ )} + {/* Page Header */}
@@ -414,7 +502,8 @@ const Repositories = () => { {filteredAndSortedRepositories.map((repo) => ( handleRowClick(repo)} > {visibleColumns.map((column) => ( { ); case "actions": return ( - - View - - +
+ +
); default: return null; diff --git a/frontend/src/pages/RepositoryDetail.jsx b/frontend/src/pages/RepositoryDetail.jsx index 8253565..a9edf9e 100644 --- a/frontend/src/pages/RepositoryDetail.jsx +++ b/frontend/src/pages/RepositoryDetail.jsx @@ -10,6 +10,7 @@ import { Server, Shield, ShieldOff, + Trash2, Unlock, } from "lucide-react"; @@ -24,13 +25,14 @@ const RepositoryDetail = () => { const priorityId = useId(); const descriptionId = useId(); const { repositoryId } = useParams(); - const queryClient = useQueryClient(); const navigate = useNavigate(); + const queryClient = useQueryClient(); const [editMode, setEditMode] = useState(false); const [formData, setFormData] = useState({}); const [searchTerm, setSearchTerm] = useState(""); const [currentPage, setCurrentPage] = useState(1); const [pageSize, setPageSize] = useState(25); + const [showDeleteModal, setShowDeleteModal] = useState(false); // Fetch repository details const { @@ -96,6 +98,15 @@ const RepositoryDetail = () => { }, }); + // Delete repository mutation + const deleteRepositoryMutation = useMutation({ + mutationFn: () => repositoryAPI.delete(repositoryId), + onSuccess: () => { + queryClient.invalidateQueries(["repositories"]); + navigate("/repositories"); + }, + }); + const handleEdit = () => { setFormData({ name: repository.name, @@ -115,6 +126,19 @@ const RepositoryDetail = () => { setFormData({}); }; + const handleDelete = () => { + setShowDeleteModal(true); + }; + + const confirmDelete = () => { + deleteRepositoryMutation.mutate(); + setShowDeleteModal(false); + }; + + const cancelDelete = () => { + setShowDeleteModal(false); + }; + if (isLoading) { return (
@@ -174,6 +198,56 @@ const RepositoryDetail = () => { return (
+ {/* Delete Confirmation Modal */} + {showDeleteModal && ( +
+
+
+ +

+ Delete Repository +

+
+
+

+ Are you sure you want to delete{" "} + "{repository?.name}"? +

+ {repository?.host_repositories?.length > 0 && ( +

+ ⚠️ This repository is currently assigned to{" "} + {repository.host_repositories.length} host + {repository.host_repositories.length !== 1 ? "s" : ""}. +

+ )} +

+ This action cannot be undone. +

+
+
+ + +
+
+
+ )} + {/* Header */}
@@ -229,9 +303,24 @@ const RepositoryDetail = () => { ) : ( - + <> + + + )}
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 5fbfeaf..0dddd37 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -132,6 +132,7 @@ export const repositoryAPI = { getByHost: (hostId) => api.get(`/repositories/host/${hostId}`), update: (repositoryId, data) => api.put(`/repositories/${repositoryId}`, data), + delete: (repositoryId) => api.delete(`/repositories/${repositoryId}`), toggleHostRepository: (hostId, repositoryId, isEnabled) => api.patch(`/repositories/host/${hostId}/repository/${repositoryId}`, { isEnabled,