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:
Henry
2026-02-01 21:17:18 +00:00
committed by GitHub
parent 5f2d409773
commit 7f5a1ab513
83 changed files with 10568 additions and 1442 deletions

View File

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