fix(dashboard): restrict task deletion dropzone and mobile actions to creators

This commit is contained in:
Chneemann 2026-08-12 11:43:04 +02:00
parent b386c2b4c5
commit 8d7a4601af
No known key found for this signature in database
7 changed files with 99 additions and 53 deletions

View file

@ -37,6 +37,7 @@ export default function Card({
}
e.dataTransfer.setData("text/plain", task.id);
e.dataTransfer.setData("sourceStatus", task.status);
e.dataTransfer.setData("isCreator", String(task.isCreator ?? false));
e.dataTransfer.effectAllowed = "move";
};
@ -88,6 +89,7 @@ export default function Card({
<CardAvatars creator={task.creator} assignees={task.assignees} />
<CardActions
currentStatus={task.status}
isCreator={task.isCreator ?? false}
onMove={handleMove}
onDelete={() => onDelete?.(task.id)}
/>

View file

@ -21,10 +21,12 @@ import { COLUMNS, TaskStatus } from "@/types/tasks";
*/
export default function CardActions({
currentStatus,
isCreator,
onMove,
onDelete,
}: {
currentStatus: TaskStatus;
isCreator: boolean;
onMove: (newStatus: TaskStatus) => void;
onDelete: () => void;
}) {
@ -113,18 +115,22 @@ export default function CardActions({
</button>
);
})}
<div className="h-px bg-border my-1" />
{/* Delete Button */}
<button
onClick={handleDeleteClick}
className="w-full text-left px-3 py-2 rounded-lg hover:bg-destructive hover:text-foreground flex items-center justify-between text-destructive group/del transition-colors cursor-pointer font-medium"
>
<span className="flex items-center gap-2">
<Trash2 size={12} />
Delete Task
</span>
</button>
{isCreator && (
<>
<div className="h-px bg-border my-2" />
<button
onClick={handleDeleteClick}
className="w-full text-left px-3 py-2 rounded-lg hover:bg-destructive hover:text-foreground flex items-center justify-between text-destructive group/del transition-colors cursor-pointer font-medium"
>
<span className="flex items-center gap-2">
<Trash2 size={12} />
Delete Task
</span>
</button>
</>
)}
</div>
</div>
);

View file

