feat: setup basic router interactions

This commit is contained in:
Henry
2026-01-26 14:45:40 +00:00
parent 9415daecd3
commit 090028d827
6 changed files with 623 additions and 1 deletions

View File

@@ -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,

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,16 @@ 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
@@ -646,4 +652,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,
};
}),
});

View File

@@ -0,0 +1,296 @@
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 };
}),
});

View File

@@ -0,0 +1,235 @@
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 as 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 as Role;
const targetRole = targetMember.role as Role;
if (!canManageRole(managerRole, targetRole)) {
throw new TRPCError({
message: "You cannot manage this member due to role hierarchy",
code: "FORBIDDEN",
});
}
}

View File

@@ -17,6 +17,7 @@ export const workspaceRoles = pgTable(
"workspace_roles",
{
id: bigserial("id", { mode: "number" }).primaryKey(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
workspaceId: bigint("workspaceId", { mode: "number" })
.notNull()
.references(() => workspaces.id, { onDelete: "cascade" }),

View File

@@ -2,3 +2,4 @@ export const name = "shared";
export * from "./constants";
export * from "./utils";
export * from "./permissions";