feat: assert user is member of workspace for board routes

This commit is contained in:
Henry
2025-05-22 22:12:33 +01:00
parent 71c92eea27
commit 2e97e2be53
5 changed files with 115 additions and 60 deletions

View File

@@ -9,6 +9,7 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { generateSlug, generateUID } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { assertUserInWorkspace } from "../utils/auth";
export const boardRouter = createTRPCRouter({
all: protectedProcedure
@@ -27,6 +28,14 @@ export const boardRouter = createTRPCRouter({
z.custom<Awaited<ReturnType<typeof boardRepo.getAllByWorkspaceId>>>(),
)
.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,
@@ -38,6 +47,8 @@ export const boardRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, workspace.id);
const result = boardRepo.getAllByWorkspaceId(ctx.db, workspace.id);
return result;
@@ -62,6 +73,27 @@ export const boardRouter = createTRPCRouter({
)
.output(z.custom<Awaited<ReturnType<typeof boardRepo.getByPublicId>>>())
.query(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
const workspaceId = await boardRepo.getWorkspaceIdByBoardPublicId(
ctx.db,
input.boardPublicId,
);
if (!workspaceId)
throw new TRPCError({
message: `Board with public ID ${input.boardPublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, workspaceId);
const result = await boardRepo.getByPublicId(
ctx.db,
input.boardPublicId,
@@ -142,6 +174,8 @@ export const boardRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, workspace.id);
let slug = generateSlug(input.name);
const isSlugUnique = await boardRepo.isSlugUnique(ctx.db, {
@@ -193,6 +227,27 @@ export const boardRouter = createTRPCRouter({
)
.output(z.custom<Awaited<ReturnType<typeof boardRepo.update>>>())
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
const workspaceId = await boardRepo.getWorkspaceIdByBoardPublicId(
ctx.db,
input.boardPublicId,
);
if (!workspaceId)
throw new TRPCError({
message: `Board with public ID ${input.boardPublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, workspaceId);
const result = await boardRepo.update(ctx.db, {
name: input.name,
slug: input.slug,
@@ -245,6 +300,8 @@ export const boardRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, board.workspaceId);
const listIds = board.lists.map((list) => list.id);
const deletedAt = new Date();

View File

@@ -0,0 +1,22 @@
import { TRPCError } from "@trpc/server";
import type { dbClient } from "@kan/db/client";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
export async function assertUserInWorkspace(
db: dbClient,
userId: string,
workspaceId: number,
) {
const isMember = await workspaceRepo.isUserInWorkspace(
db,
userId,
workspaceId,
);
if (!isMember)
throw new TRPCError({
message: `You do not have access to this workspace`,
code: "FORBIDDEN",
});
}

View File

@@ -1,60 +0,0 @@
CREATE OR REPLACE FUNCTION is_workspace_admin(user_id UUID, workspace_id BIGINT)
RETURNS BOOLEAN
LANGUAGE SQL
AS $$
SELECT EXISTS (
SELECT 1
FROM workspace_members
WHERE "workspaceId" = workspace_id
AND "userId" = user_id
AND "role" = 'admin'
);
$$;
CREATE OR REPLACE FUNCTION is_workspace_member(user_id UUID, workspace_id BIGINT)
RETURNS BOOLEAN
LANGUAGE SQL
AS $$
SELECT EXISTS (
SELECT 1
FROM workspace_members
WHERE "userId" = user_id AND "workspaceId" = workspace_id
);
$$;
/* BUCKETS */
insert into storage.buckets
(id, name, public)
values
('avatars', 'avatars', true);
alter table storage.objects enable row level security;
CREATE POLICY "Users can upload their own avatar"
ON storage.objects FOR INSERT
TO authenticated
WITH CHECK (
bucket_id = 'avatars' AND
(storage.foldername(name))[1] = auth.uid()::text
);
CREATE POLICY "Users can update their own avatar"
ON storage.objects FOR UPDATE
TO authenticated
USING (
bucket_id = 'avatars' AND
(storage.foldername(name))[1] = auth.uid()::text
);
CREATE POLICY "Users can delete their own avatar"
ON storage.objects FOR DELETE
TO authenticated
USING (
bucket_id = 'avatars' AND
(storage.foldername(name))[1] = auth.uid()::text
);
CREATE POLICY "Avatar images are publicly accessible"
ON storage.objects FOR SELECT
TO anon, authenticated
USING (bucket_id = 'avatars');

View File

@@ -311,6 +311,7 @@ export const getWithListIdsByPublicId = (
return db.query.boards.findFirst({
columns: {
id: true,
workspaceId: true,
},
with: {
lists: {
@@ -330,6 +331,7 @@ export const getWithLatestListIndexByPublicId = (
return db.query.boards.findFirst({
columns: {
id: true,
workspaceId: true,
},
with: {
lists: {
@@ -450,3 +452,17 @@ export const isSlugUnique = async (
return result === undefined;
};
export const getWorkspaceIdByBoardPublicId = async (
db: dbClient,
boardPublicId: string,
) => {
const result = await db.query.boards.findFirst({
columns: {
workspaceId: true,
},
where: eq(boards.publicId, boardPublicId),
});
return result?.workspaceId;
};

View File

@@ -220,3 +220,23 @@ export const isWorkspaceSlugAvailable = async (
return result === undefined;
};
export const isUserInWorkspace = async (
db: dbClient,
userId: string,
workspaceId: number,
) => {
const result = await db.query.workspaceMembers.findFirst({
columns: {
id: true,
},
where: and(
eq(workspaceMembers.userId, userId),
eq(workspaceMembers.workspaceId, workspaceId),
eq(workspaceMembers.status, "active"),
isNull(workspaceMembers.deletedAt),
),
});
return result?.id !== undefined;
};