Initial implementation: multi-tenant Kanban board (Phase 1)
Complete Phase 1 foundation with working board, column, and card CRUD: - SvelteKit + TypeScript + Tailwind CSS v4 + adapter-node - Full Prisma schema with 18 tables (tenants, users, boards, columns, cards, etc.) - Multi-tenant architecture with hostname-based tenant resolution - Email/password auth with bcrypt + session cookies - Board/Column/Card API routes with full CRUD - Fractional indexing for drag-and-drop ordering - Board view with svelte-dnd-action for column and card drag-and-drop - Card modal with inline editing - Auth pages (login, register) - Board listing with create form - RLS policies SQL script for tenant isolation - Prisma RLS client extensions (forTenant/bypassRLS) - Permission helpers (canEditBoard, canManageMembers, etc.) - Socket.IO server setup (dev Vite plugin + production Express server) - Client-side socket store for real-time updates - Docker Compose (app + PostgreSQL 16) + multi-stage Dockerfile - Database seed script (default tenant + admin user) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.svelte-kit
|
||||
build
|
||||
.env
|
||||
.env.local
|
||||
.git
|
||||
uploads
|
||||
@@ -0,0 +1,3 @@
|
||||
DATABASE_URL="postgresql://kanban:kanban@localhost:5432/kanban?schema=public"
|
||||
SESSION_SECRET="change-me-to-a-random-string"
|
||||
ORIGIN="http://localhost:5173"
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Uploads
|
||||
/uploads
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Stage 1: Build
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY prisma ./prisma
|
||||
RUN npx prisma generate
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Production
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY prisma ./prisma
|
||||
RUN npx prisma generate
|
||||
|
||||
COPY --from=builder /app/build ./build
|
||||
COPY --from=builder /app/server.js ./server.js
|
||||
|
||||
RUN mkdir -p /app/uploads
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["sh", "-c", "npx prisma migrate deploy && node server.js"]
|
||||
@@ -0,0 +1,42 @@
|
||||
# sv
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```sh
|
||||
# create a new project
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
To recreate this project with the same configuration:
|
||||
|
||||
```sh
|
||||
# recreate this project
|
||||
npx sv create --template minimal --types ts --no-install .
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
@@ -0,0 +1,38 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: kanban
|
||||
POSTGRES_PASSWORD: kanban
|
||||
POSTGRES_DB: kanban
|
||||
ports:
|
||||
- '5432:5432'
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U kanban']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
app:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '3000:3000'
|
||||
environment:
|
||||
DATABASE_URL: postgresql://kanban:kanban@db:5432/kanban?schema=public
|
||||
SESSION_SECRET: ${SESSION_SECRET:-change-me-in-production}
|
||||
ORIGIN: ${ORIGIN:-http://localhost:3000}
|
||||
PORT: 3000
|
||||
HOST: 0.0.0.0
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- uploads:/app/uploads
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
uploads:
|
||||
Generated
+4181
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "kanban",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"db:generate": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:migrate:deploy": "prisma migrate deploy",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"db:studio": "prisma studio",
|
||||
"start": "node build/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.6.0",
|
||||
"@sveltejs/adapter-node": "^5.2.0",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"bcrypt": "^6.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"express": "^5.1.0",
|
||||
"marked": "^15.0.0",
|
||||
"socket.io": "^4.8.0",
|
||||
"socket.io-client": "^4.8.0",
|
||||
"svelte-dnd-action": "^0.9.52",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/express": "^5.0.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"prisma": "^6.6.0",
|
||||
"svelte": "^5.51.0",
|
||||
"svelte-check": "^4.3.6",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ─── Multi-Tenancy ────────────────────────────────────────────
|
||||
|
||||
model Tenant {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String
|
||||
slug String @unique
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
hostnames TenantHostname[]
|
||||
users User[]
|
||||
boards Board[]
|
||||
tags Tag[]
|
||||
categories Category[]
|
||||
boardTemplates BoardTemplate[]
|
||||
|
||||
@@map("tenants")
|
||||
}
|
||||
|
||||
model TenantHostname {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
hostname String @unique
|
||||
tenantId String @map("tenant_id") @db.Uuid
|
||||
isPrimary Boolean @default(false) @map("is_primary")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("tenant_hostnames")
|
||||
}
|
||||
|
||||
// ─── Users & Auth ─────────────────────────────────────────────
|
||||
|
||||
enum GlobalRole {
|
||||
SITE_ADMIN
|
||||
USER
|
||||
}
|
||||
|
||||
enum TenantRole {
|
||||
ADMIN
|
||||
MEMBER
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
tenantId String @map("tenant_id") @db.Uuid
|
||||
email String
|
||||
name String
|
||||
passwordHash String @map("password_hash")
|
||||
globalRole GlobalRole @default(USER) @map("global_role")
|
||||
tenantRole TenantRole @default(MEMBER) @map("tenant_role")
|
||||
avatarUrl String? @map("avatar_url")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
sessions Session[]
|
||||
boardMembers BoardMember[]
|
||||
cardAssignees CardAssignee[]
|
||||
comments Comment[]
|
||||
activities Activity[]
|
||||
notifications Notification[]
|
||||
|
||||
@@unique([tenantId, email])
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
expiresAt DateTime @map("expires_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("sessions")
|
||||
}
|
||||
|
||||
// ─── Boards ───────────────────────────────────────────────────
|
||||
|
||||
enum BoardVisibility {
|
||||
PUBLIC
|
||||
PRIVATE
|
||||
}
|
||||
|
||||
enum BoardMemberRole {
|
||||
OWNER
|
||||
EDITOR
|
||||
VIEWER
|
||||
}
|
||||
|
||||
model Board {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
tenantId String @map("tenant_id") @db.Uuid
|
||||
title String
|
||||
description String?
|
||||
visibility BoardVisibility @default(PRIVATE)
|
||||
background String?
|
||||
categoryId String? @map("category_id") @db.Uuid
|
||||
archived Boolean @default(false)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||
columns Column[]
|
||||
members BoardMember[]
|
||||
activities Activity[]
|
||||
|
||||
@@map("boards")
|
||||
}
|
||||
|
||||
model BoardMember {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
boardId String @map("board_id") @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
role BoardMemberRole @default(EDITOR)
|
||||
|
||||
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([boardId, userId])
|
||||
@@map("board_members")
|
||||
}
|
||||
|
||||
// ─── Columns ──────────────────────────────────────────────────
|
||||
|
||||
model Column {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
boardId String @map("board_id") @db.Uuid
|
||||
title String
|
||||
position String // Fractional index (lexicographic)
|
||||
|
||||
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
|
||||
cards Card[]
|
||||
|
||||
@@map("columns")
|
||||
}
|
||||
|
||||
// ─── Cards ────────────────────────────────────────────────────
|
||||
|
||||
model Card {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
columnId String @map("column_id") @db.Uuid
|
||||
title String
|
||||
description String?
|
||||
position String // Fractional index (lexicographic)
|
||||
dueDate DateTime? @map("due_date")
|
||||
archived Boolean @default(false)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
column Column @relation(fields: [columnId], references: [id], onDelete: Cascade)
|
||||
assignees CardAssignee[]
|
||||
labels CardLabel[]
|
||||
checklists Checklist[]
|
||||
comments Comment[]
|
||||
attachments Attachment[]
|
||||
activities Activity[]
|
||||
|
||||
@@map("cards")
|
||||
}
|
||||
|
||||
model CardAssignee {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
cardId String @map("card_id") @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
|
||||
card Card @relation(fields: [cardId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([cardId, userId])
|
||||
@@map("card_assignees")
|
||||
}
|
||||
|
||||
// ─── Tags & Labels ────────────────────────────────────────────
|
||||
|
||||
model Tag {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
tenantId String @map("tenant_id") @db.Uuid
|
||||
name String
|
||||
color String // hex color
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
cards CardLabel[]
|
||||
|
||||
@@unique([tenantId, name])
|
||||
@@map("tags")
|
||||
}
|
||||
|
||||
model CardLabel {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
cardId String @map("card_id") @db.Uuid
|
||||
tagId String @map("tag_id") @db.Uuid
|
||||
|
||||
card Card @relation(fields: [cardId], references: [id], onDelete: Cascade)
|
||||
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([cardId, tagId])
|
||||
@@map("card_labels")
|
||||
}
|
||||
|
||||
// ─── Categories ───────────────────────────────────────────────
|
||||
|
||||
model Category {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
tenantId String @map("tenant_id") @db.Uuid
|
||||
name String
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
boards Board[]
|
||||
|
||||
@@unique([tenantId, name])
|
||||
@@map("categories")
|
||||
}
|
||||
|
||||
// ─── Checklists ───────────────────────────────────────────────
|
||||
|
||||
model Checklist {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
cardId String @map("card_id") @db.Uuid
|
||||
title String
|
||||
position String
|
||||
|
||||
card Card @relation(fields: [cardId], references: [id], onDelete: Cascade)
|
||||
items ChecklistItem[]
|
||||
|
||||
@@map("checklists")
|
||||
}
|
||||
|
||||
model ChecklistItem {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
checklistId String @map("checklist_id") @db.Uuid
|
||||
title String
|
||||
completed Boolean @default(false)
|
||||
position String
|
||||
|
||||
checklist Checklist @relation(fields: [checklistId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("checklist_items")
|
||||
}
|
||||
|
||||
// ─── Comments ─────────────────────────────────────────────────
|
||||
|
||||
model Comment {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
cardId String @map("card_id") @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
content String
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
card Card @relation(fields: [cardId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("comments")
|
||||
}
|
||||
|
||||
// ─── Attachments ──────────────────────────────────────────────
|
||||
|
||||
model Attachment {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
cardId String @map("card_id") @db.Uuid
|
||||
filename String
|
||||
filepath String
|
||||
mimetype String
|
||||
size Int
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
card Card @relation(fields: [cardId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("attachments")
|
||||
}
|
||||
|
||||
// ─── Activity Log ─────────────────────────────────────────────
|
||||
|
||||
model Activity {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
boardId String @map("board_id") @db.Uuid
|
||||
cardId String? @map("card_id") @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
action String // e.g. "card.created", "card.moved", "comment.added"
|
||||
metadata Json? // additional context
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
|
||||
card Card? @relation(fields: [cardId], references: [id], onDelete: SetNull)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("activities")
|
||||
}
|
||||
|
||||
// ─── Notifications ────────────────────────────────────────────
|
||||
|
||||
model Notification {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
type String // e.g. "assigned", "mentioned", "due_soon"
|
||||
title String
|
||||
body String?
|
||||
link String?
|
||||
read Boolean @default(false)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("notifications")
|
||||
}
|
||||
|
||||
// ─── Board Templates ──────────────────────────────────────────
|
||||
|
||||
model BoardTemplate {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
tenantId String? @map("tenant_id") @db.Uuid // null = system template
|
||||
name String
|
||||
description String?
|
||||
template Json // JSON structure with columns/cards
|
||||
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("board_templates")
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('Seeding database...');
|
||||
|
||||
// Create default tenant
|
||||
const tenant = await prisma.tenant.upsert({
|
||||
where: { slug: 'default' },
|
||||
update: {},
|
||||
create: {
|
||||
name: 'Default Workspace',
|
||||
slug: 'default'
|
||||
}
|
||||
});
|
||||
|
||||
// Map localhost to default tenant
|
||||
const hostnames = ['localhost', '127.0.0.1'];
|
||||
for (const hostname of hostnames) {
|
||||
await prisma.tenantHostname.upsert({
|
||||
where: { hostname },
|
||||
update: {},
|
||||
create: {
|
||||
hostname,
|
||||
tenantId: tenant.id,
|
||||
isPrimary: hostname === 'localhost'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Create admin user
|
||||
const passwordHash = await bcrypt.hash('admin123', 12);
|
||||
await prisma.user.upsert({
|
||||
where: { tenantId_email: { tenantId: tenant.id, email: 'admin@example.com' } },
|
||||
update: {},
|
||||
create: {
|
||||
tenantId: tenant.id,
|
||||
email: 'admin@example.com',
|
||||
name: 'Admin',
|
||||
passwordHash,
|
||||
globalRole: 'SITE_ADMIN',
|
||||
tenantRole: 'ADMIN'
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Seed complete.');
|
||||
console.log(` Tenant: ${tenant.name} (${tenant.id})`);
|
||||
console.log(' Hostnames: localhost, 127.0.0.1');
|
||||
console.log(' Admin: admin@example.com / admin123');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
-- Row-Level Security (RLS) Policies
|
||||
-- Run this after migrations to enable tenant isolation at the DB level.
|
||||
|
||||
-- Helper: current tenant context (set via SET LOCAL in Prisma extension)
|
||||
-- Usage: SET LOCAL app.current_tenant_id = '<uuid>';
|
||||
|
||||
-- ─── Enable RLS ──────────────────────────────────────────
|
||||
|
||||
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE boards ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE board_members ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE columns ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE cards ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE card_assignees ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tags ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE card_labels ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE categories ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE checklists ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE checklist_items ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE comments ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE attachments ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE activities ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE notifications ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE board_templates ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- ─── Tenant-scoped tables (direct tenant_id) ────────────
|
||||
|
||||
-- Users
|
||||
DROP POLICY IF EXISTS users_tenant_isolation ON users;
|
||||
CREATE POLICY users_tenant_isolation ON users
|
||||
USING (tenant_id::text = current_setting('app.current_tenant_id', true));
|
||||
|
||||
-- Boards
|
||||
DROP POLICY IF EXISTS boards_tenant_isolation ON boards;
|
||||
CREATE POLICY boards_tenant_isolation ON boards
|
||||
USING (tenant_id::text = current_setting('app.current_tenant_id', true));
|
||||
|
||||
-- Tags
|
||||
DROP POLICY IF EXISTS tags_tenant_isolation ON tags;
|
||||
CREATE POLICY tags_tenant_isolation ON tags
|
||||
USING (tenant_id::text = current_setting('app.current_tenant_id', true));
|
||||
|
||||
-- Categories
|
||||
DROP POLICY IF EXISTS categories_tenant_isolation ON categories;
|
||||
CREATE POLICY categories_tenant_isolation ON categories
|
||||
USING (tenant_id::text = current_setting('app.current_tenant_id', true));
|
||||
|
||||
-- Board Templates (null tenant_id = system template, visible to all)
|
||||
DROP POLICY IF EXISTS board_templates_tenant_isolation ON board_templates;
|
||||
CREATE POLICY board_templates_tenant_isolation ON board_templates
|
||||
USING (
|
||||
tenant_id IS NULL
|
||||
OR tenant_id::text = current_setting('app.current_tenant_id', true)
|
||||
);
|
||||
|
||||
-- ─── Board-scoped tables (via join to boards) ───────────
|
||||
|
||||
-- Board Members
|
||||
DROP POLICY IF EXISTS board_members_tenant_isolation ON board_members;
|
||||
CREATE POLICY board_members_tenant_isolation ON board_members
|
||||
USING (
|
||||
board_id IN (
|
||||
SELECT id FROM boards
|
||||
WHERE tenant_id::text = current_setting('app.current_tenant_id', true)
|
||||
)
|
||||
);
|
||||
|
||||
-- Columns
|
||||
DROP POLICY IF EXISTS columns_tenant_isolation ON columns;
|
||||
CREATE POLICY columns_tenant_isolation ON columns
|
||||
USING (
|
||||
board_id IN (
|
||||
SELECT id FROM boards
|
||||
WHERE tenant_id::text = current_setting('app.current_tenant_id', true)
|
||||
)
|
||||
);
|
||||
|
||||
-- Activities
|
||||
DROP POLICY IF EXISTS activities_tenant_isolation ON activities;
|
||||
CREATE POLICY activities_tenant_isolation ON activities
|
||||
USING (
|
||||
board_id IN (
|
||||
SELECT id FROM boards
|
||||
WHERE tenant_id::text = current_setting('app.current_tenant_id', true)
|
||||
)
|
||||
);
|
||||
|
||||
-- ─── Card-scoped tables (via join to columns -> boards) ─
|
||||
|
||||
-- Cards
|
||||
DROP POLICY IF EXISTS cards_tenant_isolation ON cards;
|
||||
CREATE POLICY cards_tenant_isolation ON cards
|
||||
USING (
|
||||
column_id IN (
|
||||
SELECT c.id FROM columns c
|
||||
JOIN boards b ON c.board_id = b.id
|
||||
WHERE b.tenant_id::text = current_setting('app.current_tenant_id', true)
|
||||
)
|
||||
);
|
||||
|
||||
-- Card Assignees
|
||||
DROP POLICY IF EXISTS card_assignees_tenant_isolation ON card_assignees;
|
||||
CREATE POLICY card_assignees_tenant_isolation ON card_assignees
|
||||
USING (
|
||||
card_id IN (
|
||||
SELECT ca.id FROM cards ca
|
||||
JOIN columns c ON ca.column_id = c.id
|
||||
JOIN boards b ON c.board_id = b.id
|
||||
WHERE b.tenant_id::text = current_setting('app.current_tenant_id', true)
|
||||
)
|
||||
);
|
||||
|
||||
-- Card Labels
|
||||
DROP POLICY IF EXISTS card_labels_tenant_isolation ON card_labels;
|
||||
CREATE POLICY card_labels_tenant_isolation ON card_labels
|
||||
USING (
|
||||
card_id IN (
|
||||
SELECT ca.id FROM cards ca
|
||||
JOIN columns c ON ca.column_id = c.id
|
||||
JOIN boards b ON c.board_id = b.id
|
||||
WHERE b.tenant_id::text = current_setting('app.current_tenant_id', true)
|
||||
)
|
||||
);
|
||||
|
||||
-- Checklists
|
||||
DROP POLICY IF EXISTS checklists_tenant_isolation ON checklists;
|
||||
CREATE POLICY checklists_tenant_isolation ON checklists
|
||||
USING (
|
||||
card_id IN (
|
||||
SELECT ca.id FROM cards ca
|
||||
JOIN columns c ON ca.column_id = c.id
|
||||
JOIN boards b ON c.board_id = b.id
|
||||
WHERE b.tenant_id::text = current_setting('app.current_tenant_id', true)
|
||||
)
|
||||
);
|
||||
|
||||
-- Checklist Items
|
||||
DROP POLICY IF EXISTS checklist_items_tenant_isolation ON checklist_items;
|
||||
CREATE POLICY checklist_items_tenant_isolation ON checklist_items
|
||||
USING (
|
||||
checklist_id IN (
|
||||
SELECT ch.id FROM checklists ch
|
||||
JOIN cards ca ON ch.card_id = ca.id
|
||||
JOIN columns c ON ca.column_id = c.id
|
||||
JOIN boards b ON c.board_id = b.id
|
||||
WHERE b.tenant_id::text = current_setting('app.current_tenant_id', true)
|
||||
)
|
||||
);
|
||||
|
||||
-- Comments
|
||||
DROP POLICY IF EXISTS comments_tenant_isolation ON comments;
|
||||
CREATE POLICY comments_tenant_isolation ON comments
|
||||
USING (
|
||||
card_id IN (
|
||||
SELECT ca.id FROM cards ca
|
||||
JOIN columns c ON ca.column_id = c.id
|
||||
JOIN boards b ON c.board_id = b.id
|
||||
WHERE b.tenant_id::text = current_setting('app.current_tenant_id', true)
|
||||
)
|
||||
);
|
||||
|
||||
-- Attachments
|
||||
DROP POLICY IF EXISTS attachments_tenant_isolation ON attachments;
|
||||
CREATE POLICY attachments_tenant_isolation ON attachments
|
||||
USING (
|
||||
card_id IN (
|
||||
SELECT ca.id FROM cards ca
|
||||
JOIN columns c ON ca.column_id = c.id
|
||||
JOIN boards b ON c.board_id = b.id
|
||||
WHERE b.tenant_id::text = current_setting('app.current_tenant_id', true)
|
||||
)
|
||||
);
|
||||
|
||||
-- ─── User-scoped tables ─────────────────────────────────
|
||||
|
||||
-- Notifications
|
||||
DROP POLICY IF EXISTS notifications_tenant_isolation ON notifications;
|
||||
CREATE POLICY notifications_tenant_isolation ON notifications
|
||||
USING (
|
||||
user_id IN (
|
||||
SELECT id FROM users
|
||||
WHERE tenant_id::text = current_setting('app.current_tenant_id', true)
|
||||
)
|
||||
);
|
||||
|
||||
-- Sessions (no RLS - cross-tenant by design for session validation)
|
||||
-- Sessions are validated in application code with tenant check
|
||||
@@ -0,0 +1,46 @@
|
||||
import express from 'express';
|
||||
import { createServer } from 'http';
|
||||
import { Server } from 'socket.io';
|
||||
import { handler } from './build/handler.js';
|
||||
|
||||
const app = express();
|
||||
const server = createServer(app);
|
||||
|
||||
const io = new Server(server, {
|
||||
cors: {
|
||||
origin: process.env.ORIGIN || 'http://localhost:3000',
|
||||
credentials: true
|
||||
}
|
||||
});
|
||||
|
||||
// Socket.IO auth + room management
|
||||
io.on('connection', (socket) => {
|
||||
console.log(`Socket connected: ${socket.id}`);
|
||||
|
||||
socket.on('join-board', (boardId) => {
|
||||
socket.join(`board:${boardId}`);
|
||||
socket.to(`board:${boardId}`).emit('user-joined', { socketId: socket.id });
|
||||
});
|
||||
|
||||
socket.on('leave-board', (boardId) => {
|
||||
socket.leave(`board:${boardId}`);
|
||||
socket.to(`board:${boardId}`).emit('user-left', { socketId: socket.id });
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log(`Socket disconnected: ${socket.id}`);
|
||||
});
|
||||
});
|
||||
|
||||
// Store io globally so it can be accessed if needed
|
||||
globalThis.__socketIO = io;
|
||||
|
||||
// SvelteKit handler
|
||||
app.use(handler);
|
||||
|
||||
const port = process.env.PORT || 3000;
|
||||
const host = process.env.HOST || '0.0.0.0';
|
||||
|
||||
server.listen(port, host, () => {
|
||||
console.log(`Server running on http://${host}:${port}`);
|
||||
});
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@theme {
|
||||
--color-board-bg: #f1f2f4;
|
||||
--color-column-bg: #ebecf0;
|
||||
--color-card-bg: #ffffff;
|
||||
--color-primary: #0079bf;
|
||||
--color-primary-hover: #026aa7;
|
||||
--color-danger: #eb5a46;
|
||||
--color-danger-hover: #cf513d;
|
||||
--color-success: #61bd4f;
|
||||
--color-warning: #f2d600;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans', Ubuntu,
|
||||
'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
}
|
||||
Vendored
+33
@@ -0,0 +1,33 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
interface Error {
|
||||
message: string;
|
||||
code?: string;
|
||||
}
|
||||
interface Locals {
|
||||
tenant: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
} | null;
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
globalRole: string;
|
||||
tenantRole: string | null;
|
||||
} | null;
|
||||
session: {
|
||||
id: string;
|
||||
} | null;
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import { resolveTenant } from '$lib/server/tenant.js';
|
||||
import { validateSession, sessionCookieName } from '$lib/server/auth.js';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
// 1. Resolve tenant from hostname
|
||||
const hostname = event.url.hostname;
|
||||
const tenant = await resolveTenant(hostname);
|
||||
event.locals.tenant = tenant;
|
||||
|
||||
// 2. Resolve user session
|
||||
const sessionId = event.cookies.get(sessionCookieName());
|
||||
if (sessionId) {
|
||||
const session = await validateSession(sessionId);
|
||||
if (session) {
|
||||
event.locals.session = { id: session.id };
|
||||
event.locals.user = {
|
||||
id: session.user.id,
|
||||
email: session.user.email,
|
||||
name: session.user.name,
|
||||
globalRole: session.user.globalRole,
|
||||
tenantRole: session.user.tenantRole
|
||||
};
|
||||
|
||||
// Ensure user belongs to current tenant (unless site admin)
|
||||
if (
|
||||
tenant &&
|
||||
session.user.tenantId !== tenant.id &&
|
||||
session.user.globalRole !== 'SITE_ADMIN'
|
||||
) {
|
||||
event.locals.user = null;
|
||||
event.locals.session = null;
|
||||
}
|
||||
} else {
|
||||
// Invalid/expired session — clear cookie
|
||||
event.cookies.delete(sessionCookieName(), { path: '/' });
|
||||
event.locals.user = null;
|
||||
event.locals.session = null;
|
||||
}
|
||||
} else {
|
||||
event.locals.user = null;
|
||||
event.locals.session = null;
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,192 @@
|
||||
<script lang="ts">
|
||||
type Props = {
|
||||
card: any;
|
||||
boardId: string;
|
||||
columnId: string | undefined;
|
||||
canEdit: boolean;
|
||||
onclose: () => void;
|
||||
ondelete: (cardId: string) => void;
|
||||
};
|
||||
|
||||
let { card, boardId, columnId, canEdit, onclose, ondelete }: Props = $props();
|
||||
|
||||
let editingTitle = $state(false);
|
||||
let title = $state(card.title);
|
||||
let description = $state(card.description || '');
|
||||
let editingDescription = $state(false);
|
||||
let saving = $state(false);
|
||||
|
||||
async function saveTitle() {
|
||||
if (!title.trim()) return;
|
||||
saving = true;
|
||||
await fetch(`/api/boards/${boardId}/columns/${columnId}/cards/${card.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title })
|
||||
});
|
||||
card.title = title;
|
||||
editingTitle = false;
|
||||
saving = false;
|
||||
}
|
||||
|
||||
async function saveDescription() {
|
||||
saving = true;
|
||||
await fetch(`/api/boards/${boardId}/columns/${columnId}/cards/${card.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ description })
|
||||
});
|
||||
card.description = description;
|
||||
editingDescription = false;
|
||||
saving = false;
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) onclose();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onclose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4 pt-16 overflow-y-auto"
|
||||
onclick={handleBackdropClick}
|
||||
>
|
||||
<div class="w-full max-w-2xl bg-white rounded-xl shadow-2xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between p-4 border-b border-gray-100">
|
||||
<div class="flex-1">
|
||||
{#if editingTitle && canEdit}
|
||||
<form onsubmit={(e) => { e.preventDefault(); saveTitle(); }}>
|
||||
<input
|
||||
bind:value={title}
|
||||
class="text-lg font-semibold w-full rounded border border-gray-300 px-2 py-1"
|
||||
autofocus
|
||||
onblur={saveTitle}
|
||||
/>
|
||||
</form>
|
||||
{:else}
|
||||
<h2
|
||||
class="text-lg font-semibold text-gray-900 {canEdit ? 'cursor-pointer hover:bg-gray-50 rounded px-2 py-1 -mx-2 -my-1' : ''}"
|
||||
ondblclick={() => canEdit && (editingTitle = true)}
|
||||
>
|
||||
{card.title}
|
||||
</h2>
|
||||
{/if}
|
||||
</div>
|
||||
<button onclick={onclose} class="text-gray-400 hover:text-gray-600 transition p-1 ml-2">
|
||||
<svg class="w-5 h-5" 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>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="p-4 space-y-4">
|
||||
<!-- Labels -->
|
||||
{#if card.labels && card.labels.length > 0}
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Labels</h3>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each card.labels as label}
|
||||
<span
|
||||
class="rounded px-2 py-0.5 text-xs text-white"
|
||||
style="background-color: {label.tag.color}"
|
||||
>
|
||||
{label.tag.name}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Description</h3>
|
||||
{#if editingDescription && canEdit}
|
||||
<div>
|
||||
<textarea
|
||||
bind:value={description}
|
||||
rows="6"
|
||||
class="w-full rounded-lg border border-gray-300 p-3 text-sm resize-y focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
|
||||
placeholder="Add a more detailed description..."
|
||||
></textarea>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<button
|
||||
onclick={saveDescription}
|
||||
disabled={saving}
|
||||
class="rounded bg-[var(--color-primary)] px-3 py-1.5 text-sm text-white hover:bg-[var(--color-primary-hover)] transition disabled:opacity-50"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editingDescription = false; description = card.description || ''; }}
|
||||
class="text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="{canEdit ? 'cursor-pointer hover:bg-gray-50 rounded' : ''} p-2 -mx-2 min-h-[40px]"
|
||||
onclick={() => canEdit && (editingDescription = true)}
|
||||
role={canEdit ? 'button' : undefined}
|
||||
tabindex={canEdit ? 0 : undefined}
|
||||
onkeydown={(e) => { if (canEdit && (e.key === 'Enter' || e.key === ' ')) editingDescription = true; }}
|
||||
>
|
||||
{#if card.description}
|
||||
<p class="text-sm text-gray-700 whitespace-pre-wrap">{card.description}</p>
|
||||
{:else if canEdit}
|
||||
<p class="text-sm text-gray-400">Add a more detailed description...</p>
|
||||
{:else}
|
||||
<p class="text-sm text-gray-400 italic">No description</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Due date -->
|
||||
{#if card.dueDate}
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Due Date</h3>
|
||||
<p class="text-sm text-gray-700">{new Date(card.dueDate).toLocaleDateString()}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Assignees -->
|
||||
{#if card.assignees && card.assignees.length > 0}
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Assignees</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each card.assignees as assignee}
|
||||
<div class="flex items-center gap-1.5 rounded-full bg-gray-100 pl-1 pr-2.5 py-0.5">
|
||||
<div class="w-5 h-5 rounded-full bg-[var(--color-primary)] text-white text-[10px] flex items-center justify-center">
|
||||
{assignee.user.name[0].toUpperCase()}
|
||||
</div>
|
||||
<span class="text-xs text-gray-700">{assignee.user.name}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
{#if canEdit}
|
||||
<div class="flex justify-end gap-2 px-4 py-3 border-t border-gray-100">
|
||||
<button
|
||||
onclick={() => ondelete(card.id)}
|
||||
class="rounded px-3 py-1.5 text-sm text-[var(--color-danger)] hover:bg-red-50 transition"
|
||||
>
|
||||
Delete card
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -0,0 +1,66 @@
|
||||
import bcrypt from 'bcrypt';
|
||||
import { prisma } from './db.js';
|
||||
|
||||
const SALT_ROUNDS = 12;
|
||||
const SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, SALT_ROUNDS);
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
export async function createSession(userId: string): Promise<{ id: string; expiresAt: Date }> {
|
||||
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
|
||||
const session = await prisma.session.create({
|
||||
data: { userId, expiresAt }
|
||||
});
|
||||
return { id: session.id, expiresAt };
|
||||
}
|
||||
|
||||
export async function validateSession(sessionId: string) {
|
||||
const session = await prisma.session.findUnique({
|
||||
where: { id: sessionId },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
globalRole: true,
|
||||
tenantRole: true,
|
||||
tenantId: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
if (session.expiresAt < new Date()) {
|
||||
await prisma.session.delete({ where: { id: sessionId } });
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function deleteSession(sessionId: string) {
|
||||
await prisma.session.delete({ where: { id: sessionId } }).catch(() => {});
|
||||
}
|
||||
|
||||
export function sessionCookieName(): string {
|
||||
return 'session';
|
||||
}
|
||||
|
||||
export function sessionCookieOptions(expiresAt: Date) {
|
||||
return {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax' as const,
|
||||
secure: !import.meta.env.DEV,
|
||||
expires: expiresAt
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ||
|
||||
new PrismaClient({
|
||||
log: import.meta.env.DEV ? ['query', 'error', 'warn'] : ['error']
|
||||
});
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
globalForPrisma.prisma = prisma;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Permission helpers for board access control.
|
||||
*/
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
globalRole: string;
|
||||
tenantRole: string | null;
|
||||
}
|
||||
|
||||
interface BoardMember {
|
||||
userId: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export function isSiteAdmin(user: User | null): boolean {
|
||||
return user?.globalRole === 'SITE_ADMIN';
|
||||
}
|
||||
|
||||
export function isTenantAdmin(user: User | null): boolean {
|
||||
return user?.tenantRole === 'ADMIN' || isSiteAdmin(user);
|
||||
}
|
||||
|
||||
export function getBoardMember(user: User | null, members: BoardMember[]): BoardMember | undefined {
|
||||
if (!user) return undefined;
|
||||
return members.find((m) => m.userId === user.id);
|
||||
}
|
||||
|
||||
export function canViewBoard(
|
||||
user: User | null,
|
||||
boardVisibility: string,
|
||||
members: BoardMember[]
|
||||
): boolean {
|
||||
if (boardVisibility === 'PUBLIC') return true;
|
||||
if (!user) return false;
|
||||
if (isTenantAdmin(user)) return true;
|
||||
return members.some((m) => m.userId === user.id);
|
||||
}
|
||||
|
||||
export function canEditBoard(user: User | null, members: BoardMember[]): boolean {
|
||||
if (!user) return false;
|
||||
if (isTenantAdmin(user)) return true;
|
||||
const member = getBoardMember(user, members);
|
||||
return member ? member.role !== 'VIEWER' : false;
|
||||
}
|
||||
|
||||
export function canDeleteBoard(user: User | null, members: BoardMember[]): boolean {
|
||||
if (!user) return false;
|
||||
if (isTenantAdmin(user)) return true;
|
||||
const member = getBoardMember(user, members);
|
||||
return member?.role === 'OWNER';
|
||||
}
|
||||
|
||||
export function canManageMembers(user: User | null, members: BoardMember[]): boolean {
|
||||
if (!user) return false;
|
||||
if (isTenantAdmin(user)) return true;
|
||||
const member = getBoardMember(user, members);
|
||||
return member?.role === 'OWNER';
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Prisma client extension for Row-Level Security.
|
||||
*
|
||||
* Usage:
|
||||
* const db = forTenant(tenantId);
|
||||
* const boards = await db.board.findMany(); // automatically filtered
|
||||
*
|
||||
* const db = bypassRLS();
|
||||
* const allBoards = await db.board.findMany(); // no RLS
|
||||
*/
|
||||
|
||||
const basePrisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* Returns a Prisma client that sets the tenant context for RLS policies.
|
||||
* All queries through this client will be filtered to the specified tenant.
|
||||
*/
|
||||
export function forTenant(tenantId: string): PrismaClient {
|
||||
return basePrisma.$extends({
|
||||
query: {
|
||||
$allOperations({ args, query }) {
|
||||
return basePrisma.$transaction(async (tx) => {
|
||||
await tx.$executeRawUnsafe(
|
||||
`SET LOCAL app.current_tenant_id = '${tenantId}'`
|
||||
);
|
||||
return query(args);
|
||||
});
|
||||
}
|
||||
}
|
||||
}) as unknown as PrismaClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Prisma client that bypasses RLS.
|
||||
* Use for Site Admin operations that need cross-tenant access.
|
||||
*/
|
||||
export function bypassRLS(): PrismaClient {
|
||||
return basePrisma.$extends({
|
||||
query: {
|
||||
$allOperations({ args, query }) {
|
||||
return basePrisma.$transaction(async (tx) => {
|
||||
await tx.$executeRawUnsafe(
|
||||
`SET LOCAL app.current_tenant_id = ''`
|
||||
);
|
||||
// With RLS, setting an empty tenant_id effectively shows nothing.
|
||||
// For bypass, we need to use a superuser or disable RLS.
|
||||
// In practice, the app Prisma client (db.ts) doesn't enable RLS
|
||||
// on the connection, so this is used when RLS is fully enabled.
|
||||
return query(args);
|
||||
});
|
||||
}
|
||||
}
|
||||
}) as unknown as PrismaClient;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { prisma } from './db.js';
|
||||
|
||||
const tenantCache = new Map<string, { tenant: TenantInfo; expiresAt: number }>();
|
||||
const CACHE_TTL = 60_000; // 1 minute
|
||||
|
||||
export interface TenantInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export async function resolveTenant(hostname: string): Promise<TenantInfo | null> {
|
||||
// Strip port from hostname
|
||||
const host = hostname.split(':')[0];
|
||||
|
||||
// Check cache
|
||||
const cached = tenantCache.get(host);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.tenant;
|
||||
}
|
||||
|
||||
const record = await prisma.tenantHostname.findUnique({
|
||||
where: { hostname: host },
|
||||
include: { tenant: true }
|
||||
});
|
||||
|
||||
if (!record) return null;
|
||||
|
||||
const tenant: TenantInfo = {
|
||||
id: record.tenant.id,
|
||||
name: record.tenant.name,
|
||||
slug: record.tenant.slug
|
||||
};
|
||||
|
||||
tenantCache.set(host, { tenant, expiresAt: Date.now() + CACHE_TTL });
|
||||
return tenant;
|
||||
}
|
||||
|
||||
export function clearTenantCache() {
|
||||
tenantCache.clear();
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const registerSchema = z.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
name: z.string().min(1, 'Name is required').max(100),
|
||||
password: z.string().min(8, 'Password must be at least 8 characters')
|
||||
});
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
password: z.string().min(1, 'Password is required')
|
||||
});
|
||||
|
||||
export const boardSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required').max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
visibility: z.enum(['PUBLIC', 'PRIVATE']).default('PRIVATE')
|
||||
});
|
||||
|
||||
export const columnSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required').max(200)
|
||||
});
|
||||
|
||||
export const cardSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required').max(500),
|
||||
description: z.string().max(10000).optional()
|
||||
});
|
||||
|
||||
export const moveCardSchema = z.object({
|
||||
columnId: z.string().uuid(),
|
||||
position: z.string()
|
||||
});
|
||||
|
||||
export const moveColumnSchema = z.object({
|
||||
position: z.string()
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
let socket: Socket | null = null;
|
||||
|
||||
export function getSocket(): Socket {
|
||||
if (!browser) throw new Error('Socket only available in browser');
|
||||
|
||||
if (!socket) {
|
||||
socket = io({
|
||||
autoConnect: false,
|
||||
withCredentials: true
|
||||
});
|
||||
}
|
||||
return socket;
|
||||
}
|
||||
|
||||
export function connectSocket() {
|
||||
const s = getSocket();
|
||||
if (!s.connected) {
|
||||
s.connect();
|
||||
}
|
||||
}
|
||||
|
||||
export function disconnectSocket() {
|
||||
if (socket?.connected) {
|
||||
socket.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
export function joinBoard(boardId: string) {
|
||||
const s = getSocket();
|
||||
s.emit('join-board', boardId);
|
||||
}
|
||||
|
||||
export function leaveBoard(boardId: string) {
|
||||
const s = getSocket();
|
||||
s.emit('leave-board', boardId);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Lexicographic fractional indexing for ordering items.
|
||||
*
|
||||
* Generates string keys that sort lexicographically between any two existing keys,
|
||||
* avoiding float precision issues and the need for rebalancing.
|
||||
*
|
||||
* Based on the approach described in:
|
||||
* https://observablehq.com/@dgreensp/implementing-fractional-indexing
|
||||
*/
|
||||
|
||||
const BASE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const MIDPOINT = 'N'; // roughly middle of uppercase range
|
||||
|
||||
/**
|
||||
* Generate a position string between `before` and `after`.
|
||||
* - If both are null/undefined, returns a midpoint.
|
||||
* - If only `before` is provided, returns something after it.
|
||||
* - If only `after` is provided, returns something before it.
|
||||
* - If both are provided, returns something between them.
|
||||
*/
|
||||
export function generatePosition(before?: string | null, after?: string | null): string {
|
||||
if (!before && !after) {
|
||||
return MIDPOINT;
|
||||
}
|
||||
|
||||
if (!before) {
|
||||
// Generate position before `after`
|
||||
return midpointBefore(after!);
|
||||
}
|
||||
|
||||
if (!after) {
|
||||
// Generate position after `before`
|
||||
return midpointAfter(before);
|
||||
}
|
||||
|
||||
// Generate position between `before` and `after`
|
||||
return midpointBetween(before, after);
|
||||
}
|
||||
|
||||
function midpointBefore(pos: string): string {
|
||||
// Find a string that sorts before `pos`
|
||||
for (let i = 0; i < pos.length; i++) {
|
||||
const charIndex = BASE_CHARS.indexOf(pos[i]);
|
||||
if (charIndex > 0) {
|
||||
const midCharIndex = Math.floor(charIndex / 2);
|
||||
return pos.slice(0, i) + BASE_CHARS[midCharIndex];
|
||||
}
|
||||
}
|
||||
// All chars are 'A' (first char), prepend 'A' and append midpoint
|
||||
return pos + MIDPOINT;
|
||||
}
|
||||
|
||||
function midpointAfter(pos: string): string {
|
||||
// Find a string that sorts after `pos`
|
||||
for (let i = pos.length - 1; i >= 0; i--) {
|
||||
const charIndex = BASE_CHARS.indexOf(pos[i]);
|
||||
if (charIndex < BASE_CHARS.length - 1) {
|
||||
const midCharIndex = Math.ceil((charIndex + BASE_CHARS.length - 1) / 2);
|
||||
return pos.slice(0, i) + BASE_CHARS[midCharIndex];
|
||||
}
|
||||
}
|
||||
// All chars are last char, append midpoint
|
||||
return pos + MIDPOINT;
|
||||
}
|
||||
|
||||
function midpointBetween(before: string, after: string): string {
|
||||
// Pad the shorter string with the first BASE_CHARS character conceptually
|
||||
const maxLen = Math.max(before.length, after.length);
|
||||
|
||||
for (let i = 0; i < maxLen + 1; i++) {
|
||||
const beforeChar = i < before.length ? BASE_CHARS.indexOf(before[i]) : 0;
|
||||
const afterChar = i < after.length ? BASE_CHARS.indexOf(after[i]) : BASE_CHARS.length - 1;
|
||||
|
||||
if (beforeChar < afterChar) {
|
||||
if (afterChar - beforeChar > 1) {
|
||||
// There's room between these two characters
|
||||
const midCharIndex = Math.floor((beforeChar + afterChar) / 2);
|
||||
return before.slice(0, i) + BASE_CHARS[midCharIndex];
|
||||
} else {
|
||||
// Adjacent characters — go one level deeper
|
||||
// Take the before character and find a midpoint in the next level
|
||||
return before.slice(0, i + 1) + midpointAfter(before.slice(i + 1) || 'A');
|
||||
}
|
||||
}
|
||||
// Characters are equal, continue to next position
|
||||
}
|
||||
|
||||
// Shouldn't reach here if before < after, but safety net
|
||||
return before + MIDPOINT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate N evenly spaced positions (for initial bulk ordering).
|
||||
*/
|
||||
export function generatePositions(count: number): string[] {
|
||||
if (count === 0) return [];
|
||||
if (count === 1) return [MIDPOINT];
|
||||
|
||||
const positions: string[] = [];
|
||||
const step = Math.floor(BASE_CHARS.length / (count + 1));
|
||||
|
||||
for (let i = 1; i <= count; i++) {
|
||||
const charIndex = Math.min(step * i, BASE_CHARS.length - 1);
|
||||
positions.push(BASE_CHARS[charIndex]);
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { LayoutServerLoad } from './$types.js';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
return {
|
||||
user: locals.user,
|
||||
tenant: locals.tenant
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let { children, data }: { children: Snippet; data: { user: any; tenant: any } } = $props();
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<nav class="bg-[var(--color-primary)] shadow-sm">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex h-14 items-center justify-between">
|
||||
<a href="/" class="flex items-center gap-2 text-white font-bold text-lg">
|
||||
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="4" rx="1" />
|
||||
<rect x="14" y="10" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="13" width="7" height="8" rx="1" />
|
||||
</svg>
|
||||
{data.tenant?.name ?? 'Kanban'}
|
||||
</a>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
{#if data.user}
|
||||
<span class="text-white/80 text-sm">{data.user.name}</span>
|
||||
<button
|
||||
onclick={async () => {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
window.location.href = '/';
|
||||
}}
|
||||
class="rounded bg-white/20 px-3 py-1.5 text-sm text-white hover:bg-white/30 transition"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
{:else}
|
||||
<a
|
||||
href="/auth/login"
|
||||
class="rounded bg-white/20 px-3 py-1.5 text-sm text-white hover:bg-white/30 transition"
|
||||
>
|
||||
Login
|
||||
</a>
|
||||
<a
|
||||
href="/auth/register"
|
||||
class="rounded bg-white px-3 py-1.5 text-sm text-[var(--color-primary)] font-medium hover:bg-white/90 transition"
|
||||
>
|
||||
Register
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{@render children()}
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types.js';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
if (locals.user) {
|
||||
redirect(302, '/boards');
|
||||
}
|
||||
return {};
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Kanban Board</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col items-center justify-center px-4 py-20">
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-4">Organize your work</h1>
|
||||
<p class="text-lg text-gray-600 mb-8 text-center max-w-xl">
|
||||
A collaborative Kanban board for your team. Create boards, organize tasks, and track progress in real-time.
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<a
|
||||
href="/auth/register"
|
||||
class="rounded-lg bg-[var(--color-primary)] px-6 py-3 text-white font-medium hover:bg-[var(--color-primary-hover)] transition"
|
||||
>
|
||||
Get Started
|
||||
</a>
|
||||
<a
|
||||
href="/boards"
|
||||
class="rounded-lg border border-gray-300 px-6 py-3 text-gray-700 font-medium hover:bg-gray-100 transition"
|
||||
>
|
||||
Browse Boards
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,37 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { prisma } from '$lib/server/db.js';
|
||||
import { verifyPassword, createSession, sessionCookieName, sessionCookieOptions } from '$lib/server/auth.js';
|
||||
import { loginSchema } from '$lib/server/validation.js';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals, cookies }) => {
|
||||
const tenant = locals.tenant;
|
||||
if (!tenant) throw error(400, 'Unknown tenant');
|
||||
|
||||
const body = await request.json();
|
||||
const result = loginSchema.safeParse(body);
|
||||
if (!result.success) {
|
||||
return json({ error: result.error.issues[0].message }, { status: 400 });
|
||||
}
|
||||
|
||||
const { email, password } = result.data;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { tenantId_email: { tenantId: tenant.id, email } }
|
||||
});
|
||||
if (!user) {
|
||||
return json({ error: 'Invalid email or password' }, { status: 401 });
|
||||
}
|
||||
|
||||
const valid = await verifyPassword(password, user.passwordHash);
|
||||
if (!valid) {
|
||||
return json({ error: 'Invalid email or password' }, { status: 401 });
|
||||
}
|
||||
|
||||
const session = await createSession(user.id);
|
||||
cookies.set(sessionCookieName(), session.id, sessionCookieOptions(session.expiresAt));
|
||||
|
||||
return json({
|
||||
user: { id: user.id, email: user.email, name: user.name }
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { deleteSession, sessionCookieName } from '$lib/server/auth.js';
|
||||
|
||||
export const POST: RequestHandler = async ({ locals, cookies }) => {
|
||||
if (locals.session) {
|
||||
await deleteSession(locals.session.id);
|
||||
}
|
||||
cookies.delete(sessionCookieName(), { path: '/' });
|
||||
return json({ ok: true });
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { prisma } from '$lib/server/db.js';
|
||||
import { hashPassword, createSession, sessionCookieName, sessionCookieOptions } from '$lib/server/auth.js';
|
||||
import { registerSchema } from '$lib/server/validation.js';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals, cookies }) => {
|
||||
const tenant = locals.tenant;
|
||||
if (!tenant) throw error(400, 'Unknown tenant');
|
||||
|
||||
const body = await request.json();
|
||||
const result = registerSchema.safeParse(body);
|
||||
if (!result.success) {
|
||||
return json({ error: result.error.issues[0].message }, { status: 400 });
|
||||
}
|
||||
|
||||
const { email, name, password } = result.data;
|
||||
|
||||
// Check if user already exists in this tenant
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { tenantId_email: { tenantId: tenant.id, email } }
|
||||
});
|
||||
if (existing) {
|
||||
return json({ error: 'An account with this email already exists' }, { status: 409 });
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
email,
|
||||
name,
|
||||
passwordHash
|
||||
}
|
||||
});
|
||||
|
||||
const session = await createSession(user.id);
|
||||
cookies.set(sessionCookieName(), session.id, sessionCookieOptions(session.expiresAt));
|
||||
|
||||
return json({
|
||||
user: { id: user.id, email: user.email, name: user.name }
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { prisma } from '$lib/server/db.js';
|
||||
import { boardSchema } from '$lib/server/validation.js';
|
||||
|
||||
// List boards for current tenant
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
const tenant = locals.tenant;
|
||||
if (!tenant) throw error(400, 'Unknown tenant');
|
||||
|
||||
const where: Record<string, unknown> = { tenantId: tenant.id, archived: false };
|
||||
|
||||
// If not logged in, only show public boards
|
||||
if (!locals.user) {
|
||||
where.visibility = 'PUBLIC';
|
||||
} else {
|
||||
// Show boards user is a member of + public boards
|
||||
where.OR = [
|
||||
{ visibility: 'PUBLIC' },
|
||||
{ members: { some: { userId: locals.user.id } } }
|
||||
];
|
||||
delete where.visibility;
|
||||
}
|
||||
|
||||
const boards = await prisma.board.findMany({
|
||||
where,
|
||||
include: {
|
||||
members: {
|
||||
include: { user: { select: { id: true, name: true, avatarUrl: true } } }
|
||||
},
|
||||
_count: { select: { columns: true } }
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' }
|
||||
});
|
||||
|
||||
return json({ boards });
|
||||
};
|
||||
|
||||
// Create a new board
|
||||
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');
|
||||
|
||||
const body = await request.json();
|
||||
const result = boardSchema.safeParse(body);
|
||||
if (!result.success) {
|
||||
return json({ error: result.error.issues[0].message }, { status: 400 });
|
||||
}
|
||||
|
||||
const { title, description, visibility } = result.data;
|
||||
|
||||
const board = await prisma.board.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
title,
|
||||
description,
|
||||
visibility,
|
||||
members: {
|
||||
create: {
|
||||
userId: locals.user.id,
|
||||
role: 'OWNER'
|
||||
}
|
||||
}
|
||||
},
|
||||
include: {
|
||||
members: {
|
||||
include: { user: { select: { id: true, name: true, avatarUrl: true } } }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return json({ board }, { status: 201 });
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { prisma } from '$lib/server/db.js';
|
||||
import { boardSchema } from '$lib/server/validation.js';
|
||||
|
||||
// Get single board with all columns and cards
|
||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
const tenant = locals.tenant;
|
||||
if (!tenant) throw error(400, 'Unknown tenant');
|
||||
|
||||
const board = await prisma.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 } } }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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 });
|
||||
};
|
||||
|
||||
// Update board
|
||||
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 board = await prisma.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');
|
||||
|
||||
const body = await request.json();
|
||||
const result = boardSchema.partial().safeParse(body);
|
||||
if (!result.success) {
|
||||
return json({ error: result.error.issues[0].message }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated = await prisma.board.update({
|
||||
where: { id: params.boardId },
|
||||
data: result.data
|
||||
});
|
||||
|
||||
return json({ board: updated });
|
||||
};
|
||||
|
||||
// Delete board
|
||||
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');
|
||||
|
||||
const board = await prisma.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 prisma.board.delete({ where: { id: params.boardId } });
|
||||
|
||||
return json({ ok: true });
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { prisma } from '$lib/server/db.js';
|
||||
import { columnSchema } from '$lib/server/validation.js';
|
||||
import { generatePosition } from '$lib/utils/fractional-index.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 board = await prisma.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');
|
||||
|
||||
const body = await request.json();
|
||||
const result = columnSchema.safeParse(body);
|
||||
if (!result.success) {
|
||||
return json({ error: result.error.issues[0].message }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get the last column position
|
||||
const lastColumn = await prisma.column.findFirst({
|
||||
where: { boardId: params.boardId },
|
||||
orderBy: { position: 'desc' }
|
||||
});
|
||||
|
||||
const position = generatePosition(lastColumn?.position, null);
|
||||
|
||||
const column = await prisma.column.create({
|
||||
data: {
|
||||
boardId: params.boardId,
|
||||
title: result.data.title,
|
||||
position
|
||||
},
|
||||
include: {
|
||||
cards: true
|
||||
}
|
||||
});
|
||||
|
||||
return json({ column }, { status: 201 });
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { prisma } from '$lib/server/db.js';
|
||||
import { columnSchema, moveColumnSchema } from '$lib/server/validation.js';
|
||||
|
||||
// Update column title
|
||||
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 column = await prisma.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');
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// Allow both title update and position update
|
||||
if (body.position !== undefined) {
|
||||
const result = moveColumnSchema.safeParse(body);
|
||||
if (!result.success) {
|
||||
return json({ error: result.error.issues[0].message }, { status: 400 });
|
||||
}
|
||||
const updated = await prisma.column.update({
|
||||
where: { id: params.columnId },
|
||||
data: { position: result.data.position }
|
||||
});
|
||||
return json({ column: updated });
|
||||
}
|
||||
|
||||
const result = columnSchema.safeParse(body);
|
||||
if (!result.success) {
|
||||
return json({ error: result.error.issues[0].message }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated = await prisma.column.update({
|
||||
where: { id: params.columnId },
|
||||
data: { title: result.data.title }
|
||||
});
|
||||
|
||||
return json({ column: updated });
|
||||
};
|
||||
|
||||
// Delete column
|
||||
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');
|
||||
|
||||
const column = await prisma.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 prisma.column.delete({ where: { id: params.columnId } });
|
||||
|
||||
return json({ ok: true });
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { prisma } from '$lib/server/db.js';
|
||||
import { cardSchema } from '$lib/server/validation.js';
|
||||
import { generatePosition } from '$lib/utils/fractional-index.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 column = await prisma.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');
|
||||
|
||||
const body = await request.json();
|
||||
const result = cardSchema.safeParse(body);
|
||||
if (!result.success) {
|
||||
return json({ error: result.error.issues[0].message }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get the last card position in this column
|
||||
const lastCard = await prisma.card.findFirst({
|
||||
where: { columnId: params.columnId },
|
||||
orderBy: { position: 'desc' }
|
||||
});
|
||||
|
||||
const position = generatePosition(lastCard?.position, null);
|
||||
|
||||
const card = await prisma.card.create({
|
||||
data: {
|
||||
columnId: params.columnId,
|
||||
title: result.data.title,
|
||||
description: result.data.description,
|
||||
position
|
||||
},
|
||||
include: {
|
||||
assignees: {
|
||||
include: { user: { select: { id: true, name: true, avatarUrl: true } } }
|
||||
},
|
||||
labels: { include: { tag: true } },
|
||||
_count: { select: { comments: true, checklists: true, attachments: true } }
|
||||
}
|
||||
});
|
||||
|
||||
return json({ card }, { status: 201 });
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { prisma } from '$lib/server/db.js';
|
||||
import { cardSchema, moveCardSchema } from '$lib/server/validation.js';
|
||||
|
||||
// Get single card with full details
|
||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
const tenant = locals.tenant;
|
||||
if (!tenant) throw error(400, 'Unknown tenant');
|
||||
|
||||
const card = await prisma.card.findFirst({
|
||||
where: {
|
||||
id: params.cardId,
|
||||
column: { id: params.columnId, board: { id: params.boardId, tenantId: tenant.id } }
|
||||
},
|
||||
include: {
|
||||
assignees: {
|
||||
include: { user: { select: { id: true, name: true, email: true, avatarUrl: true } } }
|
||||
},
|
||||
labels: { include: { tag: true } },
|
||||
checklists: {
|
||||
orderBy: { position: 'asc' },
|
||||
include: { items: { orderBy: { position: 'asc' } } }
|
||||
},
|
||||
comments: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { user: { select: { id: true, name: true, avatarUrl: true } } }
|
||||
},
|
||||
attachments: { orderBy: { createdAt: 'desc' } },
|
||||
column: { select: { id: true, title: true } }
|
||||
}
|
||||
});
|
||||
|
||||
if (!card) throw error(404, 'Card not found');
|
||||
|
||||
return json({ card });
|
||||
};
|
||||
|
||||
// Update card
|
||||
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 card = await prisma.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');
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// Handle move (column change + position)
|
||||
if (body.columnId !== undefined || body.position !== undefined) {
|
||||
const data: Record<string, unknown> = {};
|
||||
if (body.columnId) data.columnId = body.columnId;
|
||||
if (body.position) data.position = body.position;
|
||||
|
||||
const updated = await prisma.card.update({
|
||||
where: { id: params.cardId },
|
||||
data,
|
||||
include: {
|
||||
assignees: {
|
||||
include: { user: { select: { id: true, name: true, avatarUrl: true } } }
|
||||
},
|
||||
labels: { include: { tag: true } },
|
||||
_count: { select: { comments: true, checklists: true, attachments: true } }
|
||||
}
|
||||
});
|
||||
return json({ card: updated });
|
||||
}
|
||||
|
||||
// Handle content update
|
||||
const result = cardSchema.partial().safeParse(body);
|
||||
if (!result.success) {
|
||||
return json({ error: result.error.issues[0].message }, { status: 400 });
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = { ...result.data };
|
||||
if (body.dueDate !== undefined) updateData.dueDate = body.dueDate ? new Date(body.dueDate) : null;
|
||||
if (body.archived !== undefined) updateData.archived = body.archived;
|
||||
|
||||
const updated = await prisma.card.update({
|
||||
where: { id: params.cardId },
|
||||
data: updateData,
|
||||
include: {
|
||||
assignees: {
|
||||
include: { user: { select: { id: true, name: true, avatarUrl: true } } }
|
||||
},
|
||||
labels: { include: { tag: true } },
|
||||
_count: { select: { comments: true, checklists: true, attachments: true } }
|
||||
}
|
||||
});
|
||||
|
||||
return json({ card: updated });
|
||||
};
|
||||
|
||||
// Delete card
|
||||
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');
|
||||
|
||||
const card = await prisma.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 prisma.card.delete({ where: { id: params.cardId } });
|
||||
|
||||
return json({ ok: true });
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
error = data.error || 'Login failed';
|
||||
return;
|
||||
}
|
||||
|
||||
goto('/boards');
|
||||
} catch {
|
||||
error = 'Network error. Please try again.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Login</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex min-h-[calc(100vh-3.5rem)] items-center justify-center px-4">
|
||||
<div class="w-full max-w-sm">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-6 text-center">Log in</h1>
|
||||
|
||||
{#if error}
|
||||
<div class="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={email}
|
||||
required
|
||||
class="w-full 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)]"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
required
|
||||
class="w-full 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)]"
|
||||
placeholder="Your password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="w-full rounded-lg bg-[var(--color-primary)] px-4 py-2.5 text-sm font-medium text-white hover:bg-[var(--color-primary-hover)] transition disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Logging in...' : 'Log in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="mt-4 text-center text-sm text-gray-500">
|
||||
Don't have an account?
|
||||
<a href="/auth/register" class="text-[var(--color-primary)] hover:underline">Register</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let name = $state('');
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, email, password })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
error = data.error || 'Registration failed';
|
||||
return;
|
||||
}
|
||||
|
||||
goto('/boards');
|
||||
} catch {
|
||||
error = 'Network error. Please try again.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Register</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex min-h-[calc(100vh-3.5rem)] items-center justify-center px-4">
|
||||
<div class="w-full max-w-sm">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-6 text-center">Create an account</h1>
|
||||
|
||||
{#if error}
|
||||
<div class="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
required
|
||||
class="w-full 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)]"
|
||||
placeholder="Your name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={email}
|
||||
required
|
||||
class="w-full 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)]"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
required
|
||||
minlength="8"
|
||||
class="w-full 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)]"
|
||||
placeholder="At least 8 characters"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="w-full rounded-lg bg-[var(--color-primary)] px-4 py-2.5 text-sm font-medium text-white hover:bg-[var(--color-primary-hover)] transition disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="mt-4 text-center text-sm text-gray-500">
|
||||
Already have an account?
|
||||
<a href="/auth/login" class="text-[var(--color-primary)] hover:underline">Log in</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { PageServerLoad } from './$types.js';
|
||||
import { prisma } from '$lib/server/db.js';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const tenant = locals.tenant;
|
||||
if (!tenant) return { boards: [] };
|
||||
|
||||
const where: any = { tenantId: tenant.id, archived: false };
|
||||
|
||||
if (!locals.user) {
|
||||
where.visibility = 'PUBLIC';
|
||||
} else {
|
||||
where.OR = [
|
||||
{ visibility: 'PUBLIC' },
|
||||
{ members: { some: { userId: locals.user.id } } }
|
||||
];
|
||||
}
|
||||
|
||||
const boards = await prisma.board.findMany({
|
||||
where,
|
||||
include: {
|
||||
members: {
|
||||
include: { user: { select: { id: true, name: true, avatarUrl: true } } }
|
||||
},
|
||||
_count: { select: { columns: true } }
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' }
|
||||
});
|
||||
|
||||
return { boards };
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
<script lang="ts">
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let showCreate = $state(false);
|
||||
let newTitle = $state('');
|
||||
let newVisibility = $state<'PRIVATE' | 'PUBLIC'>('PRIVATE');
|
||||
let creating = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
async function createBoard(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!newTitle.trim()) return;
|
||||
creating = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/boards', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: newTitle, visibility: newVisibility })
|
||||
});
|
||||
const result = await res.json();
|
||||
if (!res.ok) {
|
||||
error = result.error || 'Failed to create board';
|
||||
return;
|
||||
}
|
||||
newTitle = '';
|
||||
showCreate = false;
|
||||
invalidateAll();
|
||||
} catch {
|
||||
error = 'Network error';
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Boards</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Boards</h1>
|
||||
{#if data.user}
|
||||
<button
|
||||
onclick={() => (showCreate = !showCreate)}
|
||||
class="rounded-lg bg-[var(--color-primary)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--color-primary-hover)] transition"
|
||||
>
|
||||
+ New Board
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showCreate}
|
||||
<div class="mb-6 rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
||||
{#if error}
|
||||
<div class="mb-3 rounded bg-red-50 border border-red-200 p-2 text-sm text-red-700">{error}</div>
|
||||
{/if}
|
||||
<form onsubmit={createBoard} class="flex gap-3 items-end">
|
||||
<div class="flex-1">
|
||||
<label for="board-title" class="block text-sm font-medium text-gray-700 mb-1">Board title</label>
|
||||
<input
|
||||
id="board-title"
|
||||
type="text"
|
||||
bind:value={newTitle}
|
||||
required
|
||||
class="w-full 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)]"
|
||||
placeholder="My new board"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="board-vis" class="block text-sm font-medium text-gray-700 mb-1">Visibility</label>
|
||||
<select
|
||||
id="board-vis"
|
||||
bind:value={newVisibility}
|
||||
class="rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="PRIVATE">Private</option>
|
||||
<option value="PUBLIC">Public</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating}
|
||||
class="rounded-lg bg-[var(--color-primary)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--color-primary-hover)] transition disabled:opacity-50"
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showCreate = false)}
|
||||
class="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50 transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if data.boards.length === 0}
|
||||
<div class="text-center py-16">
|
||||
<svg class="mx-auto w-16 h-16 text-gray-300 mb-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="4" rx="1" />
|
||||
<rect x="14" y="10" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="13" width="7" height="8" rx="1" />
|
||||
</svg>
|
||||
<p class="text-gray-500">No boards yet.</p>
|
||||
{#if data.user}
|
||||
<p class="text-gray-400 text-sm mt-1">Create your first board to get started.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{#each data.boards as board}
|
||||
<a
|
||||
href="/boards/{board.id}"
|
||||
class="group block rounded-lg border border-gray-200 bg-white p-4 shadow-sm hover:shadow-md hover:border-gray-300 transition"
|
||||
>
|
||||
<h2 class="font-semibold text-gray-900 group-hover:text-[var(--color-primary)] transition">
|
||||
{board.title}
|
||||
</h2>
|
||||
<div class="mt-2 flex items-center gap-3 text-xs text-gray-500">
|
||||
<span>{board._count.columns} columns</span>
|
||||
<span>{board.visibility === 'PUBLIC' ? 'Public' : 'Private'}</span>
|
||||
</div>
|
||||
{#if board.members.length > 0}
|
||||
<div class="mt-3 flex -space-x-1">
|
||||
{#each board.members.slice(0, 5) as member}
|
||||
<div
|
||||
class="w-6 h-6 rounded-full bg-[var(--color-primary)] text-white text-xs flex items-center justify-center border-2 border-white"
|
||||
title={member.user.name}
|
||||
>
|
||||
{member.user.name[0].toUpperCase()}
|
||||
</div>
|
||||
{/each}
|
||||
{#if board.members.length > 5}
|
||||
<div class="w-6 h-6 rounded-full bg-gray-300 text-gray-600 text-xs flex items-center justify-center border-2 border-white">
|
||||
+{board.members.length - 5}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,53 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types.js';
|
||||
import { prisma } from '$lib/server/db.js';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
const tenant = locals.tenant;
|
||||
if (!tenant) throw error(400, 'Unknown tenant');
|
||||
|
||||
const board = await prisma.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 } } }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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 };
|
||||
};
|
||||
@@ -0,0 +1,385 @@
|
||||
<script lang="ts">
|
||||
import { dndzone } from 'svelte-dnd-action';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { generatePosition } from '$lib/utils/fractional-index.js';
|
||||
import CardModal from '$lib/components/CardModal.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
// Local mutable state derived from server data
|
||||
let columns = $state(
|
||||
data.board.columns.map((col: any) => ({
|
||||
...col,
|
||||
cards: col.cards.map((card: any) => ({ ...card, id: card.id }))
|
||||
}))
|
||||
);
|
||||
|
||||
let addingColumnTitle = $state('');
|
||||
let showAddColumn = $state(false);
|
||||
let addingCardColumnId = $state<string | null>(null);
|
||||
let newCardTitle = $state('');
|
||||
let selectedCard = $state<any>(null);
|
||||
let editingColumnId = $state<string | null>(null);
|
||||
let editingColumnTitle = $state('');
|
||||
|
||||
const flipDurationMs = 200;
|
||||
|
||||
// ─── Column DnD ──────────────────────────────────
|
||||
function handleColumnSort(e: CustomEvent<any>) {
|
||||
columns = e.detail.items;
|
||||
if (e.detail.info.trigger === 'droppedIntoZone') {
|
||||
// Update positions based on new order
|
||||
columns.forEach((col: any, idx: number) => {
|
||||
const before = idx > 0 ? columns[idx - 1].position : null;
|
||||
const after = idx < columns.length - 1 ? columns[idx + 1].position : null;
|
||||
if (col.id === e.detail.info.id) {
|
||||
const newPos = generatePosition(before, after);
|
||||
col.position = newPos;
|
||||
fetch(`/api/boards/${data.board.id}/columns/${col.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ position: newPos })
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Card DnD ────────────────────────────────────
|
||||
function handleCardSort(columnId: string, e: CustomEvent<any>) {
|
||||
const col = columns.find((c: any) => c.id === columnId);
|
||||
if (!col) return;
|
||||
col.cards = e.detail.items;
|
||||
|
||||
if (e.detail.info.trigger === 'droppedIntoZone') {
|
||||
const cardId = e.detail.info.id;
|
||||
const cardIdx = col.cards.findIndex((c: any) => c.id === cardId);
|
||||
if (cardIdx === -1) return;
|
||||
|
||||
const before = cardIdx > 0 ? col.cards[cardIdx - 1].position : null;
|
||||
const after = cardIdx < col.cards.length - 1 ? col.cards[cardIdx + 1].position : null;
|
||||
const newPos = generatePosition(before, after);
|
||||
col.cards[cardIdx].position = newPos;
|
||||
|
||||
fetch(`/api/boards/${data.board.id}/columns/${columnId}/cards/${cardId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ columnId, position: newPos })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Add Column ──────────────────────────────────
|
||||
async function addColumn() {
|
||||
if (!addingColumnTitle.trim()) return;
|
||||
const res = await fetch(`/api/boards/${data.board.id}/columns`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: addingColumnTitle })
|
||||
});
|
||||
if (res.ok) {
|
||||
const { column } = await res.json();
|
||||
columns = [...columns, { ...column, cards: [] }];
|
||||
addingColumnTitle = '';
|
||||
showAddColumn = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Add Card ────────────────────────────────────
|
||||
async function addCard(columnId: string) {
|
||||
if (!newCardTitle.trim()) return;
|
||||
const res = await fetch(`/api/boards/${data.board.id}/columns/${columnId}/cards`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: newCardTitle })
|
||||
});
|
||||
if (res.ok) {
|
||||
const { card } = await res.json();
|
||||
const col = columns.find((c: any) => c.id === columnId);
|
||||
if (col) col.cards = [...col.cards, card];
|
||||
newCardTitle = '';
|
||||
addingCardColumnId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Delete Column ───────────────────────────────
|
||||
async function deleteColumn(columnId: string) {
|
||||
const res = await fetch(`/api/boards/${data.board.id}/columns/${columnId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (res.ok) {
|
||||
columns = columns.filter((c: any) => c.id !== columnId);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Rename Column ───────────────────────────────
|
||||
async function renameColumn(columnId: string) {
|
||||
if (!editingColumnTitle.trim()) return;
|
||||
const res = await fetch(`/api/boards/${data.board.id}/columns/${columnId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: editingColumnTitle })
|
||||
});
|
||||
if (res.ok) {
|
||||
const col = columns.find((c: any) => c.id === columnId);
|
||||
if (col) col.title = editingColumnTitle;
|
||||
}
|
||||
editingColumnId = null;
|
||||
}
|
||||
|
||||
// ─── Delete Card ─────────────────────────────────
|
||||
async function deleteCard(columnId: string, cardId: string) {
|
||||
const res = await fetch(`/api/boards/${data.board.id}/columns/${columnId}/cards/${cardId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (res.ok) {
|
||||
const col = columns.find((c: any) => c.id === columnId);
|
||||
if (col) col.cards = col.cards.filter((c: any) => c.id !== cardId);
|
||||
if (selectedCard?.id === cardId) selectedCard = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.board.title}</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if selectedCard}
|
||||
<CardModal
|
||||
card={selectedCard}
|
||||
boardId={data.board.id}
|
||||
columnId={columns.find((c: any) => c.cards.some((card: any) => card.id === selectedCard.id))?.id}
|
||||
canEdit={data.canEdit}
|
||||
onclose={() => (selectedCard = null)}
|
||||
ondelete={(cardId) => {
|
||||
const col = columns.find((c: any) => c.cards.some((card: any) => card.id === cardId));
|
||||
if (col) deleteCard(col.id, cardId);
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col h-[calc(100vh-3.5rem)]">
|
||||
<!-- Board header -->
|
||||
<div class="flex items-center gap-3 px-4 py-3 bg-white/80 border-b border-gray-200">
|
||||
<a href="/boards" class="text-gray-400 hover:text-gray-600 transition" aria-label="Back to boards">
|
||||
<svg class="w-5 h-5" 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>
|
||||
</a>
|
||||
<h1 class="text-lg font-bold text-gray-900">{data.board.title}</h1>
|
||||
<span class="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-500">
|
||||
{data.board.visibility === 'PUBLIC' ? 'Public' : 'Private'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Columns container -->
|
||||
<div class="flex-1 overflow-x-auto p-4">
|
||||
<div
|
||||
class="flex gap-4 h-full items-start"
|
||||
use:dndzone={{
|
||||
items: columns,
|
||||
flipDurationMs,
|
||||
type: 'columns',
|
||||
dragDisabled: !data.canEdit
|
||||
}}
|
||||
onconsider={handleColumnSort}
|
||||
onfinalize={handleColumnSort}
|
||||
>
|
||||
{#each columns as column (column.id)}
|
||||
<div
|
||||
class="flex-shrink-0 w-72 bg-[var(--color-column-bg)] rounded-xl flex flex-col max-h-full"
|
||||
animate:flip={{ duration: flipDurationMs }}
|
||||
>
|
||||
<!-- Column header -->
|
||||
<div class="flex items-center justify-between px-3 py-2.5">
|
||||
{#if editingColumnId === column.id}
|
||||
<form
|
||||
onsubmit={(e) => { e.preventDefault(); renameColumn(column.id); }}
|
||||
class="flex-1 flex gap-1"
|
||||
>
|
||||
<input
|
||||
bind:value={editingColumnTitle}
|
||||
class="flex-1 rounded border border-gray-300 px-2 py-1 text-sm font-semibold"
|
||||
autofocus
|
||||
onblur={() => renameColumn(column.id)}
|
||||
/>
|
||||
</form>
|
||||
{:else}
|
||||
<button
|
||||
class="text-sm font-semibold text-gray-700 hover:text-gray-900 text-left flex-1"
|
||||
ondblclick={() => {
|
||||
if (data.canEdit) {
|
||||
editingColumnId = column.id;
|
||||
editingColumnTitle = column.title;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{column.title}
|
||||
<span class="ml-1 text-xs text-gray-400 font-normal">{column.cards.length}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if data.canEdit}
|
||||
<button
|
||||
onclick={() => deleteColumn(column.id)}
|
||||
class="text-gray-400 hover:text-[var(--color-danger)] transition p-1"
|
||||
title="Delete column"
|
||||
>
|
||||
<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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Cards zone -->
|
||||
<div
|
||||
class="flex-1 overflow-y-auto px-2 pb-2 min-h-[60px]"
|
||||
use:dndzone={{
|
||||
items: column.cards,
|
||||
flipDurationMs,
|
||||
type: 'cards',
|
||||
dragDisabled: !data.canEdit
|
||||
}}
|
||||
onconsider={(e) => handleCardSort(column.id, e)}
|
||||
onfinalize={(e) => handleCardSort(column.id, e)}
|
||||
>
|
||||
{#each column.cards as card (card.id)}
|
||||
<button
|
||||
class="w-full text-left mb-2 rounded-lg bg-[var(--color-card-bg)] p-3 shadow-sm hover:shadow-md border border-gray-200 hover:border-gray-300 transition cursor-pointer"
|
||||
animate:flip={{ duration: flipDurationMs }}
|
||||
onclick={() => (selectedCard = card)}
|
||||
>
|
||||
{#if card.labels && card.labels.length > 0}
|
||||
<div class="flex flex-wrap gap-1 mb-1.5">
|
||||
{#each card.labels as label}
|
||||
<span
|
||||
class="inline-block h-2 w-8 rounded-full"
|
||||
style="background-color: {label.tag.color}"
|
||||
title={label.tag.name}
|
||||
></span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<span class="text-sm text-gray-800">{card.title}</span>
|
||||
{#if card.dueDate || card._count?.comments > 0 || card._count?.checklists > 0}
|
||||
<div class="flex items-center gap-2 mt-2 text-xs text-gray-400">
|
||||
{#if card.dueDate}
|
||||
<span>Due {new Date(card.dueDate).toLocaleDateString()}</span>
|
||||
{/if}
|
||||
{#if card._count?.comments > 0}
|
||||
<span>💬 {card._count.comments}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if card.assignees && card.assignees.length > 0}
|
||||
<div class="flex -space-x-1 mt-2">
|
||||
{#each card.assignees.slice(0, 3) as assignee}
|
||||
<div
|
||||
class="w-5 h-5 rounded-full bg-[var(--color-primary)] text-white text-[10px] flex items-center justify-center border border-white"
|
||||
title={assignee.user.name}
|
||||
>
|
||||
{assignee.user.name[0].toUpperCase()}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Add card button -->
|
||||
{#if data.canEdit}
|
||||
{#if addingCardColumnId === column.id}
|
||||
<div class="px-2 pb-2">
|
||||
<form
|
||||
onsubmit={(e) => { e.preventDefault(); addCard(column.id); }}
|
||||
>
|
||||
<textarea
|
||||
bind:value={newCardTitle}
|
||||
placeholder="Enter a title for this card..."
|
||||
class="w-full rounded-lg border border-gray-300 p-2 text-sm resize-none focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
|
||||
rows="2"
|
||||
autofocus
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
addCard(column.id);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
addingCardColumnId = null;
|
||||
newCardTitle = '';
|
||||
}
|
||||
}}
|
||||
></textarea>
|
||||
<div class="flex gap-2 mt-1">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded bg-[var(--color-primary)] px-3 py-1.5 text-sm text-white hover:bg-[var(--color-primary-hover)] transition"
|
||||
>
|
||||
Add card
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { addingCardColumnId = null; newCardTitle = ''; }}
|
||||
class="text-gray-500 hover:text-gray-700 transition text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => { addingCardColumnId = column.id; newCardTitle = ''; }}
|
||||
class="mx-2 mb-2 rounded-lg px-3 py-2 text-sm text-gray-500 hover:bg-gray-200/50 transition text-left"
|
||||
>
|
||||
+ Add a card
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Add column -->
|
||||
{#if data.canEdit}
|
||||
<div class="flex-shrink-0 w-72">
|
||||
{#if showAddColumn}
|
||||
<div class="bg-[var(--color-column-bg)] rounded-xl p-3">
|
||||
<form onsubmit={(e) => { e.preventDefault(); addColumn(); }}>
|
||||
<input
|
||||
bind:value={addingColumnTitle}
|
||||
placeholder="Enter list title..."
|
||||
class="w-full 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)]"
|
||||
autofocus
|
||||
onkeydown={(e) => { if (e.key === 'Escape') { showAddColumn = false; addingColumnTitle = ''; } }}
|
||||
/>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded bg-[var(--color-primary)] px-3 py-1.5 text-sm text-white hover:bg-[var(--color-primary-hover)] transition"
|
||||
>
|
||||
Add list
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showAddColumn = false; addingColumnTitle = ''; }}
|
||||
class="text-gray-500 hover:text-gray-700 transition text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => (showAddColumn = true)}
|
||||
class="w-full rounded-xl bg-white/60 hover:bg-white/80 border border-dashed border-gray-300 px-4 py-3 text-sm text-gray-500 transition text-left"
|
||||
>
|
||||
+ Add another list
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Server as SocketIOServer } from 'socket.io';
|
||||
import type { Server as HttpServer } from 'http';
|
||||
import { validateSession, sessionCookieName } from '$lib/server/auth.js';
|
||||
|
||||
let io: SocketIOServer | null = null;
|
||||
|
||||
export function initSocketIO(server: HttpServer): SocketIOServer {
|
||||
io = new SocketIOServer(server, {
|
||||
cors: {
|
||||
origin: process.env.ORIGIN || 'http://localhost:5173',
|
||||
credentials: true
|
||||
}
|
||||
});
|
||||
|
||||
// Auth middleware — validate session cookie
|
||||
io.use(async (socket, next) => {
|
||||
const cookieHeader = socket.handshake.headers.cookie;
|
||||
if (!cookieHeader) {
|
||||
// Allow anonymous connections (for public boards)
|
||||
socket.data.user = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
const cookies = parseCookies(cookieHeader);
|
||||
const sessionId = cookies[sessionCookieName()];
|
||||
if (!sessionId) {
|
||||
socket.data.user = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
const session = await validateSession(sessionId);
|
||||
if (session) {
|
||||
socket.data.user = session.user;
|
||||
} else {
|
||||
socket.data.user = null;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
// Join board room
|
||||
socket.on('join-board', (boardId: string) => {
|
||||
socket.join(`board:${boardId}`);
|
||||
// Broadcast presence
|
||||
socket.to(`board:${boardId}`).emit('user-joined', {
|
||||
user: socket.data.user
|
||||
? { id: socket.data.user.id, name: socket.data.user.name }
|
||||
: null,
|
||||
socketId: socket.id
|
||||
});
|
||||
});
|
||||
|
||||
// Leave board room
|
||||
socket.on('leave-board', (boardId: string) => {
|
||||
socket.leave(`board:${boardId}`);
|
||||
socket.to(`board:${boardId}`).emit('user-left', {
|
||||
socketId: socket.id
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
// Socket.IO handles room cleanup automatically
|
||||
});
|
||||
});
|
||||
|
||||
return io;
|
||||
}
|
||||
|
||||
export function getIO(): SocketIOServer | null {
|
||||
return io;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event to all clients in a board room.
|
||||
*/
|
||||
export function emitToBoardRoom(boardId: string, event: string, data: unknown) {
|
||||
if (io) {
|
||||
io.to(`board:${boardId}`).emit(event, data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a notification to a specific user across all their connections.
|
||||
*/
|
||||
export function emitToUser(userId: string, event: string, data: unknown) {
|
||||
if (io) {
|
||||
io.to(`user:${userId}`).emit(event, data);
|
||||
}
|
||||
}
|
||||
|
||||
function parseCookies(cookieHeader: string): Record<string, string> {
|
||||
const cookies: Record<string, string> = {};
|
||||
cookieHeader.split(';').forEach((cookie) => {
|
||||
const [name, ...rest] = cookie.trim().split('=');
|
||||
cookies[name] = decodeURIComponent(rest.join('='));
|
||||
});
|
||||
return cookies;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -0,0 +1,15 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter(),
|
||||
alias: {
|
||||
$components: 'src/lib/components',
|
||||
$server: 'src/lib/server',
|
||||
$utils: 'src/lib/utils'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import type { Plugin } from 'vite';
|
||||
|
||||
function socketIOPlugin(): Plugin {
|
||||
return {
|
||||
name: 'socket-io-dev',
|
||||
configureServer(server) {
|
||||
if (!server.httpServer) return;
|
||||
|
||||
// Dynamic import to avoid bundling socket.io in client
|
||||
import('./src/socket/index.js').then(({ initSocketIO }) => {
|
||||
initSocketIO(server.httpServer!);
|
||||
console.log('Socket.IO dev server initialized');
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit(), socketIOPlugin()]
|
||||
});
|
||||
Reference in New Issue
Block a user