@ -1,6 +1,6 @@
/**
* @file dashboard/header/DeleteDropZone.tsx
* @description Client component rendering an interactive drop zone for deleting tasks during drag-and-drop.
* @description Client component rendering an interactive drop zone for deleting tasks during drag-and-drop operations, managing drag state and drop actions.
*/
"use client";
@ -9,26 +9,49 @@ import { useState, useEffect } from "react";
import { Trash2 } from "lucide-react";
/**
* Renders a drop target zone that appears dynamically during drag-and-drop operations,
* allowing users to delete tasks by dragging them onto the designated area.
* Properties for the DeleteDropZone component.
*
* @param {Object} props - The component props.
* @param {(taskId: string) => void} props.onTaskDelete - Callback triggered when a task is dropped into the delete zone.
* @returns {JSX.Element | null} The rendered delete drop zone component, or null if no drag operation is active.
* @interface DeleteDropZoneProps
* @property {(taskId: string) => void} onTaskDelete - Callback triggered when a valid task is dropped onto the delete zone.
* @property {(isDragging: boolean) => void} onDragStateChange - Callback notified when drag status and permissions change globally.
*/
interface DeleteDropZoneProps {
onTaskDelete: (taskId: string) => void;
onDragStateChange: (isDragging: boolean) => void;
}
/**
* Renders a conditional delete drop zone during active drag events, enabling task deletion
* when dropped onto the target area if authorized.
*
* @param {DeleteDropZoneProps} props - The component props.
* @returns {JSX.Element | null} The rendered drop zone component or null if no authorized drag is active.
*/
export default function DeleteDropZone({
onTaskDelete,
}: {
onTaskDelete: (taskId: string) => void;
}) {
onDragStateChange,
}: DeleteDropZoneProps) {
const [isDragging, setIsDragging] = useState(false);
const [isAllowed, setIsAllowed] = useState(false);
const [isOver, setIsOver] = useState(false);
useEffect(() => {
const handleDragStart = () => setIsDragging(true);
const handleDragStart = (e: DragEvent) => {
const isCreatorString = e.dataTransfer?.getData("isCreator");
const allowed = isCreatorString === "true";
if (allowed) {
setIsDragging(true);
setIsAllowed(true);
onDragStateChange(true);
}
};
const handleDragEnd = () => {
setIsDragging(false);
setIsAllowed(false);
setIsOver(false);
onDragStateChange(false);
};
window.addEventListener("dragstart", handleDragStart);
@ -38,9 +61,9 @@ export default function DeleteDropZone({
window.removeEventListener("dragstart", handleDragStart);
window.removeEventListener("dragend", handleDragEnd);
};
}, []);
}, [onDragStateChange]);
if (!isDragging) return null;
if (!isDragging || !isAllowed) return null;
return (
<div
@ -58,7 +81,7 @@ export default function DeleteDropZone({
onTaskDelete(taskId);
}
}}
className={`peer flex items-center gap-2 px-4 py-2.5 rounded-xl font-medium text-sm border-2 border-dashed cursor-pointer select-none transition-all duration-300 ease-out transform ${
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl font-medium text-sm border-2 border-dashed cursor-pointer select-none transition-all duration-300 ease-out transform ${
isOver
? "bg-destructive border-destructive scale-110 shadow-lg animate-pulse"
: "bg-destructive/10 border-destructive/40 text-destructive scale-100 opacity-90 hover:opacity-100"

View file

@ -9,19 +9,28 @@ import { Sparkles } from "lucide-react";
import DeleteDropZone from "./DeleteDropZone";
import TrashLink from "./TrashLink";
import NewTaskButton from "./NewTaskButton";
import { useState } from "react";
/**
* Properties for the Header component.
*
* @interface HeaderProps
* @property {(taskId: string) => void} onTaskDelete - Callback function triggered when a task is dropped into the delete zone.
*/
interface HeaderProps {
onTaskDelete: (taskId: string) => void;
}
/**
* Renders the dashboard header section featuring title text, a task deletion drop zone,
* a new task action button, and a link to the trash bin.
* and conditionally displays the new task action button and trash link based on the drag state.
*
* @param {HeaderProps} props - The component props.
* @returns {JSX.Element} The rendered dashboard header component.
*/
export default function Header({
onTaskDelete,
}: {
onTaskDelete: (taskId: string) => void;
}) {
export default function Header({ onTaskDelete }: HeaderProps) {
const [isDraggingActive, setIsDraggingActive] = useState(false);
return (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
@ -36,11 +45,16 @@ export default function Header({
</div>
<div className="flex items-center gap-3">
<DeleteDropZone onTaskDelete={onTaskDelete} />
<div className="flex items-center gap-3 peer-not-empty:hidden">
<NewTaskButton />
<TrashLink />
</div>
<DeleteDropZone
onTaskDelete={onTaskDelete}
onDragStateChange={setIsDraggingActive}
/>
{!isDraggingActive && (
<div className="flex items-center gap-3 animate-in fade-in duration-200">
<NewTaskButton />
<TrashLink />
</div>
)}
</div>
</div>
);

View file

@ -4,17 +4,8 @@
*/
import { db } from "@/db";
import { tasksTable, taskAssigneesTable, usersTable } from "@/db/schema";
import {
and,
count,
eq,
inArray,
isNotNull,
isNull,
or,
exists,
} from "drizzle-orm";
import { taskAssigneesTable, usersTable } from "@/db/schema";
import { eq, inArray } from "drizzle-orm";
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import Board from "./Board";
@ -65,8 +56,7 @@ export default async function Dashboard() {
...task,
creator: creatorEmail,
assignees: assigneesMap.get(task.id) || [],
tags: [],
commentsCount: 0,
isCreator: task.userId === currentUserId,
}));
return (

View file

@ -4,7 +4,7 @@
*/
import { db } from "@/db";
import { tasksTable } from "@/db/schema";
import { Task, tasksTable, usersTable } from "@/db/schema";
import { eq, and, isNotNull } from "drizzle-orm";
import { auth } from "@/auth";
import { redirect } from "next/navigation";
@ -22,20 +22,31 @@ export default async function TrashPage() {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const trashedTasks = await db
.select()
const currentUserId = session.user.id;
const rawTrashedTasks = await db
.select({
task: tasksTable,
creatorEmail: usersTable.email,
})
.from(tasksTable)
.innerJoin(usersTable, eq(tasksTable.userId, usersTable.id))
.where(
and(
eq(tasksTable.userId, session.user.id),
eq(tasksTable.userId, currentUserId),
isNotNull(tasksTable.deletedAt),
),
);
const tasks: Task[] = rawTrashedTasks.map(({ task }) => ({
...task,
isCreator: task.userId === currentUserId,
}));
return (
<div className="space-y-8 max-w-7xl mx-auto pb-12">
<TrashHeader />
<TrashList tasks={trashedTasks} />
<TrashList tasks={tasks} />
</div>
);
}

View file

@ -21,9 +21,9 @@ export type TaskPriority = (typeof taskPriorityEnum.enumValues)[number];
// ==========================================
export interface Task extends Omit<DbTask, "dueDate"> {
dueDate?: Date | null;
assignees?: string[];
creator?: string;
isCreator?: boolean;
}
export interface RouteContext {