feat: customisable workspace role permissions (#345)
* feat: setup schema for workspace roles * chore: regen migration * feat: add publicId to workspace roles * feat: setup default permissions * feat: add repo funcs * feat: setup basic router interactions * feat: add card permissions * feat: assert permissions for lists * feat: assert board permissions * feat: assert permission for remaining routes * feat: add permissions page to settings * feat: enable updating member roles * feat: order members by role and createdAt * feat: allow editing individual permissions * feat: reset role defaults * feat: clear all permission overrides * feat: allow users to delete entities they have created * feat: set roleId when inviting new members * feat: disable UI elements if user does not have permissions * feat: allow admins to assign the admin role to other users * feat: allow delete:list as default * refactor: centre permissions modal * chore: translations
This commit is contained in:
@@ -9,6 +9,7 @@ import { integrationRouter } from "./routers/integration";
|
||||
import { labelRouter } from "./routers/label";
|
||||
import { listRouter } from "./routers/list";
|
||||
import { memberRouter } from "./routers/member";
|
||||
import { permissionRouter } from "./routers/permission";
|
||||
import { userRouter } from "./routers/user";
|
||||
import { workspaceRouter } from "./routers/workspace";
|
||||
import { createTRPCRouter } from "./trpc";
|
||||
@@ -24,6 +25,7 @@ export const appRouter = createTRPCRouter({
|
||||
list: listRouter,
|
||||
member: memberRouter,
|
||||
import: importRouter,
|
||||
permission: permissionRouter,
|
||||
user: userRouter,
|
||||
workspace: workspaceRouter,
|
||||
integration: integrationRouter,
|
||||
|
||||
@@ -8,7 +8,7 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { assertUserInWorkspace } from "../utils/auth";
|
||||
import { assertPermission } from "../utils/permissions";
|
||||
import { deleteObject, generateUploadUrl } from "../utils/s3";
|
||||
|
||||
export const attachmentRouter = createTRPCRouter({
|
||||
@@ -55,8 +55,7 @@ export const attachmentRouter = createTRPCRouter({
|
||||
message: `Card with public ID ${input.cardPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
||||
await assertPermission(ctx.db, userId, card.workspaceId, "card:edit");
|
||||
|
||||
// Get workspace publicId
|
||||
const workspace = await workspaceRepo.getById(ctx.db, card.workspaceId);
|
||||
@@ -131,8 +130,7 @@ export const attachmentRouter = createTRPCRouter({
|
||||
message: `Card with public ID ${input.cardPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
||||
await assertPermission(ctx.db, userId, card.workspaceId, "card:edit");
|
||||
|
||||
const attachment = await cardAttachmentRepo.create(ctx.db, {
|
||||
cardId: card.id,
|
||||
@@ -186,8 +184,7 @@ export const attachmentRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
const workspaceId = attachment.card.list.board.workspaceId;
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, workspaceId);
|
||||
await assertPermission(ctx.db, userId, workspaceId, "card:edit");
|
||||
|
||||
const bucket = process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
|
||||
if (bucket) {
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "@kan/shared/utils";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { assertUserInWorkspace } from "../utils/auth";
|
||||
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
|
||||
|
||||
export const boardRouter = createTRPCRouter({
|
||||
all: protectedProcedure
|
||||
@@ -58,7 +58,7 @@ export const boardRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, workspace.id);
|
||||
await assertPermission(ctx.db, userId, workspace.id, "board:view");
|
||||
|
||||
const result = boardRepo.getAllByWorkspaceId(ctx.db, workspace.id, {
|
||||
type: input.type,
|
||||
@@ -119,7 +119,7 @@ export const boardRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, board.workspaceId);
|
||||
await assertPermission(ctx.db, userId, board.workspaceId, "board:view");
|
||||
|
||||
// Convert semantic string filters to date ranges expected by the repo
|
||||
const dueDateFilters = input.dueDateFilters
|
||||
@@ -255,7 +255,7 @@ export const boardRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, workspace.id);
|
||||
await assertPermission(ctx.db, userId, workspace.id, "board:create");
|
||||
|
||||
// If sourceBoardPublicId is provided, clone the source board
|
||||
if (input.sourceBoardPublicId) {
|
||||
@@ -422,7 +422,13 @@ export const boardRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, board.workspaceId);
|
||||
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 assertUserInWorkspace(ctx.db, userId, board.workspaceId);
|
||||
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 { assertUserInWorkspace } from "../utils/auth";
|
||||
import { assertCanDelete, assertCanEdit, 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,8 +256,6 @@ export const cardRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
||||
|
||||
const existingComment = await cardCommentRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.commentPublicId,
|
||||
@@ -275,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,
|
||||
@@ -340,8 +334,6 @@ export const cardRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
||||
|
||||
const existingComment = await cardCommentRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.commentPublicId,
|
||||
@@ -353,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(),
|
||||
@@ -412,7 +412,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 +504,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 +616,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 +725,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 +788,13 @@ export const cardRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
||||
await assertCanEdit(
|
||||
ctx.db,
|
||||
userId,
|
||||
card.workspaceId,
|
||||
"card:edit",
|
||||
card.createdBy,
|
||||
);
|
||||
|
||||
const existingCard = await cardRepo.getByPublicId(
|
||||
ctx.db,
|
||||
@@ -958,7 +964,13 @@ export const cardRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
||||
await assertCanDelete(
|
||||
ctx.db,
|
||||
userId,
|
||||
card.workspaceId,
|
||||
"card:delete",
|
||||
card.createdBy,
|
||||
);
|
||||
|
||||
const deletedAt = new Date();
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
|
||||
import * as checklistRepo from "@kan/db/repository/checklist.repo";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { assertUserInWorkspace } from "../utils/auth";
|
||||
import { assertPermission } from "../utils/permissions";
|
||||
|
||||
const checklistSchema = z.object({
|
||||
publicId: z.string().length(12),
|
||||
@@ -57,8 +57,7 @@ export const checklistRouter = createTRPCRouter({
|
||||
message: `Card with public ID ${input.cardPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
||||
await assertPermission(ctx.db, userId, card.workspaceId, "card:edit");
|
||||
|
||||
const newChecklist = await checklistRepo.create(ctx.db, {
|
||||
name: input.name,
|
||||
@@ -106,11 +105,11 @@ export const checklistRouter = createTRPCRouter({
|
||||
message: `Checklist with public ID ${input.checklistPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(
|
||||
await assertPermission(
|
||||
ctx.db,
|
||||
userId,
|
||||
checklist.card.list.board.workspace.id,
|
||||
"card:edit",
|
||||
);
|
||||
|
||||
const previousName = checklist.name;
|
||||
@@ -166,11 +165,11 @@ export const checklistRouter = createTRPCRouter({
|
||||
message: `Checklist with public ID ${input.checklistPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(
|
||||
await assertPermission(
|
||||
ctx.db,
|
||||
userId,
|
||||
checklist.card.list.board.workspace.id,
|
||||
"card:edit",
|
||||
);
|
||||
|
||||
await checklistRepo.softDeleteAllItemsByChecklistId(ctx.db, {
|
||||
@@ -237,11 +236,11 @@ export const checklistRouter = createTRPCRouter({
|
||||
message: `Checklist with public ID ${input.checklistPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(
|
||||
await assertPermission(
|
||||
ctx.db,
|
||||
userId,
|
||||
checklist.card.list.board.workspace.id,
|
||||
"card:edit",
|
||||
);
|
||||
|
||||
const newChecklistItem = await checklistRepo.createItem(ctx.db, {
|
||||
@@ -304,11 +303,11 @@ export const checklistRouter = createTRPCRouter({
|
||||
message: `Checklist item with public ID ${input.checklistItemPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(
|
||||
await assertPermission(
|
||||
ctx.db,
|
||||
userId,
|
||||
item.checklist.card.list.board.workspace.id,
|
||||
"card:edit",
|
||||
);
|
||||
|
||||
const previousTitle = item.title;
|
||||
@@ -394,11 +393,11 @@ export const checklistRouter = createTRPCRouter({
|
||||
message: `Checklist item with public ID ${input.checklistItemPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(
|
||||
await assertPermission(
|
||||
ctx.db,
|
||||
userId,
|
||||
item.checklist.card.list.board.workspace.id,
|
||||
"card:edit",
|
||||
);
|
||||
|
||||
const deleted = await checklistRepo.softDeleteItemById(ctx.db, {
|
||||
|
||||
@@ -14,7 +14,7 @@ import { colours } from "@kan/shared/constants";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { assertUserInWorkspace } from "../utils/auth";
|
||||
import { assertPermission } from "../utils/permissions";
|
||||
import { apiKeys, urls } from "./integration";
|
||||
|
||||
export interface TrelloBoard {
|
||||
@@ -180,8 +180,7 @@ export const importRouter = createTRPCRouter({
|
||||
message: `Workspace with public ID ${input.workspacePublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, workspace.id);
|
||||
await assertPermission(ctx.db, userId, workspace.id, "board:create");
|
||||
|
||||
const newImport = await importRepo.create(ctx.db, {
|
||||
source: "trello",
|
||||
|
||||
@@ -6,7 +6,7 @@ import * as cardRepo from "@kan/db/repository/card.repo";
|
||||
import * as labelRepo from "@kan/db/repository/label.repo";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { assertUserInWorkspace } from "../utils/auth";
|
||||
import { assertPermission } from "../utils/permissions";
|
||||
|
||||
const labelSchema = z.object({
|
||||
publicId: z.string(),
|
||||
@@ -47,8 +47,7 @@ export const labelRouter = createTRPCRouter({
|
||||
message: `Label with public ID ${input.labelPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, label.workspaceId);
|
||||
await assertPermission(ctx.db, userId, label.workspaceId, "board:view");
|
||||
|
||||
const result = await labelRepo.getByPublicId(ctx.db, input.labelPublicId);
|
||||
|
||||
@@ -102,8 +101,7 @@ export const labelRouter = createTRPCRouter({
|
||||
message: `Board with public ID ${input.boardPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, board.workspaceId);
|
||||
await assertPermission(ctx.db, userId, board.workspaceId, "board:edit");
|
||||
|
||||
const result = await labelRepo.create(ctx.db, {
|
||||
name: input.name,
|
||||
@@ -162,8 +160,7 @@ export const labelRouter = createTRPCRouter({
|
||||
message: `Label with public ID ${input.labelPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, label.workspaceId);
|
||||
await assertPermission(ctx.db, userId, label.workspaceId, "board:edit");
|
||||
|
||||
const result = await labelRepo.update(ctx.db, input);
|
||||
|
||||
@@ -211,8 +208,7 @@ export const labelRouter = createTRPCRouter({
|
||||
message: `Label with public ID ${input.labelPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, label.workspaceId);
|
||||
await assertPermission(ctx.db, userId, label.workspaceId, "board:edit");
|
||||
|
||||
await cardRepo.hardDeleteAllCardLabelRelationships(ctx.db, label.id);
|
||||
|
||||
|
||||
@@ -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 { assertUserInWorkspace } from "../utils/auth";
|
||||
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
|
||||
|
||||
export const listRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
@@ -48,7 +48,7 @@ export const listRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, board.workspaceId);
|
||||
await assertPermission(ctx.db, userId, board.workspaceId, "list:create");
|
||||
|
||||
const result = await listRepo.create(ctx.db, {
|
||||
name: input.name,
|
||||
@@ -101,7 +101,13 @@ export const listRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, list.workspaceId);
|
||||
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 assertUserInWorkspace(ctx.db, userId, list.workspaceId);
|
||||
await assertCanEdit(
|
||||
ctx.db,
|
||||
userId,
|
||||
list.workspaceId,
|
||||
"list:edit",
|
||||
list.createdBy,
|
||||
);
|
||||
|
||||
let result: { name: string; publicId: string } | undefined;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from "zod";
|
||||
|
||||
import * as inviteLinkRepo from "@kan/db/repository/inviteLink.repo";
|
||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||
import * as permissionRepo from "@kan/db/repository/permission.repo";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import * as userRepo from "@kan/db/repository/user.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
@@ -11,11 +12,15 @@ import {
|
||||
generateUID,
|
||||
getSubscriptionByPlan,
|
||||
hasUnlimitedSeats,
|
||||
} from "@kan/shared/utils";
|
||||
} from "@kan/shared";
|
||||
import { updateSubscriptionSeats } from "@kan/stripe";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { assertUserInWorkspace } from "../utils/auth";
|
||||
import {
|
||||
assertCanManageMember,
|
||||
assertCanManageRole,
|
||||
assertPermission,
|
||||
} from "../utils/permissions";
|
||||
|
||||
export const memberRouter = createTRPCRouter({
|
||||
invite: protectedProcedure
|
||||
@@ -56,7 +61,7 @@ export const memberRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, workspace.id, "admin");
|
||||
await assertPermission(ctx.db, userId, workspace.id, "member:invite");
|
||||
|
||||
const isInvitedEmailAlreadyMember = workspace.members.some(
|
||||
(member) => member.email === input.email,
|
||||
@@ -112,12 +117,20 @@ export const memberRouter = createTRPCRouter({
|
||||
|
||||
const existingUser = await userRepo.getByEmail(ctx.db, input.email);
|
||||
|
||||
// Get the workspace role to set roleId
|
||||
const memberRole = await permissionRepo.getRoleByWorkspaceIdAndName(
|
||||
ctx.db,
|
||||
workspace.id,
|
||||
"member",
|
||||
);
|
||||
|
||||
const invite = await memberRepo.create(ctx.db, {
|
||||
workspaceId: workspace.id,
|
||||
email: input.email,
|
||||
userId: existingUser?.id ?? null,
|
||||
createdBy: userId,
|
||||
role: "member",
|
||||
roleId: memberRole?.id ?? null,
|
||||
status: "invited",
|
||||
});
|
||||
|
||||
@@ -190,7 +203,7 @@ export const memberRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, workspace.id, "admin");
|
||||
await assertPermission(ctx.db, userId, workspace.id, "member:remove");
|
||||
|
||||
const member = await memberRepo.getByPublicId(
|
||||
ctx.db,
|
||||
@@ -292,8 +305,8 @@ export const memberRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
// Check if user is in workspace
|
||||
await assertUserInWorkspace(ctx.db, userId, workspace.id);
|
||||
// Check if user can view members
|
||||
await assertPermission(ctx.db, userId, workspace.id, "member:view");
|
||||
|
||||
// Get active invite link for this workspace
|
||||
const activeInviteLink = await inviteLinkRepo.getActiveForWorkspace(
|
||||
@@ -360,8 +373,8 @@ export const memberRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
// Check if user is in workspace
|
||||
await assertUserInWorkspace(ctx.db, userId, workspace.id, "admin");
|
||||
// Check if user can edit members (admin-equivalent)
|
||||
await assertPermission(ctx.db, userId, workspace.id, "member:edit");
|
||||
|
||||
// Check subscription for cloud environment
|
||||
if (process.env.NEXT_PUBLIC_KAN_ENV === "cloud") {
|
||||
@@ -461,8 +474,8 @@ export const memberRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
// Check if user is in workspace
|
||||
await assertUserInWorkspace(ctx.db, userId, workspace.id, "admin");
|
||||
// Check if user can edit members (admin-equivalent)
|
||||
await assertPermission(ctx.db, userId, workspace.id, "member:edit");
|
||||
|
||||
// Deactivate all active invite links
|
||||
await inviteLinkRepo.deactivateAllActiveForWorkspace(ctx.db, {
|
||||
@@ -631,12 +644,20 @@ export const memberRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
|
||||
// Get the workspace role to set roleId
|
||||
const memberRole = await permissionRepo.getRoleByWorkspaceIdAndName(
|
||||
ctx.db,
|
||||
invite.workspaceId,
|
||||
"member",
|
||||
);
|
||||
|
||||
await memberRepo.create(ctx.db, {
|
||||
workspaceId: invite.workspaceId,
|
||||
email: user.email,
|
||||
userId: user.id,
|
||||
createdBy: user.id,
|
||||
role: "member",
|
||||
roleId: memberRole?.id ?? null,
|
||||
status: "active",
|
||||
});
|
||||
|
||||
@@ -646,4 +667,85 @@ export const memberRouter = createTRPCRouter({
|
||||
workspaceSlug: workspace.slug,
|
||||
};
|
||||
}),
|
||||
updateRole: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Update member role",
|
||||
method: "PUT",
|
||||
path: "/workspaces/{workspacePublicId}/members/{memberPublicId}/role",
|
||||
description: "Updates a member's role in a workspace",
|
||||
tags: ["Workspaces"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
memberPublicId: z.string().min(12),
|
||||
role: z.enum(["admin", "member", "guest"]),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
success: z.boolean(),
|
||||
role: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
}
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!workspace) {
|
||||
throw new TRPCError({
|
||||
message: "Workspace not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
await assertPermission(ctx.db, userId, workspace.id, "member:edit");
|
||||
|
||||
const member = await memberRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.memberPublicId,
|
||||
);
|
||||
|
||||
if (!member) {
|
||||
throw new TRPCError({
|
||||
message: "Member not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
await assertCanManageMember(ctx.db, userId, workspace.id, member.id);
|
||||
await assertCanManageRole(ctx.db, userId, workspace.id, input.role);
|
||||
|
||||
// Get the workspace role to set roleId
|
||||
const workspaceRole = await permissionRepo.getRoleByWorkspaceIdAndName(
|
||||
ctx.db,
|
||||
workspace.id,
|
||||
input.role,
|
||||
);
|
||||
|
||||
await memberRepo.updateRole(ctx.db, {
|
||||
memberId: member.id,
|
||||
role: input.role,
|
||||
roleId: workspaceRole?.id ?? null,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
role: input.role,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
776
packages/api/src/routers/permission.ts
Normal file
776
packages/api/src/routers/permission.ts
Normal file
@@ -0,0 +1,776 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||
import * as permissionRepo from "@kan/db/repository/permission.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import type { Permission } from "@kan/shared";
|
||||
import { allPermissions } from "@kan/shared";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import {
|
||||
assertCanManageMember,
|
||||
assertPermission,
|
||||
getMemberEffectivePermissions,
|
||||
getUserPermissions,
|
||||
} from "../utils/permissions";
|
||||
|
||||
const permissionsList = [...allPermissions] as [string, ...string[]];
|
||||
|
||||
export const permissionRouter = createTRPCRouter({
|
||||
getMyPermissions: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Get my permissions",
|
||||
method: "GET",
|
||||
path: "/workspaces/{workspacePublicId}/permissions/me",
|
||||
description: "Get the current user's permissions in a workspace",
|
||||
tags: ["Permissions"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
permissions: z.array(z.string()),
|
||||
role: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
}
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!workspace) {
|
||||
throw new TRPCError({
|
||||
message: "Workspace not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await getUserPermissions(ctx.db, userId, workspace.id);
|
||||
|
||||
if (!result) {
|
||||
throw new TRPCError({
|
||||
message: "You are not a member of this workspace",
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}),
|
||||
getMemberPermissions: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Get member permissions",
|
||||
method: "GET",
|
||||
path: "/workspaces/{workspacePublicId}/members/{memberPublicId}/permissions",
|
||||
description: "Get a specific member's permissions in a workspace",
|
||||
tags: ["Permissions"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
memberPublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
memberPublicId: z.string(),
|
||||
role: z.string(),
|
||||
permissions: z.array(z.string()),
|
||||
overrides: z.array(
|
||||
z.object({
|
||||
permission: z.string(),
|
||||
granted: z.boolean(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
}
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!workspace) {
|
||||
throw new TRPCError({
|
||||
message: "Workspace not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
// Check user has permission to view member permissions
|
||||
await assertPermission(ctx.db, userId, workspace.id, "member:view");
|
||||
|
||||
const member = await memberRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.memberPublicId,
|
||||
);
|
||||
|
||||
if (!member) {
|
||||
throw new TRPCError({
|
||||
message: "Member not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
const effectivePermissions = await getMemberEffectivePermissions(
|
||||
ctx.db,
|
||||
member.id,
|
||||
member.roleId ?? null,
|
||||
member.role,
|
||||
);
|
||||
const overrides = await permissionRepo.getMemberPermissionOverrides(
|
||||
ctx.db,
|
||||
member.id,
|
||||
);
|
||||
|
||||
return {
|
||||
memberPublicId: member.publicId,
|
||||
role: member.role,
|
||||
permissions: effectivePermissions,
|
||||
overrides: overrides.map((o) => ({
|
||||
permission: o.permission,
|
||||
granted: o.granted,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
grantPermission: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Grant permission to member",
|
||||
method: "POST",
|
||||
path: "/workspaces/{workspacePublicId}/members/{memberPublicId}/permissions/grant",
|
||||
description: "Grant a specific permission to a member",
|
||||
tags: ["Permissions"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
memberPublicId: z.string().min(12),
|
||||
permission: z.enum(permissionsList),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ success: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
}
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!workspace) {
|
||||
throw new TRPCError({
|
||||
message: "Workspace not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
await assertPermission(ctx.db, userId, workspace.id, "member:edit");
|
||||
|
||||
const member = await memberRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.memberPublicId,
|
||||
);
|
||||
|
||||
if (!member) {
|
||||
throw new TRPCError({
|
||||
message: "Member not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
await assertCanManageMember(ctx.db, userId, workspace.id, member.id);
|
||||
|
||||
await permissionRepo.grantPermission(
|
||||
ctx.db,
|
||||
member.id,
|
||||
input.permission as Permission,
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
revokePermission: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Revoke permission from member",
|
||||
method: "POST",
|
||||
path: "/workspaces/{workspacePublicId}/members/{memberPublicId}/permissions/revoke",
|
||||
description: "Revoke a specific permission from a member",
|
||||
tags: ["Permissions"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
memberPublicId: z.string().min(12),
|
||||
permission: z.enum(permissionsList),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ success: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
}
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!workspace) {
|
||||
throw new TRPCError({
|
||||
message: "Workspace not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
await assertPermission(ctx.db, userId, workspace.id, "member:edit");
|
||||
|
||||
const member = await memberRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.memberPublicId,
|
||||
);
|
||||
|
||||
if (!member) {
|
||||
throw new TRPCError({
|
||||
message: "Member not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
await assertCanManageMember(ctx.db, userId, workspace.id, member.id);
|
||||
|
||||
await permissionRepo.revokePermission(
|
||||
ctx.db,
|
||||
member.id,
|
||||
input.permission as Permission,
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
resetMemberPermissions: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Reset member permissions to role defaults",
|
||||
method: "POST",
|
||||
path: "/workspaces/{workspacePublicId}/members/{memberPublicId}/permissions/reset",
|
||||
description:
|
||||
"Clears all custom permission overrides for a member so their effective permissions come only from their role",
|
||||
tags: ["Permissions"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
memberPublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ success: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
}
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!workspace) {
|
||||
throw new TRPCError({
|
||||
message: "Workspace not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
await assertPermission(ctx.db, userId, workspace.id, "member:edit");
|
||||
|
||||
const member = await memberRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.memberPublicId,
|
||||
);
|
||||
|
||||
if (!member) {
|
||||
throw new TRPCError({
|
||||
message: "Member not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
await assertCanManageMember(ctx.db, userId, workspace.id, member.id);
|
||||
|
||||
await permissionRepo.clearMemberPermissionOverrides(ctx.db, member.id);
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
resetWorkspaceMemberPermissions: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Reset all member permission overrides in a workspace",
|
||||
method: "POST",
|
||||
path: "/workspaces/{workspacePublicId}/members/permissions/reset",
|
||||
description:
|
||||
"Clears all custom permission overrides for all members in a workspace so their effective permissions come only from their roles",
|
||||
tags: ["Permissions"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ success: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
}
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!workspace) {
|
||||
throw new TRPCError({
|
||||
message: "Workspace not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
await assertPermission(ctx.db, userId, workspace.id, "member:edit");
|
||||
|
||||
await permissionRepo.clearAllMemberPermissionOverridesForWorkspace(
|
||||
ctx.db,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
getWorkspaceRoles: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Get workspace roles",
|
||||
method: "GET",
|
||||
path: "/workspaces/{workspacePublicId}/roles",
|
||||
description: "Get all roles for a workspace",
|
||||
tags: ["Permissions"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
roles: z.array(
|
||||
z.object({
|
||||
publicId: z.string().min(12),
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
hierarchyLevel: z.number(),
|
||||
isSystem: z.boolean(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
}
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!workspace) {
|
||||
throw new TRPCError({
|
||||
message: "Workspace not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
await assertPermission(ctx.db, userId, workspace.id, "member:view");
|
||||
|
||||
const roles = await permissionRepo.getRolesByWorkspaceId(
|
||||
ctx.db,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
return {
|
||||
roles: roles.map((role) => ({
|
||||
publicId: role.publicId,
|
||||
name: role.name,
|
||||
description: role.description ?? null,
|
||||
hierarchyLevel: role.hierarchyLevel,
|
||||
isSystem: role.isSystem,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
getWorkspaceRolePermissions: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Get workspace role permissions",
|
||||
method: "GET",
|
||||
path: "/workspaces/{workspacePublicId}/roles/permissions",
|
||||
description:
|
||||
"Get all roles for a workspace with their granted permissions",
|
||||
tags: ["Permissions"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
roles: z.array(
|
||||
z.object({
|
||||
publicId: z.string().min(12),
|
||||
name: z.string(),
|
||||
permissions: z.array(z.string()),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
}
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!workspace) {
|
||||
throw new TRPCError({
|
||||
message: "Workspace not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
await assertPermission(ctx.db, userId, workspace.id, "member:view");
|
||||
|
||||
const roles = await permissionRepo.getRolesByWorkspaceId(
|
||||
ctx.db,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
const rolesWithPermissions = await Promise.all(
|
||||
roles.map(async (role) => {
|
||||
const permissionsForRole = await permissionRepo.getPermissionsByRoleId(
|
||||
ctx.db,
|
||||
role.id,
|
||||
);
|
||||
|
||||
return {
|
||||
publicId: role.publicId,
|
||||
name: role.name,
|
||||
permissions: permissionsForRole,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
roles: rolesWithPermissions,
|
||||
};
|
||||
}),
|
||||
getRolePermissions: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Get role permissions",
|
||||
method: "GET",
|
||||
path: "/workspaces/{workspacePublicId}/roles/{rolePublicId}/permissions",
|
||||
description: "Get permissions granted to a specific role in a workspace",
|
||||
tags: ["Permissions"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
rolePublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
rolePublicId: z.string(),
|
||||
name: z.string(),
|
||||
permissions: z.array(z.string()),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
}
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!workspace) {
|
||||
throw new TRPCError({
|
||||
message: "Workspace not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
await assertPermission(ctx.db, userId, workspace.id, "member:view");
|
||||
|
||||
const role = await permissionRepo.getRoleByWorkspaceIdAndPublicId(
|
||||
ctx.db,
|
||||
workspace.id,
|
||||
input.rolePublicId,
|
||||
);
|
||||
|
||||
if (!role) {
|
||||
throw new TRPCError({
|
||||
message: "Role not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
const permissionsForRole = await permissionRepo.getPermissionsByRoleId(
|
||||
ctx.db,
|
||||
role.id,
|
||||
);
|
||||
|
||||
return {
|
||||
rolePublicId: role.publicId,
|
||||
name: role.name,
|
||||
permissions: permissionsForRole,
|
||||
};
|
||||
}),
|
||||
grantRolePermission: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Grant permission to role",
|
||||
method: "POST",
|
||||
path: "/workspaces/{workspacePublicId}/roles/{rolePublicId}/permissions/grant",
|
||||
description: "Grant a specific permission to a role",
|
||||
tags: ["Permissions"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
rolePublicId: z.string().min(12),
|
||||
permission: z.enum(permissionsList),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ success: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
}
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!workspace) {
|
||||
throw new TRPCError({
|
||||
message: "Workspace not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
// Require ability to edit members/roles
|
||||
await assertPermission(ctx.db, userId, workspace.id, "member:edit");
|
||||
|
||||
const role = await permissionRepo.getRoleByWorkspaceIdAndPublicId(
|
||||
ctx.db,
|
||||
workspace.id,
|
||||
input.rolePublicId,
|
||||
);
|
||||
|
||||
if (!role) {
|
||||
throw new TRPCError({
|
||||
message: "Role not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
if (role.name === "admin" && role.isSystem) {
|
||||
throw new TRPCError({
|
||||
message: "Admin role permissions cannot be modified",
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
|
||||
// Never allow non-admin roles to manage billing or delete workspace
|
||||
if (
|
||||
(input.permission === "workspace:manage" ||
|
||||
input.permission === "workspace:delete") &&
|
||||
role.name !== "admin"
|
||||
) {
|
||||
throw new TRPCError({
|
||||
message:
|
||||
"Only the admin role can manage billing or delete the workspace",
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
|
||||
await permissionRepo.grantRolePermission(
|
||||
ctx.db,
|
||||
role.id,
|
||||
input.permission as Permission,
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
revokeRolePermission: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Revoke permission from role",
|
||||
method: "POST",
|
||||
path: "/workspaces/{workspacePublicId}/roles/{rolePublicId}/permissions/revoke",
|
||||
description: "Revoke a specific permission from a role",
|
||||
tags: ["Permissions"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
rolePublicId: z.string().min(12),
|
||||
permission: z.enum(permissionsList),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ success: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
}
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!workspace) {
|
||||
throw new TRPCError({
|
||||
message: "Workspace not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
await assertPermission(ctx.db, userId, workspace.id, "member:edit");
|
||||
|
||||
const role = await permissionRepo.getRoleByWorkspaceIdAndPublicId(
|
||||
ctx.db,
|
||||
workspace.id,
|
||||
input.rolePublicId,
|
||||
);
|
||||
|
||||
if (!role) {
|
||||
throw new TRPCError({
|
||||
message: "Role not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
if (role.name === "admin" && role.isSystem) {
|
||||
throw new TRPCError({
|
||||
message: "Admin role permissions cannot be modified",
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
|
||||
await permissionRepo.revokeRolePermission(
|
||||
ctx.db,
|
||||
role.id,
|
||||
input.permission as Permission,
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import * as workspaceSlugRepo from "@kan/db/repository/workspaceSlug.repo";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { assertUserInWorkspace } from "../utils/auth";
|
||||
import { assertPermission } from "../utils/permissions";
|
||||
|
||||
export const workspaceRouter = createTRPCRouter({
|
||||
all: protectedProcedure
|
||||
@@ -74,8 +74,7 @@ export const workspaceRouter = createTRPCRouter({
|
||||
message: `Workspace not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, result.id);
|
||||
await assertPermission(ctx.db, userId, result.id, "workspace:view");
|
||||
|
||||
// Check if user is an admin
|
||||
const userMember = result.members.find(
|
||||
@@ -84,7 +83,8 @@ export const workspaceRouter = createTRPCRouter({
|
||||
const isAdmin = userMember?.role === "admin";
|
||||
|
||||
// Show emails if user is admin OR workspace setting allows it
|
||||
const shouldShowEmails = isAdmin || result.showEmailsToMembers === true;
|
||||
const shouldShowEmails =
|
||||
isAdmin || result.showEmailsToMembers === true;
|
||||
|
||||
// If emails should be hidden, filter them out
|
||||
if (!shouldShowEmails) {
|
||||
@@ -164,8 +164,7 @@ export const workspaceRouter = createTRPCRouter({
|
||||
message: `Workspace not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, result.id);
|
||||
await assertPermission(ctx.db, userId, result.id, "workspace:view");
|
||||
|
||||
return result;
|
||||
}),
|
||||
@@ -296,8 +295,7 @@ export const workspaceRouter = createTRPCRouter({
|
||||
message: `Workspace not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, workspace.id, "admin");
|
||||
await assertPermission(ctx.db, userId, workspace.id, "workspace:edit");
|
||||
|
||||
if (input.slug) {
|
||||
const reservedOrPremiumWorkspaceSlug =
|
||||
@@ -379,8 +377,7 @@ export const workspaceRouter = createTRPCRouter({
|
||||
message: `Workspace not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, workspace.id, "admin");
|
||||
await assertPermission(ctx.db, userId, workspace.id, "workspace:delete");
|
||||
|
||||
const result = await workspaceRepo.hardDelete(
|
||||
ctx.db,
|
||||
@@ -518,8 +515,7 @@ export const workspaceRouter = createTRPCRouter({
|
||||
message: `Workspace not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, workspace.id);
|
||||
await assertPermission(ctx.db, userId, workspace.id, "workspace:view");
|
||||
|
||||
const result = await workspaceRepo.searchBoardsAndCards(
|
||||
ctx.db,
|
||||
|
||||
295
packages/api/src/utils/permissions.ts
Normal file
295
packages/api/src/utils/permissions.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||
import * as permissionRepo from "@kan/db/repository/permission.repo";
|
||||
import type { Permission, Role } from "@kan/shared";
|
||||
import { canManageRole, getDefaultPermissions } from "@kan/shared";
|
||||
|
||||
/**
|
||||
* Get effective permissions for a member by combining role permissions with overrides
|
||||
*/
|
||||
export async function getMemberEffectivePermissions(
|
||||
db: dbClient,
|
||||
workspaceMemberId: number,
|
||||
roleId: number | null,
|
||||
roleName: string,
|
||||
): Promise<Permission[]> {
|
||||
let roleDefaults: Set<Permission>;
|
||||
|
||||
// Get role permissions from database or fallback to code defaults
|
||||
if (roleId) {
|
||||
const dbPermissions = await permissionRepo.getPermissionsByRoleId(
|
||||
db,
|
||||
roleId,
|
||||
);
|
||||
roleDefaults = new Set<Permission>(dbPermissions);
|
||||
} else {
|
||||
const codeDefaults = getDefaultPermissions(roleName as Role);
|
||||
roleDefaults = new Set<Permission>([...codeDefaults]);
|
||||
}
|
||||
|
||||
// Get and apply custom overrides
|
||||
const overrides = await permissionRepo.getMemberPermissionOverrides(
|
||||
db,
|
||||
workspaceMemberId,
|
||||
);
|
||||
|
||||
for (const override of overrides) {
|
||||
if (override.granted) {
|
||||
roleDefaults.add(override.permission as Permission);
|
||||
} else {
|
||||
roleDefaults.delete(override.permission as Permission);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(roleDefaults);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a member has a specific permission
|
||||
*/
|
||||
export async function memberHasPermission(
|
||||
db: dbClient,
|
||||
workspaceMemberId: number,
|
||||
roleId: number | null,
|
||||
roleName: string,
|
||||
permission: Permission,
|
||||
): Promise<boolean> {
|
||||
let hasRoleDefault: boolean;
|
||||
|
||||
// Check role permission from database or fallback to code defaults
|
||||
if (roleId) {
|
||||
const dbPermissions = await permissionRepo.getPermissionsByRoleId(
|
||||
db,
|
||||
roleId,
|
||||
);
|
||||
hasRoleDefault = dbPermissions.includes(permission);
|
||||
} else {
|
||||
const codeDefaults = getDefaultPermissions(roleName as Role);
|
||||
hasRoleDefault = codeDefaults.includes(permission);
|
||||
}
|
||||
|
||||
// Check for override
|
||||
const override = await permissionRepo.getMemberPermissionOverride(
|
||||
db,
|
||||
workspaceMemberId,
|
||||
permission,
|
||||
);
|
||||
|
||||
// Override takes precedence
|
||||
if (override) {
|
||||
return override.granted;
|
||||
}
|
||||
|
||||
return hasRoleDefault;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has a specific permission in a workspace
|
||||
*/
|
||||
export async function hasPermission(
|
||||
db: dbClient,
|
||||
userId: string,
|
||||
workspaceId: number,
|
||||
permission: Permission,
|
||||
): Promise<boolean> {
|
||||
const member = await permissionRepo.getMemberWithRole(db, userId, workspaceId);
|
||||
|
||||
if (!member) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return memberHasPermission(
|
||||
db,
|
||||
member.id,
|
||||
member.roleId,
|
||||
member.role,
|
||||
permission,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all permissions for a user in a workspace
|
||||
*/
|
||||
export async function getUserPermissions(
|
||||
db: dbClient,
|
||||
userId: string,
|
||||
workspaceId: number,
|
||||
): Promise<{
|
||||
permissions: Permission[];
|
||||
role: string;
|
||||
roleId: number | null;
|
||||
} | null> {
|
||||
const member = await permissionRepo.getMemberWithRole(db, userId, workspaceId);
|
||||
|
||||
if (!member) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const permissions = await getMemberEffectivePermissions(
|
||||
db,
|
||||
member.id,
|
||||
member.roleId,
|
||||
member.role,
|
||||
);
|
||||
|
||||
return {
|
||||
permissions,
|
||||
role: member.role,
|
||||
roleId: member.roleId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert user has permission - throws FORBIDDEN if not
|
||||
*/
|
||||
export async function assertPermission(
|
||||
db: dbClient,
|
||||
userId: string,
|
||||
workspaceId: number,
|
||||
permission: Permission,
|
||||
): Promise<void> {
|
||||
const hasIt = await hasPermission(db, userId, workspaceId, permission);
|
||||
|
||||
if (!hasIt) {
|
||||
throw new TRPCError({
|
||||
message: `You do not have permission to perform this action (${permission})`,
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert user can assign a specific role (based on hierarchy)
|
||||
*/
|
||||
export async function assertCanManageRole(
|
||||
db: dbClient,
|
||||
managerUserId: string,
|
||||
workspaceId: number,
|
||||
targetRoleName: string,
|
||||
): Promise<void> {
|
||||
const managerMember = await permissionRepo.getMemberWithRole(
|
||||
db,
|
||||
managerUserId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!managerMember) {
|
||||
throw new TRPCError({
|
||||
message: "You are not a member of this workspace",
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
|
||||
const managerRole = managerMember.role;
|
||||
|
||||
if (!canManageRole(managerRole, targetRoleName as Role)) {
|
||||
throw new TRPCError({
|
||||
message: `You cannot assign the "${targetRoleName}" role`,
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert user can manage another member based on role hierarchy
|
||||
*/
|
||||
export async function assertCanManageMember(
|
||||
db: dbClient,
|
||||
managerUserId: string,
|
||||
workspaceId: number,
|
||||
targetMemberId: number,
|
||||
): Promise<void> {
|
||||
const managerMember = await permissionRepo.getMemberWithRole(
|
||||
db,
|
||||
managerUserId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!managerMember) {
|
||||
throw new TRPCError({
|
||||
message: "You are not a member of this workspace",
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
|
||||
const targetMember = await memberRepo.getById(db, targetMemberId);
|
||||
|
||||
if (!targetMember) {
|
||||
throw new TRPCError({
|
||||
message: "Target member not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
const managerRole = managerMember.role;
|
||||
const targetRole = targetMember.role;
|
||||
|
||||
if (!canManageRole(managerRole, targetRole)) {
|
||||
throw new TRPCError({
|
||||
message: "You cannot manage this member due to role hierarchy",
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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",
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user