feat: add repository deletion functionality with confirmation modal

This commit is contained in:
tigattack
2025-10-01 01:14:30 +01:00
parent 71b27b4bcf
commit 32ab004f3f
4 changed files with 269 additions and 15 deletions

View File

@@ -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",

View File

@@ -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;

View File

@@ -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>

View File

@@ -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,