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, HiOutlineTrash,
} from "react-icons/hi2"; } from "react-icons/hi2";
import { authClient } from "@kan/auth/client";
import Dropdown from "~/components/Dropdown"; import Dropdown from "~/components/Dropdown";
import { usePermissions } from "~/hooks/usePermissions"; import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
@@ -24,6 +26,7 @@ interface ListProps {
interface List { interface List {
publicId: string; publicId: string;
name: string; name: string;
createdBy?: string | null;
} }
interface FormValues { interface FormValues {
@@ -41,6 +44,9 @@ export default function List({
}: ListProps) { }: ListProps) {
const { openModal } = useModal(); const { openModal } = useModal();
const { canCreateCard, canEditList, canDeleteList } = usePermissions(); 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) => { const openNewCardForm = (publicListId: PublicListId) => {
if (!canCreateCard) return; if (!canCreateCard) return;
@@ -62,7 +68,7 @@ export default function List({
}); });
const onSubmit = (values: FormValues) => { const onSubmit = (values: FormValues) => {
if (!canEditList) return; if (!canEdit) return;
updateList.mutate({ updateList.mutate({
listPublicId: values.listPublicId, listPublicId: values.listPublicId,
name: values.name, name: values.name,
@@ -94,7 +100,7 @@ export default function List({
type="text" type="text"
{...register("name")} {...register("name")}
onBlur={handleSubmit(onSubmit)} 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" 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> </form>
@@ -124,7 +130,7 @@ export default function List({
}, },
] ]
: []), : []),
...(canDeleteList ...(canDeleteList || isCreator
? [ ? [
{ {
label: t`Delete list`, label: t`Delete list`,

View File

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

View File

@@ -5,13 +5,21 @@ import {
HiOutlineTrash, HiOutlineTrash,
} from "react-icons/hi2"; } from "react-icons/hi2";
import { authClient } from "@kan/auth/client";
import Dropdown from "~/components/Dropdown"; import Dropdown from "~/components/Dropdown";
import { usePermissions } from "~/hooks/usePermissions"; import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
export default function CardDropdown() { export default function CardDropdown({
cardCreatedBy,
}: {
cardCreatedBy?: string | null;
}) {
const { openModal } = useModal(); const { openModal } = useModal();
const { canEditCard, canDeleteCard } = usePermissions(); const { canEditCard, canDeleteCard } = usePermissions();
const { data: session } = authClient.useSession();
const isCreator = cardCreatedBy && session?.user.id === cardCreatedBy;
const items = [ const items = [
...(canEditCard ...(canEditCard
@@ -25,7 +33,7 @@ export default function CardDropdown() {
}, },
] ]
: []), : []),
...(canDeleteCard ...(canDeleteCard || isCreator
? [ ? [
{ {
label: t`Delete card`, label: t`Delete card`,

View File

@@ -14,6 +14,8 @@ 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 { authClient } from "@kan/auth/client";
import { usePermissions } from "~/hooks/usePermissions"; 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";
@@ -46,6 +48,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 { canEditCard } = usePermissions();
const { data: session } = authClient.useSession();
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;
@@ -54,6 +57,9 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
cardPublicId: cardId ?? "", cardPublicId: cardId ?? "",
}); });
const isCreator = card?.createdBy && session?.user.id === card.createdBy;
const canEdit = canEditCard || isCreator;
const board = card?.list.board; const board = card?.list.board;
const labels = board?.labels; const labels = board?.labels;
const workspaceMembers = board?.workspace.members; const workspaceMembers = board?.workspace.members;
@@ -112,7 +118,7 @@ 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">
{canEditCard && ( {canEdit && (
<> <>
<div className="mb-4 flex w-full flex-row pt-[18px]"> <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> <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 { showPopup } = usePopup();
const { workspace } = useWorkspace(); const { workspace } = useWorkspace();
const { canEditCard } = usePermissions(); const { canEditCard } = usePermissions();
const { data: session } = authClient.useSession();
const [activeChecklistForm, setActiveChecklistForm] = useState<string | null>( const [activeChecklistForm, setActiveChecklistForm] = useState<string | null>(
null, null,
); );
@@ -181,6 +188,9 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
cardPublicId: cardId ?? "", cardPublicId: cardId ?? "",
}); });
const isCreator = card?.createdBy && session?.user.id === card.createdBy;
const canEdit = canEditCard || isCreator;
const refetchCard = async () => { const refetchCard = async () => {
if (cardId) await utils.card.byId.refetch({ cardPublicId: cardId }); if (cardId) await utils.card.byId.refetch({ cardPublicId: cardId });
}; };
@@ -305,7 +315,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
</Link> </Link>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Dropdown /> <Dropdown cardCreatedBy={card?.createdBy} />
</div> </div>
</> </>
)} )}
@@ -333,10 +343,10 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
<textarea <textarea
id="title" id="title"
{...register("title")} {...register("title")}
onBlur={canEditCard ? handleSubmit(onSubmit) : undefined} onBlur={canEdit ? handleSubmit(onSubmit) : undefined}
rows={1} rows={1}
disabled={!canEditCard} 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] ${!canEditCard ? "cursor-default" : ""}`} 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) => { onInput={(e) => {
const target = e.target as HTMLTextAreaElement; const target = e.target as HTMLTextAreaElement;
target.style.height = "auto"; target.style.height = "auto";
@@ -362,10 +372,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={canEditCard ? (e) => setValue("description", e) : undefined} onChange={canEdit ? (e) => setValue("description", e) : undefined}
onBlur={canEditCard ? () => handleSubmit(onSubmit)() : undefined} onBlur={canEdit ? () => handleSubmit(onSubmit)() : undefined}
workspaceMembers={board?.workspace.members ?? []} workspaceMembers={board?.workspace.members ?? []}
readOnly={!canEditCard} readOnly={!canEdit}
/> />
</div> </div>
</form> </form>
@@ -375,7 +385,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
cardPublicId={cardId} cardPublicId={cardId}
activeChecklistForm={activeChecklistForm} activeChecklistForm={activeChecklistForm}
setActiveChecklistForm={setActiveChecklistForm} setActiveChecklistForm={setActiveChecklistForm}
viewOnly={!canEditCard} viewOnly={!canEdit}
/> />
{!isTemplate && ( {!isTemplate && (
<> <>
@@ -384,11 +394,11 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
<AttachmentThumbnails <AttachmentThumbnails
attachments={card.attachments} attachments={card.attachments}
cardPublicId={cardId ?? ""} cardPublicId={cardId ?? ""}
isReadOnly={!canEditCard} isReadOnly={!canEdit}
/> />
</div> </div>
)} )}
{canEditCard && ( {canEdit && (
<div className="mt-6"> <div className="mt-6">
<AttachmentUpload cardPublicId={cardId} /> <AttachmentUpload cardPublicId={cardId} />
</div> </div>

View File

@@ -15,7 +15,7 @@ import {
} from "@kan/shared/utils"; } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { assertPermission } from "../utils/permissions"; import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
export const boardRouter = createTRPCRouter({ export const boardRouter = createTRPCRouter({
all: protectedProcedure all: protectedProcedure
@@ -422,7 +422,13 @@ export const boardRouter = createTRPCRouter({
code: "NOT_FOUND", 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) { if (input.slug) {
const isBoardSlugAvailable = await boardRepo.isBoardSlugAvailable( const isBoardSlugAvailable = await boardRepo.isBoardSlugAvailable(
@@ -491,7 +497,13 @@ export const boardRouter = createTRPCRouter({
code: "NOT_FOUND", 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); 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 { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { mergeActivities } from "../utils/activities"; import { mergeActivities } from "../utils/activities";
import { assertPermission } from "../utils/permissions"; import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
import { generateDownloadUrl } from "../utils/s3"; import { generateDownloadUrl } from "../utils/s3";
export const cardRouter = createTRPCRouter({ export const cardRouter = createTRPCRouter({
@@ -256,8 +256,6 @@ export const cardRouter = createTRPCRouter({
code: "NOT_FOUND", code: "NOT_FOUND",
}); });
await assertPermission(ctx.db, userId, card.workspaceId, "comment:edit");
const existingComment = await cardCommentRepo.getByPublicId( const existingComment = await cardCommentRepo.getByPublicId(
ctx.db, ctx.db,
input.commentPublicId, input.commentPublicId,
@@ -269,11 +267,13 @@ export const cardRouter = createTRPCRouter({
code: "NOT_FOUND", code: "NOT_FOUND",
}); });
if (existingComment.createdBy !== userId) await assertCanEdit(
throw new TRPCError({ ctx.db,
message: `You do not have permission to update this comment`, userId,
code: "FORBIDDEN", card.workspaceId,
}); "comment:edit",
existingComment.createdBy,
);
const updatedComment = await cardCommentRepo.update(ctx.db, { const updatedComment = await cardCommentRepo.update(ctx.db, {
id: existingComment.id, id: existingComment.id,
@@ -334,8 +334,6 @@ export const cardRouter = createTRPCRouter({
code: "NOT_FOUND", code: "NOT_FOUND",
}); });
await assertPermission(ctx.db, userId, card.workspaceId, "comment:delete");
const existingComment = await cardCommentRepo.getByPublicId( const existingComment = await cardCommentRepo.getByPublicId(
ctx.db, ctx.db,
input.commentPublicId, input.commentPublicId,
@@ -347,6 +345,14 @@ export const cardRouter = createTRPCRouter({
code: "NOT_FOUND", code: "NOT_FOUND",
}); });
await assertCanDelete(
ctx.db,
userId,
card.workspaceId,
"comment:delete",
existingComment.createdBy,
);
const deletedComment = await cardCommentRepo.softDelete(ctx.db, { const deletedComment = await cardCommentRepo.softDelete(ctx.db, {
commentId: existingComment.id, commentId: existingComment.id,
deletedAt: new Date(), deletedAt: new Date(),
@@ -782,7 +788,13 @@ export const cardRouter = createTRPCRouter({
code: "NOT_FOUND", 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( const existingCard = await cardRepo.getByPublicId(
ctx.db, ctx.db,
@@ -952,7 +964,13 @@ export const cardRouter = createTRPCRouter({
code: "NOT_FOUND", 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(); 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 * as listRepo from "@kan/db/repository/list.repo";
import { createTRPCRouter, protectedProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure } from "../trpc";
import { assertPermission } from "../utils/permissions"; import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
export const listRouter = createTRPCRouter({ export const listRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure
@@ -101,7 +101,13 @@ export const listRouter = createTRPCRouter({
code: "NOT_FOUND", 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(); const deletedAt = new Date();
@@ -183,7 +189,13 @@ export const listRouter = createTRPCRouter({
code: "NOT_FOUND", 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; 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)) { if (!canManageRole(managerRole, targetRoleName as Role)) {
throw new TRPCError({ throw new TRPCError({
@@ -223,8 +223,8 @@ export async function assertCanManageMember(
}); });
} }
const managerRole = managerMember.role as Role; const managerRole = managerMember.role;
const targetRole = targetMember.role as Role; const targetRole = targetMember.role;
if (!canManageRole(managerRole, targetRole)) { if (!canManageRole(managerRole, targetRole)) {
throw new TRPCError({ 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: { columns: {
id: true, id: true,
workspaceId: true, workspaceId: true,
createdBy: true,
}, },
with: { with: {
lists: { lists: {
@@ -671,6 +672,7 @@ export const getWorkspaceAndBoardIdByBoardPublicId = async (
columns: { columns: {
id: true, id: true,
workspaceId: true, workspaceId: true,
createdBy: true,
}, },
where: eq(boards.publicId, boardPublicId), where: eq(boards.publicId, boardPublicId),
}); });

View File

@@ -424,6 +424,7 @@ export const getWithListAndMembersByPublicId = async (
title: true, title: true,
description: true, description: true,
dueDate: true, dueDate: true,
createdBy: true,
}, },
with: { with: {
labels: { labels: {
@@ -938,7 +939,7 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
cardPublicId: string, cardPublicId: string,
) => { ) => {
const result = await db.query.cards.findFirst({ const result = await db.query.cards.findFirst({
columns: { id: true }, columns: { id: true, createdBy: true },
where: and(eq(cards.publicId, cardPublicId), isNull(cards.deletedAt)), where: and(eq(cards.publicId, cardPublicId), isNull(cards.deletedAt)),
with: { with: {
list: { list: {
@@ -958,6 +959,7 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
return result return result
? { ? {
id: result.id, id: result.id,
createdBy: result.createdBy,
workspaceId: result.list.board.workspaceId, workspaceId: result.list.board.workspaceId,
workspaceVisibility: result.list.board.visibility, workspaceVisibility: result.list.board.visibility,
} }

View File

@@ -419,7 +419,7 @@ export const getWorkspaceAndListIdByListPublicId = async (
listPublicId: string, listPublicId: string,
) => { ) => {
const result = await db.query.lists.findFirst({ const result = await db.query.lists.findFirst({
columns: { id: true }, columns: { id: true, createdBy: true },
where: and(eq(lists.publicId, listPublicId), isNull(lists.deletedAt)), where: and(eq(lists.publicId, listPublicId), isNull(lists.deletedAt)),
with: { with: {
board: { board: {
@@ -431,6 +431,10 @@ export const getWorkspaceAndListIdByListPublicId = async (
}); });
return result return result
? { id: result.id, workspaceId: result.board.workspaceId } ? {
id: result.id,
createdBy: result.createdBy,
workspaceId: result.board.workspaceId,
}
: null; : null;
}; };