diff --git a/apps/web/src/views/board/components/List.tsx b/apps/web/src/views/board/components/List.tsx
index 463dfeb6..cfed9de1 100644
--- a/apps/web/src/views/board/components/List.tsx
+++ b/apps/web/src/views/board/components/List.tsx
@@ -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"
/>
@@ -124,7 +130,7 @@ export default function List({
},
]
: []),
- ...(canDeleteList
+ ...(canDeleteList || isCreator
? [
{
label: t`Delete list`,
diff --git a/apps/web/src/views/card/components/Comment.tsx b/apps/web/src/views/card/components/Comment.tsx
index b51b076c..e9587c36 100644
--- a/apps/web/src/views/card/components/Comment.tsx
+++ b/apps/web/src/views/card/components/Comment.tsx
@@ -91,7 +91,7 @@ const Comment = ({
},
]
: []),
- ...((isAuthor || isAdmin) && canDeleteComment
+ ...((isAuthor || canDeleteComment)
? [
{
label: t`Delete comment`,
diff --git a/apps/web/src/views/card/components/Dropdown.tsx b/apps/web/src/views/card/components/Dropdown.tsx
index af8f2309..c6308398 100644
--- a/apps/web/src/views/card/components/Dropdown.tsx
+++ b/apps/web/src/views/card/components/Dropdown.tsx
@@ -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`,
diff --git a/apps/web/src/views/card/index.tsx b/apps/web/src/views/card/index.tsx
index 8e726fa3..700b34a4 100644
--- a/apps/web/src/views/card/index.tsx
+++ b/apps/web/src/views/card/index.tsx
@@ -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 (
- {canEditCard && (
+ {canEdit && (
<>
{t`List`}
@@ -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
(
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 }) {
-
+
>
)}
@@ -333,10 +343,10 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
)}
- {canEditCard && (
+ {canEdit && (
diff --git a/packages/api/src/routers/board.ts b/packages/api/src/routers/board.ts
index 829cc2ff..a811d4b8 100644
--- a/packages/api/src/routers/board.ts
+++ b/packages/api/src/routers/board.ts
@@ -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);
diff --git a/packages/api/src/routers/card.ts b/packages/api/src/routers/card.ts
index fb26a9f8..e4dfa631 100644
--- a/packages/api/src/routers/card.ts
+++ b/packages/api/src/routers/card.ts
@@ -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();
diff --git a/packages/api/src/routers/list.ts b/packages/api/src/routers/list.ts
index 2f358dce..e0085b2b 100644
--- a/packages/api/src/routers/list.ts
+++ b/packages/api/src/routers/list.ts
@@ -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;
diff --git a/packages/api/src/utils/permissions.ts b/packages/api/src/utils/permissions.ts
index ca2dbca1..c0401f7b 100644
--- a/packages/api/src/utils/permissions.ts
+++ b/packages/api/src/utils/permissions.ts
@@ -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 {
+ // 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 {
+ // 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",
+ });
+}
diff --git a/packages/db/src/repository/board.repo.ts b/packages/db/src/repository/board.repo.ts
index a71d9d2f..659e2992 100644
--- a/packages/db/src/repository/board.repo.ts
+++ b/packages/db/src/repository/board.repo.ts
@@ -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),
});
diff --git a/packages/db/src/repository/card.repo.ts b/packages/db/src/repository/card.repo.ts
index 1df46151..2b034bd2 100644
--- a/packages/db/src/repository/card.repo.ts
+++ b/packages/db/src/repository/card.repo.ts
@@ -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,
}
diff --git a/packages/db/src/repository/list.repo.ts b/packages/db/src/repository/list.repo.ts
index 3d47c0c0..0afc3973 100644
--- a/packages/db/src/repository/list.repo.ts
+++ b/packages/db/src/repository/list.repo.ts
@@ -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;
};