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}
+
+
+
+
+
+
+ {#if data.canManage}
+
+
Add member
+
+ {#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}
+
+