From e86e59c817f0b80601ac21293d59c1e648d9afa9 Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 26 Jan 2026 16:44:53 +0000 Subject: [PATCH] feat: add card permissions --- apps/web/src/hooks/usePermissions.ts | 71 ++++++++++++++ apps/web/src/views/board/components/List.tsx | 59 +++++++----- apps/web/src/views/board/index.tsx | 59 +++++++----- .../web/src/views/card/components/Comment.tsx | 6 +- .../src/views/card/components/Dropdown.tsx | 50 ++++++---- .../views/card/components/NewCommentForm.tsx | 6 ++ apps/web/src/views/card/index.tsx | 93 +++++++++++-------- packages/api/src/routers/card.ts | 28 +++--- packages/db/src/repository/permission.repo.ts | 22 +++++ 9 files changed, 270 insertions(+), 124 deletions(-) create mode 100644 apps/web/src/hooks/usePermissions.ts diff --git a/apps/web/src/hooks/usePermissions.ts b/apps/web/src/hooks/usePermissions.ts new file mode 100644 index 00000000..c44ec4db --- /dev/null +++ b/apps/web/src/hooks/usePermissions.ts @@ -0,0 +1,71 @@ +import type { Permission } from "@kan/shared"; + +import { useWorkspace } from "~/providers/workspace"; +import { api } from "~/utils/api"; + +interface UsePermissionsResult { + permissions: Permission[]; + role: string | null; + isLoading: boolean; + hasPermission: (permission: Permission) => boolean; + canViewCard: boolean; + canCreateCard: boolean; + canEditCard: boolean; + canDeleteCard: boolean; + canCreateList: boolean; + canEditList: boolean; + canDeleteList: boolean; + canCreateBoard: boolean; + canEditBoard: boolean; + canDeleteBoard: boolean; + canViewComment: boolean; + canCreateComment: boolean; + canEditComment: boolean; + canDeleteComment: boolean; + canInviteMember: boolean; + canEditMember: boolean; + canRemoveMember: boolean; +} + +export function usePermissions(): UsePermissionsResult { + const { workspace } = useWorkspace(); + + const { data, isLoading } = api.permission.getMyPermissions.useQuery( + { workspacePublicId: workspace.publicId }, + { + enabled: !!workspace.publicId, + }, + ); + + const permissions = (data?.permissions ?? []) as Permission[]; + const role = data?.role ?? null; + + const hasPermission = (permission: Permission): boolean => { + return permissions.includes(permission); + }; + + return { + permissions, + role, + isLoading, + hasPermission, + canViewCard: hasPermission("card:view"), + canCreateCard: hasPermission("card:create"), + canEditCard: hasPermission("card:edit"), + canDeleteCard: hasPermission("card:delete"), + canCreateList: hasPermission("list:create"), + canEditList: hasPermission("list:edit"), + canDeleteList: hasPermission("list:delete"), + canCreateBoard: hasPermission("board:create"), + canEditBoard: hasPermission("board:edit"), + canDeleteBoard: hasPermission("board:delete"), + canViewComment: hasPermission("comment:view"), + canCreateComment: hasPermission("comment:create"), + canEditComment: hasPermission("comment:edit"), + canDeleteComment: hasPermission("comment:delete"), + canInviteMember: hasPermission("member:invite"), + canEditMember: hasPermission("member:edit"), + canRemoveMember: hasPermission("member:remove"), + }; +} + diff --git a/apps/web/src/views/board/components/List.tsx b/apps/web/src/views/board/components/List.tsx index c2654ae7..18a3cb83 100644 --- a/apps/web/src/views/board/components/List.tsx +++ b/apps/web/src/views/board/components/List.tsx @@ -10,6 +10,7 @@ import { } from "react-icons/hi2"; import Dropdown from "~/components/Dropdown"; +import { usePermissions } from "~/hooks/usePermissions"; import { useModal } from "~/providers/modal"; import { api } from "~/utils/api"; @@ -39,8 +40,10 @@ export default function List({ setSelectedPublicListId, }: ListProps) { const { openModal } = useModal(); + const { canCreateCard, canDeleteList } = usePermissions(); const openNewCardForm = (publicListId: PublicListId) => { + if (!canCreateCard) return; openModal("NEW_CARD"); setSelectedPublicListId(publicListId); }; @@ -94,32 +97,42 @@ export default function List({ />
- + {canCreateCard && ( + + )}
openNewCardForm(list.publicId), - icon: ( - - ), - }, - { - label: t`Delete list`, - action: handleOpenDeleteListConfirmation, - icon: ( - - ), - }, + ...(canCreateCard + ? [ + { + label: t`Add a card`, + action: () => openNewCardForm(list.publicId), + icon: ( + + ), + }, + ] + : []), + ...(canDeleteList + ? [ + { + label: t`Delete list`, + action: handleOpenDeleteListConfirmation, + icon: ( + + ), + }, + ] + : []), ]} > diff --git a/apps/web/src/views/board/index.tsx b/apps/web/src/views/board/index.tsx index 2854386a..267c3067 100644 --- a/apps/web/src/views/board/index.tsx +++ b/apps/web/src/views/board/index.tsx @@ -26,6 +26,7 @@ import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppab import { Tooltip } from "~/components/Tooltip"; import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal"; import { useDragToScroll } from "~/hooks/useDragToScroll"; +import { usePermissions } from "~/hooks/usePermissions"; import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts"; import { useModal } from "~/providers/modal"; import { usePopup } from "~/providers/popup"; @@ -63,11 +64,13 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) { direction: "horizontal", }); + const { canCreateList } = usePermissions(); + const { tooltipContent: createListShortcutTooltipContent } = useKeyboardShortcut({ type: "PRESS", stroke: { key: "C" }, - action: () => boardId && openNewListForm(boardId), + action: () => boardId && canCreateList && openNewListForm(boardId), description: t`Create new list`, group: "ACTIONS", }); @@ -460,22 +463,24 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) { )} )} - - - + {canCreateList && ( + + + + )}

