diff --git a/apps/web/src/components/SettingsLayout.tsx b/apps/web/src/components/SettingsLayout.tsx index d38623b6..29d8a48a 100644 --- a/apps/web/src/components/SettingsLayout.tsx +++ b/apps/web/src/components/SettingsLayout.tsx @@ -14,8 +14,10 @@ import { HiOutlineBanknotes, HiOutlineCodeBracketSquare, HiOutlineRectangleGroup, + HiOutlineShieldCheck, HiOutlineUser, } from "react-icons/hi2"; +import { useWorkspace } from "~/providers/workspace"; interface SettingsLayoutProps { children: React.ReactNode; @@ -24,8 +26,11 @@ interface SettingsLayoutProps { export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) { const router = useRouter(); + const { workspace } = useWorkspace(); const [selectedTabIndex, setSelectedTabIndex] = useState(0); + const isAdmin = workspace.role === "admin"; + const settingsTabs = [ { key: "account", @@ -39,11 +44,17 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) { label: t`Workspace`, condition: true, }, + { + key: "permissions", + icon: , + label: t`Permissions`, + condition: isAdmin, + }, { key: "billing", label: t`Billing`, icon: , - condition: env("NEXT_PUBLIC_KAN_ENV") === "cloud", + condition: env("NEXT_PUBLIC_KAN_ENV") === "cloud" && isAdmin, }, { key: "api", diff --git a/apps/web/src/pages/settings/permissions.tsx b/apps/web/src/pages/settings/permissions.tsx new file mode 100644 index 00000000..b4364347 --- /dev/null +++ b/apps/web/src/pages/settings/permissions.tsx @@ -0,0 +1,18 @@ +import type { NextPageWithLayout } from "~/pages/_app"; +import { getDashboardLayout } from "~/components/Dashboard"; +import { SettingsLayout } from "~/components/SettingsLayout"; +import PermissionsSettings from "~/views/settings/PermissionsSettings"; + +const PermissionsSettingsPage: NextPageWithLayout = () => { + return ( + + + + ); +}; + +PermissionsSettingsPage.getLayout = (page) => getDashboardLayout(page); + +export default PermissionsSettingsPage; + + diff --git a/apps/web/src/views/settings/PermissionsSettings.tsx b/apps/web/src/views/settings/PermissionsSettings.tsx new file mode 100644 index 00000000..01c30138 --- /dev/null +++ b/apps/web/src/views/settings/PermissionsSettings.tsx @@ -0,0 +1,36 @@ +import { t } from "@lingui/core/macro"; + +import { PageHead } from "~/components/PageHead"; +import { useWorkspace } from "~/providers/workspace"; +import { RolePermissions } from "./components/RolePermissions"; + +export default function PermissionsSettings() { + const { workspace } = useWorkspace(); + + const isAdmin = workspace.role === "admin"; + + return ( + <> + + +
+

+ {t`Workspace permissions`} +

+

+ {t`Configure which actions are allowed for each workspace role. These permissions apply to all members with that role.`} +

+ + {isAdmin ? ( + + ) : ( +

+ {t`You need to be an admin to manage workspace permissions.`} +

+ )} +
+ + ); +} + + diff --git a/apps/web/src/views/settings/WorkspaceSettings.tsx b/apps/web/src/views/settings/WorkspaceSettings.tsx index 8c20c155..24039c3c 100644 --- a/apps/web/src/views/settings/WorkspaceSettings.tsx +++ b/apps/web/src/views/settings/WorkspaceSettings.tsx @@ -85,7 +85,9 @@ export default function WorkspaceSettings() { {env("NEXT_PUBLIC_KAN_ENV") === "cloud" && diff --git a/apps/web/src/views/settings/components/RolePermissions.tsx b/apps/web/src/views/settings/components/RolePermissions.tsx new file mode 100644 index 00000000..91ba4887 --- /dev/null +++ b/apps/web/src/views/settings/components/RolePermissions.tsx @@ -0,0 +1,196 @@ +import { t } from "@lingui/core/macro"; + +import { permissionCategories, roles } from "@kan/shared"; +import type { Permission, Role } from "@kan/shared"; + +import { useWorkspace } from "~/providers/workspace"; +import { api } from "~/utils/api"; + +function formatRoleLabel(role: Role) { + return role.charAt(0).toUpperCase() + role.slice(1); +} + +const permissionLabels: Record = { + "workspace:view": t`Can view workspace`, + "workspace:edit": t`Can edit workspace`, + "workspace:delete": t`Can delete workspace`, + "workspace:manage": t`Can manage workspace settings and billing`, + + "board:view": t`Can view boards`, + "board:create": t`Can create boards`, + "board:edit": t`Can edit boards`, + "board:delete": t`Can delete boards`, + + "list:view": t`Can view lists`, + "list:create": t`Can create lists`, + "list:edit": t`Can edit lists`, + "list:delete": t`Can delete lists`, + + "card:view": t`Can view cards`, + "card:create": t`Can create cards`, + "card:edit": t`Can edit cards`, + "card:delete": t`Can delete cards`, + + "comment:view": t`Can view comments`, + "comment:create": t`Can add comments`, + "comment:edit": t`Can edit comments`, + "comment:delete": t`Can delete comments`, + + "member:view": t`Can view members`, + "member:invite": t`Can invite members`, + "member:edit": t`Can edit member roles and permissions`, + "member:remove": t`Can remove members`, +}; + +export function RolePermissions() { + const { workspace } = useWorkspace(); + + const utils = api.useUtils(); + + const { data, isLoading } = + api.permission.getWorkspaceRolePermissions.useQuery( + { workspacePublicId: workspace.publicId }, + { enabled: !!workspace.publicId }, + ); + + const systemRoles = (data?.roles ?? []).filter((role) => + (roles).includes(role.name as Role), + ); + + const orderedRoleNames: Role[] = ["admin", "member", "guest"].filter( + (role) => systemRoles.some((r) => r.name === role), + ) as Role[]; + + const grantMutation = api.permission.grantRolePermission.useMutation({ + onSettled: async () => { + if (!workspace.publicId) return; + await utils.permission.getWorkspaceRolePermissions.invalidate({ + workspacePublicId: workspace.publicId, + }); + }, + }); + + const revokeMutation = api.permission.revokeRolePermission.useMutation({ + onSettled: async () => { + if (!workspace.publicId) return; + await utils.permission.getWorkspaceRolePermissions.invalidate({ + workspacePublicId: workspace.publicId, + }); + }, + }); + + const isBusy = grantMutation.isPending || revokeMutation.isPending; + + const handleToggle = ( + rolePublicId: string, + permission: Permission, + checked: boolean, + ) => { + if (!workspace.publicId || !rolePublicId) return; + + if (checked) { + grantMutation.mutate({ + workspacePublicId: workspace.publicId, + rolePublicId, + permission, + }); + } else { + revokeMutation.mutate({ + workspacePublicId: workspace.publicId, + rolePublicId, + permission, + }); + } + }; + + return ( +
+ {orderedRoleNames.length === 0 && !isLoading ? ( +

+ {t`No roles found for this workspace yet.`} +

+ ) : null} + +
+ + + + + {orderedRoleNames.map((role) => ( + + ))} + + + {Object.values(permissionCategories).map((category) => ( + + + + + {category.permissions.map((permission) => ( + + + {orderedRoleNames.map((roleName) => { + const role = systemRoles.find((r) => r.name === roleName); + const checked = role?.permissions.includes(permission); + const isAdminRole = roleName === "admin"; + const isBillingOrDeletePermission = + permission === "workspace:manage" || + permission === "workspace:delete"; + + return ( + + ); + })} + + ))} + + ))} +
+ {t`Permission`} + + {formatRoleLabel(role)} +
+ {category.label} +
+ {permissionLabels[permission] ?? permission} + + + !isAdminRole && + !isBillingOrDeletePermission && + role && + handleToggle( + role.publicId, + permission, + e.target.checked, + ) + } + /> +
+
+
+ ); +} + diff --git a/packages/api/src/routers/permission.ts b/packages/api/src/routers/permission.ts index ce5093a5..48ad28a5 100644 --- a/packages/api/src/routers/permission.ts +++ b/packages/api/src/routers/permission.ts @@ -5,9 +5,7 @@ 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 { allPermissions } from "@kan/shared"; import { createTRPCRouter, protectedProcedure } from "../trpc"; import { @@ -291,6 +289,378 @@ export const permissionRouter = createTRPCRouter({ input.permission as Permission, ); + 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 }; }), }); diff --git a/packages/db/src/repository/permission.repo.ts b/packages/db/src/repository/permission.repo.ts index 393e3caa..e64020dc 100644 --- a/packages/db/src/repository/permission.repo.ts +++ b/packages/db/src/repository/permission.repo.ts @@ -52,6 +52,28 @@ export const getRoleByWorkspaceIdAndName = async ( return role; }; +/** + * Get role by workspace ID and publicId + */ +export const getRoleByWorkspaceIdAndPublicId = async ( + db: dbClient, + workspaceId: number, + rolePublicId: string, +) => { + const [role] = await db + .select() + .from(workspaceRoles) + .where( + and( + eq(workspaceRoles.workspaceId, workspaceId), + eq(workspaceRoles.publicId, rolePublicId), + ), + ) + .limit(1); + + return role; +}; + /** * Get all custom permission overrides for a workspace member */ @@ -352,3 +374,83 @@ export const createRole = async ( return role; }; + +/** + * Grant a permission to a role + */ +export const grantRolePermission = async ( + db: dbClient, + roleId: number, + permission: Permission, +) => { + const [result] = await db + .insert(workspaceRolePermissions) + .values({ + workspaceRoleId: roleId, + permission, + granted: true, + }) + .onConflictDoUpdate({ + target: [ + workspaceRolePermissions.workspaceRoleId, + workspaceRolePermissions.permission, + ], + set: { + granted: true, + }, + }) + .returning(); + + return result; +}; + +/** + * Revoke a permission from a role + */ +export const revokeRolePermission = async ( + db: dbClient, + roleId: number, + permission: Permission, +) => { + const [result] = await db + .insert(workspaceRolePermissions) + .values({ + workspaceRoleId: roleId, + permission, + granted: false, + }) + .onConflictDoUpdate({ + target: [ + workspaceRolePermissions.workspaceRoleId, + workspaceRolePermissions.permission, + ], + set: { + granted: false, + }, + }) + .returning(); + + return result; +}; + +/** + * Get all roles for a workspace + */ +export const getRolesByWorkspaceId = async ( + db: dbClient, + workspaceId: number, +) => { + return db + .select({ + id: workspaceRoles.id, + publicId: workspaceRoles.publicId, + name: workspaceRoles.name, + description: workspaceRoles.description, + hierarchyLevel: workspaceRoles.hierarchyLevel, + isSystem: workspaceRoles.isSystem, + }) + .from(workspaceRoles) + .where(eq(workspaceRoles.workspaceId, workspaceId)); +}; + +