feat: add card permissions
This commit is contained in:
71
apps/web/src/hooks/usePermissions.ts
Normal file
71
apps/web/src/hooks/usePermissions.ts
Normal file
@@ -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"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "react-icons/hi2";
|
} from "react-icons/hi2";
|
||||||
|
|
||||||
import Dropdown from "~/components/Dropdown";
|
import Dropdown from "~/components/Dropdown";
|
||||||
|
import { usePermissions } from "~/hooks/usePermissions";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
@@ -39,8 +40,10 @@ export default function List({
|
|||||||
setSelectedPublicListId,
|
setSelectedPublicListId,
|
||||||
}: ListProps) {
|
}: ListProps) {
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
|
const { canCreateCard, canDeleteList } = usePermissions();
|
||||||
|
|
||||||
const openNewCardForm = (publicListId: PublicListId) => {
|
const openNewCardForm = (publicListId: PublicListId) => {
|
||||||
|
if (!canCreateCard) return;
|
||||||
openModal("NEW_CARD");
|
openModal("NEW_CARD");
|
||||||
setSelectedPublicListId(publicListId);
|
setSelectedPublicListId(publicListId);
|
||||||
};
|
};
|
||||||
@@ -94,32 +97,42 @@ export default function List({
|
|||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<button
|
{canCreateCard && (
|
||||||
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-400 dark:hover:bg-dark-200"
|
<button
|
||||||
onClick={() => openNewCardForm(list.publicId)}
|
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-400 dark:hover:bg-dark-200"
|
||||||
>
|
onClick={() => openNewCardForm(list.publicId)}
|
||||||
<HiOutlinePlusSmall
|
>
|
||||||
className="h-5 w-5 text-dark-900"
|
<HiOutlinePlusSmall
|
||||||
aria-hidden="true"
|
className="h-5 w-5 text-dark-900"
|
||||||
/>
|
aria-hidden="true"
|
||||||
</button>
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="relative mr-1 inline-block">
|
<div className="relative mr-1 inline-block">
|
||||||
<Dropdown
|
<Dropdown
|
||||||
items={[
|
items={[
|
||||||
{
|
...(canCreateCard
|
||||||
label: t`Add a card`,
|
? [
|
||||||
action: () => openNewCardForm(list.publicId),
|
{
|
||||||
icon: (
|
label: t`Add a card`,
|
||||||
<HiOutlineSquaresPlus className="h-[18px] w-[18px] text-dark-900" />
|
action: () => openNewCardForm(list.publicId),
|
||||||
),
|
icon: (
|
||||||
},
|
<HiOutlineSquaresPlus className="h-[18px] w-[18px] text-dark-900" />
|
||||||
{
|
),
|
||||||
label: t`Delete list`,
|
},
|
||||||
action: handleOpenDeleteListConfirmation,
|
]
|
||||||
icon: (
|
: []),
|
||||||
<HiOutlineTrash className="h-[18px] w-[18px] text-dark-900" />
|
...(canDeleteList
|
||||||
),
|
? [
|
||||||
},
|
{
|
||||||
|
label: t`Delete list`,
|
||||||
|
action: handleOpenDeleteListConfirmation,
|
||||||
|
icon: (
|
||||||
|
<HiOutlineTrash className="h-[18px] w-[18px] text-dark-900" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppab
|
|||||||
import { Tooltip } from "~/components/Tooltip";
|
import { Tooltip } from "~/components/Tooltip";
|
||||||
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
|
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
|
||||||
import { useDragToScroll } from "~/hooks/useDragToScroll";
|
import { useDragToScroll } from "~/hooks/useDragToScroll";
|
||||||
|
import { usePermissions } from "~/hooks/usePermissions";
|
||||||
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
@@ -63,11 +64,13 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
direction: "horizontal",
|
direction: "horizontal",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { canCreateList } = usePermissions();
|
||||||
|
|
||||||
const { tooltipContent: createListShortcutTooltipContent } =
|
const { tooltipContent: createListShortcutTooltipContent } =
|
||||||
useKeyboardShortcut({
|
useKeyboardShortcut({
|
||||||
type: "PRESS",
|
type: "PRESS",
|
||||||
stroke: { key: "C" },
|
stroke: { key: "C" },
|
||||||
action: () => boardId && openNewListForm(boardId),
|
action: () => boardId && canCreateList && openNewListForm(boardId),
|
||||||
description: t`Create new list`,
|
description: t`Create new list`,
|
||||||
group: "ACTIONS",
|
group: "ACTIONS",
|
||||||
});
|
});
|
||||||
@@ -460,22 +463,24 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Tooltip content={createListShortcutTooltipContent}>
|
{canCreateList && (
|
||||||
<Button
|
<Tooltip content={createListShortcutTooltipContent}>
|
||||||
iconLeft={
|
<Button
|
||||||
<HiOutlinePlusSmall
|
iconLeft={
|
||||||
className="-mr-0.5 h-5 w-5"
|
<HiOutlinePlusSmall
|
||||||
aria-hidden="true"
|
className="-mr-0.5 h-5 w-5"
|
||||||
/>
|
aria-hidden="true"
|
||||||
}
|
/>
|
||||||
onClick={() => {
|
}
|
||||||
if (boardId) openNewListForm(boardId);
|
onClick={() => {
|
||||||
}}
|
if (boardId) openNewListForm(boardId);
|
||||||
disabled={!boardData}
|
}}
|
||||||
>
|
disabled={!boardData}
|
||||||
{t`New list`}
|
>
|
||||||
</Button>
|
{t`New list`}
|
||||||
</Tooltip>
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<BoardDropdown
|
<BoardDropdown
|
||||||
isTemplate={!!isTemplate}
|
isTemplate={!!isTemplate}
|
||||||
isLoading={!boardData}
|
isLoading={!boardData}
|
||||||
@@ -506,16 +511,20 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
{t`No lists`}
|
{t`No lists`}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[14px] text-light-900 dark:text-dark-900">
|
<p className="text-[14px] text-light-900 dark:text-dark-900">
|
||||||
{t`Get started by creating a new list`}
|
{canCreateList
|
||||||
|
? t`Get started by creating a new list`
|
||||||
|
: t`No lists have been created yet`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{canCreateList && (
|
||||||
onClick={() => {
|
<Button
|
||||||
if (boardId) openNewListForm(boardId);
|
onClick={() => {
|
||||||
}}
|
if (boardId) openNewListForm(boardId);
|
||||||
>
|
}}
|
||||||
{t`Create new list`}
|
>
|
||||||
</Button>
|
{t`Create new list`}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DragDropContext onDragEnd={onDragEnd}>
|
<DragDropContext onDragEnd={onDragEnd}>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { HiEllipsisHorizontal, HiPencil, HiTrash } from "react-icons/hi2";
|
|||||||
import Avatar from "~/components/Avatar";
|
import Avatar from "~/components/Avatar";
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import Dropdown from "~/components/Dropdown";
|
import Dropdown from "~/components/Dropdown";
|
||||||
|
import { usePermissions } from "~/hooks/usePermissions";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
@@ -49,6 +50,7 @@ const Comment = ({
|
|||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
|
const { canEditComment, canDeleteComment } = usePermissions();
|
||||||
const { handleSubmit, setValue, watch } = useForm<FormValues>({
|
const { handleSubmit, setValue, watch } = useForm<FormValues>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
comment,
|
comment,
|
||||||
@@ -80,7 +82,7 @@ const Comment = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const dropdownItems = [
|
const dropdownItems = [
|
||||||
...(isAuthor
|
...(isAuthor && canEditComment
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: t`Edit comment`,
|
label: t`Edit comment`,
|
||||||
@@ -89,7 +91,7 @@ const Comment = ({
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(isAuthor || isAdmin
|
...((isAuthor || isAdmin) && canDeleteComment
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: t`Delete comment`,
|
label: t`Delete comment`,
|
||||||
|
|||||||
@@ -6,28 +6,44 @@ import {
|
|||||||
} from "react-icons/hi2";
|
} from "react-icons/hi2";
|
||||||
|
|
||||||
import Dropdown from "~/components/Dropdown";
|
import Dropdown from "~/components/Dropdown";
|
||||||
|
import { usePermissions } from "~/hooks/usePermissions";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
|
|
||||||
export default function BoardDropdown() {
|
export default function CardDropdown() {
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
|
const { canEditCard, canDeleteCard } = usePermissions();
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
...(canEditCard
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: t`Add checklist`,
|
||||||
|
action: () => openModal("ADD_CHECKLIST"),
|
||||||
|
icon: (
|
||||||
|
<HiOutlineCheckCircle className="h-[16px] w-[16px] text-dark-900" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(canDeleteCard
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: t`Delete card`,
|
||||||
|
action: () => openModal("DELETE_CARD"),
|
||||||
|
icon: (
|
||||||
|
<HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown
|
<Dropdown items={items}>
|
||||||
items={[
|
|
||||||
{
|
|
||||||
label: t`Add checklist`,
|
|
||||||
action: () => openModal("ADD_CHECKLIST"),
|
|
||||||
icon: (
|
|
||||||
<HiOutlineCheckCircle className="h-[16px] w-[16px] text-dark-900" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t`Delete card`,
|
|
||||||
action: () => openModal("DELETE_CARD"),
|
|
||||||
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useForm } from "react-hook-form";
|
|||||||
import { HiOutlineArrowUp } from "react-icons/hi2";
|
import { HiOutlineArrowUp } from "react-icons/hi2";
|
||||||
|
|
||||||
import LoadingSpinner from "~/components/LoadingSpinner";
|
import LoadingSpinner from "~/components/LoadingSpinner";
|
||||||
|
import { usePermissions } from "~/hooks/usePermissions";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||||
@@ -15,6 +16,7 @@ interface FormValues {
|
|||||||
const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
|
const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
|
const { canCreateComment } = usePermissions();
|
||||||
const { handleSubmit, setValue, watch, reset } = useForm<FormValues>({
|
const { handleSubmit, setValue, watch, reset } = useForm<FormValues>({
|
||||||
values: {
|
values: {
|
||||||
comment: "",
|
comment: "",
|
||||||
@@ -46,6 +48,10 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!canCreateComment) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import Modal from "~/components/modal";
|
|||||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
|
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
|
||||||
|
import { usePermissions } from "~/hooks/usePermissions";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
@@ -44,6 +45,7 @@ interface FormValues {
|
|||||||
|
|
||||||
export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { canEditCard } = usePermissions();
|
||||||
const cardId = Array.isArray(router.query.cardId)
|
const cardId = Array.isArray(router.query.cardId)
|
||||||
? router.query.cardId[0]
|
? router.query.cardId[0]
|
||||||
: router.query.cardId;
|
: router.query.cardId;
|
||||||
@@ -110,40 +112,44 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-[360px] border-l-[1px] border-light-300 bg-light-50 p-8 text-light-900 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900">
|
<div className="h-full w-[360px] border-l-[1px] border-light-300 bg-light-50 p-8 text-light-900 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900">
|
||||||
<div className="mb-4 flex w-full flex-row pt-[18px]">
|
{canEditCard && (
|
||||||
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`List`}</p>
|
<>
|
||||||
<ListSelector
|
<div className="mb-4 flex w-full flex-row pt-[18px]">
|
||||||
cardPublicId={cardId ?? ""}
|
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`List`}</p>
|
||||||
lists={formattedLists}
|
<ListSelector
|
||||||
isLoading={!card}
|
cardPublicId={cardId ?? ""}
|
||||||
/>
|
lists={formattedLists}
|
||||||
</div>
|
isLoading={!card}
|
||||||
<div className="mb-4 flex w-full flex-row">
|
/>
|
||||||
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Labels`}</p>
|
</div>
|
||||||
<LabelSelector
|
<div className="mb-4 flex w-full flex-row">
|
||||||
cardPublicId={cardId ?? ""}
|
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Labels`}</p>
|
||||||
labels={formattedLabels}
|
<LabelSelector
|
||||||
isLoading={!card}
|
cardPublicId={cardId ?? ""}
|
||||||
/>
|
labels={formattedLabels}
|
||||||
</div>
|
isLoading={!card}
|
||||||
{!isTemplate && (
|
/>
|
||||||
<div className="mb-4 flex w-full flex-row">
|
</div>
|
||||||
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Members`}</p>
|
{!isTemplate && (
|
||||||
<MemberSelector
|
<div className="mb-4 flex w-full flex-row">
|
||||||
cardPublicId={cardId ?? ""}
|
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Members`}</p>
|
||||||
members={formattedMembers}
|
<MemberSelector
|
||||||
isLoading={!card}
|
cardPublicId={cardId ?? ""}
|
||||||
/>
|
members={formattedMembers}
|
||||||
</div>
|
isLoading={!card}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mb-4 flex w-full flex-row">
|
||||||
|
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Due date`}</p>
|
||||||
|
<DueDateSelector
|
||||||
|
cardPublicId={cardId ?? ""}
|
||||||
|
dueDate={card?.dueDate}
|
||||||
|
isLoading={!card}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="mb-4 flex w-full flex-row">
|
|
||||||
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Due date`}</p>
|
|
||||||
<DueDateSelector
|
|
||||||
cardPublicId={cardId ?? ""}
|
|
||||||
dueDate={card?.dueDate}
|
|
||||||
isLoading={!card}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -162,6 +168,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
} = useModal();
|
} = useModal();
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
const { workspace } = useWorkspace();
|
const { workspace } = useWorkspace();
|
||||||
|
const { canEditCard } = usePermissions();
|
||||||
const [activeChecklistForm, setActiveChecklistForm] = useState<string | null>(
|
const [activeChecklistForm, setActiveChecklistForm] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -326,9 +333,10 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
<textarea
|
<textarea
|
||||||
id="title"
|
id="title"
|
||||||
{...register("title")}
|
{...register("title")}
|
||||||
onBlur={handleSubmit(onSubmit)}
|
onBlur={canEditCard ? handleSubmit(onSubmit) : undefined}
|
||||||
rows={1}
|
rows={1}
|
||||||
className="block w-full resize-none overflow-hidden border-0 bg-transparent p-0 py-0 font-bold leading-relaxed text-neutral-900 focus:ring-0 dark:text-dark-1000 sm:text-[1.2rem]"
|
disabled={!canEditCard}
|
||||||
|
className={`block w-full resize-none overflow-hidden border-0 bg-transparent p-0 py-0 font-bold leading-relaxed text-neutral-900 focus:ring-0 dark:text-dark-1000 sm:text-[1.2rem] ${!canEditCard ? "cursor-default" : ""}`}
|
||||||
onInput={(e) => {
|
onInput={(e) => {
|
||||||
const target = e.target as HTMLTextAreaElement;
|
const target = e.target as HTMLTextAreaElement;
|
||||||
target.style.height = "auto";
|
target.style.height = "auto";
|
||||||
@@ -354,9 +362,10 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<Editor
|
<Editor
|
||||||
content={card.description}
|
content={card.description}
|
||||||
onChange={(e) => setValue("description", e)}
|
onChange={canEditCard ? (e) => setValue("description", e) : undefined}
|
||||||
onBlur={() => handleSubmit(onSubmit)()}
|
onBlur={canEditCard ? () => handleSubmit(onSubmit)() : undefined}
|
||||||
workspaceMembers={board?.workspace.members ?? []}
|
workspaceMembers={board?.workspace.members ?? []}
|
||||||
|
readOnly={!canEditCard}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -366,6 +375,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
cardPublicId={cardId}
|
cardPublicId={cardId}
|
||||||
activeChecklistForm={activeChecklistForm}
|
activeChecklistForm={activeChecklistForm}
|
||||||
setActiveChecklistForm={setActiveChecklistForm}
|
setActiveChecklistForm={setActiveChecklistForm}
|
||||||
|
viewOnly={!canEditCard}
|
||||||
/>
|
/>
|
||||||
{!isTemplate && (
|
{!isTemplate && (
|
||||||
<>
|
<>
|
||||||
@@ -374,12 +384,15 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
<AttachmentThumbnails
|
<AttachmentThumbnails
|
||||||
attachments={card.attachments}
|
attachments={card.attachments}
|
||||||
cardPublicId={cardId ?? ""}
|
cardPublicId={cardId ?? ""}
|
||||||
|
isReadOnly={!canEditCard}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mt-6">
|
{canEditCard && (
|
||||||
<AttachmentUpload cardPublicId={cardId} />
|
<div className="mt-6">
|
||||||
</div>
|
<AttachmentUpload cardPublicId={cardId} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="border-t-[1px] border-light-300 pt-12 dark:border-dark-300">
|
<div className="border-t-[1px] border-light-300 pt-12 dark:border-dark-300">
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
|||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||||
import { mergeActivities } from "../utils/activities";
|
import { mergeActivities } from "../utils/activities";
|
||||||
import { assertUserInWorkspace } from "../utils/auth";
|
import { assertPermission } from "../utils/permissions";
|
||||||
import { generateDownloadUrl } from "../utils/s3";
|
import { generateDownloadUrl } from "../utils/s3";
|
||||||
|
|
||||||
export const cardRouter = createTRPCRouter({
|
export const cardRouter = createTRPCRouter({
|
||||||
@@ -57,13 +57,7 @@ export const cardRouter = createTRPCRouter({
|
|||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, list.workspaceId);
|
await assertPermission(ctx.db, userId, list.workspaceId, "card:create");
|
||||||
|
|
||||||
if (!userId)
|
|
||||||
throw new TRPCError({
|
|
||||||
message: `User not authenticated`,
|
|
||||||
code: "UNAUTHORIZED",
|
|
||||||
});
|
|
||||||
|
|
||||||
const newCard = await cardRepo.create(ctx.db, {
|
const newCard = await cardRepo.create(ctx.db, {
|
||||||
title: input.title,
|
title: input.title,
|
||||||
@@ -199,7 +193,7 @@ export const cardRouter = createTRPCRouter({
|
|||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
await assertPermission(ctx.db, userId, card.workspaceId, "comment:create");
|
||||||
|
|
||||||
const newComment = await cardCommentRepo.create(ctx.db, {
|
const newComment = await cardCommentRepo.create(ctx.db, {
|
||||||
comment: input.comment,
|
comment: input.comment,
|
||||||
@@ -262,7 +256,7 @@ export const cardRouter = createTRPCRouter({
|
|||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
await assertPermission(ctx.db, userId, card.workspaceId, "comment:edit");
|
||||||
|
|
||||||
const existingComment = await cardCommentRepo.getByPublicId(
|
const existingComment = await cardCommentRepo.getByPublicId(
|
||||||
ctx.db,
|
ctx.db,
|
||||||
@@ -340,7 +334,7 @@ export const cardRouter = createTRPCRouter({
|
|||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
await assertPermission(ctx.db, userId, card.workspaceId, "comment:delete");
|
||||||
|
|
||||||
const existingComment = await cardCommentRepo.getByPublicId(
|
const existingComment = await cardCommentRepo.getByPublicId(
|
||||||
ctx.db,
|
ctx.db,
|
||||||
@@ -412,7 +406,7 @@ export const cardRouter = createTRPCRouter({
|
|||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
await assertPermission(ctx.db, userId, card.workspaceId, "card:edit");
|
||||||
|
|
||||||
const label = await labelRepo.getByPublicId(ctx.db, input.labelPublicId);
|
const label = await labelRepo.getByPublicId(ctx.db, input.labelPublicId);
|
||||||
|
|
||||||
@@ -504,7 +498,7 @@ export const cardRouter = createTRPCRouter({
|
|||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
await assertPermission(ctx.db, userId, card.workspaceId, "card:edit");
|
||||||
|
|
||||||
const member = await workspaceRepo.getMemberByPublicId(
|
const member = await workspaceRepo.getMemberByPublicId(
|
||||||
ctx.db,
|
ctx.db,
|
||||||
@@ -616,7 +610,7 @@ export const cardRouter = createTRPCRouter({
|
|||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
});
|
});
|
||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
await assertPermission(ctx.db, userId, card.workspaceId, "card:view");
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await cardRepo.getWithListAndMembersByPublicId(
|
const result = await cardRepo.getWithListAndMembersByPublicId(
|
||||||
@@ -725,7 +719,7 @@ export const cardRouter = createTRPCRouter({
|
|||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
});
|
});
|
||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
await assertPermission(ctx.db, userId, card.workspaceId, "card:view");
|
||||||
}
|
}
|
||||||
|
|
||||||
const cursor = input.cursor ? new Date(input.cursor) : undefined;
|
const cursor = input.cursor ? new Date(input.cursor) : undefined;
|
||||||
@@ -788,7 +782,7 @@ export const cardRouter = createTRPCRouter({
|
|||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
await assertPermission(ctx.db, userId, card.workspaceId, "card:edit");
|
||||||
|
|
||||||
const existingCard = await cardRepo.getByPublicId(
|
const existingCard = await cardRepo.getByPublicId(
|
||||||
ctx.db,
|
ctx.db,
|
||||||
@@ -958,7 +952,7 @@ export const cardRouter = createTRPCRouter({
|
|||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
await assertPermission(ctx.db, userId, card.workspaceId, "card:delete");
|
||||||
|
|
||||||
const deletedAt = new Date();
|
const deletedAt = new Date();
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,28 @@ export const getMemberPermissionOverrides = async (
|
|||||||
.where(eq(workspaceMemberPermissions.workspaceMemberId, workspaceMemberId));
|
.where(eq(workspaceMemberPermissions.workspaceMemberId, workspaceMemberId));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single permission override for a member
|
||||||
|
*/
|
||||||
|
export const getMemberPermissionOverride = async (
|
||||||
|
db: dbClient,
|
||||||
|
workspaceMemberId: number,
|
||||||
|
permission: string,
|
||||||
|
) => {
|
||||||
|
const [override] = await db
|
||||||
|
.select()
|
||||||
|
.from(workspaceMemberPermissions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(workspaceMemberPermissions.workspaceMemberId, workspaceMemberId),
|
||||||
|
eq(workspaceMemberPermissions.permission, permission),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return override;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get effective permissions for a workspace member
|
* Get effective permissions for a workspace member
|
||||||
* Combines role template (from DB) with custom overrides
|
* Combines role template (from DB) with custom overrides
|
||||||
|
|||||||
Reference in New Issue
Block a user