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 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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'])
|
||||
});
|
||||
|
||||
@@ -21,6 +21,14 @@
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
{#if data.user}
|
||||
{#if data.user.tenantRole === 'ADMIN' || data.user.globalRole === 'SITE_ADMIN'}
|
||||
<a
|
||||
href="/admin"
|
||||
class="rounded bg-white/20 px-3 py-1.5 text-sm text-white hover:bg-white/30 transition"
|
||||
>
|
||||
Admin
|
||||
</a>
|
||||
{/if}
|
||||
<span class="text-white/80 text-sm">{data.user.name}</span>
|
||||
<button
|
||||
onclick={async () => {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types.js';
|
||||
import { withTenant } from '$lib/server/rls.js';
|
||||
import { requireTenantAdmin } from '$lib/server/guards.js';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const tenant = locals.tenant;
|
||||
if (!tenant) throw error(400, 'Unknown tenant');
|
||||
requireTenantAdmin(locals.user);
|
||||
|
||||
const { users, boards } = await withTenant(tenant.id, async (tx) => {
|
||||
const [users, boards] = await Promise.all([
|
||||
tx.user.findMany({
|
||||
where: { tenantId: tenant.id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
tenantRole: true,
|
||||
globalRole: true,
|
||||
createdAt: true
|
||||
},
|
||||
orderBy: { createdAt: 'asc' }
|
||||
}),
|
||||
tx.board.findMany({
|
||||
where: { tenantId: tenant.id, archived: false },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
visibility: true,
|
||||
_count: { select: { members: true } }
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' }
|
||||
})
|
||||
]);
|
||||
return { users, boards };
|
||||
});
|
||||
|
||||
return { users, boards, tenantName: tenant.name };
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
let { data } = $props();
|
||||
|
||||
let users = $state(data.users.map((u: any) => ({ ...u })));
|
||||
|
||||
async function changeRole(userId: string, tenantRole: string) {
|
||||
const res = await fetch(`/api/admin/users/${userId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tenantRole })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const json = await res.json();
|
||||
alert(json.error || json.message || 'Failed to change role');
|
||||
return;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
users = users.map((u: any) => (u.id === userId ? { ...u, tenantRole: json.user.tenantRole } : u));
|
||||
}
|
||||
|
||||
function formatDate(d: string) {
|
||||
return new Date(d).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Admin - {data.tenantName}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-5xl px-4 py-8">
|
||||
<h1 class="text-xl font-bold text-gray-900 mb-6">Workspace Admin</h1>
|
||||
|
||||
<!-- Users -->
|
||||
<section class="mb-8">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-3">Users</h2>
|
||||
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-100 text-left text-xs text-gray-500 uppercase tracking-wider">
|
||||
<th class="px-4 py-3">Name</th>
|
||||
<th class="px-4 py-3">Email</th>
|
||||
<th class="px-4 py-3">Role</th>
|
||||
<th class="px-4 py-3">Joined</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
{#each users as user (user.id)}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 font-medium text-gray-900">
|
||||
{user.name}
|
||||
{#if user.globalRole === 'SITE_ADMIN'}
|
||||
<span class="ml-1 rounded bg-purple-100 px-1.5 py-0.5 text-[10px] font-semibold text-purple-700">SITE ADMIN</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-600">{user.email}</td>
|
||||
<td class="px-4 py-3">
|
||||
<select
|
||||
value={user.tenantRole}
|
||||
onchange={(e) => changeRole(user.id, e.currentTarget.value)}
|
||||
class="rounded border border-gray-300 px-2 py-1 text-xs focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
|
||||
>
|
||||
<option value="ADMIN">Admin</option>
|
||||
<option value="MEMBER">Member</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-500">{formatDate(user.createdAt)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Boards -->
|
||||
<section>
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-3">Boards</h2>
|
||||
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-100 text-left text-xs text-gray-500 uppercase tracking-wider">
|
||||
<th class="px-4 py-3">Title</th>
|
||||
<th class="px-4 py-3">Visibility</th>
|
||||
<th class="px-4 py-3">Members</th>
|
||||
<th class="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
{#each data.boards as board (board.id)}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 font-medium text-gray-900">{board.title}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
|
||||
{board.visibility === 'PUBLIC' ? 'Public' : 'Private'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-600">{board._count.members}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<a
|
||||
href="/boards/{board.id}"
|
||||
class="text-[var(--color-primary)] hover:underline text-xs"
|
||||
>
|
||||
View
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{#if data.boards.length === 0}
|
||||
<tr>
|
||||
<td colspan="4" class="px-4 py-8 text-center text-gray-500">No boards yet</td>
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,41 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { changeTenantRoleSchema } from '$lib/server/validation.js';
|
||||
import { withTenant } from '$lib/server/rls.js';
|
||||
import { requireTenantAdmin } from '$lib/server/guards.js';
|
||||
|
||||
// Change user's tenant role
|
||||
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||
const tenant = locals.tenant;
|
||||
if (!tenant) throw error(400, 'Unknown tenant');
|
||||
requireTenantAdmin(locals.user);
|
||||
|
||||
const body = await request.json();
|
||||
const result = changeTenantRoleSchema.safeParse(body);
|
||||
if (!result.success) {
|
||||
return json({ error: result.error.issues[0].message }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated = await withTenant(tenant.id, async (tx) => {
|
||||
const targetUser = await tx.user.findFirst({
|
||||
where: { id: params.userId, tenantId: tenant.id }
|
||||
});
|
||||
if (!targetUser) throw error(404, 'User not found');
|
||||
|
||||
// Prevent demoting the last tenant admin
|
||||
if (targetUser.tenantRole === 'ADMIN' && result.data.tenantRole === 'MEMBER') {
|
||||
const adminCount = await tx.user.count({
|
||||
where: { tenantId: tenant.id, tenantRole: 'ADMIN' }
|
||||
});
|
||||
if (adminCount <= 1) throw error(400, 'Cannot demote the last tenant admin');
|
||||
}
|
||||
|
||||
return tx.user.update({
|
||||
where: { id: params.userId },
|
||||
data: { tenantRole: result.data.tenantRole },
|
||||
select: { id: true, name: true, email: true, tenantRole: true }
|
||||
});
|
||||
});
|
||||
|
||||
return json({ user: updated });
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { boardSchema } from '$lib/server/validation.js';
|
||||
import { withTenant } from '$lib/server/rls.js';
|
||||
import { requireAuth } from '$lib/server/guards.js';
|
||||
|
||||
// List boards for current tenant
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
@@ -41,7 +42,7 @@ export const GET: RequestHandler = async ({ locals }) => {
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
const tenant = locals.tenant;
|
||||
if (!tenant) throw error(400, 'Unknown tenant');
|
||||
if (!locals.user) throw error(401, 'Not authenticated');
|
||||
requireAuth(locals.user);
|
||||
|
||||
const body = await request.json();
|
||||
const result = boardSchema.safeParse(body);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { boardSchema } from '$lib/server/validation.js';
|
||||
import { withTenant } from '$lib/server/rls.js';
|
||||
import { requireBoardView, requireBoardEditor, requireBoardOwner } from '$lib/server/guards.js';
|
||||
|
||||
// Get single board with all columns and cards
|
||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
@@ -9,42 +10,8 @@ export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
if (!tenant) throw error(400, 'Unknown tenant');
|
||||
|
||||
const board = await withTenant(tenant.id, async (tx) => {
|
||||
return tx.board.findFirst({
|
||||
where: { id: params.boardId, tenantId: tenant.id },
|
||||
include: {
|
||||
columns: {
|
||||
orderBy: { position: 'asc' },
|
||||
include: {
|
||||
cards: {
|
||||
where: { archived: false },
|
||||
orderBy: { position: 'asc' },
|
||||
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 } } }
|
||||
}
|
||||
}
|
||||
return requireBoardView(tx, locals.user, params.boardId, tenant.id);
|
||||
});
|
||||
});
|
||||
|
||||
if (!board) throw error(404, 'Board not found');
|
||||
|
||||
// Check access
|
||||
if (board.visibility === 'PRIVATE') {
|
||||
if (!locals.user) throw error(401, 'Not authenticated');
|
||||
const isMember = board.members.some((m) => m.userId === locals.user!.id);
|
||||
const isAdmin =
|
||||
locals.user.globalRole === 'SITE_ADMIN' || locals.user.tenantRole === 'ADMIN';
|
||||
if (!isMember && !isAdmin) throw error(403, 'Access denied');
|
||||
}
|
||||
|
||||
return json({ board });
|
||||
};
|
||||
@@ -53,7 +20,6 @@ export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||
const tenant = locals.tenant;
|
||||
if (!tenant) throw error(400, 'Unknown tenant');
|
||||
if (!locals.user) throw error(401, 'Not authenticated');
|
||||
|
||||
const body = await request.json();
|
||||
const result = boardSchema.partial().safeParse(body);
|
||||
@@ -62,17 +28,7 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||
}
|
||||
|
||||
const updated = await withTenant(tenant.id, async (tx) => {
|
||||
const board = await tx.board.findFirst({
|
||||
where: { id: params.boardId, tenantId: tenant.id },
|
||||
include: { members: true }
|
||||
});
|
||||
if (!board) throw error(404, 'Board not found');
|
||||
|
||||
const member = board.members.find((m) => m.userId === locals.user!.id);
|
||||
const isAdmin =
|
||||
locals.user!.globalRole === 'SITE_ADMIN' || locals.user!.tenantRole === 'ADMIN';
|
||||
if (!member && !isAdmin) throw error(403, 'Access denied');
|
||||
if (member && member.role === 'VIEWER') throw error(403, 'Viewers cannot edit boards');
|
||||
await requireBoardEditor(tx, locals.user, params.boardId, tenant.id);
|
||||
|
||||
return tx.board.update({
|
||||
where: { id: params.boardId },
|
||||
@@ -87,20 +43,9 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||
const tenant = locals.tenant;
|
||||
if (!tenant) throw error(400, 'Unknown tenant');
|
||||
if (!locals.user) throw error(401, 'Not authenticated');
|
||||
|
||||
await withTenant(tenant.id, async (tx) => {
|
||||
const board = await tx.board.findFirst({
|
||||
where: { id: params.boardId, tenantId: tenant.id },
|
||||
include: { members: true }
|
||||
});
|
||||
if (!board) throw error(404, 'Board not found');
|
||||
|
||||
const member = board.members.find((m) => m.userId === locals.user!.id);
|
||||
const isAdmin =
|
||||
locals.user!.globalRole === 'SITE_ADMIN' || locals.user!.tenantRole === 'ADMIN';
|
||||
if (member?.role !== 'OWNER' && !isAdmin) throw error(403, 'Only owners can delete boards');
|
||||
|
||||
await requireBoardOwner(tx, locals.user, params.boardId, tenant.id);
|
||||
await tx.board.delete({ where: { id: params.boardId } });
|
||||
});
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@ import type { RequestHandler } from './$types.js';
|
||||
import { columnSchema } from '$lib/server/validation.js';
|
||||
import { generatePosition } from '$lib/utils/fractional-index.js';
|
||||
import { withTenant } from '$lib/server/rls.js';
|
||||
import { requireBoardEditor } from '$lib/server/guards.js';
|
||||
|
||||
// Create column
|
||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
const tenant = locals.tenant;
|
||||
if (!tenant) throw error(400, 'Unknown tenant');
|
||||
if (!locals.user) throw error(401, 'Not authenticated');
|
||||
|
||||
const body = await request.json();
|
||||
const result = columnSchema.safeParse(body);
|
||||
@@ -17,16 +17,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
}
|
||||
|
||||
const column = await withTenant(tenant.id, async (tx) => {
|
||||
const board = await tx.board.findFirst({
|
||||
where: { id: params.boardId, tenantId: tenant.id },
|
||||
include: { members: true }
|
||||
});
|
||||
if (!board) throw error(404, 'Board not found');
|
||||
|
||||
const member = board.members.find((m) => m.userId === locals.user!.id);
|
||||
const isAdmin = locals.user!.globalRole === 'SITE_ADMIN' || locals.user!.tenantRole === 'ADMIN';
|
||||
if (!member && !isAdmin) throw error(403, 'Access denied');
|
||||
if (member && member.role === 'VIEWER') throw error(403, 'Viewers cannot add columns');
|
||||
await requireBoardEditor(tx, locals.user, params.boardId, tenant.id);
|
||||
|
||||
// Get the last column position
|
||||
const lastColumn = await tx.column.findFirst({
|
||||
|
||||
@@ -2,26 +2,17 @@ import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { columnSchema, moveColumnSchema } from '$lib/server/validation.js';
|
||||
import { withTenant } from '$lib/server/rls.js';
|
||||
import { requireColumnEditor } from '$lib/server/guards.js';
|
||||
|
||||
// Update column title
|
||||
// Update column title or position
|
||||
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||
const tenant = locals.tenant;
|
||||
if (!tenant) throw error(400, 'Unknown tenant');
|
||||
if (!locals.user) throw error(401, 'Not authenticated');
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
const updated = await withTenant(tenant.id, async (tx) => {
|
||||
const column = await tx.column.findFirst({
|
||||
where: { id: params.columnId, board: { id: params.boardId, tenantId: tenant.id } },
|
||||
include: { board: { include: { members: true } } }
|
||||
});
|
||||
if (!column) throw error(404, 'Column not found');
|
||||
|
||||
const member = column.board.members.find((m) => m.userId === locals.user!.id);
|
||||
const isAdmin = locals.user!.globalRole === 'SITE_ADMIN' || locals.user!.tenantRole === 'ADMIN';
|
||||
if (!member && !isAdmin) throw error(403, 'Access denied');
|
||||
if (member && member.role === 'VIEWER') throw error(403, 'Viewers cannot edit columns');
|
||||
await requireColumnEditor(tx, locals.user, params.columnId, params.boardId, tenant.id);
|
||||
|
||||
// Allow both title update and position update
|
||||
if (body.position !== undefined) {
|
||||
@@ -53,20 +44,9 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||
const tenant = locals.tenant;
|
||||
if (!tenant) throw error(400, 'Unknown tenant');
|
||||
if (!locals.user) throw error(401, 'Not authenticated');
|
||||
|
||||
await withTenant(tenant.id, async (tx) => {
|
||||
const column = await tx.column.findFirst({
|
||||
where: { id: params.columnId, board: { id: params.boardId, tenantId: tenant.id } },
|
||||
include: { board: { include: { members: true } } }
|
||||
});
|
||||
if (!column) throw error(404, 'Column not found');
|
||||
|
||||
const member = column.board.members.find((m) => m.userId === locals.user!.id);
|
||||
const isAdmin = locals.user!.globalRole === 'SITE_ADMIN' || locals.user!.tenantRole === 'ADMIN';
|
||||
if (member?.role === 'VIEWER' && !isAdmin) throw error(403, 'Viewers cannot delete columns');
|
||||
if (!member && !isAdmin) throw error(403, 'Access denied');
|
||||
|
||||
await requireColumnEditor(tx, locals.user, params.columnId, params.boardId, tenant.id);
|
||||
await tx.column.delete({ where: { id: params.columnId } });
|
||||
});
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@ import type { RequestHandler } from './$types.js';
|
||||
import { cardSchema } from '$lib/server/validation.js';
|
||||
import { generatePosition } from '$lib/utils/fractional-index.js';
|
||||
import { withTenant } from '$lib/server/rls.js';
|
||||
import { requireColumnEditor } from '$lib/server/guards.js';
|
||||
|
||||
// Create card in column
|
||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
const tenant = locals.tenant;
|
||||
if (!tenant) throw error(400, 'Unknown tenant');
|
||||
if (!locals.user) throw error(401, 'Not authenticated');
|
||||
|
||||
const body = await request.json();
|
||||
const result = cardSchema.safeParse(body);
|
||||
@@ -17,16 +17,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
}
|
||||
|
||||
const card = await withTenant(tenant.id, async (tx) => {
|
||||
const column = await tx.column.findFirst({
|
||||
where: { id: params.columnId, board: { id: params.boardId, tenantId: tenant.id } },
|
||||
include: { board: { include: { members: true } } }
|
||||
});
|
||||
if (!column) throw error(404, 'Column not found');
|
||||
|
||||
const member = column.board.members.find((m) => m.userId === locals.user!.id);
|
||||
const isAdmin = locals.user!.globalRole === 'SITE_ADMIN' || locals.user!.tenantRole === 'ADMIN';
|
||||
if (!member && !isAdmin) throw error(403, 'Access denied');
|
||||
if (member && member.role === 'VIEWER') throw error(403, 'Viewers cannot add cards');
|
||||
await requireColumnEditor(tx, locals.user, params.columnId, params.boardId, tenant.id);
|
||||
|
||||
// Get the last card position in this column
|
||||
const lastCard = await tx.card.findFirst({
|
||||
|
||||
@@ -2,6 +2,8 @@ import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { cardSchema, moveCardSchema } from '$lib/server/validation.js';
|
||||
import { withTenant } from '$lib/server/rls.js';
|
||||
import { requireBoardView, requireCardEditor } from '$lib/server/guards.js';
|
||||
import { canViewBoard } from '$lib/server/permissions.js';
|
||||
|
||||
// Get single card with full details
|
||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
@@ -9,7 +11,8 @@ export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
if (!tenant) throw error(400, 'Unknown tenant');
|
||||
|
||||
const card = await withTenant(tenant.id, async (tx) => {
|
||||
return tx.card.findFirst({
|
||||
// Fetch the card with board membership info for access check
|
||||
const result = await tx.card.findFirst({
|
||||
where: {
|
||||
id: params.cardId,
|
||||
column: { id: params.columnId, board: { id: params.boardId, tenantId: tenant.id } }
|
||||
@@ -28,12 +31,29 @@ export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
include: { user: { select: { id: true, name: true, avatarUrl: true } } }
|
||||
},
|
||||
attachments: { orderBy: { createdAt: 'desc' } },
|
||||
column: { select: { id: true, title: true } }
|
||||
column: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
board: {
|
||||
select: { visibility: true, members: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if (!result) throw error(404, 'Card not found');
|
||||
|
||||
if (!card) throw error(404, 'Card not found');
|
||||
// Check view permission on the board
|
||||
if (!canViewBoard(locals.user, result.column.board.visibility, result.column.board.members)) {
|
||||
if (!locals.user) throw error(401, 'Not authenticated');
|
||||
throw error(403, 'Access denied');
|
||||
}
|
||||
|
||||
// Strip nested board data from response
|
||||
const { board: _, ...columnData } = result.column;
|
||||
return { ...result, column: columnData };
|
||||
});
|
||||
|
||||
return json({ card });
|
||||
};
|
||||
@@ -42,24 +62,11 @@ export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||
const tenant = locals.tenant;
|
||||
if (!tenant) throw error(400, 'Unknown tenant');
|
||||
if (!locals.user) throw error(401, 'Not authenticated');
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
const updated = await withTenant(tenant.id, async (tx) => {
|
||||
const card = await tx.card.findFirst({
|
||||
where: {
|
||||
id: params.cardId,
|
||||
column: { board: { id: params.boardId, tenantId: tenant.id } }
|
||||
},
|
||||
include: { column: { include: { board: { include: { members: true } } } } }
|
||||
});
|
||||
if (!card) throw error(404, 'Card not found');
|
||||
|
||||
const member = card.column.board.members.find((m) => m.userId === locals.user!.id);
|
||||
const isAdmin = locals.user!.globalRole === 'SITE_ADMIN' || locals.user!.tenantRole === 'ADMIN';
|
||||
if (!member && !isAdmin) throw error(403, 'Access denied');
|
||||
if (member && member.role === 'VIEWER') throw error(403, 'Viewers cannot edit cards');
|
||||
await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
|
||||
|
||||
// Handle move (column change + position)
|
||||
if (body.columnId !== undefined || body.position !== undefined) {
|
||||
@@ -110,23 +117,9 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||
const tenant = locals.tenant;
|
||||
if (!tenant) throw error(400, 'Unknown tenant');
|
||||
if (!locals.user) throw error(401, 'Not authenticated');
|
||||
|
||||
await withTenant(tenant.id, async (tx) => {
|
||||
const card = await tx.card.findFirst({
|
||||
where: {
|
||||
id: params.cardId,
|
||||
column: { board: { id: params.boardId, tenantId: tenant.id } }
|
||||
},
|
||||
include: { column: { include: { board: { include: { members: true } } } } }
|
||||
});
|
||||
if (!card) throw error(404, 'Card not found');
|
||||
|
||||
const member = card.column.board.members.find((m) => m.userId === locals.user!.id);
|
||||
const isAdmin = locals.user!.globalRole === 'SITE_ADMIN' || locals.user!.tenantRole === 'ADMIN';
|
||||
if (!member && !isAdmin) throw error(403, 'Access denied');
|
||||
if (member && member.role === 'VIEWER') throw error(403, 'Viewers cannot delete cards');
|
||||
|
||||
await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
|
||||
await tx.card.delete({ where: { id: params.cardId } });
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { addMemberSchema } from '$lib/server/validation.js';
|
||||
import { withTenant } from '$lib/server/rls.js';
|
||||
import { requireBoardMemberManager } from '$lib/server/guards.js';
|
||||
|
||||
// Add member by email
|
||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
const tenant = locals.tenant;
|
||||
if (!tenant) throw error(400, 'Unknown tenant');
|
||||
|
||||
const body = await request.json();
|
||||
const result = addMemberSchema.safeParse(body);
|
||||
if (!result.success) {
|
||||
return json({ error: result.error.issues[0].message }, { status: 400 });
|
||||
}
|
||||
|
||||
const { email, role } = result.data;
|
||||
|
||||
const member = await withTenant(tenant.id, async (tx) => {
|
||||
await requireBoardMemberManager(tx, locals.user, params.boardId, tenant.id);
|
||||
|
||||
// Find user by email within this tenant
|
||||
const targetUser = await tx.user.findFirst({
|
||||
where: { email, tenantId: tenant.id }
|
||||
});
|
||||
if (!targetUser) throw error(404, 'No user found with that email in this workspace');
|
||||
|
||||
// Check if already a member
|
||||
const existing = await tx.boardMember.findFirst({
|
||||
where: { boardId: params.boardId, userId: targetUser.id }
|
||||
});
|
||||
if (existing) throw error(409, 'User is already a member of this board');
|
||||
|
||||
return tx.boardMember.create({
|
||||
data: {
|
||||
boardId: params.boardId,
|
||||
userId: targetUser.id,
|
||||
role
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true, avatarUrl: true } }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return json({ member }, { status: 201 });
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { changeMemberRoleSchema } from '$lib/server/validation.js';
|
||||
import { withTenant } from '$lib/server/rls.js';
|
||||
import { requireBoardMemberManager } from '$lib/server/guards.js';
|
||||
|
||||
// Change member role
|
||||
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||
const tenant = locals.tenant;
|
||||
if (!tenant) throw error(400, 'Unknown tenant');
|
||||
|
||||
const body = await request.json();
|
||||
const result = changeMemberRoleSchema.safeParse(body);
|
||||
if (!result.success) {
|
||||
return json({ error: result.error.issues[0].message }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated = await withTenant(tenant.id, async (tx) => {
|
||||
await requireBoardMemberManager(tx, locals.user, params.boardId, tenant.id);
|
||||
|
||||
const member = await tx.boardMember.findFirst({
|
||||
where: { id: params.memberId, boardId: params.boardId }
|
||||
});
|
||||
if (!member) throw error(404, 'Member not found');
|
||||
|
||||
// Prevent demoting the last owner
|
||||
if (member.role === 'OWNER' && result.data.role !== 'OWNER') {
|
||||
const ownerCount = await tx.boardMember.count({
|
||||
where: { boardId: params.boardId, role: 'OWNER' }
|
||||
});
|
||||
if (ownerCount <= 1) throw error(400, 'Cannot demote the last owner');
|
||||
}
|
||||
|
||||
return tx.boardMember.update({
|
||||
where: { id: params.memberId },
|
||||
data: { role: result.data.role },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true, avatarUrl: true } }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return json({ member: updated });
|
||||
};
|
||||
|
||||
// Remove member
|
||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||
const tenant = locals.tenant;
|
||||
if (!tenant) throw error(400, 'Unknown tenant');
|
||||
|
||||
await withTenant(tenant.id, async (tx) => {
|
||||
await requireBoardMemberManager(tx, locals.user, params.boardId, tenant.id);
|
||||
|
||||
const member = await tx.boardMember.findFirst({
|
||||
where: { id: params.memberId, boardId: params.boardId }
|
||||
});
|
||||
if (!member) throw error(404, 'Member not found');
|
||||
|
||||
// Prevent removing the last owner
|
||||
if (member.role === 'OWNER') {
|
||||
const ownerCount = await tx.boardMember.count({
|
||||
where: { boardId: params.boardId, role: 'OWNER' }
|
||||
});
|
||||
if (ownerCount <= 1) throw error(400, 'Cannot remove the last owner');
|
||||
}
|
||||
|
||||
await tx.boardMember.delete({ where: { id: params.memberId } });
|
||||
});
|
||||
|
||||
return json({ ok: true });
|
||||
};
|
||||
@@ -1,55 +1,19 @@
|
||||
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 { canEditBoard } 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 tx.board.findFirst({
|
||||
where: { id: params.boardId, tenantId: tenant.id },
|
||||
include: {
|
||||
columns: {
|
||||
orderBy: { position: 'asc' },
|
||||
include: {
|
||||
cards: {
|
||||
where: { archived: false },
|
||||
orderBy: { position: 'asc' },
|
||||
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 } } }
|
||||
}
|
||||
}
|
||||
});
|
||||
return requireBoardView(tx, locals.user, params.boardId, tenant.id);
|
||||
});
|
||||
|
||||
if (!board) throw error(404, 'Board not found');
|
||||
|
||||
// Access check
|
||||
if (board.visibility === 'PRIVATE') {
|
||||
if (!locals.user) throw error(401, 'Not authenticated');
|
||||
const isMember = board.members.some((m) => m.userId === locals.user!.id);
|
||||
const isAdmin =
|
||||
locals.user.globalRole === 'SITE_ADMIN' || locals.user.tenantRole === 'ADMIN';
|
||||
if (!isMember && !isAdmin) throw error(403, 'Access denied');
|
||||
}
|
||||
|
||||
const canEdit = (() => {
|
||||
if (!locals.user) return false;
|
||||
if (locals.user.globalRole === 'SITE_ADMIN' || locals.user.tenantRole === 'ADMIN') return true;
|
||||
const member = board.members.find((m) => m.userId === locals.user!.id);
|
||||
return member ? member.role !== 'VIEWER' : false;
|
||||
})();
|
||||
|
||||
return { board, canEdit };
|
||||
return {
|
||||
board,
|
||||
canEdit: canEditBoard(locals.user, board.members)
|
||||
};
|
||||
};
|
||||
|
||||
@@ -170,6 +170,12 @@
|
||||
<span class="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-500">
|
||||
{data.board.visibility === 'PUBLIC' ? 'Public' : 'Private'}
|
||||
</span>
|
||||
<a
|
||||
href="/boards/{data.board.id}/members"
|
||||
class="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-500 hover:bg-gray-200 hover:text-gray-700 transition"
|
||||
>
|
||||
{data.board.members.length} {data.board.members.length === 1 ? 'member' : 'members'}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Columns container -->
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
<script lang="ts">
|
||||
let { data } = $props();
|
||||
|
||||
let members = $state(data.board.members.map((m: any) => ({ ...m })));
|
||||
let addEmail = $state('');
|
||||
let addRole = $state('EDITOR');
|
||||
let addError = $state('');
|
||||
let addLoading = $state(false);
|
||||
|
||||
async function addMember() {
|
||||
if (!addEmail.trim()) return;
|
||||
addError = '';
|
||||
addLoading = true;
|
||||
|
||||
const res = await fetch(`/api/boards/${data.board.id}/members`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: addEmail, role: addRole })
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
addLoading = false;
|
||||
|
||||
if (!res.ok) {
|
||||
addError = json.error || json.message || 'Failed to add member';
|
||||
return;
|
||||
}
|
||||
|
||||
members = [...members, json.member];
|
||||
addEmail = '';
|
||||
addRole = 'EDITOR';
|
||||
}
|
||||
|
||||
async function changeRole(memberId: string, role: string) {
|
||||
const res = await fetch(`/api/boards/${data.board.id}/members/${memberId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const json = await res.json();
|
||||
alert(json.error || json.message || 'Failed to change role');
|
||||
return;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
members = members.map((m: any) => (m.id === memberId ? json.member : m));
|
||||
}
|
||||
|
||||
async function removeMember(memberId: string) {
|
||||
if (!confirm('Remove this member from the board?')) return;
|
||||
|
||||
const res = await fetch(`/api/boards/${data.board.id}/members/${memberId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const json = await res.json();
|
||||
alert(json.error || json.message || 'Failed to remove member');
|
||||
return;
|
||||
}
|
||||
|
||||
members = members.filter((m: any) => m.id !== memberId);
|
||||
}
|
||||
|
||||
function roleColor(role: string) {
|
||||
if (role === 'OWNER') return 'bg-amber-100 text-amber-800';
|
||||
if (role === 'EDITOR') return 'bg-blue-100 text-blue-800';
|
||||
return 'bg-gray-100 text-gray-600';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Members - {data.board.title}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-2xl px-4 py-8">
|
||||
<div class="mb-6">
|
||||
<a
|
||||
href="/boards/{data.board.id}"
|
||||
class="text-sm text-gray-500 hover:text-gray-700 transition flex items-center gap-1"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M17 10a.75.75 0 01-.75.75H5.612l4.158 3.96a.75.75 0 11-1.04 1.08l-5.5-5.25a.75.75 0 010-1.08l5.5-5.25a.75.75 0 111.04 1.08L5.612 9.25H16.25A.75.75 0 0117 10z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Back to board
|
||||
</a>
|
||||
<h1 class="text-xl font-bold text-gray-900 mt-2">Members of {data.board.title}</h1>
|
||||
</div>
|
||||
|
||||
<!-- Add member form -->
|
||||
{#if data.canManage}
|
||||
<div class="mb-6 rounded-xl border border-gray-200 bg-white p-4">
|
||||
<h2 class="text-sm font-semibold text-gray-700 mb-3">Add member</h2>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
addMember();
|
||||
}}
|
||||
class="flex gap-2"
|
||||
>
|
||||
<input
|
||||
bind:value={addEmail}
|
||||
type="email"
|
||||
placeholder="Email address"
|
||||
class="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
|
||||
/>
|
||||
<select
|
||||
bind:value={addRole}
|
||||
class="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
|
||||
>
|
||||
<option value="EDITOR">Editor</option>
|
||||
<option value="VIEWER">Viewer</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={addLoading}
|
||||
class="rounded-lg bg-[var(--color-primary)] px-4 py-2 text-sm text-white hover:bg-[var(--color-primary-hover)] transition disabled:opacity-50"
|
||||
>
|
||||
{addLoading ? 'Adding...' : 'Add'}
|
||||
</button>
|
||||
</form>
|
||||
{#if addError}
|
||||
<p class="mt-2 text-sm text-red-600">{addError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Member list -->
|
||||
<div class="rounded-xl border border-gray-200 bg-white divide-y divide-gray-100">
|
||||
{#each members as member (member.id)}
|
||||
<div class="flex items-center gap-3 px-4 py-3">
|
||||
<div
|
||||
class="w-9 h-9 rounded-full bg-[var(--color-primary)] text-white text-sm flex items-center justify-center font-medium flex-shrink-0"
|
||||
>
|
||||
{member.user.name[0].toUpperCase()}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">{member.user.name}</p>
|
||||
<p class="text-xs text-gray-500 truncate">{member.user.email}</p>
|
||||
</div>
|
||||
{#if data.canManage}
|
||||
<select
|
||||
value={member.role}
|
||||
onchange={(e) => changeRole(member.id, e.currentTarget.value)}
|
||||
class="rounded border border-gray-300 px-2 py-1 text-xs focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
|
||||
>
|
||||
<option value="OWNER">Owner</option>
|
||||
<option value="EDITOR">Editor</option>
|
||||
<option value="VIEWER">Viewer</option>
|
||||
</select>
|
||||
<button
|
||||
onclick={() => removeMember(member.id)}
|
||||
class="text-gray-400 hover:text-red-600 transition p-1"
|
||||
title="Remove member"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{:else}
|
||||
<span class="rounded-full px-2.5 py-0.5 text-xs font-medium {roleColor(member.role)}">
|
||||
{member.role}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if members.length === 0}
|
||||
<div class="px-4 py-8 text-center text-sm text-gray-500">No members yet</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user