feat: add card permissions

This commit is contained in:
Henry
2026-01-26 16:44:53 +00:00
parent 090028d827
commit e86e59c817
9 changed files with 270 additions and 124 deletions

View 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"),
};
}

View File

@@ -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({
/>
</form>
<div className="flex items-center">
<button
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"
aria-hidden="true"
/>
</button>
{canCreateCard && (
<button
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"
aria-hidden="true"
/>
</button>
)}
<div className="relative mr-1 inline-block">
<Dropdown
items={[
{
label: t`Add a card`,
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" />
),
},
...(canCreateCard
? [
{
label: t`Add a card`,
action: () => openNewCardForm(list.publicId),
icon: (
<HiOutlineSquaresPlus 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" />

View File

@@ -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 }) {
)}
</>
)}
<Tooltip content={createListShortcutTooltipContent}>
<Button
iconLeft={
<HiOutlinePlusSmall
className="-mr-0.5 h-5 w-5"
aria-hidden="true"
/>
}
onClick={() => {
if (boardId) openNewListForm(boardId);
}}
disabled={!boardData}
>
{t`New list`}
</Button>
</Tooltip>
{canCreateList && (
<Tooltip content={createListShortcutTooltipContent}>
<Button
iconLeft={
<HiOutlinePlusSmall
className="-mr-0.5 h-5 w-5"
aria-hidden="true"
/>
}
onClick={() => {
if (boardId) openNewListForm(boardId);
}}
disabled={!boardData}
>
{t`New list`}
</Button>
</Tooltip>
)}
<BoardDropdown
isTemplate={!!isTemplate}
isLoading={!boardData}
@@ -506,16 +511,20 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
{t`No lists`}
</p>
<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>
</div>
<Button
onClick={() => {
if (boardId) openNewListForm(boardId);
}}
>
{t`Create new list`}
</Button>
{canCreateList && (
<Button
onClick={() => {
if (boardId) openNewListForm(boardId);
}}
>
{t`Create new list`}
</Button>
)}
</div>
) : (
<DragDropContext onDragEnd={onDragEnd}>

View File

@@ -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<FormValues>({
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`,

View File

@@ -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: (
<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 (
<Dropdown
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" />,
},
]}
>
<Dropdown items={items}>
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
</Dropdown>
);

View File

@@ -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<FormValues>({
values: {
comment: "",
@@ -46,6 +48,10 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
});
};
if (!canCreateComment) {
return null;
}
return (
<form
onSubmit={handleSubmit(onSubmit)}

View File

@@ -14,6 +14,7 @@ import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
@@ -44,6 +45,7 @@ interface FormValues {
export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
const router = useRouter();
const { canEditCard } = usePermissions();
const cardId = Array.isArray(router.query.cardId)
? router.query.cardId[0]
: router.query.cardId;
@@ -110,40 +112,44 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
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="mb-4 flex w-full flex-row pt-[18px]">
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`List`}</p>
<ListSelector
cardPublicId={cardId ?? ""}
lists={formattedLists}
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`Labels`}</p>
<LabelSelector
cardPublicId={cardId ?? ""}
labels={formattedLabels}
isLoading={!card}
/>
</div>
{!isTemplate && (
<div className="mb-4 flex w-full flex-row">
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Members`}</p>
<MemberSelector
cardPublicId={cardId ?? ""}
members={formattedMembers}
isLoading={!card}
/>
</div>
{canEditCard && (
<>
<div className="mb-4 flex w-full flex-row pt-[18px]">
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`List`}</p>
<ListSelector
cardPublicId={cardId ?? ""}
lists={formattedLists}
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`Labels`}</p>
<LabelSelector
cardPublicId={cardId ?? ""}
labels={formattedLabels}
isLoading={!card}
/>
</div>
{!isTemplate && (
<div className="mb-4 flex w-full flex-row">
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Members`}</p>
<MemberSelector
cardPublicId={cardId ?? ""}
members={formattedMembers}
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>
);
}
@@ -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<string | null>(
null,
);
@@ -326,9 +333,10 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
<textarea
id="title"
{...register("title")}
onBlur={handleSubmit(onSubmit)}
onBlur={canEditCard ? handleSubmit(onSubmit) : undefined}
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) => {
const target = e.target as HTMLTextAreaElement;
target.style.height = "auto";
@@ -354,9 +362,10 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
<div className="mt-2">
<Editor
content={card.description}
onChange={(e) => setValue("description", e)}
onBlur={() => handleSubmit(onSubmit)()}
onChange={canEditCard ? (e) => setValue("description", e) : undefined}
onBlur={canEditCard ? () => handleSubmit(onSubmit)() : undefined}
workspaceMembers={board?.workspace.members ?? []}
readOnly={!canEditCard}
/>
</div>
</form>
@@ -366,6 +375,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
cardPublicId={cardId}
activeChecklistForm={activeChecklistForm}
setActiveChecklistForm={setActiveChecklistForm}
viewOnly={!canEditCard}
/>
{!isTemplate && (
<>
@@ -374,12 +384,15 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
<AttachmentThumbnails
attachments={card.attachments}
cardPublicId={cardId ?? ""}
isReadOnly={!canEditCard}
/>
</div>
)}
<div className="mt-6">
<AttachmentUpload cardPublicId={cardId} />
</div>
{canEditCard && (
<div className="mt-6">
<AttachmentUpload cardPublicId={cardId} />
</div>
)}
</>
)}
<div className="border-t-[1px] border-light-300 pt-12 dark:border-dark-300">

View File

@@ -10,7 +10,7 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { mergeActivities } from "../utils/activities";
import { assertUserInWorkspace } from "../utils/auth";
import { assertPermission } from "../utils/permissions";
import { generateDownloadUrl } from "../utils/s3";
export const cardRouter = createTRPCRouter({
@@ -57,13 +57,7 @@ export const cardRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, list.workspaceId);
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
await assertPermission(ctx.db, userId, list.workspaceId, "card:create");
const newCard = await cardRepo.create(ctx.db, {
title: input.title,
@@ -199,7 +193,7 @@ export const cardRouter = createTRPCRouter({
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, {
comment: input.comment,
@@ -262,7 +256,7 @@ export const cardRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
await assertPermission(ctx.db, userId, card.workspaceId, "comment:edit");
const existingComment = await cardCommentRepo.getByPublicId(
ctx.db,
@@ -340,7 +334,7 @@ export const cardRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
await assertPermission(ctx.db, userId, card.workspaceId, "comment:delete");
const existingComment = await cardCommentRepo.getByPublicId(
ctx.db,
@@ -412,7 +406,7 @@ export const cardRouter = createTRPCRouter({
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);
@@ -504,7 +498,7 @@ export const cardRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
await assertPermission(ctx.db, userId, card.workspaceId, "card:edit");
const member = await workspaceRepo.getMemberByPublicId(
ctx.db,
@@ -616,7 +610,7 @@ export const cardRouter = createTRPCRouter({
code: "UNAUTHORIZED",
});
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
await assertPermission(ctx.db, userId, card.workspaceId, "card:view");
}
const result = await cardRepo.getWithListAndMembersByPublicId(
@@ -725,7 +719,7 @@ export const cardRouter = createTRPCRouter({
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;
@@ -788,7 +782,7 @@ export const cardRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
await assertPermission(ctx.db, userId, card.workspaceId, "card:edit");
const existingCard = await cardRepo.getByPublicId(
ctx.db,
@@ -958,7 +952,7 @@ export const cardRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
await assertPermission(ctx.db, userId, card.workspaceId, "card:delete");
const deletedAt = new Date();

View File

@@ -65,6 +65,28 @@ export const getMemberPermissionOverrides = async (
.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
* Combines role template (from DB) with custom overrides