- {t`Get started by creating a new list`} + {canCreateList + ? t`Get started by creating a new list` + : t`No lists have been created yet`}

- + {canCreateList && ( + + )}
) : ( diff --git a/apps/web/src/views/card/components/Comment.tsx b/apps/web/src/views/card/components/Comment.tsx index 8fcd4176..b51b076c 100644 --- a/apps/web/src/views/card/components/Comment.tsx +++ b/apps/web/src/views/card/components/Comment.tsx @@ -8,6 +8,7 @@ import { HiEllipsisHorizontal, HiPencil, HiTrash } from "react-icons/hi2"; import Avatar from "~/components/Avatar"; import Button from "~/components/Button"; import Dropdown from "~/components/Dropdown"; +import { usePermissions } from "~/hooks/usePermissions"; import { useModal } from "~/providers/modal"; import { usePopup } from "~/providers/popup"; import { api } from "~/utils/api"; @@ -49,6 +50,7 @@ const Comment = ({ const utils = api.useUtils(); const { showPopup } = usePopup(); const { openModal } = useModal(); + const { canEditComment, canDeleteComment } = usePermissions(); const { handleSubmit, setValue, watch } = useForm({ defaultValues: { comment, @@ -80,7 +82,7 @@ const Comment = ({ }; const dropdownItems = [ - ...(isAuthor + ...(isAuthor && canEditComment ? [ { label: t`Edit comment`, @@ -89,7 +91,7 @@ const Comment = ({ }, ] : []), - ...(isAuthor || isAdmin + ...((isAuthor || isAdmin) && canDeleteComment ? [ { label: t`Delete comment`, diff --git a/apps/web/src/views/card/components/Dropdown.tsx b/apps/web/src/views/card/components/Dropdown.tsx index 67b05023..af8f2309 100644 --- a/apps/web/src/views/card/components/Dropdown.tsx +++ b/apps/web/src/views/card/components/Dropdown.tsx @@ -6,28 +6,44 @@ import { } from "react-icons/hi2"; import Dropdown from "~/components/Dropdown"; +import { usePermissions } from "~/hooks/usePermissions"; import { useModal } from "~/providers/modal"; -export default function BoardDropdown() { +export default function CardDropdown() { const { openModal } = useModal(); + const { canEditCard, canDeleteCard } = usePermissions(); + + const items = [ + ...(canEditCard + ? [ + { + label: t`Add checklist`, + action: () => openModal("ADD_CHECKLIST"), + icon: ( + + ), + }, + ] + : []), + ...(canDeleteCard + ? [ + { + label: t`Delete card`, + action: () => openModal("DELETE_CARD"), + icon: ( + + ), + }, + ] + : []), + ]; + + if (items.length === 0) { + return null; + } return ( - openModal("ADD_CHECKLIST"), - icon: ( - - ), - }, - { - label: t`Delete card`, - action: () => openModal("DELETE_CARD"), - icon: , - }, - ]} - > + ); diff --git a/apps/web/src/views/card/components/NewCommentForm.tsx b/apps/web/src/views/card/components/NewCommentForm.tsx index 3dc8e2ac..3cd9b1e5 100644 --- a/apps/web/src/views/card/components/NewCommentForm.tsx +++ b/apps/web/src/views/card/components/NewCommentForm.tsx @@ -4,6 +4,7 @@ import { useForm } from "react-hook-form"; import { HiOutlineArrowUp } from "react-icons/hi2"; import LoadingSpinner from "~/components/LoadingSpinner"; +import { usePermissions } from "~/hooks/usePermissions"; import { usePopup } from "~/providers/popup"; import { api } from "~/utils/api"; import { invalidateCard } from "~/utils/cardInvalidation"; @@ -15,6 +16,7 @@ interface FormValues { const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => { const utils = api.useUtils(); const { showPopup } = usePopup(); + const { canCreateComment } = usePermissions(); const { handleSubmit, setValue, watch, reset } = useForm({ values: { comment: "", @@ -46,6 +48,10 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => { }); }; + if (!canCreateComment) { + return null; + } + return (
-
-

{t`List`}

- -
-
-

{t`Labels`}

- -
- {!isTemplate && ( -
-

{t`Members`}

- -
+ {canEditCard && ( + <> +
+

{t`List`}

+ +
+
+

{t`Labels`}

+ +
+ {!isTemplate && ( +
+

{t`Members`}

+ +
+ )} +
+

{t`Due date`}

+ +
+ )} -
-

{t`Due date`}

- -
); } @@ -162,6 +168,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) { } = useModal(); const { showPopup } = usePopup(); const { workspace } = useWorkspace(); + const { canEditCard } = usePermissions(); const [activeChecklistForm, setActiveChecklistForm] = useState( null, ); @@ -326,9 +333,10 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {