From 64f9f560b2034fcc2ff64eac832befabfc1e6339 Mon Sep 17 00:00:00 2001 From: Tin Date: Sat, 8 Aug 2026 12:28:55 +0200 Subject: [PATCH 1/3] fix(web): respect granular task permissions Fixes #1505 --- .../bulk-selection/backlog-bulk-toolbar.tsx | 55 +++++++----- .../bulk-selection/bulk-toolbar.tsx | 57 ++++++++----- .../kanban-board/column/column-header.tsx | 4 +- .../task-card-context-menu-content.tsx | 58 ++++++++----- .../shared/modals/create-task-modal.test.tsx | 2 +- .../shared/modals/create-task-modal.tsx | 4 +- .../task/subtask-status-popover.tsx | 4 +- .../src/components/task/task-description.tsx | 4 +- .../components/task/task-due-date-popover.tsx | 4 +- .../components/task/task-labels-popover.tsx | 42 ++++----- .../components/task/task-priority-popover.tsx | 4 +- .../src/components/task/task-relations.tsx | 4 +- .../task/task-start-date-popover.tsx | 4 +- .../task/task-status-popover.test.tsx | 2 +- .../components/task/task-status-popover.tsx | 4 +- .../components/task/task-subtasks.test.tsx | 2 +- .../web/src/components/task/task-subtasks.tsx | 4 +- apps/web/src/components/task/task-title.tsx | 4 +- .../fetchers/label/attach-label-to-task.ts | 24 ++++++ .../fetchers/label/detach-label-from-task.ts | 19 +++++ .../label/use-attach-label-to-task.ts | 54 ++++++++++++ .../label/use-detach-label-from-task.ts | 46 ++++++++++ .../hooks/use-workspace-permission.test.tsx | 73 ++++++++++++++++ .../web/src/hooks/use-workspace-permission.ts | 14 ++- .../dashboard/settings/workspace/labels.tsx | 85 ++++++++++--------- 25 files changed, 422 insertions(+), 155 deletions(-) create mode 100644 apps/web/src/fetchers/label/attach-label-to-task.ts create mode 100644 apps/web/src/fetchers/label/detach-label-from-task.ts create mode 100644 apps/web/src/hooks/mutations/label/use-attach-label-to-task.ts create mode 100644 apps/web/src/hooks/mutations/label/use-detach-label-from-task.ts create mode 100644 apps/web/src/hooks/use-workspace-permission.test.tsx diff --git a/apps/web/src/components/bulk-selection/backlog-bulk-toolbar.tsx b/apps/web/src/components/bulk-selection/backlog-bulk-toolbar.tsx index b45e5d155..c39c0b4dc 100644 --- a/apps/web/src/components/bulk-selection/backlog-bulk-toolbar.tsx +++ b/apps/web/src/components/bulk-selection/backlog-bulk-toolbar.tsx @@ -104,9 +104,12 @@ function BacklogBulkToolbar() { const { data: workspaceLabels = [] } = useGetLabelsByWorkspace( workspace?.id ?? "", ); - const { canManageTasks, canAssignTasks } = useWorkspacePermission(); - const canEdit = canManageTasks(); + const { canUpdateTasks, canDeleteTasks, canAssignTasks, canUpdateLabels } = + useWorkspacePermission(); + const canEdit = canUpdateTasks(); + const canDelete = canDeleteTasks(); const canAssign = canAssignTasks(); + const canEditLabels = canUpdateLabels(); const [isActionsOpen, setIsActionsOpen] = useState(false); const [isDatePickerOpen, setIsDatePickerOpen] = useState(false); @@ -261,27 +264,35 @@ function BacklogBulkToolbar() { const groupedItems = useMemo(() => { const groups: BacklogActionGroup[] = []; - if (canEdit) { + if (canEdit || canDelete) { groups.push({ value: "actions", label: t("tasks:bulk.actions"), items: [ - { - value: "bulk-delete", - label: t("tasks:bulk.delete"), - icon: , - onRun: () => { - void handleBulkDelete(); - }, - }, - { - value: "bulk-archive", - label: t("tasks:bulk.archive"), - icon: , - onRun: () => { - void handleBulkArchive(); - }, - }, + ...(canDelete + ? [ + { + value: "bulk-delete", + label: t("tasks:bulk.delete"), + icon: , + onRun: () => { + void handleBulkDelete(); + }, + }, + ] + : []), + ...(canEdit + ? [ + { + value: "bulk-archive", + label: t("tasks:bulk.archive"), + icon: , + onRun: () => { + void handleBulkArchive(); + }, + }, + ] + : []), ], }); } @@ -322,6 +333,8 @@ function BacklogBulkToolbar() { }, })), }); + } + if (canEditLabels) { groups.push({ value: "label", label: t("tasks:bulk.addLabel"), @@ -347,7 +360,9 @@ function BacklogBulkToolbar() { return groups; }, [ canEdit, + canDelete, canAssign, + canEditLabels, workspaceUsers?.members, uniqueLabels, handleBulkDelete, @@ -360,7 +375,7 @@ function BacklogBulkToolbar() { ]); if (selectedCount === 0) return null; - if (!canEdit && !canAssign) return null; + if (!canEdit && !canDelete && !canAssign && !canEditLabels) return null; return (
diff --git a/apps/web/src/components/bulk-selection/bulk-toolbar.tsx b/apps/web/src/components/bulk-selection/bulk-toolbar.tsx index a431fc221..6fd91395b 100644 --- a/apps/web/src/components/bulk-selection/bulk-toolbar.tsx +++ b/apps/web/src/components/bulk-selection/bulk-toolbar.tsx @@ -98,9 +98,12 @@ function BulkToolbar() { const { data: workspaceLabels = [] } = useGetLabelsByWorkspace( workspace?.id ?? "", ); - const { canManageTasks, canAssignTasks } = useWorkspacePermission(); - const canEdit = canManageTasks(); + const { canUpdateTasks, canDeleteTasks, canAssignTasks, canUpdateLabels } = + useWorkspacePermission(); + const canEdit = canUpdateTasks(); + const canDelete = canDeleteTasks(); const canAssign = canAssignTasks(); + const canEditLabels = canUpdateLabels(); const [isActionsOpen, setIsActionsOpen] = useState(false); const [isDatePickerOpen, setIsDatePickerOpen] = useState(false); @@ -266,29 +269,39 @@ function BulkToolbar() { const groupedItems = useMemo(() => { const groups: BulkActionGroup[] = []; - if (canEdit) { + if (canEdit || canDelete) { groups.push({ value: "actions", label: t("tasks:bulk.actions"), items: [ - { - value: "bulk-delete", - label: t("tasks:bulk.delete"), - icon: , - onRun: () => { - void handleBulkDelete(); - }, - }, - { - value: "bulk-archive", - label: t("tasks:bulk.archive"), - icon: , - onRun: () => { - void handleBulkArchive(); - }, - }, + ...(canDelete + ? [ + { + value: "bulk-delete", + label: t("tasks:bulk.delete"), + icon: , + onRun: () => { + void handleBulkDelete(); + }, + }, + ] + : []), + ...(canEdit + ? [ + { + value: "bulk-archive", + label: t("tasks:bulk.archive"), + icon: , + onRun: () => { + void handleBulkArchive(); + }, + }, + ] + : []), ], }); + } + if (canEdit) { groups.push({ value: "status", label: t("tasks:bulk.changeStatus"), @@ -339,6 +352,8 @@ function BulkToolbar() { }, })), }); + } + if (canEditLabels) { groups.push({ value: "label", label: t("tasks:bulk.addLabel"), @@ -364,7 +379,9 @@ function BulkToolbar() { return groups; }, [ canEdit, + canDelete, canAssign, + canEditLabels, project?.columns, workspaceUsers?.members, uniqueLabels, @@ -380,7 +397,7 @@ function BulkToolbar() { if (selectedCount === 0) return null; // Nothing the user can do in bulk → no toolbar. - if (!canEdit && !canAssign) return null; + if (!canEdit && !canDelete && !canAssign && !canEditLabels) return null; return (
diff --git a/apps/web/src/components/kanban-board/column/column-header.tsx b/apps/web/src/components/kanban-board/column/column-header.tsx index 58aeb71dd..08464c22d 100644 --- a/apps/web/src/components/kanban-board/column/column-header.tsx +++ b/apps/web/src/components/kanban-board/column/column-header.tsx @@ -19,8 +19,8 @@ export function ColumnHeader({ column }: ColumnHeaderProps) { const { t } = useTranslation(); const { project, setProject } = useProjectStore(); const { mutate: updateTask } = useUpdateTask(); - const { canManageTasks, canCreateTasks } = useWorkspacePermission(); - const canTask = canManageTasks(); + const { canUpdateTasks, canCreateTasks } = useWorkspacePermission(); + const canTask = canUpdateTasks(); const canCreate = canCreateTasks(); const [isArchiveModalOpen, setIsArchiveModalOpen] = useState(false); diff --git a/apps/web/src/components/kanban-board/task-card-context-menu/task-card-context-menu-content.tsx b/apps/web/src/components/kanban-board/task-card-context-menu/task-card-context-menu-content.tsx index 96a6fbb3f..c4c842661 100644 --- a/apps/web/src/components/kanban-board/task-card-context-menu/task-card-context-menu-content.tsx +++ b/apps/web/src/components/kanban-board/task-card-context-menu/task-card-context-menu-content.tsx @@ -74,8 +74,10 @@ export default function TaskCardContextMenuContent({ const { mutateAsync: updateTaskTitle } = useUpdateTaskTitle(); const { mutateAsync: updateTaskDescription } = useUpdateTaskDescription(); const { mutateAsync: updateTaskDueDate } = useUpdateTaskDueDate(); - const { canManageTasks, canAssignTasks } = useWorkspacePermission(); - const canEdit = canManageTasks(); + const { canUpdateTasks, canDeleteTasks, canAssignTasks } = + useWorkspacePermission(); + const canEdit = canUpdateTasks(); + const canDelete = canDeleteTasks(); const canAssign = canAssignTasks(); const usersOptions = useMemo(() => { @@ -293,31 +295,43 @@ export default function TaskCardContextMenuContent({ )} - {canEdit && ( + {(canEdit || canDelete) && ( <> - + {canEdit && ( + <> + - handleChange("status", "archived")}> - {t("tasks:actions.archive")} - + handleChange("status", "archived")} + > + {t("tasks:actions.archive")} + - handleChange("status", "planned")}> - {t("tasks:actions.markAsPlanned")} - + handleChange("status", "planned")} + > + {t("tasks:actions.markAsPlanned")} + + + )} - + {canDelete && ( + <> + - { - e.preventDefault(); - setTimeout(() => { - onDeleteClick(); - }, 0); - }} - > - {t("tasks:actions.delete")} - + { + e.preventDefault(); + setTimeout(() => { + onDeleteClick(); + }, 0); + }} + > + {t("tasks:actions.delete")} + + + )} )} diff --git a/apps/web/src/components/shared/modals/create-task-modal.test.tsx b/apps/web/src/components/shared/modals/create-task-modal.test.tsx index 90d85fe97..f76cee1ae 100644 --- a/apps/web/src/components/shared/modals/create-task-modal.test.tsx +++ b/apps/web/src/components/shared/modals/create-task-modal.test.tsx @@ -59,7 +59,7 @@ vi.mock( vi.mock("@/hooks/use-workspace-permission", () => ({ useWorkspacePermission: () => ({ canCreateTasks: () => true, - canManageLabels: () => true, + canCreateLabels: () => true, }), })); diff --git a/apps/web/src/components/shared/modals/create-task-modal.tsx b/apps/web/src/components/shared/modals/create-task-modal.tsx index b4c188f10..cb3f627d7 100644 --- a/apps/web/src/components/shared/modals/create-task-modal.tsx +++ b/apps/web/src/components/shared/modals/create-task-modal.tsx @@ -178,9 +178,9 @@ function CreateTaskModal({ const { data: workspaceLabels = [] } = useGetLabelsByWorkspace( workspace?.id || "", ); - const { canCreateTasks, canManageLabels } = useWorkspacePermission(); + const { canCreateTasks, canCreateLabels } = useWorkspacePermission(); const canCreateTaskCapability = canCreateTasks(); - const canCreateLabelCapability = canManageLabels(); + const canCreateLabelCapability = canCreateLabels(); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); diff --git a/apps/web/src/components/task/subtask-status-popover.tsx b/apps/web/src/components/task/subtask-status-popover.tsx index 142cf6681..fba6188fe 100644 --- a/apps/web/src/components/task/subtask-status-popover.tsx +++ b/apps/web/src/components/task/subtask-status-popover.tsx @@ -38,8 +38,8 @@ export default function SubtaskStatusPopover({ isFinal: col.isFinal, })); const { mutateAsync: updateTaskStatus } = useUpdateTaskStatus(); - const { canManageTasks } = useWorkspacePermission(); - const canEdit = canManageTasks(); + const { canUpdateTasks } = useWorkspacePermission(); + const canEdit = canUpdateTasks(); const allSameStatus = tasks.length > 0 && tasks.every((t) => t.status === tasks[0].status); diff --git a/apps/web/src/components/task/task-description.tsx b/apps/web/src/components/task/task-description.tsx index 84f600f44..00bdfda33 100644 --- a/apps/web/src/components/task/task-description.tsx +++ b/apps/web/src/components/task/task-description.tsx @@ -264,8 +264,8 @@ export default function TaskDescription({ taskId }: TaskDescriptionProps) { const { t } = useTranslation(); const { data: task } = useGetTask(taskId); const { mutateAsync: updateTaskDescription } = useUpdateTaskDescription(); - const { canManageTasks } = useWorkspacePermission(); - const canEdit = canManageTasks(); + const { canUpdateTasks } = useWorkspacePermission(); + const canEdit = canUpdateTasks(); const editorShellRef = useRef(null); const imageInputRef = useRef(null); diff --git a/apps/web/src/components/task/task-due-date-popover.tsx b/apps/web/src/components/task/task-due-date-popover.tsx index 7b222b2bf..e87ad51e7 100644 --- a/apps/web/src/components/task/task-due-date-popover.tsx +++ b/apps/web/src/components/task/task-due-date-popover.tsx @@ -25,8 +25,8 @@ export default function TaskDueDatePopover({ const { t } = useTranslation(); const [open, setOpen] = useState(false); const { mutateAsync: updateTaskDueDate } = useUpdateTaskDueDate(); - const { canManageTasks } = useWorkspacePermission(); - const canEdit = canManageTasks(); + const { canUpdateTasks } = useWorkspacePermission(); + const canEdit = canUpdateTasks(); const handleDateChange = async (date: Date | undefined) => { try { diff --git a/apps/web/src/components/task/task-labels-popover.tsx b/apps/web/src/components/task/task-labels-popover.tsx index 2e07c89e6..91f78f46b 100644 --- a/apps/web/src/components/task/task-labels-popover.tsx +++ b/apps/web/src/components/task/task-labels-popover.tsx @@ -8,8 +8,9 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import useAttachLabelToTask from "@/hooks/mutations/label/use-attach-label-to-task"; import useCreateLabel from "@/hooks/mutations/label/use-create-label"; -import useDeleteLabel from "@/hooks/mutations/label/use-delete-label"; +import useDetachLabelFromTask from "@/hooks/mutations/label/use-detach-label-from-task"; import useGetLabelsByTask from "@/hooks/queries/label/use-get-labels-by-task"; import useGetLabelsByWorkspace from "@/hooks/queries/label/use-get-labels-by-workspace"; import { useWorkspacePermission } from "@/hooks/use-workspace-permission"; @@ -65,15 +66,12 @@ export default function TaskLabelsPopover({ const searchInputRef = useRef(null); const queryClient = useQueryClient(); + const { mutateAsync: attachLabel } = useAttachLabelToTask(); const { mutateAsync: createLabel } = useCreateLabel(); - const { mutateAsync: deleteLabel } = useDeleteLabel(); - // Attaching/removing labels from a task is a task mutation; creating a new - // workspace label needs the label capability. We gate the popover trigger - // on whichever is required: any flow needs at least task-edit since the - // result lives on the task. - const { canManageTasks, canManageLabels } = useWorkspacePermission(); - const canEdit = canManageTasks(); - const canCreateLabels = canManageLabels(); + const { mutateAsync: detachLabel } = useDetachLabelFromTask(); + const { canCreateLabels, canUpdateLabels } = useWorkspacePermission(); + const canCreate = canCreateLabels(); + const canEdit = canUpdateLabels(); const { data: taskLabels = [] } = useGetLabelsByTask(task.id); const { data: workspaceLabels = [] } = useGetLabelsByWorkspace(workspaceId); @@ -139,16 +137,13 @@ export default function TaskLabelsPopover({ (l) => l.name === workspaceLabel.name, ); if (taskLabel?.id) { - await deleteLabel({ id: taskLabel.id }); + await detachLabel({ labelId: taskLabel.id }); toast.success(t("tasks:popover.labels.removeSuccess")); } } else { - // Add label to task - await createLabel({ - name: workspaceLabel.name, - color: workspaceLabel.color as LabelColor, + await attachLabel({ + labelId: workspaceLabel.id, taskId: task.id, - workspaceId, }); toast.success(t("tasks:popover.labels.addSuccess")); } @@ -178,18 +173,15 @@ export default function TaskLabelsPopover({ try { // First create the label in the workspace - await createLabel({ + const createdLabel = await createLabel({ name: newLabelName.trim(), color: color, workspaceId, }); - // Then assign it to the task - await createLabel({ - name: newLabelName.trim(), - color: color, + await attachLabel({ + labelId: createdLabel.id, taskId: task.id, - workspaceId, }); await queryClient.invalidateQueries({ @@ -250,10 +242,10 @@ export default function TaskLabelsPopover({ ))} - {canCreateLabels && isCreatingNewLabel && filteredLabels.length > 0 && ( + {canCreate && isCreatingNewLabel && filteredLabels.length > 0 && (
)} - {canCreateLabels && isCreatingNewLabel && ( + {canCreate && isCreatingNewLabel && (
- {canEdit && ( + {(canUpdate || canDelete) && (
- - + {canUpdate && ( + + )} + {canDelete && ( + + )}
)}
From 642fe11b96f8588708ddb942c61fcbf2fbdb1dc3 Mon Sep 17 00:00:00 2001 From: Tin Date: Sat, 8 Aug 2026 13:51:40 +0200 Subject: [PATCH 2/3] fix(web): address task permission review feedback --- .../src/components/task/task-description.tsx | 100 ++++++++++-------- .../components/task/task-labels-popover.tsx | 26 +++-- .../components/task/task-subtasks.test.tsx | 28 ++++- .../web/src/components/task/task-subtasks.tsx | 9 +- .../src/lib/get-task-label-options.test.ts | 27 +++++ apps/web/src/lib/get-task-label-options.ts | 22 ++++ 6 files changed, 146 insertions(+), 66 deletions(-) create mode 100644 apps/web/src/lib/get-task-label-options.test.ts create mode 100644 apps/web/src/lib/get-task-label-options.ts diff --git a/apps/web/src/components/task/task-description.tsx b/apps/web/src/components/task/task-description.tsx index 00bdfda33..cdef036b4 100644 --- a/apps/web/src/components/task/task-description.tsx +++ b/apps/web/src/components/task/task-description.tsx @@ -266,6 +266,8 @@ export default function TaskDescription({ taskId }: TaskDescriptionProps) { const { mutateAsync: updateTaskDescription } = useUpdateTaskDescription(); const { canUpdateTasks } = useWorkspacePermission(); const canEdit = canUpdateTasks(); + const canEditRef = useRef(canEdit); + canEditRef.current = canEdit; const editorShellRef = useRef(null); const imageInputRef = useRef(null); @@ -389,6 +391,7 @@ export default function TaskDescription({ taskId }: TaskDescriptionProps) { const handleAssetFileUpload = useCallback( async (file: File, targetEditor?: Editor | null, range?: SlashRange) => { + if (!canEditRef.current) return; const activeEditor = targetEditor || lastEditorRef.current; if (!activeEditor) { @@ -428,6 +431,7 @@ export default function TaskDescription({ taskId }: TaskDescriptionProps) { const openImagePicker = useCallback( (activeEditor?: Editor | null, range?: SlashRange) => { + if (!canEditRef.current) return; pendingImageInsertRef.current = activeEditor ? { editor: activeEditor, range } : null; @@ -444,7 +448,7 @@ export default function TaskDescription({ taskId }: TaskDescriptionProps) { const handleShellDragEnter = useCallback( (event: React.DragEvent) => { - if (!taskId || !hasFileDrag(event)) return; + if (!canEditRef.current || !taskId || !hasFileDrag(event)) return; event.preventDefault(); dragDepthRef.current += 1; setIsDragActive(true); @@ -454,7 +458,7 @@ export default function TaskDescription({ taskId }: TaskDescriptionProps) { const handleShellDragOver = useCallback( (event: React.DragEvent) => { - if (!taskId || !hasFileDrag(event)) return; + if (!canEditRef.current || !taskId || !hasFileDrag(event)) return; event.preventDefault(); event.dataTransfer.dropEffect = "copy"; if (!isDragActive) { @@ -466,7 +470,7 @@ export default function TaskDescription({ taskId }: TaskDescriptionProps) { const handleShellDragLeave = useCallback( (event: React.DragEvent) => { - if (!taskId || !hasFileDrag(event)) return; + if (!canEditRef.current || !taskId || !hasFileDrag(event)) return; event.preventDefault(); dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); if (dragDepthRef.current === 0) { @@ -478,7 +482,7 @@ export default function TaskDescription({ taskId }: TaskDescriptionProps) { const handleShellDrop = useCallback( (event: React.DragEvent) => { - if (!taskId || !hasFileDrag(event)) return; + if (!canEditRef.current || !taskId || !hasFileDrag(event)) return; dragDepthRef.current = 0; setIsDragActive(false); }, @@ -750,7 +754,7 @@ export default function TaskDescription({ taskId }: TaskDescriptionProps) { }, }, onUpdate: ({ editor: activeEditor }) => { - if (isSyncingExternalContentRef.current) return; + if (!canEditRef.current || isSyncingExternalContentRef.current) return; const markdown = formatMarkdown(activeEditor.getMarkdown()); if (markdown === latestSyncedMarkdownRef.current) return; latestSyncedMarkdownRef.current = markdown; @@ -768,7 +772,7 @@ export default function TaskDescription({ taskId }: TaskDescriptionProps) { }, [editor, shikiHighlighter]); // Toggle Tiptap's editable flag based on workspace permission. When the - // user can't manage tasks, the description renders as read-only: slash + // user can't update tasks, the description renders as read-only: slash // menus, paste handlers, and toolbar buttons all become no-ops because // the editor refuses content mutations. useEffect(() => { @@ -830,7 +834,7 @@ export default function TaskDescription({ taskId }: TaskDescriptionProps) { const setLink = useCallback( (prefilledUrl?: string) => { - if (!editor) return; + if (!canEditRef.current || !editor) return; const previousUrl = editor.getAttributes("link").href as | string | undefined; @@ -896,7 +900,7 @@ export default function TaskDescription({ taskId }: TaskDescriptionProps) { const runSlashCommand = useCallback( (command: SlashCommand) => { - if (!editor || !slashMenuRef.current) return; + if (!canEditRef.current || !editor || !slashMenuRef.current) return; command.run(editor, { from: slashMenuRef.current.from, to: slashMenuRef.current.to, @@ -1016,7 +1020,7 @@ export default function TaskDescription({ taskId }: TaskDescriptionProps) { const submitEmbedComposer = useCallback( (mode: "embed" | "link") => { - if (!editor || !embedComposer) return; + if (!canEditRef.current || !editor || !embedComposer) return; const url = normalizeUrl(embedComposer.url); if (!url) { setEmbedComposerError(t("tasks:detail.editor.embed.errors.invalidUrl")); @@ -1169,7 +1173,7 @@ export default function TaskDescription({ taskId }: TaskDescriptionProps) { }, [filteredSlashCommands, slashMenu]); const setCodeLanguage = (language: string | null) => { - if (!editor || !hoveredCodeBlock) return; + if (!canEdit || !editor || !hoveredCodeBlock) return; const { nodePos } = hoveredCodeBlock; const resolvedLanguage = language || "auto"; @@ -1400,45 +1404,47 @@ export default function TaskDescription({ taskId }: TaskDescriptionProps) { : t("tasks:detail.editor.copy")} - - - - - - + + + - - {t("tasks:detail.editor.autoDetect")} - - - {codeLanguages.map(({ value, label }) => ( - - {label} + + + {t("tasks:detail.editor.autoDetect")} - ))} - - - + + {codeLanguages.map(({ value, label }) => ( + + {label} + + ))} + + + + )}
)} - {editor && ( + {editor && canEdit && ( )} - {editor && ( + {editor && canEdit && ( )} - {editor && slashMenu && ( + {editor && canEdit && slashMenu && (
)} - {editor && embedComposer && ( + {editor && canEdit && embedComposer && (
)} - {isDragActive && ( + {canEdit && isDragActive && (
{t("tasks:detail.editor.dropToUpload")}
diff --git a/apps/web/src/components/task/task-labels-popover.tsx b/apps/web/src/components/task/task-labels-popover.tsx index 91f78f46b..64c61b8c5 100644 --- a/apps/web/src/components/task/task-labels-popover.tsx +++ b/apps/web/src/components/task/task-labels-popover.tsx @@ -15,6 +15,7 @@ import useGetLabelsByTask from "@/hooks/queries/label/use-get-labels-by-task"; import useGetLabelsByWorkspace from "@/hooks/queries/label/use-get-labels-by-workspace"; import { useWorkspacePermission } from "@/hooks/use-workspace-permission"; import { cn } from "@/lib/cn"; +import { getTaskLabelOptions } from "@/lib/get-task-label-options"; import { toast } from "@/lib/toast"; import type Task from "@/types/task"; @@ -81,29 +82,25 @@ export default function TaskLabelsPopover({ [taskLabels], ); + const workspaceLevelLabels = useMemo( + () => workspaceLabels.filter((label) => label.taskId === null), + [workspaceLabels], + ); + const filteredLabels = useMemo(() => { - const searchFiltered = workspaceLabels.filter((label) => + const selectableLabels = getTaskLabelOptions(workspaceLabels, task.id); + return selectableLabels.filter((label) => label.name.toLowerCase().includes(searchValue.toLowerCase()), ); - - const labelMap = new Map(); - for (const label of searchFiltered) { - const existing = labelMap.get(label.name); - if (!existing || (label.taskId === null && existing.taskId !== null)) { - labelMap.set(label.name, label); - } - } - - return Array.from(labelMap.values()); - }, [workspaceLabels, searchValue]); + }, [workspaceLabels, searchValue, task.id]); const isCreatingNewLabel = useMemo( () => searchValue && - !workspaceLabels.some( + !workspaceLevelLabels.some( (label) => label.name.toLowerCase() === searchValue.toLowerCase(), ), - [workspaceLabels, searchValue], + [workspaceLevelLabels, searchValue], ); useEffect(() => { @@ -141,6 +138,7 @@ export default function TaskLabelsPopover({ toast.success(t("tasks:popover.labels.removeSuccess")); } } else { + if (workspaceLabel.taskId !== null) return; await attachLabel({ labelId: workspaceLabel.id, taskId: task.id, diff --git a/apps/web/src/components/task/task-subtasks.test.tsx b/apps/web/src/components/task/task-subtasks.test.tsx index 9a556885b..221d8ed54 100644 --- a/apps/web/src/components/task/task-subtasks.test.tsx +++ b/apps/web/src/components/task/task-subtasks.test.tsx @@ -9,6 +9,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import TaskSubtasks from "./task-subtasks"; const mocks = vi.hoisted(() => ({ + canCreateTasks: vi.fn(), + canUpdateTasks: vi.fn(), createTask: vi.fn(), createRelation: vi.fn(), getColumns: vi.fn(), @@ -49,11 +51,16 @@ vi.mock( }), ); vi.mock("@/hooks/use-workspace-permission", () => ({ - useWorkspacePermission: () => ({ canUpdateTasks: () => true }), + useWorkspacePermission: () => ({ + canCreateTasks: mocks.canCreateTasks, + canUpdateTasks: mocks.canUpdateTasks, + }), })); vi.mock("@/lib/toast", () => ({ toast: { error: vi.fn(), success: vi.fn() } })); beforeEach(() => { + mocks.canCreateTasks.mockReturnValue(true); + mocks.canUpdateTasks.mockReturnValue(true); mocks.getColumns.mockReturnValue({ data: [ { id: "todo", slug: "to-do", name: "To Do", isFinal: false }, @@ -151,4 +158,23 @@ describe("TaskSubtasks", () => { }), ).toBeDisabled(); }); + + it("hides subtask creation without task-create permission", () => { + mocks.canCreateTasks.mockReturnValue(false); + + render( + , + ); + + expect( + screen.queryByRole("button", { + name: "tasks:subtasks.addAction tasks:subtasks.title", + }), + ).not.toBeInTheDocument(); + }); }); diff --git a/apps/web/src/components/task/task-subtasks.tsx b/apps/web/src/components/task/task-subtasks.tsx index 37c515c17..737777209 100644 --- a/apps/web/src/components/task/task-subtasks.tsx +++ b/apps/web/src/components/task/task-subtasks.tsx @@ -68,8 +68,9 @@ export default function TaskSubtasks({ const { mutateAsync: updateTaskStatus } = useUpdateTaskStatus(); const { data: columns = [], isLoading: isLoadingColumns } = useGetColumns(projectId); - const { canUpdateTasks } = useWorkspacePermission(); + const { canCreateTasks, canUpdateTasks } = useWorkspacePermission(); const canEdit = canUpdateTasks(); + const canCreate = canCreateTasks(); // Map the completion checkbox to the project's actual column slugs (the API // validates status against columns). A subtask counts as completed when its @@ -258,7 +259,7 @@ export default function TaskSubtasks({ ]); const handleAddSubtask = async () => { - if (!newTitle.trim()) return; + if (!canCreate || !canEdit || !newTitle.trim()) return; const initialStatus = parentStatus === "planned" ? "planned" : todoSlug; if (!initialStatus) return; @@ -337,7 +338,7 @@ export default function TaskSubtasks({ )}
- {canEdit && ( + {canEdit && canCreate && (
- {isAdding && ( + {isAdding && canEdit && canCreate && (
{ + it("excludes labels assigned to other tasks", () => { + const labels = [ + { id: "workspace", name: "Bug", taskId: null }, + { id: "current", name: "Imported", taskId: "task-1" }, + { id: "other", name: "Other", taskId: "task-2" }, + ]; + + const options = getTaskLabelOptions(labels, "task-1"); + + expect(options.map((label) => label.id)).toEqual(["workspace", "current"]); + }); + + it("prefers a workspace label over a task-scoped copy", () => { + const labels = [ + { id: "task-copy", name: "Bug", taskId: "task-1" }, + { id: "workspace", name: "Bug", taskId: null }, + ]; + + const options = getTaskLabelOptions(labels, "task-1"); + + expect(options).toEqual([{ id: "workspace", name: "Bug", taskId: null }]); + }); +}); diff --git a/apps/web/src/lib/get-task-label-options.ts b/apps/web/src/lib/get-task-label-options.ts new file mode 100644 index 000000000..6c0d3e63e --- /dev/null +++ b/apps/web/src/lib/get-task-label-options.ts @@ -0,0 +1,22 @@ +type TaskScopedLabel = { + name: string; + taskId: string | null; +}; + +export function getTaskLabelOptions( + labels: T[], + taskId: string, +) { + const labelMap = new Map(); + + for (const label of labels) { + if (label.taskId !== null && label.taskId !== taskId) continue; + + const existing = labelMap.get(label.name); + if (!existing || (label.taskId === null && existing.taskId !== null)) { + labelMap.set(label.name, label); + } + } + + return Array.from(labelMap.values()); +} From c7b5910629d771f89e902efac06e9e5eb3ade5c5 Mon Sep 17 00:00:00 2001 From: Tin Date: Sun, 9 Aug 2026 12:39:04 +0200 Subject: [PATCH 3/3] fix(web): recheck description edit permission --- apps/web/src/components/task/task-description.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/components/task/task-description.tsx b/apps/web/src/components/task/task-description.tsx index cdef036b4..0e97f08f4 100644 --- a/apps/web/src/components/task/task-description.tsx +++ b/apps/web/src/components/task/task-description.tsx @@ -532,6 +532,8 @@ export default function TaskDescription({ taskId }: TaskDescriptionProps) { const debouncedUpdate = useCallback( debounce(async (markdown: string) => { + if (!canEditRef.current) return; + const currentTask = taskRef.current; const updateTaskFn = updateTaskRef.current; if (!currentTask || !updateTaskFn) return;