From 9b5708af645e20898e2aab44ad7e973c9b15dc2c Mon Sep 17 00:00:00 2001 From: Catherine Renelle <32781029+catrenelle@users.noreply.github.com> Date: Wed, 25 Feb 2026 23:13:29 -0500 Subject: [PATCH] Phase 3: Roles & permissions enforcement with guards, member management, and admin panel Centralize permission checks into guards.ts (requireAuth, requireBoardView, requireBoardEditor, requireBoardOwner, requireColumnEditor, requireCardEditor, requireBoardMemberManager, requireTenantAdmin) that fetch entities and check permissions in a single call, replacing ~100 lines of duplicated inline checks across all API routes. Adds board member management (add by email, change role, remove with last-owner protection), tenant admin panel (user role toggle with last-admin protection, board overview), and fixes missing view guard on card GET. Co-Authored-By: Claude Opus 4.6 --- src/lib/server/guards.ts | 151 +++++++++++++++ src/lib/server/validation.ts | 13 ++ src/routes/+layout.svelte | 8 + src/routes/admin/+page.server.ts | 40 ++++ src/routes/admin/+page.svelte | 118 ++++++++++++ .../api/admin/users/[userId]/+server.ts | 41 ++++ src/routes/api/boards/+server.ts | 3 +- src/routes/api/boards/[boardId]/+server.ts | 63 +----- .../api/boards/[boardId]/columns/+server.ts | 13 +- .../[boardId]/columns/[columnId]/+server.ts | 28 +-- .../columns/[columnId]/cards/+server.ts | 13 +- .../[columnId]/cards/[cardId]/+server.ts | 59 +++--- .../api/boards/[boardId]/members/+server.ts | 48 +++++ .../[boardId]/members/[memberId]/+server.ts | 71 +++++++ src/routes/boards/[boardId]/+page.server.ts | 50 +---- src/routes/boards/[boardId]/+page.svelte | 6 + .../boards/[boardId]/members/+page.server.ts | 23 +++ .../boards/[boardId]/members/+page.svelte | 181 ++++++++++++++++++ 18 files changed, 747 insertions(+), 182 deletions(-) create mode 100644 src/lib/server/guards.ts create mode 100644 src/routes/admin/+page.server.ts create mode 100644 src/routes/admin/+page.svelte create mode 100644 src/routes/api/admin/users/[userId]/+server.ts create mode 100644 src/routes/api/boards/[boardId]/members/+server.ts create mode 100644 src/routes/api/boards/[boardId]/members/[memberId]/+server.ts create mode 100644 src/routes/boards/[boardId]/members/+page.server.ts create mode 100644 src/routes/boards/[boardId]/members/+page.svelte diff --git a/src/lib/server/guards.ts b/src/lib/server/guards.ts new file mode 100644 index 0000000..975ff98 --- /dev/null +++ b/src/lib/server/guards.ts @@ -0,0 +1,151 @@ +import { error } from '@sveltejs/kit'; +import type { Prisma } from '@prisma/client'; +import { canViewBoard, canEditBoard, canDeleteBoard, canManageMembers, isTenantAdmin } from './permissions.js'; + +type TxClient = Prisma.TransactionClient; + +type SessionUser = { + id: string; + email: string; + name: string; + globalRole: string; + tenantRole: string | null; +}; + +const boardWithMembers = { + members: { + include: { user: { select: { id: true, name: true, email: true, avatarUrl: true } } } + } +} as const; + +const boardWithFull = { + columns: { + orderBy: { position: 'asc' as const }, + include: { + cards: { + where: { archived: false }, + orderBy: { position: 'asc' as const }, + include: { + assignees: { + include: { user: { select: { id: true, name: true, avatarUrl: true } } } + }, + labels: { include: { tag: true } }, + _count: { select: { comments: true, checklists: true, attachments: true } } + } + } + } + }, + members: { + include: { user: { select: { id: true, name: true, email: true, avatarUrl: true } } } + } +} as const; + +/** + * Asserts user is authenticated. Throws 401 if not. + */ +export function requireAuth(user: SessionUser | null): asserts user is SessionUser { + if (!user) throw error(401, 'Not authenticated'); +} + +/** + * Asserts user is a tenant admin. Throws 403 if not. + */ +export function requireTenantAdmin(user: SessionUser | null): asserts user is SessionUser { + requireAuth(user); + if (!isTenantAdmin(user)) throw error(403, 'Tenant admin access required'); +} + +/** + * Fetches a board with full includes (columns/cards/members) and checks view permission. + * Returns the board. + */ +export async function requireBoardView(tx: TxClient, user: SessionUser | null, boardId: string, tenantId: string) { + const board = await tx.board.findFirst({ + where: { id: boardId, tenantId }, + include: boardWithFull + }); + if (!board) throw error(404, 'Board not found'); + if (!canViewBoard(user, board.visibility, board.members)) { + if (!user) throw error(401, 'Not authenticated'); + throw error(403, 'Access denied'); + } + return board; +} + +/** + * Fetches a board with members and checks edit permission. + * Returns the board. + */ +export async function requireBoardEditor(tx: TxClient, user: SessionUser | null, boardId: string, tenantId: string) { + requireAuth(user); + const board = await tx.board.findFirst({ + where: { id: boardId, tenantId }, + include: boardWithMembers + }); + if (!board) throw error(404, 'Board not found'); + if (!canEditBoard(user, board.members)) throw error(403, 'Access denied'); + return board; +} + +/** + * Fetches a board with members and checks owner/delete permission. + * Returns the board. + */ +export async function requireBoardOwner(tx: TxClient, user: SessionUser | null, boardId: string, tenantId: string) { + requireAuth(user); + const board = await tx.board.findFirst({ + where: { id: boardId, tenantId }, + include: boardWithMembers + }); + if (!board) throw error(404, 'Board not found'); + if (!canDeleteBoard(user, board.members)) throw error(403, 'Only owners can delete boards'); + return board; +} + +/** + * Fetches a board with members and checks member management permission. + * Returns the board. + */ +export async function requireBoardMemberManager(tx: TxClient, user: SessionUser | null, boardId: string, tenantId: string) { + requireAuth(user); + const board = await tx.board.findFirst({ + where: { id: boardId, tenantId }, + include: boardWithMembers + }); + if (!board) throw error(404, 'Board not found'); + if (!canManageMembers(user, board.members)) throw error(403, 'Only owners can manage members'); + return board; +} + +/** + * Fetches a column (verifying it belongs to the board/tenant) and checks edit permission. + * Returns the column with its board and members. + */ +export async function requireColumnEditor(tx: TxClient, user: SessionUser | null, columnId: string, boardId: string, tenantId: string) { + requireAuth(user); + const column = await tx.column.findFirst({ + where: { id: columnId, board: { id: boardId, tenantId } }, + include: { board: { include: { members: true } } } + }); + if (!column) throw error(404, 'Column not found'); + if (!canEditBoard(user, column.board.members)) throw error(403, 'Access denied'); + return column; +} + +/** + * Fetches a card (verifying it belongs to the board/tenant) and checks edit permission. + * Returns the card with its column, board, and members. + */ +export async function requireCardEditor(tx: TxClient, user: SessionUser | null, cardId: string, boardId: string, tenantId: string) { + requireAuth(user); + const card = await tx.card.findFirst({ + where: { + id: cardId, + column: { board: { id: boardId, tenantId } } + }, + include: { column: { include: { board: { include: { members: true } } } } } + }); + if (!card) throw error(404, 'Card not found'); + if (!canEditBoard(user, card.column.board.members)) throw error(403, 'Access denied'); + return card; +} diff --git a/src/lib/server/validation.ts b/src/lib/server/validation.ts index 2cf38c4..bc7111b 100644 --- a/src/lib/server/validation.ts +++ b/src/lib/server/validation.ts @@ -34,3 +34,16 @@ export const moveCardSchema = z.object({ export const moveColumnSchema = z.object({ position: z.string() }); + +export const addMemberSchema = z.object({ + email: z.string().email('Invalid email address'), + role: z.enum(['EDITOR', 'VIEWER']).default('EDITOR') +}); + +export const changeMemberRoleSchema = z.object({ + role: z.enum(['OWNER', 'EDITOR', 'VIEWER']) +}); + +export const changeTenantRoleSchema = z.object({ + tenantRole: z.enum(['ADMIN', 'MEMBER']) +}); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 813ab03..a8b2879 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -21,6 +21,14 @@
{#if data.user} + {#if data.user.tenantRole === 'ADMIN' || data.user.globalRole === 'SITE_ADMIN'} + + Admin + + {/if} {data.user.name}
diff --git a/src/routes/boards/[boardId]/members/+page.server.ts b/src/routes/boards/[boardId]/members/+page.server.ts new file mode 100644 index 0000000..6a51de8 --- /dev/null +++ b/src/routes/boards/[boardId]/members/+page.server.ts @@ -0,0 +1,23 @@ +import { error } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types.js'; +import { withTenant } from '$lib/server/rls.js'; +import { requireBoardView } from '$lib/server/guards.js'; +import { canManageMembers } from '$lib/server/permissions.js'; + +export const load: PageServerLoad = async ({ params, locals }) => { + const tenant = locals.tenant; + if (!tenant) throw error(400, 'Unknown tenant'); + + const board = await withTenant(tenant.id, async (tx) => { + return requireBoardView(tx, locals.user, params.boardId, tenant.id); + }); + + return { + board: { + id: board.id, + title: board.title, + members: board.members + }, + canManage: canManageMembers(locals.user, board.members) + }; +}; diff --git a/src/routes/boards/[boardId]/members/+page.svelte b/src/routes/boards/[boardId]/members/+page.svelte new file mode 100644 index 0000000..eec111c --- /dev/null +++ b/src/routes/boards/[boardId]/members/+page.svelte @@ -0,0 +1,181 @@ + + + + Members - {data.board.title} + + +
+
+ + + + + Back to board + +

Members of {data.board.title}

+
+ + + {#if data.canManage} +
+

Add member

+
{ + e.preventDefault(); + addMember(); + }} + class="flex gap-2" + > + + + +
+ {#if addError} +

{addError}

+ {/if} +
+ {/if} + + +
+ {#each members as member (member.id)} +
+
+ {member.user.name[0].toUpperCase()} +
+
+

{member.user.name}

+

{member.user.email}

+
+ {#if data.canManage} + + + {:else} + + {member.role} + + {/if} +
+ {/each} + {#if members.length === 0} +
No members yet
+ {/if} +
+