mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-10-23 07:42:05 +00:00
feat: add repository deletion functionality with confirmation modal
This commit is contained in:
@@ -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",
|
||||
|
@@ -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 (
|
||||
<div className="h-[calc(100vh-7rem)] flex flex-col overflow-hidden">
|
||||
{/* Delete Confirmation Modal */}
|
||||
{deleteModalData && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<div className="flex items-center mb-4">
|
||||
<AlertTriangle className="h-6 w-6 text-red-500 mr-3" />
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Delete Repository
|
||||
</h3>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<p className="text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
Are you sure you want to delete{" "}
|
||||
<strong>"{deleteModalData.name}"</strong>?
|
||||
</p>
|
||||
{deleteModalData.hostCount > 0 && (
|
||||
<p className="text-amber-600 dark:text-amber-400 text-sm">
|
||||
⚠️ This repository is currently assigned to{" "}
|
||||
{deleteModalData.hostCount} host
|
||||
{deleteModalData.hostCount !== 1 ? "s" : ""}.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-red-600 dark:text-red-400 text-sm mt-2">
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelDelete}
|
||||
className="px-4 py-2 text-secondary-600 dark:text-secondary-400 hover:text-secondary-800 dark:hover:text-secondary-200 transition-colors"
|
||||
disabled={deleteRepositoryMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confirmDelete}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={deleteRepositoryMutation.isPending}
|
||||
>
|
||||
{deleteRepositoryMutation.isPending
|
||||
? "Deleting..."
|
||||
: "Delete Repository"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
@@ -414,7 +502,8 @@ const Repositories = () => {
|
||||
{filteredAndSortedRepositories.map((repo) => (
|
||||
<tr
|
||||
key={repo.id}
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors cursor-pointer"
|
||||
onClick={() => handleRowClick(repo)}
|
||||
>
|
||||
{visibleColumns.map((column) => (
|
||||
<td
|
||||
@@ -518,13 +607,17 @@ const Repositories = () => {
|
||||
);
|
||||
case "actions":
|
||||
return (
|
||||
<Link
|
||||
to={`/repositories/${repo.id}`}
|
||||
className="text-primary-600 hover:text-primary-900 flex items-center gap-1"
|
||||
>
|
||||
View
|
||||
<Eye className="h-3 w-3" />
|
||||
</Link>
|
||||
<div className="flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleDeleteRepository(repo, e)}
|
||||
className="text-orange-600 hover:text-red-900 dark:text-orange-600 dark:hover:text-red-400 flex items-center gap-1"
|
||||
disabled={deleteRepositoryMutation.isPending}
|
||||
title="Delete repository"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
|
@@ -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 (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
@@ -174,6 +198,56 @@ const RepositoryDetail = () => {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<div className="flex items-center mb-4">
|
||||
<AlertTriangle className="h-6 w-6 text-red-500 mr-3" />
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Delete Repository
|
||||
</h3>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<p className="text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
Are you sure you want to delete{" "}
|
||||
<strong>"{repository?.name}"</strong>?
|
||||
</p>
|
||||
{repository?.host_repositories?.length > 0 && (
|
||||
<p className="text-amber-600 dark:text-amber-400 text-sm">
|
||||
⚠️ This repository is currently assigned to{" "}
|
||||
{repository.host_repositories.length} host
|
||||
{repository.host_repositories.length !== 1 ? "s" : ""}.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-red-600 dark:text-red-400 text-sm mt-2">
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelDelete}
|
||||
className="px-4 py-2 text-secondary-600 dark:text-secondary-400 hover:text-secondary-800 dark:hover:text-secondary-200 transition-colors"
|
||||
disabled={deleteRepositoryMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confirmDelete}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={deleteRepositoryMutation.isPending}
|
||||
>
|
||||
{deleteRepositoryMutation.isPending
|
||||
? "Deleting..."
|
||||
: "Delete Repository"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -229,9 +303,24 @@ const RepositoryDetail = () => {
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" onClick={handleEdit} className="btn-primary">
|
||||
Edit Repository
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
className="btn-outline border-red-200 text-red-600 hover:bg-red-50 hover:border-red-300 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-900/20 dark:hover:border-red-700 flex items-center gap-2"
|
||||
disabled={deleteRepositoryMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{deleteRepositoryMutation.isPending ? "Deleting..." : "Delete"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEdit}
|
||||
className="btn-primary"
|
||||
>
|
||||
Edit Repository
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -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,
|
||||
|
Reference in New Issue
Block a user