feat: allow users to delete entities they have created

This commit is contained in:
Henry
2026-01-29 20:50:38 +00:00
parent 62725ffa38
commit e3640542b3
11 changed files with 175 additions and 41 deletions

View File

@@ -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`,

View File

@@ -91,7 +91,7 @@ const Comment = ({
},
]
: []),
...((isAuthor || isAdmin) && canDeleteComment
...((isAuthor || canDeleteComment)
? [
{
label: t`Delete comment`,

View File

@@ -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`,

View File

@@ -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>

View File

@@ -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);

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 { 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();

View File

@@ -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;

View File

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

View File

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

View File

@@ -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,
}

View File

@@ -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;
};