feat: allow users to delete entities they have created
This commit is contained in:
@@ -9,6 +9,8 @@ import {
|
||||
HiOutlineTrash,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
@@ -24,6 +26,7 @@ interface ListProps {
|
||||
interface List {
|
||||
publicId: string;
|
||||
name: string;
|
||||
createdBy?: string | null;
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
@@ -41,6 +44,9 @@ export default function List({
|
||||
}: ListProps) {
|
||||
const { openModal } = useModal();
|
||||
const { canCreateCard, canEditList, canDeleteList } = usePermissions();
|
||||
const { data: session } = authClient.useSession();
|
||||
const isCreator = list.createdBy && session?.user.id === list.createdBy;
|
||||
const canEdit = canEditList || isCreator;
|
||||
|
||||
const openNewCardForm = (publicListId: PublicListId) => {
|
||||
if (!canCreateCard) return;
|
||||
@@ -62,7 +68,7 @@ export default function List({
|
||||
});
|
||||
|
||||
const onSubmit = (values: FormValues) => {
|
||||
if (!canEditList) return;
|
||||
if (!canEdit) return;
|
||||
updateList.mutate({
|
||||
listPublicId: values.listPublicId,
|
||||
name: values.name,
|
||||
@@ -94,7 +100,7 @@ export default function List({
|
||||
type="text"
|
||||
{...register("name")}
|
||||
onBlur={handleSubmit(onSubmit)}
|
||||
readOnly={!canEditList}
|
||||
readOnly={!canEdit}
|
||||
className="w-full border-0 bg-transparent px-4 pt-1 text-sm font-medium text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000"
|
||||
/>
|
||||
</form>
|
||||
@@ -124,7 +130,7 @@ export default function List({
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canDeleteList
|
||||
...(canDeleteList || isCreator
|
||||
? [
|
||||
{
|
||||
label: t`Delete list`,
|
||||
|
||||
@@ -91,7 +91,7 @@ const Comment = ({
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...((isAuthor || isAdmin) && canDeleteComment
|
||||
...((isAuthor || canDeleteComment)
|
||||
? [
|
||||
{
|
||||
label: t`Delete comment`,
|
||||
|
||||
@@ -5,13 +5,21 @@ import {
|
||||
HiOutlineTrash,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
|
||||
export default function CardDropdown() {
|
||||
export default function CardDropdown({
|
||||
cardCreatedBy,
|
||||
}: {
|
||||
cardCreatedBy?: string | null;
|
||||
}) {
|
||||
const { openModal } = useModal();
|
||||
const { canEditCard, canDeleteCard } = usePermissions();
|
||||
const { data: session } = authClient.useSession();
|
||||
const isCreator = cardCreatedBy && session?.user.id === cardCreatedBy;
|
||||
|
||||
const items = [
|
||||
...(canEditCard
|
||||
@@ -25,7 +33,7 @@ export default function CardDropdown() {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canDeleteCard
|
||||
...(canDeleteCard || isCreator
|
||||
? [
|
||||
{
|
||||
label: t`Delete card`,
|
||||
|
||||
@@ -14,6 +14,8 @@ import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
@@ -46,6 +48,7 @@ interface FormValues {
|
||||
export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const router = useRouter();
|
||||
const { canEditCard } = usePermissions();
|
||||
const { data: session } = authClient.useSession();
|
||||
const cardId = Array.isArray(router.query.cardId)
|
||||
? router.query.cardId[0]
|
||||
: router.query.cardId;
|
||||
@@ -54,6 +57,9 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
||||
cardPublicId: cardId ?? "",
|
||||
});
|
||||
|
||||
const isCreator = card?.createdBy && session?.user.id === card.createdBy;
|
||||
const canEdit = canEditCard || isCreator;
|
||||
|
||||
const board = card?.list.board;
|
||||
const labels = board?.labels;
|
||||
const workspaceMembers = board?.workspace.members;
|
||||
@@ -112,7 +118,7 @@ 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">
|
||||
{canEditCard && (
|
||||
{canEdit && (
|
||||
<>
|
||||
<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>
|
||||
@@ -169,6 +175,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const { showPopup } = usePopup();
|
||||
const { workspace } = useWorkspace();
|
||||
const { canEditCard } = usePermissions();
|
||||
const { data: session } = authClient.useSession();
|
||||
const [activeChecklistForm, setActiveChecklistForm] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
@@ -181,6 +188,9 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
cardPublicId: cardId ?? "",
|
||||
});
|
||||
|
||||
const isCreator = card?.createdBy && session?.user.id === card.createdBy;
|
||||
const canEdit = canEditCard || isCreator;
|
||||
|
||||
const refetchCard = async () => {
|
||||
if (cardId) await utils.card.byId.refetch({ cardPublicId: cardId });
|
||||
};
|
||||
@@ -305,7 +315,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Dropdown />
|
||||
<Dropdown cardCreatedBy={card?.createdBy} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -333,10 +343,10 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
<textarea
|
||||
id="title"
|
||||
{...register("title")}
|
||||
onBlur={canEditCard ? handleSubmit(onSubmit) : undefined}
|
||||
onBlur={canEdit ? handleSubmit(onSubmit) : undefined}
|
||||
rows={1}
|
||||
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" : ""}`}
|
||||
disabled={!canEdit}
|
||||
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] ${!canEdit ? "cursor-default" : ""}`}
|
||||
onInput={(e) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
target.style.height = "auto";
|
||||
@@ -362,10 +372,10 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
<div className="mt-2">
|
||||
<Editor
|
||||
content={card.description}
|
||||
onChange={canEditCard ? (e) => setValue("description", e) : undefined}
|
||||
onBlur={canEditCard ? () => handleSubmit(onSubmit)() : undefined}
|
||||
onChange={canEdit ? (e) => setValue("description", e) : undefined}
|
||||
onBlur={canEdit ? () => handleSubmit(onSubmit)() : undefined}
|
||||
workspaceMembers={board?.workspace.members ?? []}
|
||||
readOnly={!canEditCard}
|
||||
readOnly={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
@@ -375,7 +385,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
cardPublicId={cardId}
|
||||
activeChecklistForm={activeChecklistForm}
|
||||
setActiveChecklistForm={setActiveChecklistForm}
|
||||
viewOnly={!canEditCard}
|
||||
viewOnly={!canEdit}
|
||||
/>
|
||||
{!isTemplate && (
|
||||
<>
|
||||
@@ -384,11 +394,11 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
<AttachmentThumbnails
|
||||
attachments={card.attachments}
|
||||
cardPublicId={cardId ?? ""}
|
||||
isReadOnly={!canEditCard}
|
||||
isReadOnly={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{canEditCard && (
|
||||
{canEdit && (
|
||||
<div className="mt-6">
|
||||
<AttachmentUpload cardPublicId={cardId} />
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "@kan/shared/utils";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { assertPermission } from "../utils/permissions";
|
||||
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
|
||||
|
||||
export const boardRouter = createTRPCRouter({
|
||||
all: protectedProcedure
|
||||
@@ -422,7 +422,13 @@ export const boardRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertPermission(ctx.db, userId, board.workspaceId, "board:edit");
|
||||
await assertCanEdit(
|
||||
ctx.db,
|
||||
userId,
|
||||
board.workspaceId,
|
||||
"board:edit",
|
||||
board.createdBy ?? null,
|
||||
);
|
||||
|
||||
if (input.slug) {
|
||||
const isBoardSlugAvailable = await boardRepo.isBoardSlugAvailable(
|
||||
@@ -491,7 +497,13 @@ export const boardRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertPermission(ctx.db, userId, board.workspaceId, "board:delete");
|
||||
await assertCanDelete(
|
||||
ctx.db,
|
||||
userId,
|
||||
board.workspaceId,
|
||||
"board:delete",
|
||||
board.createdBy ?? null,
|
||||
);
|
||||
|
||||
const listIds = board.lists.map((list) => list.id);
|
||||
|
||||
|
||||
@@ -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 { assertPermission } from "../utils/permissions";
|
||||
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
|
||||
import { generateDownloadUrl } from "../utils/s3";
|
||||
|
||||
export const cardRouter = createTRPCRouter({
|
||||
@@ -256,8 +256,6 @@ export const cardRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertPermission(ctx.db, userId, card.workspaceId, "comment:edit");
|
||||
|
||||
const existingComment = await cardCommentRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.commentPublicId,
|
||||
@@ -269,11 +267,13 @@ export const cardRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
if (existingComment.createdBy !== userId)
|
||||
throw new TRPCError({
|
||||
message: `You do not have permission to update this comment`,
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
await assertCanEdit(
|
||||
ctx.db,
|
||||
userId,
|
||||
card.workspaceId,
|
||||
"comment:edit",
|
||||
existingComment.createdBy,
|
||||
);
|
||||
|
||||
const updatedComment = await cardCommentRepo.update(ctx.db, {
|
||||
id: existingComment.id,
|
||||
@@ -334,8 +334,6 @@ export const cardRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertPermission(ctx.db, userId, card.workspaceId, "comment:delete");
|
||||
|
||||
const existingComment = await cardCommentRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.commentPublicId,
|
||||
@@ -347,6 +345,14 @@ export const cardRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertCanDelete(
|
||||
ctx.db,
|
||||
userId,
|
||||
card.workspaceId,
|
||||
"comment:delete",
|
||||
existingComment.createdBy,
|
||||
);
|
||||
|
||||
const deletedComment = await cardCommentRepo.softDelete(ctx.db, {
|
||||
commentId: existingComment.id,
|
||||
deletedAt: new Date(),
|
||||
@@ -782,7 +788,13 @@ export const cardRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertPermission(ctx.db, userId, card.workspaceId, "card:edit");
|
||||
await assertCanEdit(
|
||||
ctx.db,
|
||||
userId,
|
||||
card.workspaceId,
|
||||
"card:edit",
|
||||
card.createdBy,
|
||||
);
|
||||
|
||||
const existingCard = await cardRepo.getByPublicId(
|
||||
ctx.db,
|
||||
@@ -952,7 +964,13 @@ export const cardRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertPermission(ctx.db, userId, card.workspaceId, "card:delete");
|
||||
await assertCanDelete(
|
||||
ctx.db,
|
||||
userId,
|
||||
card.workspaceId,
|
||||
"card:delete",
|
||||
card.createdBy,
|
||||
);
|
||||
|
||||
const deletedAt = new Date();
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as activityRepo from "@kan/db/repository/cardActivity.repo";
|
||||
import * as listRepo from "@kan/db/repository/list.repo";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { assertPermission } from "../utils/permissions";
|
||||
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
|
||||
|
||||
export const listRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
@@ -101,7 +101,13 @@ export const listRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertPermission(ctx.db, userId, list.workspaceId, "list:delete");
|
||||
await assertCanDelete(
|
||||
ctx.db,
|
||||
userId,
|
||||
list.workspaceId,
|
||||
"list:delete",
|
||||
list.createdBy,
|
||||
);
|
||||
|
||||
const deletedAt = new Date();
|
||||
|
||||
@@ -183,7 +189,13 @@ export const listRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertPermission(ctx.db, userId, list.workspaceId, "list:edit");
|
||||
await assertCanEdit(
|
||||
ctx.db,
|
||||
userId,
|
||||
list.workspaceId,
|
||||
"list:edit",
|
||||
list.createdBy,
|
||||
);
|
||||
|
||||
let result: { name: string; publicId: string } | undefined;
|
||||
|
||||
|
||||
@@ -182,7 +182,7 @@ export async function assertCanManageRole(
|
||||
});
|
||||
}
|
||||
|
||||
const managerRole = managerMember.role as Role;
|
||||
const managerRole = managerMember.role;
|
||||
|
||||
if (!canManageRole(managerRole, targetRoleName as Role)) {
|
||||
throw new TRPCError({
|
||||
@@ -223,8 +223,8 @@ export async function assertCanManageMember(
|
||||
});
|
||||
}
|
||||
|
||||
const managerRole = managerMember.role as Role;
|
||||
const targetRole = targetMember.role as Role;
|
||||
const managerRole = managerMember.role;
|
||||
const targetRole = targetMember.role;
|
||||
|
||||
if (!canManageRole(managerRole, targetRole)) {
|
||||
throw new TRPCError({
|
||||
@@ -233,3 +233,63 @@ export async function assertCanManageMember(
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert user can delete an entity - either has the delete permission OR is the creator
|
||||
*/
|
||||
export async function assertCanDelete(
|
||||
db: dbClient,
|
||||
userId: string,
|
||||
workspaceId: number,
|
||||
permission: Permission,
|
||||
createdBy: string | null,
|
||||
): Promise<void> {
|
||||
// Check if user has the general delete permission
|
||||
const hasDeletePermission = await hasPermission(db, userId, workspaceId, permission);
|
||||
|
||||
// If user has permission, allow deletion
|
||||
if (hasDeletePermission) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If user doesn't have permission, check if they are the creator
|
||||
if (createdBy && createdBy === userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Neither condition met - deny deletion
|
||||
throw new TRPCError({
|
||||
message: `You do not have permission to delete this entity (${permission})`,
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert user can edit an entity - either has the edit permission OR is the creator
|
||||
*/
|
||||
export async function assertCanEdit(
|
||||
db: dbClient,
|
||||
userId: string,
|
||||
workspaceId: number,
|
||||
permission: Permission,
|
||||
createdBy: string | null,
|
||||
): Promise<void> {
|
||||
// Check if user has the general edit permission
|
||||
const hasEditPermission = await hasPermission(db, userId, workspaceId, permission);
|
||||
|
||||
// If user has permission, allow editing
|
||||
if (hasEditPermission) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If user doesn't have permission, check if they are the creator
|
||||
if (createdBy && createdBy === userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Neither condition met - deny editing
|
||||
throw new TRPCError({
|
||||
message: `You do not have permission to edit this entity (${permission})`,
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -518,6 +518,7 @@ export const getWithListIdsByPublicId = (
|
||||
columns: {
|
||||
id: true,
|
||||
workspaceId: true,
|
||||
createdBy: true,
|
||||
},
|
||||
with: {
|
||||
lists: {
|
||||
@@ -671,6 +672,7 @@ export const getWorkspaceAndBoardIdByBoardPublicId = async (
|
||||
columns: {
|
||||
id: true,
|
||||
workspaceId: true,
|
||||
createdBy: true,
|
||||
},
|
||||
where: eq(boards.publicId, boardPublicId),
|
||||
});
|
||||
|
||||
@@ -424,6 +424,7 @@ export const getWithListAndMembersByPublicId = async (
|
||||
title: true,
|
||||
description: true,
|
||||
dueDate: true,
|
||||
createdBy: true,
|
||||
},
|
||||
with: {
|
||||
labels: {
|
||||
@@ -938,7 +939,7 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
|
||||
cardPublicId: string,
|
||||
) => {
|
||||
const result = await db.query.cards.findFirst({
|
||||
columns: { id: true },
|
||||
columns: { id: true, createdBy: true },
|
||||
where: and(eq(cards.publicId, cardPublicId), isNull(cards.deletedAt)),
|
||||
with: {
|
||||
list: {
|
||||
@@ -958,6 +959,7 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
|
||||
return result
|
||||
? {
|
||||
id: result.id,
|
||||
createdBy: result.createdBy,
|
||||
workspaceId: result.list.board.workspaceId,
|
||||
workspaceVisibility: result.list.board.visibility,
|
||||
}
|
||||
|
||||
@@ -419,7 +419,7 @@ export const getWorkspaceAndListIdByListPublicId = async (
|
||||
listPublicId: string,
|
||||
) => {
|
||||
const result = await db.query.lists.findFirst({
|
||||
columns: { id: true },
|
||||
columns: { id: true, createdBy: true },
|
||||
where: and(eq(lists.publicId, listPublicId), isNull(lists.deletedAt)),
|
||||
with: {
|
||||
board: {
|
||||
@@ -431,6 +431,10 @@ export const getWorkspaceAndListIdByListPublicId = async (
|
||||
});
|
||||
|
||||
return result
|
||||
? { id: result.id, workspaceId: result.board.workspaceId }
|
||||
? {
|
||||
id: result.id,
|
||||
createdBy: result.createdBy,
|
||||
workspaceId: result.board.workspaceId,
|
||||
}
|
||||
: null;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user