🌊 TeamFlow — Modern Trello alternative with email integration

- Full-stack: React 18 + Express + SQLite
- Drag-and-drop kanban boards with @hello-pangea/dnd
- Google App Password email integration (SMTP + IMAP)
- Inbound email: create cards by sending emails
- Reply-to-card: email replies become comments
- Admin/user management with role-based access
- Setup wizard: email config → admin creation
- Checklists, time tracking, priorities, labels, due dates
- Real-time notifications with activity feed
- Beautiful HTML email templates
This commit is contained in:
admin
2026-04-03 15:11:27 +00:00
Unverified
commit 460f83aef8
40 changed files with 8512 additions and 0 deletions

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
PORT=3001
JWT_SECRET=change-me-in-production
APP_URL=http://localhost:5173

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
.env
server/data/
*.db
*.pem

234
README.md Normal file
View File

@@ -0,0 +1,234 @@
<div align="center">
# 🌊 TeamFlow
### The Modern Trello Alternative for Small Teams
**Kanban boards. Integrated email. Zero friction.**
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Node.js](https://img.shields.io/badge/Node.js-22-green.svg)](https://nodejs.org)
[![React](https://img.shields.io/badge/React-18-61DAFB.svg)](https://react.dev)
<br />
> **Developed end to end, single prompt by [Z.AI GLM-5-TURBO](https://z.ai/subscribe?ic=ROK78RJKNW)**
>
> *Full-stack application — backend, API, database, frontend, email integration — generated from a single natural language instruction.*
<br />
[🚀 Live Demo](https://teamflow.95-216-124-247.sslip.io) · [📖 Features](#-features) · [⚡ Quick Start](#-quick-start) · [📧 Email Integration](#-email-integration)
</div>
---
## ✨ Features
### 🏗️ Kanban Boards
- **Drag & drop** cards and lists with smooth animations (@hello-pangea/dnd)
- **Multiple boards** with gradient backgrounds
- **Labels** with custom colors per board
- **Priority levels** — None, Low, Medium, High, Urgent (color-coded)
- **Due dates** with overdue and upcoming alerts on the dashboard
- **Card colors** for quick visual categorization
- **Rich card detail modal** with everything in one place
### ✅ Checklists & Time Tracking
- **Per-card checklists** with progress indicators
- **Time estimation** — set expected hours per card
- **Time logging** — track actual hours spent
- **Progress bars** on cards in the board view
### 👥 Team Management
- **Role-based access** — Admin & Member roles
- **Admin-only user creation** — no public registration
- **Board-level membership** — invite users to specific boards
- **Password management** — admins can reset any user's password
- **User enable/disable** — deactivate accounts without deleting
### 📧 Email Integration (Google App Password)
- **SMTP setup wizard** with live verification
- **Beautiful HTML notification emails** — assignments, comments, due dates, card moves
- **Inbound email processing** via IMAP — send emails to create cards
- **Reply-to-card** — reply to notification emails to add comments automatically
- **Email log & stats** — full visibility into sent/received/failed messages
- **Test email** button before going live
- **Configurable polling interval** for inbound processing
### 🔔 Notifications
- **Real-time notification bell** with unread count
- **Per-card activity feed** — see every action with timestamps
- **Mark as read / mark all / delete** notification management
- **Auto-refresh** every 30 seconds
### 🔐 Security
- **JWT authentication** with 7-day tokens
- **bcrypt password hashing** (12 salt rounds)
- **Route protection** — admin-only pages for user/email management
- **Auto-logout on token expiry**
---
## 🚀 Quick Start
```bash
# Clone the repo
git clone https://github.rommark.dev/admin/TeamFlow--Trello-Like-.git
cd TeamFlow--Trello-Like-
# Install all dependencies (root + server + client)
npm run install:all
# Start development servers (API on :3001, client on :5173)
npm run dev
```
Open **http://localhost:5173** — the setup wizard will guide you through:
1. **📧 Configure Email** — Enter your SMTP credentials (Google App Password supported)
2. **👤 Create Admin** — Set up the first admin account
3. **🎉 Start Working** — Create boards, invite your team, ship faster
### Production Build
```bash
# Build the React frontend
cd client && npx vite build && cd ..
# Start the server (serves API + static files)
node server/index.js
```
### Environment Variables
```env
PORT=3001 # API server port
JWT_SECRET=your-secret-here # JWT signing secret
APP_URL=https://your-domain.com # Public URL for email links
```
---
## 📧 Email Integration
TeamFlow turns email into a first-class workflow tool:
### Outbound (Notifications)
Every card action can trigger a beautifully styled email:
- ✅ Card assigned → notification with direct link
- 💬 Comment added → full comment in email body
- 📋 Card moved → list change notification
- ⏰ Due date approaching → reminder alert
### Inbound (Create Cards via Email)
Enable IMAP in **Settings → Email** and start sending emails:
```
To: your-team@gmail.com
Subject: [tf-project-alpha] Fix login bug
Body: Users report timeout on the login page after 30 seconds...
```
This automatically creates a card in the "project-alpha" board!
### Reply-to-Card
Reply to any notification email — your reply becomes a comment on the card automatically.
### Board Email Prefix
Default: `tf-` — customize in settings. Matches the beginning of your board title:
- Board: **"Project Alpha"** → prefix match: `tf-project-alpha`
---
## 🏗️ Architecture
```
teamflow/
├── server/
│ ├── index.js # Express server + HTTPS support
│ ├── db.js # SQLite schema (auto-migrates)
│ ├── middleware/
│ │ └── auth.js # JWT auth + admin guard
│ ├── routes/
│ │ ├── setup.js # Setup wizard API
│ │ ├── auth.js # Login + profile
│ │ ├── users.js # User CRUD + email invite
│ │ ├── boards.js # Board CRUD + membership
│ │ ├── cards.js # Cards, lists, checklists, labels, comments
│ │ ├── notifications.js # Notification management
│ │ └── email.js # Email config + stats + log
│ └── email/
│ ├── transporter.js # Nodemailer SMTP + HTML templates
│ └── imap.js # ImapFlow inbound processing
├── client/
│ ├── src/
│ │ ├── App.jsx # Router + route guards
│ │ ├── api.js # API client
│ │ ├── context/
│ │ │ └── AuthContext.jsx
│ │ ├── components/
│ │ │ ├── Layout.jsx # Header + sidebar + notifications
│ │ │ ├── Sidebar.jsx
│ │ │ ├── SetupWizard.jsx # Email → Admin flow
│ │ │ ├── CardModal.jsx # Full card detail view
│ │ │ └── NotificationPanel.jsx
│ │ └── pages/
│ │ ├── Login.jsx
│ │ ├── Dashboard.jsx # Board grid
│ │ ├── BoardView.jsx # Kanban board + DnD
│ │ ├── UserManagement.jsx
│ │ └── EmailSettings.jsx
│ └── ...config files
└── package.json
```
### Tech Stack
| Layer | Technology |
|-------|-----------|
| Frontend | React 18, Vite 6, Tailwind CSS 3 |
| Drag & Drop | @hello-pangea/dnd |
| Icons | Lucide React |
| Backend | Express.js 4 |
| Database | SQLite via better-sqlite3 |
| Authentication | JWT + bcryptjs |
| Email (SMTP) | Nodemailer |
| Email (IMAP) | ImapFlow + mailparser |
| Date Formatting | date-fns |
---
## 🎨 Screenshots
### Setup Wizard
Step-by-step email configuration and admin creation — verified live before saving.
### Kanban Board
Drag-and-drop cards between lists. Priority indicators, due dates, labels, assignees, and checklist progress — all visible at a glance.
### Card Detail Modal
Full-featured card editor with description, checklists, time tracking, comments, labels, priority, and activity history.
### User Management
Admin panel to create users, manage roles, reset passwords, and enable/disable accounts.
### Email Settings
Configure SMTP/IMAP, send test emails, view email logs and stats, and manage inbound processing.
---
## 📄 License
MIT — use it, fork it, ship it.
---
<div align="center">
**Built with 🌊 by TeamFlow**
*Developed end to end, single prompt by [Z.AI GLM-5-TURBO](https://z.ai/subscribe?ic=ROK78RJKNW)*
</div>

13
client/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TeamFlow</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌊</text></svg>" />
</head>
<body class="bg-gray-50">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

2922
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
client/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "teamflow-client",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@hello-pangea/dnd": "^18.0.1",
"lucide-react": "^0.469.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.1.1",
"date-fns": "^4.1.0"
},
"devDependencies": {
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"vite": "^6.0.7"
}
}

6
client/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

58
client/src/App.jsx Normal file
View File

@@ -0,0 +1,58 @@
import React from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './context/AuthContext';
import { api } from './api';
import SetupWizard from './components/SetupWizard';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import BoardView from './pages/BoardView';
import UserManagement from './pages/UserManagement';
import EmailSettings from './pages/EmailSettings';
import Layout from './components/Layout';
function ProtectedRoute({ children, adminOnly }) {
const { user, loading } = useAuth();
if (loading) return <div className="flex items-center justify-center h-screen"><div className="animate-spin h-8 w-8 border-4 border-brand-500 border-t-transparent rounded-full"></div></div>;
if (!user) return <Navigate to="/login" />;
if (adminOnly && user.role !== 'admin') return <Navigate to="/" />;
return children;
}
function SetupRoute() {
const { user, loading } = useAuth();
if (loading) return null;
if (user) return <Navigate to="/" />;
return <SetupWrapper />;
}
function SetupWrapper() {
const [status, setStatus] = React.useState(null);
const [checking, setChecking] = React.useState(true);
React.useEffect(() => {
api.setup.status().then(s => { setStatus(s); setChecking(false); });
}, []);
if (checking) return <div className="flex items-center justify-center h-screen"><div className="animate-spin h-8 w-8 border-4 border-brand-500 border-t-transparent rounded-full"></div></div>;
if (status?.setupComplete) return <Navigate to="/login" />;
return <SetupWizard status={status} onStatusChange={setStatus} />;
}
export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<Routes>
<Route path="/setup" element={<SetupRoute />} />
<Route path="/login" element={<Login />} />
<Route path="/" element={<ProtectedRoute><Layout /></ProtectedRoute>}>
<Route index element={<Dashboard />} />
<Route path="board/:id" element={<BoardView />} />
<Route path="users" element={<ProtectedRoute adminOnly><UserManagement /></ProtectedRoute>} />
<Route path="email-settings" element={<ProtectedRoute adminOnly><EmailSettings /></ProtectedRoute>} />
</Route>
</Routes>
</AuthProvider>
</BrowserRouter>
);
}

85
client/src/api.js Normal file
View File

@@ -0,0 +1,85 @@
const API = '/api';
function getToken() {
return localStorage.getItem('teamflow_token');
}
async function request(path, options = {}) {
const token = getToken();
const headers = { 'Content-Type': 'application/json', ...options.headers };
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${API}${path}`, { ...options, headers });
if (res.status === 401) {
localStorage.removeItem('teamflow_token');
localStorage.removeItem('teamflow_user');
window.location.href = '/login';
throw new Error('Unauthorized');
}
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Request failed');
return data;
}
export const api = {
setup: {
status: () => fetch(`${API}/setup/status`).then(r => r.json()),
configureEmail: (data) => request('/setup/email', { method: 'POST', body: JSON.stringify(data) }),
testEmail: (to) => request('/setup/email/test', { method: 'POST', body: JSON.stringify({ to }) }),
createAdmin: (data) => request('/setup/admin', { method: 'POST', body: JSON.stringify(data) }),
updateInbound: (data) => request('/setup/email/inbound', { method: 'PUT', body: JSON.stringify(data) }),
},
auth: {
login: (data) => request('/auth/login', { method: 'POST', body: JSON.stringify(data) }),
me: () => request('/auth/me'),
update: (data) => request('/auth/me', { method: 'PUT', body: JSON.stringify(data) }),
},
users: {
list: () => request('/users/'),
create: (data) => request('/users/', { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => request(`/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
resetPassword: (id, password) => request(`/users/${id}/reset-password`, { method: 'PUT', body: JSON.stringify({ password }) }),
boardMembers: (boardId) => request(`/users/board/${boardId}`),
},
boards: {
list: () => request('/boards/'),
get: (id) => request(`/boards/${id}`),
full: (id) => request(`/boards/${id}/full`),
create: (data) => request('/boards/', { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => request(`/boards/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
delete: (id) => request(`/boards/${id}`, { method: 'DELETE' }),
addMember: (id, data) => request(`/boards/${id}/members`, { method: 'POST', body: JSON.stringify(data) }),
removeMember: (id, userId) => request(`/boards/${id}/members/${userId}`, { method: 'DELETE' }),
},
cards: {
create: (listId, data) => request(`/cards/lists/${listId}/cards`, { method: 'POST', body: JSON.stringify(data) }),
get: (id) => request(`/cards/${id}`),
update: (id, data) => request(`/cards/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
delete: (id) => request(`/cards/${id}`, { method: 'DELETE' }),
reorder: (cards) => request('/cards/reorder', { method: 'PUT', body: JSON.stringify({ cards }) }),
addComment: (id, content) => request(`/cards/${id}/comments`, { method: 'POST', body: JSON.stringify({ content }) }),
createList: (boardId, title) => request(`/cards/${boardId}/lists`, { method: 'POST', body: JSON.stringify({ title }) }),
updateList: (id, data) => request(`/cards/lists/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
reorderList: (id, position) => request(`/cards/lists/${id}/reorder`, { method: 'PUT', body: JSON.stringify({ position }) }),
addLabel: (boardId, data) => request(`/cards/${boardId}/labels`, { method: 'POST', body: JSON.stringify(data) }),
updateLabel: (id, data) => request(`/cards/labels/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteLabel: (id) => request(`/cards/labels/${id}`, { method: 'DELETE' }),
updateCardLabels: (id, labels) => request(`/cards/${id}/labels`, { method: 'PUT', body: JSON.stringify({ labels }) }),
createChecklist: (cardId, title) => request(`/cards/${cardId}/checklists`, { method: 'POST', body: JSON.stringify({ title }) }),
addChecklistItem: (checklistId, text) => request(`/cards/checklists/${checklistId}/items`, { method: 'POST', body: JSON.stringify({ text }) }),
updateChecklistItem: (id, data) => request(`/cards/checklist-items/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteChecklistItem: (id) => request(`/cards/checklist-items/${id}`, { method: 'DELETE' }),
deleteChecklist: (id) => request(`/cards/checklists/${id}`, { method: 'DELETE' }),
},
notifications: {
list: () => request('/notifications/'),
read: (id) => request(`/notifications/${id}/read`, { method: 'PUT' }),
readAll: () => request('/notifications/read-all', { method: 'PUT' }),
delete: (id) => request(`/notifications/${id}`, { method: 'DELETE' }),
},
email: {
config: () => request('/email/config'),
log: () => request('/email/log'),
poll: () => request('/email/poll', { method: 'POST' }),
stats: () => request('/email/stats'),
},
};

View File

@@ -0,0 +1,301 @@
import React, { useState, useEffect, useRef } from 'react';
import { api } from '../api';
export default function CardModal({ cardId, boardId, onClose }) {
const [card, setCard] = useState(null);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(null);
const [newComment, setNewComment] = useState('');
const [showLabelPicker, setShowLabelPicker] = useState(false);
const [newChecklist, setNewChecklist] = useState('');
const modalRef = useRef(null);
const commentRef = useRef(null);
useEffect(() => {
loadCard();
const handler = (e) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [cardId]);
const loadCard = async () => {
try {
const data = await api.cards.get(cardId);
setCard(data);
} catch {}
setLoading(false);
};
const updateCard = async (updates) => {
try {
await api.cards.update(cardId, updates);
loadCard();
} catch (err) { alert(err.message); }
};
const addComment = async (e) => {
e.preventDefault();
if (!newComment.trim()) return;
try {
await api.cards.addComment(cardId, newComment);
setNewComment('');
loadCard();
} catch (err) { alert(err.message); }
};
const toggleLabel = async (labelId) => {
const current = card.labels.map(l => l.id);
const next = current.includes(labelId) ? current.filter(id => id !== labelId) : [...current, labelId];
try {
await api.cards.updateCardLabels(cardId, next);
loadCard();
} catch {}
};
const createChecklist = async (e) => {
e.preventDefault();
if (!newChecklist.trim()) return;
try {
await api.cards.createChecklist(cardId, newChecklist);
setNewChecklist('');
loadCard();
} catch {}
};
const addChecklistItem = async (checklistId, text) => {
if (!text.trim()) return;
try {
await api.cards.addChecklistItem(checklistId, text);
loadCard();
} catch {}
};
const toggleCheckItem = async (itemId, checked) => {
try {
await api.cards.updateChecklistItem(itemId, { is_checked: !checked });
loadCard();
} catch {}
};
const priorityOptions = ['none', 'low', 'medium', 'high', 'urgent'];
const priorityColors = { none: 'gray', low: 'green', medium: 'yellow', high: 'orange', urgent: 'red' };
if (loading) return null;
if (!card) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-start justify-center z-50 pt-16 px-4" onClick={onClose}>
<div ref={modalRef} onClick={e => e.stopPropagation()} className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
{/* Header */}
<div className={`px-6 py-4 border-b flex items-center justify-between ${card.color ? '' : 'bg-gray-50'}`} style={card.color ? { backgroundColor: card.color + '15' } : {}}>
<div className="flex items-center gap-3 flex-1 min-w-0">
<span className="text-xs bg-gray-200 text-gray-600 px-2 py-0.5 rounded-full">#{card.id}</span>
{card.priority !== 'none' && (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium bg-${priorityColors[card.priority]}-100 text-${priorityColors[card.priority]}-700`}>
{card.priority.toUpperCase()}
</span>
)}
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 p-1"></button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-5">
{/* Title */}
<input
type="text"
value={editing === 'title' ? editing : card.title}
onChange={e => setCard({ ...card, title: e.target.value })}
onBlur={() => updateCard({ title: card.title })}
className="w-full text-xl font-bold text-gray-900 border-0 p-0 focus:ring-0 mb-3"
/>
{/* Meta row */}
<div className="flex flex-wrap gap-2 mb-4">
{/* Assignee */}
<div className="relative">
<select
value={card.assigned_to || ''}
onChange={e => updateCard({ assigned_to: e.target.value ? parseInt(e.target.value) : null })}
className="text-sm bg-gray-100 border-0 rounded-lg px-3 py-1.5 focus:ring-2 focus:ring-brand-500 cursor-pointer"
>
<option value="">👤 Unassigned</option>
{card._members?.map?.(m => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
</div>
{/* Due Date */}
<input
type="date"
value={card.due_date ? card.due_date.split('T')[0] : ''}
onChange={e => updateCard({ due_date: e.target.value || null })}
className="text-sm bg-gray-100 border-0 rounded-lg px-3 py-1.5 focus:ring-2 focus:ring-brand-500"
/>
{/* Priority */}
<select
value={card.priority}
onChange={e => updateCard({ priority: e.target.value })}
className="text-sm bg-gray-100 border-0 rounded-lg px-3 py-1.5 focus:ring-2 focus:ring-brand-500 cursor-pointer"
>
{priorityOptions.map(p => <option key={p} value={p}>{p.charAt(0).toUpperCase() + p.slice(1)}</option>)}
</select>
{/* Labels */}
<div className="relative">
<button onClick={() => setShowLabelPicker(!showLabelPicker)} className="text-sm bg-gray-100 rounded-lg px-3 py-1.5 hover:bg-gray-200 flex items-center gap-1">
🏷 Labels {card.labels?.length > 0 && `(${card.labels.length})`}
</button>
{showLabelPicker && (
<div className="absolute top-full mt-1 left-0 bg-white rounded-xl shadow-xl border border-gray-200 p-3 w-56 z-10">
{card._labels?.map?.(label => (
<button key={label.id} onClick={() => toggleLabel(label.id)} className="flex items-center gap-2 w-full text-left px-2 py-1.5 rounded hover:bg-gray-50 text-sm">
<span className={`w-4 h-4 rounded ${card.labels.find(l => l.id === label.id) ? 'ring-2 ring-brand-500 ring-offset-1' : ''}`} style={{ backgroundColor: label.color }} />
{label.name}
</button>
)) || <p className="text-sm text-gray-400">No labels on this board</p>}
</div>
)}
</div>
</div>
{/* Labels display */}
{card.labels?.length > 0 && (
<div className="flex gap-1.5 mb-4 flex-wrap">
{card.labels.map(l => (
<span key={l.id} className="text-xs px-2.5 py-1 rounded-full text-white font-medium" style={{ backgroundColor: l.color }}>{l.name}</span>
))}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Main content */}
<div className="md:col-span-2 space-y-5">
{/* Description */}
<div>
<h4 className="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-1">📝 Description</h4>
<textarea
value={card.description || ''}
onChange={e => setCard({ ...card, description: e.target.value })}
onBlur={() => updateCard({ description: card.description })}
placeholder="Add a description..."
rows={3}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-brand-500 focus:border-transparent resize-none"
/>
</div>
{/* Checklists */}
<div>
<h4 className="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-1"> Checklists</h4>
{card.checklists?.map(cl => (
<div key={cl.id} className="mb-3 bg-gray-50 rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700">{cl.title}</span>
<span className="text-xs text-gray-500">{cl.done_items}/{cl.total_items}</span>
</div>
<div className="space-y-1">
{(card.checklistItems?.[cl.id] || []).map(item => (
<label key={item.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-gray-100 rounded px-1 py-0.5">
<input type="checkbox" checked={!!item.is_checked} onChange={() => toggleCheckItem(item.id, item.is_checked)} className="rounded border-gray-300 text-brand-600 focus:ring-brand-500" />
<span className={item.is_checked ? 'line-through text-gray-400' : 'text-gray-700'}>{item.text}</span>
</label>
))}
</div>
<ChecklistItemAdd checklistId={cl.id} onAdd={addChecklistItem} />
</div>
))}
<form onSubmit={createChecklist} className="flex gap-2">
<input type="text" value={newChecklist} onChange={e => setNewChecklist(e.target.value)} placeholder="New checklist..." className="flex-1 px-2 py-1 text-sm border border-gray-200 rounded-lg focus:ring-1 focus:ring-brand-500" />
<button type="submit" className="text-sm bg-gray-200 hover:bg-gray-300 px-3 py-1 rounded-lg font-medium">Add</button>
</form>
</div>
{/* Comments */}
<div>
<h4 className="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-1">💬 Comments ({card.comments?.length || 0})</h4>
{card.comments?.map(comment => (
<div key={comment.id} className="flex gap-3 mb-3">
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-medium shrink-0" style={{ backgroundColor: comment.avatar_color || '#6366f1' }}>
{comment.name?.charAt(0)?.toUpperCase() || '?'}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-800">{comment.name}</span>
<span className="text-xs text-gray-400">{new Date(comment.created_at).toLocaleString()}</span>
{comment.is_email_reply && <span className="text-xs bg-blue-100 text-blue-600 px-1.5 py-0.5 rounded">📧 email</span>}
</div>
<p className="text-sm text-gray-600 mt-0.5 whitespace-pre-wrap">{comment.content}</p>
</div>
</div>
))}
<form onSubmit={addComment} className="flex gap-2 mt-3">
<input ref={commentRef} type="text" value={newComment} onChange={e => setNewComment(e.target.value)} placeholder="Write a comment..." className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-brand-500 focus:border-transparent" />
<button type="submit" className="bg-brand-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-700">Send</button>
</form>
</div>
</div>
{/* Sidebar */}
<div className="space-y-4">
{/* Time Tracking */}
<div className="bg-gray-50 rounded-lg p-3">
<h4 className="text-sm font-semibold text-gray-700 mb-2"> Time</h4>
<div className="space-y-2">
<div>
<label className="text-xs text-gray-500">Estimated (hrs)</label>
<input type="number" step="0.5" min="0" value={card.estimated_hours || ''} onChange={e => updateCard({ estimated_hours: e.target.value ? parseFloat(e.target.value) : null })} className="w-full mt-0.5 px-2 py-1 text-sm border border-gray-200 rounded focus:ring-1 focus:ring-brand-500" />
</div>
<div>
<label className="text-xs text-gray-500">Spent (hrs)</label>
<input type="number" step="0.5" min="0" value={card.time_spent || ''} onChange={e => updateCard({ time_spent: e.target.value ? parseFloat(e.target.value) : null })} className="w-full mt-0.5 px-2 py-1 text-sm border border-gray-200 rounded focus:ring-1 focus:ring-brand-500" />
</div>
</div>
</div>
{/* Activity */}
<div className="bg-gray-50 rounded-lg p-3">
<h4 className="text-sm font-semibold text-gray-700 mb-2">📜 Activity</h4>
<div className="space-y-2 max-h-48 overflow-y-auto">
{card.activity?.map(a => (
<div key={a.id} className="flex items-start gap-2 text-xs">
<div className="w-5 h-5 rounded-full flex items-center justify-center text-white shrink-0" style={{ backgroundColor: a.avatar_color || '#94a3b8' }}>
{a.name?.charAt(0)?.toUpperCase()}
</div>
<div>
<span className="text-gray-700"><strong>{a.name}</strong> {a.action}</span>
<p className="text-gray-400 mt-0.5">{new Date(a.created_at).toLocaleString()}</p>
</div>
</div>
))}
</div>
</div>
{/* Actions */}
<div className="space-y-2">
<button onClick={() => updateCard({ color: card.color ? '' : '#fef3c7' })} className="w-full text-left text-sm text-gray-600 hover:bg-gray-100 px-3 py-2 rounded-lg transition-colors">
🎨 {card.color ? 'Remove color' : 'Set color'}
</button>
<button onClick={async () => { if (confirm('Delete this card?')) { await api.cards.delete(cardId); onClose(); } }} className="w-full text-left text-sm text-red-500 hover:bg-red-50 px-3 py-2 rounded-lg transition-colors">
🗑 Delete card
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
function ChecklistItemAdd({ checklistId, onAdd }) {
const [text, setText] = useState('');
const [show, setShow] = useState(false);
if (!show) return <button onClick={() => setShow(true)} className="text-xs text-gray-400 hover:text-gray-600 mt-1">+ Add item</button>;
return (
<form onSubmit={(e) => { e.preventDefault(); onAdd(checklistId, text); setText(''); setShow(false); }} className="flex gap-1 mt-1">
<input type="text" value={text} onChange={e => setText(e.target.value)} placeholder="Item..." autoFocus className="flex-1 px-2 py-0.5 text-xs border border-gray-200 rounded focus:ring-1 focus:ring-brand-500" />
<button type="submit" className="text-xs text-brand-600 font-medium">Add</button>
</form>
);
}

View File

@@ -0,0 +1,80 @@
import React from 'react';
import { Outlet, NavLink, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { api } from '../api';
import Sidebar from './Sidebar';
import NotificationPanel from './NotificationPanel';
export default function Layout() {
const { user, logout, refresh } = useAuth();
const [showNotifications, setShowNotifications] = React.useState(false);
const [notifications, setNotifications] = React.useState([]);
const [unread, setUnread] = React.useState(0);
const loadNotifications = React.useCallback(async () => {
try {
const data = await api.notifications.list();
setNotifications(data.notifications);
setUnread(data.unread);
} catch {}
}, []);
React.useEffect(() => {
loadNotifications();
const interval = setInterval(loadNotifications, 30000);
return () => clearInterval(interval);
}, [loadNotifications]);
return (
<div className="flex h-screen overflow-hidden bg-gray-100">
<Sidebar user={user} />
<div className="flex-1 flex flex-col overflow-hidden">
<header className="bg-white border-b border-gray-200 px-6 py-3 flex items-center justify-between shrink-0">
<div className="flex items-center gap-2 text-sm text-gray-500">
<span>🌊</span>
<span className="font-semibold text-brand-600">TeamFlow</span>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => setShowNotifications(!showNotifications)}
className="relative p-2 text-gray-500 hover:text-brand-600 hover:bg-brand-50 rounded-lg transition-colors"
>
<BellIcon />
{unread > 0 && (
<span className="absolute -top-0.5 -right-0.5 bg-red-500 text-white text-xs rounded-full h-5 w-5 flex items-center justify-center font-medium">
{unread > 9 ? '9+' : unread}
</span>
)}
</button>
<div className="flex items-center gap-2 pl-3 border-l border-gray-200">
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium" style={{ backgroundColor: user?.avatar_color || '#6366f1' }}>
{user?.name?.charAt(0)?.toUpperCase()}
</div>
<span className="text-sm font-medium text-gray-700">{user?.name}</span>
</div>
</div>
</header>
{showNotifications && (
<NotificationPanel
notifications={notifications}
onClose={() => setShowNotifications(false)}
onRead={async (id) => { await api.notifications.read(id); loadNotifications(); }}
onReadAll={async () => { await api.notifications.readAll(); loadNotifications(); }}
onDelete={async (id) => { await api.notifications.delete(id); loadNotifications(); }}
/>
)}
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
</div>
);
}
function BellIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" /><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
</svg>
);
}

View File

@@ -0,0 +1,35 @@
import React from 'react';
export default function NotificationPanel({ notifications, onClose, onRead, onReadAll, onDelete }) {
return (
<div className="bg-white border-b border-gray-200 shadow-lg z-50">
<div className="max-h-80 overflow-auto">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 sticky top-0 bg-white">
<h3 className="font-semibold text-gray-900">Notifications</h3>
<div className="flex gap-2">
<button onClick={onReadAll} className="text-xs text-brand-600 hover:underline">Mark all read</button>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"></button>
</div>
</div>
{notifications.length === 0 ? (
<div className="px-4 py-8 text-center text-gray-400 text-sm">No notifications</div>
) : (
notifications.map(n => (
<div
key={n.id}
className={`flex items-start gap-3 px-4 py-3 hover:bg-gray-50 cursor-pointer border-b border-gray-50 ${!n.is_read ? 'bg-brand-50/50' : ''}`}
onClick={() => onRead(n.id)}
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{n.title}</p>
<p className="text-xs text-gray-500 mt-0.5">{n.message}</p>
<p className="text-xs text-gray-400 mt-1">{new Date(n.created_at).toLocaleString()}</p>
</div>
<button onClick={(e) => { e.stopPropagation(); onDelete(n.id); }} className="text-gray-300 hover:text-red-400 text-xs"></button>
</div>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,157 @@
import React, { useState } from 'react';
import { api } from '../api';
export default function SetupWizard({ status, onStatusChange }) {
const [step, setStep] = useState(status?.hasEmail ? 2 : 1);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
// Step 1: Email
const [email, setEmail] = useState('');
const [appPassword, setAppPassword] = useState('');
const [smtpHost, setSmtpHost] = useState('smtp.gmail.com');
const [smtpPort, setSmtpPort] = useState('587');
const [emailVerified, setEmailVerified] = useState(false);
// Step 2: Admin
const [adminName, setAdminName] = useState('');
const [adminEmail, setAdminEmail] = useState('');
const [adminPassword, setAdminPassword] = useState('');
const handleEmailSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await api.setup.configureEmail({ smtp_host: smtpHost, smtp_port: parseInt(smtpPort), email, app_password });
setEmailVerified(true);
onStatusChange({ ...status, hasEmail: true });
setTimeout(() => setStep(2), 600);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleAdminSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const data = await api.setup.createAdmin({ email: adminEmail, name: adminName, password: adminPassword });
localStorage.setItem('teamflow_token', data.token);
localStorage.setItem('teamflow_user', JSON.stringify(data.user));
window.location.href = '/';
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-brand-600 via-brand-700 to-indigo-800 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="text-5xl mb-4">🌊</div>
<h1 className="text-3xl font-bold text-white">Welcome to TeamFlow</h1>
<p className="text-brand-200 mt-2">Set up your workspace in two quick steps</p>
</div>
{/* Progress */}
<div className="flex items-center justify-center gap-4 mb-8">
<StepDot num={1} active={step === 1} done={step > 1} label="Email" />
<div className={`h-0.5 w-16 ${step > 1 ? 'bg-white' : 'bg-brand-500'}`} />
<StepDot num={2} active={step === 2} done={false} label="Admin" />
</div>
<div className="bg-white rounded-2xl shadow-2xl p-8">
{step === 1 && (
<form onSubmit={handleEmailSubmit}>
<h2 className="text-xl font-bold text-gray-900 mb-1">Configure Email</h2>
<p className="text-sm text-gray-500 mb-6">Connect your email for notifications and inbound card creation</p>
<div className="space-y-4">
<div className="p-3 bg-blue-50 rounded-lg text-sm text-blue-700">
<strong> Gmail:</strong> Use an <a href="https://myaccount.google.com/apppasswords" target="_blank" className="underline font-medium">App Password</a> (not your regular password).
Enable 2FA first, then generate a 16-character app password.
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">SMTP Host</label>
<input type="text" value={smtpHost} onChange={e => setSmtpHost(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-transparent" />
</div>
<div className="flex gap-3">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1">Port</label>
<select value={smtpPort} onChange={e => setSmtpPort(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-transparent">
<option value="587">587 (TLS)</option>
<option value="465">465 (SSL)</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email Address</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required placeholder="you@gmail.com" className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-transparent" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">App Password</label>
<input type="password" value={appPassword} onChange={e => setAppPassword(e.target.value)} required placeholder="xxxx xxxx xxxx xxxx" className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-transparent font-mono" />
</div>
</div>
{error && <div className="mt-4 p-3 bg-red-50 rounded-lg text-sm text-red-700">{error}</div>}
{emailVerified && <div className="mt-4 p-3 bg-green-50 rounded-lg text-sm text-green-700 font-medium"> Email verified! Moving to next step...</div>}
<button type="submit" disabled={loading || emailVerified} className="mt-6 w-full bg-brand-600 text-white py-2.5 rounded-lg font-medium hover:bg-brand-700 disabled:opacity-50 transition-colors">
{loading ? 'Verifying...' : 'Connect Email'}
</button>
</form>
)}
{step === 2 && (
<form onSubmit={handleAdminSubmit}>
<h2 className="text-xl font-bold text-gray-900 mb-1">Create Admin Account</h2>
<p className="text-sm text-gray-500 mb-6">This is the first user with full administrative access</p>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Full Name</label>
<input type="text" value={adminName} onChange={e => setAdminName(e.target.value)} required placeholder="John Doe" className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-transparent" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input type="email" value={adminEmail} onChange={e => setAdminEmail(e.target.value)} required placeholder="admin@company.com" className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-transparent" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input type="password" value={adminPassword} onChange={e => setAdminPassword(e.target.value)} required minLength={6} placeholder="Min 6 characters" className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-transparent" />
</div>
</div>
{error && <div className="mt-4 p-3 bg-red-50 rounded-lg text-sm text-red-700">{error}</div>}
<button type="submit" disabled={loading} className="mt-6 w-full bg-brand-600 text-white py-2.5 rounded-lg font-medium hover:bg-brand-700 disabled:opacity-50 transition-colors">
{loading ? 'Creating...' : 'Create Admin & Launch'}
</button>
<button type="button" onClick={() => setStep(1)} className="mt-2 w-full text-sm text-gray-500 hover:text-gray-700"> Back to email config</button>
</form>
)}
</div>
</div>
</div>
);
}
function StepDot({ num, active, done, label }) {
return (
<div className="flex flex-col items-center gap-1">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold transition-colors ${
done ? 'bg-green-400 text-white' : active ? 'bg-white text-brand-600' : 'bg-brand-500 text-brand-200'
}`}>
{done ? '✓' : num}
</div>
<span className={`text-xs ${active ? 'text-white font-medium' : 'text-brand-300'}`}>{label}</span>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import React from 'react';
import { NavLink, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
export default function Sidebar({ user }) {
const { logout } = useAuth();
const navigate = useNavigate();
const handleLogout = () => {
logout();
navigate('/login');
};
return (
<aside className="w-60 bg-brand-900 text-white flex flex-col shrink-0">
<div className="p-5 flex items-center gap-3">
<div className="text-2xl">🌊</div>
<div>
<h1 className="text-lg font-bold tracking-tight">TeamFlow</h1>
<p className="text-xs text-brand-300">Board Management</p>
</div>
</div>
<nav className="flex-1 px-3 py-2 space-y-1">
<SidebarLink to="/" icon="🏠" label="Boards" />
{user?.role === 'admin' && (
<>
<SidebarLink to="/users" icon="👥" label="Users" />
<SidebarLink to="/email-settings" icon="📧" label="Email Settings" />
</>
)}
</nav>
<div className="p-3 border-t border-brand-700">
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-brand-300 hover:text-white hover:bg-brand-800 rounded-lg transition-colors"
>
<span>🚪</span> Sign Out
</button>
</div>
</aside>
);
}
function SidebarLink({ to, icon, label }) {
return (
<NavLink
to={to}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors ${
isActive ? 'bg-brand-700 text-white' : 'text-brand-300 hover:text-white hover:bg-brand-800'
}`
}
>
<span>{icon}</span> {label}
</NavLink>
);
}

View File

@@ -0,0 +1,51 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { api } from '../api';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(() => {
try { return JSON.parse(localStorage.getItem('teamflow_user')); } catch { return null; }
});
const [loading, setLoading] = useState(true);
useEffect(() => {
const token = localStorage.getItem('teamflow_token');
if (token && !user) {
api.auth.me().then(u => { setUser(u); localStorage.setItem('teamflow_user', JSON.stringify(u)); setLoading(false); })
.catch(() => { localStorage.removeItem('teamflow_token'); setLoading(false); });
} else {
setLoading(false);
}
}, []);
const login = async (email, password) => {
const data = await api.auth.login({ email, password });
localStorage.setItem('teamflow_token', data.token);
localStorage.setItem('teamflow_user', JSON.stringify(data.user));
setUser(data.user);
return data;
};
const logout = () => {
localStorage.removeItem('teamflow_token');
localStorage.removeItem('teamflow_user');
setUser(null);
};
const refresh = async () => {
try {
const u = await api.auth.me();
localStorage.setItem('teamflow_user', JSON.stringify(u));
setUser(u);
} catch {}
};
return (
<AuthContext.Provider value={{ user, login, logout, refresh, loading }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);

27
client/src/index.css Normal file
View File

@@ -0,0 +1,27 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
@layer base {
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
}
.gradient-blue { background: linear-gradient(135deg, #1e40af, #3b82f6, #60a5fa); }
.gradient-purple { background: linear-gradient(135deg, #6d28d9, #8b5cf6, #a78bfa); }
.gradient-green { background: linear-gradient(135deg, #047857, #10b981, #34d399); }
.gradient-orange { background: linear-gradient(135deg, #c2410c, #f97316, #fb923c); }
.gradient-pink { background: linear-gradient(135deg, #be185d, #ec4899, #f472b6); }
.gradient-teal { background: linear-gradient(135deg, #0f766e, #14b8a6, #2dd4bf); }
.gradient-indigo { background: linear-gradient(135deg, #4338ca, #6366f1, #818cf8); }
.gradient-red { background: linear-gradient(135deg, #b91c1c, #ef4444, #f87171); }
.card-enter { animation: cardIn 0.2s ease-out; }
@keyframes cardIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}

6
client/src/main.jsx Normal file
View File

@@ -0,0 +1,6 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
createRoot(document.getElementById('root')).render(<App />);

View File

@@ -0,0 +1,288 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
import { api } from '../api';
import CardModal from '../components/CardModal';
export default function BoardView() {
const { id } = useParams();
const [board, setBoard] = useState(null);
const [loading, setLoading] = useState(true);
const [activeCard, setActiveCard] = useState(null);
const [addingList, setAddingList] = useState(false);
const [newListTitle, setNewListTitle] = useState('');
const [showMembers, setShowMembers] = useState(false);
const [addMemberEmail, setAddMemberEmail] = useState('');
const loadBoard = useCallback(async () => {
try {
const data = await api.boards.full(id);
setBoard(data);
} catch {}
setLoading(false);
}, [id]);
useEffect(() => { loadBoard(); }, [loadBoard]);
const handleDragEnd = async (result) => {
if (!result.destination) return;
const { source, destination, draggableId, type } = result;
if (type === 'list') {
const lists = [...board.lists];
const [moved] = lists.splice(source.index, 1);
lists.splice(destination.index, 0, moved);
lists.forEach((l, i) => l.position = i * 65536);
setBoard({ ...board, lists });
await api.cards.reorderList(moved.id, destination.index * 65536);
return;
}
const srcList = board.lists.find(l => l.id === parseInt(source.droppableId));
const dstList = board.lists.find(l => l.id === parseInt(destination.droppableId));
if (!srcList || !dstList) return;
const srcCards = [...srcList.cards];
const [movedCard] = srcCards.splice(source.index, 1);
const moved = source.droppableId !== destination.droppableId;
if (moved) movedCard.list_id = dstList.id;
const dstCards = moved ? [...dstList.cards] : srcCards;
dstCards.splice(destination.index, 0, movedCard);
dstCards.forEach((c, i) => c.position = i * 65536);
const newLists = board.lists.map(l => {
if (l.id === dstList.id) return { ...l, cards: dstCards };
if (l.id === srcList.id && moved) return { ...l, cards: srcCards };
return l;
});
setBoard({ ...board, lists: newLists });
try {
const cardUpdates = dstCards.map((c, i) => ({
id: c.id, list_id: c.list_id, position: i * 65536,
moved: moved && c.id === movedCard.id,
old_list_id: moved ? srcList.id : c.list_id,
}));
await api.cards.reorder(cardUpdates);
} catch {
loadBoard();
}
};
const createList = async (e) => {
e.preventDefault();
if (!newListTitle.trim()) return;
try {
await api.cards.createList(id, newListTitle);
setNewListTitle('');
setAddingList(false);
loadBoard();
} catch (err) { alert(err.message); }
};
const addMember = async (e) => {
e.preventDefault();
if (!addMemberEmail.trim()) return;
try {
const allUsers = await api.users.list();
const u = allUsers.find(u => u.email === addMemberEmail);
if (!u) { alert('User not found. Admin needs to create them first.'); return; }
await api.boards.addMember(id, { user_id: u.id });
setAddMemberEmail('');
loadBoard();
} catch (err) { alert(err.message); }
};
const bgClasses = {
'gradient-blue': 'gradient-blue',
'gradient-purple': 'gradient-purple',
'gradient-green': 'gradient-green',
'gradient-orange': 'gradient-orange',
'gradient-pink': 'gradient-pink',
'gradient-teal': 'gradient-teal',
'gradient-indigo': 'gradient-indigo',
'gradient-red': 'gradient-red',
};
if (loading) return (
<div className="flex items-center justify-center h-full"><div className="animate-spin h-8 w-8 border-4 border-brand-500 border-t-transparent rounded-full"></div></div>
);
if (!board) return <div className="p-6 text-gray-500">Board not found</div>;
return (
<div className="h-full flex flex-col">
{/* Board Header */}
<div className={`${bgClasses[board.background] || 'gradient-blue'} px-6 py-4 flex items-center justify-between shrink-0`}>
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold text-white">{board.title}</h1>
{board.description && <span className="text-white/70 text-sm">{board.description}</span>}
</div>
<div className="flex items-center gap-3">
<div className="flex -space-x-2">
{board.members.slice(0, 5).map(m => (
<div key={m.id} className="w-8 h-8 rounded-full border-2 border-white flex items-center justify-center text-white text-xs font-medium" style={{ backgroundColor: m.avatar_color }} title={m.name}>
{m.name.charAt(0).toUpperCase()}
</div>
))}
</div>
<button onClick={() => setShowMembers(!showMembers)} className="text-white/80 hover:text-white text-sm flex items-center gap-1">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
{board.members.length}
</button>
</div>
</div>
{showMembers && (
<div className="bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-3 shrink-0">
{board.members.map(m => (
<div key={m.id} className="flex items-center gap-2 bg-gray-100 rounded-lg px-3 py-1.5 text-sm">
<div className="w-6 h-6 rounded-full flex items-center justify-center text-white text-xs font-medium" style={{ backgroundColor: m.avatar_color }}>{m.name.charAt(0)}</div>
<span className="font-medium">{m.name}</span>
<span className="text-gray-400 text-xs">{m.board_role}</span>
</div>
))}
<form onSubmit={addMember} className="flex gap-1">
<input type="email" value={addMemberEmail} onChange={e => setAddMemberEmail(e.target.value)} placeholder="Add by email..." className="px-2 py-1 text-sm border border-gray-300 rounded-lg w-48 focus:ring-1 focus:ring-brand-500 focus:border-transparent" />
<button type="submit" className="text-brand-600 hover:bg-brand-50 px-2 py-1 rounded text-sm font-medium">+</button>
</form>
</div>
)}
{/* Kanban */}
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="board" type="list" direction="horizontal">
{(provided) => (
<div ref={provided.innerRef} {...provided.droppableProps} className="flex-1 flex gap-4 p-6 overflow-x-auto overflow-y-hidden">
{board.lists.map((list, listIdx) => (
<Draggable key={list.id} draggableId={`list-${list.id}`} index={listIdx}>
{(dragProvided) => (
<div ref={dragProvided.innerRef} {...dragProvided.draggableProps} className="w-72 shrink-0 flex flex-col bg-gray-100 rounded-xl max-h-full">
<div {...dragProvided.dragHandleProps} className="px-3 py-2.5 flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-800 text-sm">{list.title}</h3>
<span className="bg-gray-300 text-gray-600 text-xs px-1.5 py-0.5 rounded-full font-medium">{list.cards.length}</span>
</div>
</div>
<Droppable droppableId={String(list.id)}>
{(dropProvided, snapshot) => (
<div ref={dropProvided.innerRef} {...dropProvided.droppableProps} className={`flex-1 overflow-y-auto px-2 pb-2 space-y-2 min-h-[4px] ${snapshot.isDraggingOver ? 'bg-brand-50 rounded-lg' : ''}`}>
{list.cards.map((card, cardIdx) => (
<Draggable key={card.id} draggableId={String(card.id)} index={cardIdx}>
{(cardDragProvided) => (
<div ref={cardDragProvided.innerRef} {...cardDragProvided.draggableProps} {...cardDragProvided.dragHandleProps}>
<CardComponent card={card} onClick={() => setActiveCard(card)} />
</div>
)}
</Draggable>
))}
{dropProvided.placeholder}
</div>
)}
</Droppable>
<div className="px-2 pb-2">
<AddCardForm listId={list.id} onAdded={loadBoard} />
</div>
</div>
)}
</Draggable>
))}
{provided.placeholder}
{addingList ? (
<div className="w-72 shrink-0 bg-white rounded-xl p-3 shadow-sm">
<form onSubmit={createList}>
<input type="text" value={newListTitle} onChange={e => setNewListTitle(e.target.value)} placeholder="List title" autoFocus className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-lg mb-2 focus:ring-2 focus:ring-brand-500 focus:border-transparent" />
<div className="flex gap-1">
<button type="submit" className="bg-brand-600 text-white px-3 py-1 rounded text-sm font-medium hover:bg-brand-700">Add</button>
<button type="button" onClick={() => { setAddingList(false); setNewListTitle(''); }} className="px-3 py-1 text-gray-500 hover:bg-gray-100 rounded text-sm">Cancel</button>
</div>
</form>
</div>
) : (
<button onClick={() => setAddingList(true)} className="w-72 shrink-0 bg-white/80 hover:bg-white rounded-xl p-3 flex items-center gap-2 text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors">
<span className="text-lg">+</span> Add List
</button>
)}
</div>
)}
</Droppable>
</DragDropContext>
{activeCard && (
<CardModal cardId={activeCard.id} boardId={id} onClose={() => { setActiveCard(null); loadBoard(); }} />
)}
</div>
);
}
function CardComponent({ card, onClick }) {
const priorityStyles = { none: '', low: 'border-l-green-400', medium: 'border-l-yellow-400', high: 'border-l-orange-400', urgent: 'border-l-red-500' };
const priorityBg = { none: '', low: 'bg-green-50', medium: 'bg-yellow-50', high: 'bg-orange-50', urgent: 'bg-red-50' };
const dueBadge = card.due_date ? (() => {
const due = new Date(card.due_date);
const now = new Date();
const days = Math.ceil((due - now) / 86400000);
if (days < 0) return <span className="text-xs text-red-600 bg-red-50 px-1.5 py-0.5 rounded">Overdue</span>;
if (days <= 3) return <span className="text-xs text-orange-600 bg-orange-50 px-1.5 py-0.5 rounded">Due {due.toLocaleDateString()}</span>;
return <span className="text-xs text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded">{due.toLocaleDateString()}</span>;
})() : null;
return (
<div onClick={onClick} className={`bg-white rounded-lg p-3 shadow-sm border border-gray-200 cursor-pointer hover:border-brand-300 hover:shadow-md transition-all border-l-4 ${priorityStyles[card.priority] || ''} card-enter`}>
{card.labels?.length > 0 && (
<div className="flex gap-1 mb-2 flex-wrap">
{card.labels.map(l => (
<span key={l.id} className="text-xs px-2 py-0.5 rounded-full text-white font-medium" style={{ backgroundColor: l.color }}>{l.name}</span>
))}
</div>
)}
<p className="text-sm font-medium text-gray-800 mb-1">{card.title}</p>
{card.description && <p className="text-xs text-gray-500 line-clamp-2 mb-2">{card.description}</p>}
<div className="flex items-center justify-between mt-1">
<div className="flex items-center gap-1.5">
{dueBadge}
{card.total_items > 0 && (
<span className={`text-xs px-1.5 py-0.5 rounded ${card.done_items === card.total_items ? 'bg-green-100 text-green-700' : 'bg-blue-100 text-blue-700'}`}>
{card.done_items}/{card.total_items}
</span>
)}
</div>
{card.assignee_color && (
<div className="w-6 h-6 rounded-full flex items-center justify-center text-white text-xs font-medium" style={{ backgroundColor: card.assignee_color }} title={card.assignee_name}>
{card.assignee_name?.charAt(0)?.toUpperCase()}
</div>
)}
</div>
</div>
);
}
function AddCardForm({ listId, onAdded }) {
const [show, setShow] = useState(false);
const [title, setTitle] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
if (!title.trim()) return;
try {
await api.cards.create(listId, { title });
setTitle('');
setShow(false);
onAdded();
} catch (err) { alert(err.message); }
};
if (!show) return <button onClick={() => setShow(true)} className="w-full text-left px-2 py-1.5 text-gray-500 hover:bg-gray-200 rounded-lg text-sm transition-colors">+ Add card</button>;
return (
<form onSubmit={handleSubmit} className="bg-white rounded-lg p-2 shadow-sm border border-gray-200">
<input type="text" value={title} onChange={e => setTitle(e.target.value)} placeholder="Card title..." autoFocus className="w-full px-2 py-1 text-sm border border-gray-300 rounded mb-1 focus:ring-1 focus:ring-brand-500 focus:border-transparent" onBlur={() => { if (!title) setShow(false); }} />
<div className="flex gap-1">
<button type="submit" className="bg-brand-600 text-white px-2 py-0.5 rounded text-xs font-medium hover:bg-brand-700">Add</button>
<button type="button" onClick={() => { setShow(false); setTitle(''); }} className="px-2 py-0.5 text-gray-400 hover:text-gray-600 text-xs"></button>
</div>
</form>
);
}

View File

@@ -0,0 +1,111 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { api } from '../api';
export default function Dashboard() {
const [boards, setBoards] = useState([]);
const [loading, setLoading] = useState(true);
const [showNew, setShowNew] = useState(false);
const [newTitle, setNewTitle] = useState('');
const [newDesc, setNewDesc] = useState('');
const { user } = useAuth();
const navigate = useNavigate();
const loadBoards = async () => {
try {
const data = await api.boards.list();
setBoards(data);
} catch {}
setLoading(false);
};
useEffect(() => { loadBoards(); }, []);
const createBoard = async (e) => {
e.preventDefault();
if (!newTitle.trim()) return;
try {
const board = await api.boards.create({ title: newTitle, description: newDesc });
setNewTitle('');
setNewDesc('');
setShowNew(false);
navigate(`/board/${board.id}`);
} catch (err) {
alert(err.message);
}
};
const bgColors = {
'gradient-blue': 'from-blue-600 to-blue-400',
'gradient-purple': 'from-purple-600 to-purple-400',
'gradient-green': 'from-green-600 to-green-400',
'gradient-orange': 'from-orange-600 to-orange-400',
'gradient-pink': 'from-pink-600 to-pink-400',
'gradient-teal': 'from-teal-600 to-teal-400',
'gradient-indigo': 'from-indigo-600 to-indigo-400',
'gradient-red': 'from-red-600 to-red-400',
};
return (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Your Boards</h1>
<p className="text-sm text-gray-500 mt-1">Welcome back, {user?.name}</p>
</div>
<button onClick={() => setShowNew(!showNew)} className="bg-brand-600 text-white px-4 py-2 rounded-lg font-medium hover:bg-brand-700 transition-colors flex items-center gap-2">
<span className="text-lg leading-none">+</span> New Board
</button>
</div>
{showNew && (
<form onSubmit={createBoard} className="bg-white rounded-xl border border-gray-200 p-5 mb-6 shadow-sm">
<h3 className="font-semibold text-gray-900 mb-3">Create New Board</h3>
<input type="text" value={newTitle} onChange={e => setNewTitle(e.target.value)} placeholder="Board title" autoFocus className="w-full px-3 py-2 border border-gray-300 rounded-lg mb-3 focus:ring-2 focus:ring-brand-500 focus:border-transparent" />
<input type="text" value={newDesc} onChange={e => setNewDesc(e.target.value)} placeholder="Description (optional)" className="w-full px-3 py-2 border border-gray-300 rounded-lg mb-3 focus:ring-2 focus:ring-brand-500 focus:border-transparent" />
<div className="flex gap-2">
<button type="submit" className="bg-brand-600 text-white px-4 py-2 rounded-lg font-medium hover:bg-brand-700">Create</button>
<button type="button" onClick={() => setShowNew(false)} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg">Cancel</button>
</div>
</form>
)}
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="animate-spin h-8 w-8 border-4 border-brand-500 border-t-transparent rounded-full"></div>
</div>
) : boards.length === 0 ? (
<div className="text-center py-20">
<div className="text-6xl mb-4">📋</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">No boards yet</h3>
<p className="text-gray-500 mb-4">Create your first board to get started</p>
<button onClick={() => setShowNew(true)} className="bg-brand-600 text-white px-6 py-2.5 rounded-lg font-medium hover:bg-brand-700">Create Board</button>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{boards.map(board => (
<div
key={board.id}
onClick={() => navigate(`/board/${board.id}`)}
className={`bg-gradient-to-br ${bgColors[board.background] || bgColors['gradient-blue']} rounded-xl p-5 text-white cursor-pointer hover:scale-[1.02] transition-transform shadow-md hover:shadow-lg min-h-[140px] flex flex-col justify-between`}
>
<div>
<h3 className="font-bold text-lg">{board.title}</h3>
{board.description && <p className="text-white/80 text-sm mt-1 line-clamp-2">{board.description}</p>}
</div>
<div className="flex items-center justify-between mt-4">
<span className="text-xs bg-white/20 px-2 py-0.5 rounded-full">{board.my_role}</span>
{board.due_soon_count > 0 && (
<span className="text-xs bg-yellow-400/90 text-yellow-900 px-2 py-0.5 rounded-full font-medium">
{board.due_soon_count}
</span>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,176 @@
import React, { useState, useEffect } from 'react';
import { api } from '../api';
export default function EmailSettings() {
const [config, setConfig] = useState(null);
const [stats, setStats] = useState(null);
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
const [testEmail, setTestEmail] = useState('');
const [testStatus, setTestStatus] = useState('');
const [inbound, setInbound] = useState({ enabled: false, folder: 'INBOX', prefix: 'tf-' });
const load = async () => {
try {
const [cfg, st, lg] = await Promise.all([api.email.config(), api.email.stats(), api.email.log()]);
setConfig(cfg);
setStats(st);
setLogs(lg);
setInbound({ enabled: !!cfg.inbound_enabled, folder: cfg.inbound_folder || 'INBOX', prefix: cfg.board_email_prefix || 'tf-' });
} catch {}
setLoading(false);
};
useEffect(() => { load(); }, []);
const sendTest = async () => {
if (!testEmail) return;
setTestStatus('sending...');
try {
await api.setup.testEmail(testEmail);
setTestStatus('✅ Sent!');
load();
} catch (err) {
setTestStatus(`${err.message}`);
}
setTimeout(() => setTestStatus(''), 5000);
};
const saveInbound = async () => {
try {
await api.setup.updateInbound(inbound);
load();
} catch (err) { alert(err.message); }
};
const pollNow = async () => {
try {
await api.email.poll();
load();
alert('Inbox polled successfully');
} catch (err) { alert(err.message); }
};
if (loading) return <div className="flex justify-center py-12"><div className="animate-spin h-8 w-8 border-4 border-brand-500 border-t-transparent rounded-full"></div></div>;
return (
<div className="p-6 max-w-4xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-6">📧 Email Settings</h1>
{/* SMTP Config */}
<div className="bg-white rounded-xl border border-gray-200 p-5 mb-6 shadow-sm">
<h2 className="font-semibold text-gray-900 mb-3">SMTP Configuration</h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
<div>
<label className="block text-xs text-gray-500 mb-1">Host</label>
<p className="text-sm font-mono bg-gray-100 px-3 py-2 rounded-lg">{config?.smtp_host}</p>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Port</label>
<p className="text-sm font-mono bg-gray-100 px-3 py-2 rounded-lg">{config?.smtp_port}</p>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Email</label>
<p className="text-sm font-mono bg-gray-100 px-3 py-2 rounded-lg">{config?.email}</p>
</div>
</div>
<div className="flex items-center gap-2 mb-3">
<input type="email" value={testEmail} onChange={e => setTestEmail(e.target.value)} placeholder="Send test to..." className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-brand-500" />
<button onClick={sendTest} disabled={testStatus === 'sending...'} className="bg-brand-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
Send Test
</button>
{testStatus && <span className="text-sm">{testStatus}</span>}
</div>
<p className="text-xs text-gray-400">App password is stored encrypted. Configure via the setup wizard to change it.</p>
</div>
{/* Stats */}
{stats && (
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="bg-white rounded-xl border border-gray-200 p-4 text-center shadow-sm">
<p className="text-2xl font-bold text-brand-600">{stats.sent}</p>
<p className="text-xs text-gray-500 mt-1">Emails Sent</p>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4 text-center shadow-sm">
<p className="text-2xl font-bold text-green-600">{stats.received}</p>
<p className="text-xs text-gray-500 mt-1">Emails Processed</p>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4 text-center shadow-sm">
<p className="text-2xl font-bold text-red-600">{stats.failed}</p>
<p className="text-xs text-gray-500 mt-1">Failed</p>
</div>
</div>
)}
{/* Inbound Settings */}
<div className="bg-white rounded-xl border border-gray-200 p-5 mb-6 shadow-sm">
<div className="flex items-center justify-between mb-3">
<h2 className="font-semibold text-gray-900">📥 Inbound Email</h2>
<button onClick={pollNow} className="text-sm text-brand-600 hover:bg-brand-50 px-3 py-1 rounded-lg font-medium">Poll Now</button>
</div>
<p className="text-sm text-gray-500 mb-4">Process incoming emails to create cards and add comments. Enable IMAP in your Google account settings.</p>
<div className="space-y-3">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={inbound.enabled} onChange={e => setInbound({ ...inbound, enabled: e.target.checked })} className="rounded border-gray-300 text-brand-600 focus:ring-brand-500" />
<span className="text-sm font-medium text-gray-700">Enable inbound email processing</span>
</label>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs text-gray-500 mb-1">IMAP Folder</label>
<input type="text" value={inbound.folder} onChange={e => setInbound({ ...inbound, folder: e.target.value })} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-brand-500" />
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Board Email Prefix</label>
<input type="text" value={inbound.prefix} onChange={e => setInbound({ ...inbound, prefix: e.target.value })} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-brand-500" />
</div>
</div>
<button onClick={saveInbound} className="bg-brand-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-700">Save Inbound Settings</button>
</div>
<div className="mt-4 p-3 bg-blue-50 rounded-lg text-xs text-blue-700">
<strong>How it works:</strong> Send an email with subject <code className="bg-blue-100 px-1 rounded">[{inbound.prefix}board-name] Card title</code> to create a card.
Reply to notification emails to add comments.
</div>
</div>
{/* Email Log */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<div className="px-5 py-3 border-b border-gray-200">
<h2 className="font-semibold text-gray-900">📋 Email Log</h2>
</div>
<div className="max-h-80 overflow-y-auto">
<table className="w-full">
<thead>
<tr className="bg-gray-50">
<th className="text-left px-4 py-2 text-xs font-semibold text-gray-500">Direction</th>
<th className="text-left px-4 py-2 text-xs font-semibold text-gray-500">To/From</th>
<th className="text-left px-4 py-2 text-xs font-semibold text-gray-500">Subject</th>
<th className="text-left px-4 py-2 text-xs font-semibold text-gray-500">Status</th>
<th className="text-left px-4 py-2 text-xs font-semibold text-gray-500">Time</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{logs.map(l => (
<tr key={l.id} className="hover:bg-gray-50">
<td className="px-4 py-2">
<span className={`text-xs px-2 py-0.5 rounded-full ${l.direction === 'sent' ? 'bg-blue-100 text-blue-700' : 'bg-green-100 text-green-700'}`}>
{l.direction}
</span>
</td>
<td className="px-4 py-2 text-sm text-gray-600 max-w-48 truncate">{l.direction === 'sent' ? l.to_email : l.from_email}</td>
<td className="px-4 py-2 text-sm text-gray-600 max-w-64 truncate">{l.subject}</td>
<td className="px-4 py-2">
<span className={`text-xs px-2 py-0.5 rounded-full ${l.status === 'sent' || l.status === 'processed' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
{l.status}
</span>
</td>
<td className="px-4 py-2 text-xs text-gray-400">{new Date(l.created_at).toLocaleString()}</td>
</tr>
))}
{logs.length === 0 && <tr><td colSpan={5} className="px-4 py-8 text-center text-sm text-gray-400">No emails sent or received yet</td></tr>}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
export default function Login() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await login(email, password);
navigate('/');
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-brand-600 via-brand-700 to-indigo-800 flex items-center justify-center p-4">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<div className="text-5xl mb-4">🌊</div>
<h1 className="text-3xl font-bold text-white">TeamFlow</h1>
<p className="text-brand-200 mt-1">Sign in to your workspace</p>
</div>
<div className="bg-white rounded-2xl shadow-2xl p-8">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required placeholder="you@company.com" className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-transparent" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required placeholder="••••••••" className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-transparent" />
</div>
{error && <div className="p-3 bg-red-50 rounded-lg text-sm text-red-700">{error}</div>}
<button type="submit" disabled={loading} className="w-full bg-brand-600 text-white py-2.5 rounded-lg font-medium hover:bg-brand-700 disabled:opacity-50 transition-colors">
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,188 @@
import React, { useState, useEffect } from 'react';
import { api } from '../api';
export default function UserManagement() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [form, setForm] = useState({ name: '', email: '', password: '', role: 'member' });
const [editingUser, setEditingUser] = useState(null);
const [resetPwdFor, setResetPwdFor] = useState(null);
const [newPassword, setNewPassword] = useState('');
const [error, setError] = useState('');
const loadUsers = async () => {
try { setUsers(await api.users.list()); } catch {}
setLoading(false);
};
useEffect(() => { loadUsers(); }, []);
const createUser = async (e) => {
e.preventDefault();
setError('');
try {
await api.users.create(form);
setForm({ name: '', email: '', password: '', role: 'member' });
setShowCreate(false);
loadUsers();
} catch (err) { setError(err.message); }
};
const updateUser = async (e) => {
e.preventDefault();
setError('');
try {
await api.users.update(editingUser.id, { name: editingUser.name, role: editingUser.role, is_active: editingUser.is_active });
setEditingUser(null);
loadUsers();
} catch (err) { setError(err.message); }
};
const resetPassword = async (e) => {
e.preventDefault();
if (!newPassword || newPassword.length < 6) { setError('Password must be at least 6 characters'); return; }
try {
await api.users.resetPassword(resetPwdFor, newPassword);
setResetPwdFor(null);
setNewPassword('');
setError('');
alert('Password reset successfully');
} catch (err) { setError(err.message); }
};
return (
<div className="p-6 max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">User Management</h1>
<p className="text-sm text-gray-500 mt-1">{users.length} users</p>
</div>
<button onClick={() => setShowCreate(!showCreate)} className="bg-brand-600 text-white px-4 py-2 rounded-lg font-medium hover:bg-brand-700 flex items-center gap-2">
<span className="text-lg leading-none">+</span> New User
</button>
</div>
{error && <div className="mb-4 p-3 bg-red-50 rounded-lg text-sm text-red-700">{error}</div>}
{showCreate && (
<form onSubmit={createUser} className="bg-white rounded-xl border border-gray-200 p-5 mb-6 shadow-sm">
<h3 className="font-semibold text-gray-900 mb-3">Create New User</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input type="text" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} required className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input type="email" value={form.email} onChange={e => setForm({ ...form, email: e.target.value })} required className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input type="password" value={form.password} onChange={e => setForm({ ...form, password: e.target.value })} required minLength={6} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Role</label>
<select value={form.role} onChange={e => setForm({ ...form, role: e.target.value })} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500">
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
<div className="flex gap-2">
<button type="submit" className="bg-brand-600 text-white px-4 py-2 rounded-lg font-medium hover:bg-brand-700">Create User</button>
<button type="button" onClick={() => setShowCreate(false)} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg">Cancel</button>
</div>
</form>
)}
{loading ? (
<div className="flex justify-center py-12"><div className="animate-spin h-8 w-8 border-4 border-brand-500 border-t-transparent rounded-full"></div></div>
) : (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<table className="w-full">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-500 uppercase">User</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-500 uppercase">Role</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-500 uppercase">Status</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-500 uppercase">Created</th>
<th className="text-right px-4 py-3 text-xs font-semibold text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{users.map(user => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-medium" style={{ backgroundColor: user.avatar_color }}>
{user.name.charAt(0).toUpperCase()}
</div>
<div>
<p className="text-sm font-medium text-gray-900">{user.name}</p>
<p className="text-xs text-gray-500">{user.email}</p>
</div>
</div>
</td>
<td className="px-4 py-3">
{editingUser?.id === user.id ? (
<select value={editingUser.role} onChange={e => setEditingUser({ ...editingUser, role: e.target.value })} className="text-sm border border-gray-300 rounded px-2 py-1">
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
) : (
<span className={`text-xs px-2 py-1 rounded-full font-medium ${user.role === 'admin' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-600'}`}>
{user.role}
</span>
)}
</td>
<td className="px-4 py-3">
{editingUser?.id === user.id ? (
<select value={editingUser.is_active} onChange={e => setEditingUser({ ...editingUser, is_active: e.target.value })} className="text-sm border border-gray-300 rounded px-2 py-1">
<option value={1}>Active</option>
<option value={0}>Disabled</option>
</select>
) : (
<span className={`text-xs px-2 py-1 rounded-full ${user.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
{user.is_active ? 'Active' : 'Disabled'}
</span>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-500">{new Date(user.created_at).toLocaleDateString()}</td>
<td className="px-4 py-3 text-right">
{editingUser?.id === user.id ? (
<div className="flex justify-end gap-1">
<button onClick={updateUser} className="text-xs bg-brand-600 text-white px-2 py-1 rounded hover:bg-brand-700">Save</button>
<button onClick={() => setEditingUser(null)} className="text-xs px-2 py-1 text-gray-500 hover:bg-gray-100 rounded">Cancel</button>
</div>
) : (
<div className="flex justify-end gap-1">
<button onClick={() => setEditingUser(user)} className="text-xs text-gray-500 hover:text-brand-600 px-2 py-1 hover:bg-brand-50 rounded">Edit</button>
<button onClick={() => { setResetPwdFor(user.id); setNewPassword(''); }} className="text-xs text-gray-500 hover:text-orange-600 px-2 py-1 hover:bg-orange-50 rounded">Reset Pwd</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{resetPwdFor && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setResetPwdFor(null)}>
<div onClick={e => e.stopPropagation()} className="bg-white rounded-xl p-6 w-96 shadow-xl">
<h3 className="font-semibold text-gray-900 mb-3">Reset Password</h3>
<form onSubmit={resetPassword}>
<input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} placeholder="New password (min 6 chars)" required minLength={6} autoFocus className="w-full px-3 py-2 border border-gray-300 rounded-lg mb-3 focus:ring-2 focus:ring-brand-500" />
<div className="flex gap-2">
<button type="submit" className="bg-brand-600 text-white px-4 py-2 rounded-lg font-medium hover:bg-brand-700">Reset</button>
<button type="button" onClick={() => setResetPwdFor(null)} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg">Cancel</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

16
client/tailwind.config.js Normal file
View File

@@ -0,0 +1,16 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,jsx}'],
theme: {
extend: {
colors: {
brand: {
50: '#eef2ff', 100: '#e0e7ff', 200: '#c7d2fe', 300: '#a5b4fc',
400: '#818cf8', 500: '#6366f1', 600: '#4f46e5', 700: '#4338ca',
800: '#3730a3', 900: '#312e81',
},
},
},
},
plugins: [],
};

15
client/vite.config.js Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:3001',
},
},
build: {
outDir: 'dist',
},
});

372
package-lock.json generated Normal file
View File

@@ -0,0 +1,372 @@
{
"name": "teamflow",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "teamflow",
"version": "1.0.0",
"devDependencies": {
"concurrently": "^8.2.2"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/concurrently": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz",
"integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "^4.1.2",
"date-fns": "^2.30.0",
"lodash": "^4.17.21",
"rxjs": "^7.8.1",
"shell-quote": "^1.8.1",
"spawn-command": "0.0.2",
"supports-color": "^8.1.1",
"tree-kill": "^1.2.2",
"yargs": "^17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": "^14.13.0 || >=16.0.0"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/date-fns": {
"version": "2.30.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.21.0"
},
"engines": {
"node": ">=0.11"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/date-fns"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/spawn-command": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz",
"integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==",
"dev": true
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
}
}
}

18
package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "teamflow",
"version": "1.0.0",
"description": "Innovative Trello alternative with email integration for small teams",
"private": true,
"scripts": {
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
"dev:server": "cd server && node index.js",
"dev:client": "cd client && npx vite --host",
"build": "cd client && npx vite build",
"start": "cd server && NODE_ENV=production node index.js",
"setup": "cd server && node index.js && cd ../client && npx vite build",
"install:all": "npm install && cd client && npm install"
},
"devDependencies": {
"concurrently": "^8.2.2"
}
}

182
server/db.js Normal file
View File

@@ -0,0 +1,182 @@
import Database from 'better-sqlite3';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DB_PATH = join(__dirname, 'data', 'teamflow.db');
import { mkdirSync } from 'fs';
mkdirSync(join(__dirname, 'data'), { recursive: true });
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS app_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
name TEXT NOT NULL,
avatar_color TEXT DEFAULT '#6366f1',
role TEXT DEFAULT 'member',
is_active INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS boards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT DEFAULT '',
background TEXT DEFAULT 'gradient-blue',
is_archived INTEGER DEFAULT 0,
created_by INTEGER REFERENCES users(id),
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS board_members (
board_id INTEGER REFERENCES boards(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
role TEXT DEFAULT 'member',
joined_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (board_id, user_id)
);
CREATE TABLE IF NOT EXISTS lists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
board_id INTEGER REFERENCES boards(id) ON DELETE CASCADE,
title TEXT NOT NULL,
position REAL NOT NULL,
is_archived INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS cards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
list_id INTEGER REFERENCES lists(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT DEFAULT '',
position REAL NOT NULL,
due_date TEXT,
priority TEXT DEFAULT 'none',
color TEXT DEFAULT '',
estimated_hours REAL,
time_spent REAL DEFAULT 0,
created_by INTEGER REFERENCES users(id),
assigned_to INTEGER REFERENCES users(id),
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS labels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
board_id INTEGER REFERENCES boards(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS card_labels (
card_id INTEGER REFERENCES cards(id) ON DELETE CASCADE,
label_id INTEGER REFERENCES labels(id) ON DELETE CASCADE,
PRIMARY KEY (card_id, label_id)
);
CREATE TABLE IF NOT EXISTS card_comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
card_id INTEGER REFERENCES cards(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id),
content TEXT NOT NULL,
is_email_reply INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS card_activity (
id INTEGER PRIMARY KEY AUTOINCREMENT,
card_id INTEGER REFERENCES cards(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id),
action TEXT NOT NULL,
details TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS email_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
smtp_host TEXT DEFAULT 'smtp.gmail.com',
smtp_port INTEGER DEFAULT 587,
email TEXT NOT NULL,
app_password TEXT NOT NULL,
imap_host TEXT DEFAULT 'imap.gmail.com',
imap_port INTEGER DEFAULT 993,
inbound_enabled INTEGER DEFAULT 0,
inbound_folder TEXT DEFAULT 'INBOX',
board_email_prefix TEXT DEFAULT 'tf-',
configured_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS email_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
from_email TEXT,
to_email TEXT,
subject TEXT,
card_id INTEGER REFERENCES cards(id),
direction TEXT,
status TEXT,
error TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS email_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
card_id INTEGER REFERENCES cards(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id),
token TEXT UNIQUE NOT NULL,
purpose TEXT DEFAULT 'reply',
expires_at TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT DEFAULT '',
card_id INTEGER,
board_id INTEGER,
is_read INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS checklists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
card_id INTEGER REFERENCES cards(id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT 'Checklist',
position REAL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS checklist_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
checklist_id INTEGER REFERENCES checklists(id) ON DELETE CASCADE,
text TEXT NOT NULL,
is_checked INTEGER DEFAULT 0,
position REAL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_cards_list ON cards(list_id);
CREATE INDEX IF NOT EXISTS idx_card_activity_card ON card_activity(card_id);
CREATE INDEX IF NOT EXISTS idx_card_comments_card ON card_comments(card_id);
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id, is_read);
CREATE INDEX IF NOT EXISTS idx_lists_board ON lists(board_id);
CREATE INDEX IF NOT EXISTS idx_email_tokens_token ON email_tokens(token);
`);
export default db;

155
server/email/imap.js Normal file
View File

@@ -0,0 +1,155 @@
import { ImapFlow } from 'imapflow';
import { simpleParser } from 'mailparser';
import db from '../db.js';
import { generateToken } from '../middleware/auth.js';
import { v4 as uuidv4 } from 'uuid';
let imapClient = null;
let pollInterval = null;
function getConfig() {
return db.prepare('SELECT * FROM email_config WHERE id = 1').get();
}
function getBoardByPrefix(prefix) {
const boards = db.prepare('SELECT id, title FROM boards WHERE is_archived = 0').all();
return boards.find(b => b.title.toLowerCase().replace(/\s+/g, '-').startsWith(prefix.toLowerCase()));
}
function getCardByToken(token) {
const record = db.prepare(`
SELECT et.*, c.title as card_title, l.board_id, b.title as board_title, u.name as user_name
FROM email_tokens et
JOIN cards c ON c.id = et.card_id
JOIN lists l ON l.id = c.list_id
JOIN boards b ON b.id = l.board_id
LEFT JOIN users u ON u.id = et.user_id
WHERE et.token = ? AND et.expires_at > datetime('now')
`).get(token);
return record;
}
async function processMessage(msg) {
const config = getConfig();
if (!config || !config.inbound_enabled) return;
const parsed = await simpleParser(msg.source);
const fromAddr = parsed.from?.value?.[0]?.address || '';
const subject = parsed.subject || '(no subject)';
const bodyText = parsed.text || '';
const bodyHtml = parsed.html || '';
const messageId = parsed.messageId || '';
const references = parsed.references || '';
const inReplyTo = parsed.inReplyTo || '';
const sender = db.prepare('SELECT id, name, email FROM users WHERE email = ? AND is_active = 1').get(fromAddr);
if (!sender) return;
// Check if this is a reply to an existing card
if (inReplyTo || references) {
const refStr = (inReplyTo + ' ' + references).toLowerCase();
const tokens = db.prepare('SELECT * FROM email_tokens').all();
for (const t of tokens) {
if (refStr.includes(t.token.toLowerCase())) {
const card = getCardByToken(t.token);
if (card) {
db.prepare(`INSERT INTO card_comments (card_id, user_id, content, is_email_reply) VALUES (?, ?, ?, 1)`)
.run(card.card_id, sender.id, `📧 ${bodyText.trim()}`);
db.prepare(`INSERT INTO card_activity (card_id, user_id, action, details) VALUES (?, ?, 'email_reply', ?)`)
.run(card.card_id, sender.id, `Replied via email`);
return;
}
}
}
}
// Check if subject matches board prefix pattern (e.g., [tf-board-name] Card title)
const prefixMatch = subject.match(/^\[(.+?)\]\s*(.+)/);
if (prefixMatch) {
const board = getBoardByPrefix(prefixMatch[1]);
if (board) {
const firstList = db.prepare('SELECT id FROM lists WHERE board_id = ? AND is_archived = 0 ORDER BY position LIMIT 1').get(board.id);
if (firstList) {
const result = db.prepare(`INSERT INTO cards (list_id, title, description, position, created_by) VALUES (?, ?, ?, ?, ?)`)
.run(firstList.id, prefixMatch[2].trim(), bodyText.substring(0, 2000), Date.now(), sender.id);
db.prepare(`INSERT INTO card_activity (card_id, user_id, action, details) VALUES (?, ?, 'created_via_email', ?)`)
.run(result.lastInsertRowid, sender.id, `Created via email from ${fromAddr}`);
db.prepare(`INSERT INTO email_log (from_email, subject, card_id, direction, status) VALUES (?, ?, ?, 'received', 'processed')`)
.run(fromAddr, subject, result.lastInsertRowid);
return;
}
}
}
}
async function pollInbox() {
const config = getConfig();
if (!config || !config.inbound_enabled) {
return;
}
try {
if (!imapClient) {
imapClient = new ImapFlow({
host: config.imap_host,
port: config.imap_port,
secure: true,
auth: {
user: config.email,
pass: config.app_password,
},
logger: false,
});
await imapClient.connect();
}
const lock = await imapClient.getMailboxLock(config.inbound_folder);
try {
const lastPoll = db.prepare("SELECT value FROM app_state WHERE key = 'last_imap_poll'").get();
let since = new Date(Date.now() - 24 * 60 * 60 * 1000);
if (lastPoll) {
const d = new Date(lastPoll.value);
if (!isNaN(d)) since = d;
}
for await (const msg of imapClient.fetch({ since }, { source: true })) {
try {
await processMessage(msg);
} catch (err) {
console.error('Error processing email:', err.message);
}
}
db.prepare("INSERT OR REPLACE INTO app_state (key, value) VALUES ('last_imap_poll', ?)")
.run(new Date().toISOString());
} finally {
lock.release();
}
} catch (err) {
console.error('IMAP poll error:', err.message);
imapClient = null;
}
}
export function startImapPolling() {
if (pollInterval) clearInterval(pollInterval);
const config = getConfig();
if (config?.inbound_enabled) {
pollInterval = setInterval(pollInbox, 60000);
pollInbox();
}
}
export function stopImapPolling() {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
if (imapClient) {
imapClient.logout().catch(() => {});
imapClient = null;
}
}
export { pollInbox };

110
server/email/transporter.js Normal file
View File

@@ -0,0 +1,110 @@
import nodemailer from 'nodemailer';
import db from '../db.js';
let transporter = null;
export function getEmailConfig() {
return db.prepare('SELECT * FROM email_config WHERE id = 1').get();
}
export async function initTransporter() {
const config = getEmailConfig();
if (!config) return null;
try {
transporter = nodemailer.createTransport({
host: config.smtp_host,
port: config.smtp_port,
secure: config.smtp_port === 465,
auth: {
user: config.email,
pass: config.app_password,
},
});
await transporter.verify();
return true;
} catch (err) {
console.error('Email transporter init failed:', err.message);
transporter = null;
return false;
}
}
export async function testConnection(host, port, email, password) {
const t = nodemailer.createTransport({
host,
port,
secure: port === 465,
auth: { user: email, pass: password },
});
await t.verify();
await t.close();
}
export async function sendMail({ to, subject, html, replyTo, inReplyTo, references }) {
if (!transporter) await initTransporter();
if (!transporter) throw new Error('Email not configured');
const config = getEmailConfig();
const msg = {
from: `"TeamFlow" <${config.email}>`,
to,
subject,
html,
headers: {},
};
if (replyTo) msg.replyTo = replyTo;
if (inReplyTo) msg.headers['In-Reply-To'] = inReplyTo;
if (references) msg.headers.References = references;
const info = await transporter.sendMail(msg);
db.prepare(`INSERT INTO email_log (from_email, to_email, subject, direction, status, message_id)
VALUES (?, ?, ?, 'sent', 'sent', ?)`)
.run(config.email, to, subject, info.messageId);
return info;
}
export function buildCardUrl(cardId, boardId) {
const origin = process.env.APP_URL || 'http://localhost:5173';
return `${origin}/board/${boardId}?card=${cardId}`;
}
export function buildCardNotificationHtml({ userName, boardTitle, cardTitle, action, cardUrl, comment }) {
const actionMessages = {
assigned: `assigned you to a card`,
commented: `commented on a card`,
moved: `moved a card`,
mentioned: `mentioned you in a comment`,
due_soon: `has a due date coming up`,
};
return `<!DOCTYPE html>
<html><head><meta charset="utf-8"><style>
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f1f5f9;margin:0;padding:0}
.container{max-width:560px;margin:40px auto;background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.1)}
.header{background:linear-gradient(135deg,#6366f1,#8b5cf6);padding:32px;color:#fff}
.header h1{margin:0;font-size:20px;font-weight:600}
.header p{margin:8px 0 0;opacity:.9;font-size:14px}
.body{padding:32px}
.card-preview{background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:16px;margin:16px 0}
.card-preview h3{margin:0 0 8px;font-size:16px}
.card-preview p{margin:0;color:#64748b;font-size:14px}
.comment{background:#f1f5f9;border-radius:8px;padding:16px;margin:16px 0;font-size:14px;line-height:1.6;color:#334155}
.btn{display:inline-block;background:#6366f1;color:#fff!important;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:500;margin-top:16px}
.footer{padding:20px 32px;background:#f8fafc;border-top:1px solid #e2e8f0;font-size:12px;color:#94a3b8;text-align:center}
</style></head><body>
<div class="container">
<div class="header">
<h1>TeamFlow</h1>
<p>${userName} ${actionMessages[action] || action}</p>
</div>
<div class="body">
<p style="color:#64748b;margin:0 0 8px">Board: <strong>${boardTitle}</strong></p>
<div class="card-preview">
<h3>${cardTitle}</h3>
${comment ? `<div class="comment">${comment}</div>` : ''}
</div>
<a href="${cardUrl}" class="btn">View Card</a>
</div>
<div class="footer">
<p>Sent by TeamFlow · <a href="${cardUrl}" style="color:#6366f1">Open in browser</a></p>
</div>
</div>
</body></html>`;
}

66
server/index.js Normal file
View File

@@ -0,0 +1,66 @@
import express from 'express';
import cors from 'cors';
import { readFileSync } from 'fs';
import { createServer as createHttpsServer } from 'https';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import db from './db.js';
import { initTransporter } from './email/transporter.js';
import { startImapPolling } from './email/imap.js';
import setupRoutes from './routes/setup.js';
import authRoutes from './routes/auth.js';
import userRoutes from './routes/users.js';
import boardRoutes from './routes/boards.js';
import cardRoutes from './routes/cards.js';
import notificationRoutes from './routes/notifications.js';
import emailRoutes from './routes/email.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const app = express();
const PORT = process.env.PORT || 3001;
app.use(cors());
app.use(express.json({ limit: '10mb' }));
app.use('/api/setup', setupRoutes);
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
app.use('/api/boards', boardRoutes);
app.use('/api/cards', cardRoutes);
app.use('/api/notifications', notificationRoutes);
app.use('/api/email', emailRoutes);
app.use('/api', (req, res) => res.status(404).json({ error: 'Not found' }));
const clientDist = join(__dirname, '..', 'client', 'dist');
app.use(express.static(clientDist));
app.get('*', (req, res) => {
if (req.path.startsWith('/api')) return res.status(404).json({ error: 'Not found' });
res.sendFile(join(clientDist, 'index.html'));
});
const SSL_KEY = process.env.SSL_KEY || '';
const SSL_CERT = process.env.SSL_CERT || '';
async function startServer() {
const hasEmail = !!db.prepare('SELECT 1 FROM email_config WHERE id = 1').get();
if (hasEmail) {
const ok = await initTransporter();
if (ok) console.log('✉️ Email configured and verified');
startImapPolling();
}
if (SSL_KEY && SSL_CERT) {
const server = createHttpsServer({
key: readFileSync(SSL_KEY),
cert: readFileSync(SSL_CERT),
}, app);
server.listen(PORT, () => console.log(`🚀 TeamFlow server running on https://localhost:${PORT}`));
} else {
app.listen(PORT, () => console.log(`🚀 TeamFlow server running on http://localhost:${PORT}`));
}
}
startServer();

34
server/middleware/auth.js Normal file
View File

@@ -0,0 +1,34 @@
import jwt from 'jsonwebtoken';
import db from '../db.js';
const JWT_SECRET = process.env.JWT_SECRET || 'teamflow-secret-change-in-production';
export function generateToken(user) {
return jwt.sign(
{ id: user.id, email: user.email, role: user.role },
JWT_SECRET,
{ expiresIn: '7d' }
);
}
export function authMiddleware(req, res, next) {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const token = header.split(' ')[1];
const payload = jwt.verify(token, JWT_SECRET);
const user = db.prepare('SELECT id, email, name, avatar_color, role, is_active FROM users WHERE id = ?').get(payload.id);
if (!user || !user.is_active) return res.status(401).json({ error: 'Invalid token' });
req.user = user;
next();
} catch {
return res.status(401).json({ error: 'Invalid token' });
}
}
export function adminOnly(req, res, next) {
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
next();
}

1945
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
server/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "teamflow-server",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.7.0",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"imapflow": "^1.0.173",
"jsonwebtoken": "^9.0.2",
"mailparser": "^3.7.2",
"nodemailer": "^6.10.0",
"uuid": "^11.0.5"
}
}

36
server/routes/auth.js Normal file
View File

@@ -0,0 +1,36 @@
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import db from '../db.js';
import { generateToken, authMiddleware } from '../middleware/auth.js';
const router = Router();
router.post('/login', (req, res) => {
const { email, password } = req.body;
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
if (!user || !user.is_active) return res.status(401).json({ error: 'Invalid credentials' });
if (!bcrypt.compareSync(password, user.password)) return res.status(401).json({ error: 'Invalid credentials' });
const token = generateToken(user);
res.json({ user: { id: user.id, email: user.email, name: user.name, avatar_color: user.avatar_color, role: user.role }, token });
});
router.get('/me', authMiddleware, (req, res) => {
res.json(req.user);
});
router.put('/me', authMiddleware, async (req, res) => {
const { name, avatar_color, current_password, new_password } = req.body;
if (name) db.prepare('UPDATE users SET name = ? WHERE id = ?').run(name, req.user.id);
if (avatar_color) db.prepare('UPDATE users SET avatar_color = ? WHERE id = ?').run(avatar_color, req.user.id);
if (new_password) {
const user = db.prepare('SELECT password FROM users WHERE id = ?').get(req.user.id);
if (!bcrypt.compareSync(current_password, user.password)) return res.status(400).json({ error: 'Current password incorrect' });
const hash = await bcrypt.hash(new_password, 12);
db.prepare('UPDATE users SET password = ? WHERE id = ?').run(hash, req.user.id);
}
const updated = db.prepare('SELECT id, email, name, avatar_color, role FROM users WHERE id = ?').get(req.user.id);
res.json(updated);
});
export default router;

129
server/routes/boards.js Normal file
View File

@@ -0,0 +1,129 @@
import { Router } from 'express';
import db from '../db.js';
import { authMiddleware } from '../middleware/auth.js';
const router = Router();
router.use(authMiddleware);
const BACKGROUNDS = [
'gradient-blue', 'gradient-purple', 'gradient-green', 'gradient-orange',
'gradient-pink', 'gradient-teal', 'gradient-indigo', 'gradient-red',
];
function canAccessBoard(userId, boardId) {
const m = db.prepare('SELECT 1 FROM board_members WHERE board_id = ? AND user_id = ?').get(boardId, userId);
return !!m;
}
router.get('/', (req, res) => {
const boards = db.prepare(`
SELECT b.*, bm.role as my_role,
(SELECT COUNT(*) FROM lists l JOIN cards c ON c.list_id = l.id WHERE l.board_id = b.id AND c.assigned_to = ? AND c.due_date IS NOT NULL AND date(c.due_date) <= date('now', '+3 days')) as due_soon_count
FROM boards b
JOIN board_members bm ON bm.board_id = b.id AND bm.user_id = ?
WHERE b.is_archived = 0
ORDER BY b.created_at DESC
`).all(req.user.id, req.user.id);
res.json(boards);
});
router.post('/', (req, res) => {
const { title, description, background } = req.body;
if (!title) return res.status(400).json({ error: 'Title required' });
const bg = background && BACKGROUNDS.includes(background) ? background : BACKGROUNDS[Math.floor(Math.random() * BACKGROUNDS.length)];
const result = db.prepare('INSERT INTO boards (title, description, background, created_by) VALUES (?, ?, ?, ?)')
.run(title, description || '', bg, req.user.id);
db.prepare('INSERT INTO board_members (board_id, user_id, role) VALUES (?, ?, ?)').run(result.lastInsertRowid, req.user.id, 'admin');
const defaultLists = ['To Do', 'In Progress', 'Review', 'Done'];
const insertList = db.prepare('INSERT INTO lists (board_id, title, position) VALUES (?, ?, ?)');
defaultLists.forEach((l, i) => insertList.run(result.lastInsertRowid, l, i * 65536));
const board = db.prepare('SELECT * FROM boards WHERE id = ?').get(result.lastInsertRowid);
board.my_role = 'admin';
res.json(board);
});
router.get('/:id', (req, res) => {
if (!canAccessBoard(req.user.id, req.params.id)) return res.status(403).json({ error: 'Access denied' });
const board = db.prepare('SELECT * FROM boards WHERE id = ?').get(req.params.id);
res.json(board);
});
router.put('/:id', (req, res) => {
if (!canAccessBoard(req.user.id, req.params.id)) return res.status(403).json({ error: 'Access denied' });
const { title, description, background, is_archived } = req.body;
const board = db.prepare('SELECT * FROM boards WHERE id = ?').get(req.params.id);
if (!board) return res.status(404).json({ error: 'Not found' });
if (title !== undefined) db.prepare('UPDATE boards SET title = ? WHERE id = ?').run(title, req.params.id);
if (description !== undefined) db.prepare('UPDATE boards SET description = ? WHERE id = ?').run(description, req.params.id);
if (background !== undefined) db.prepare('UPDATE boards SET background = ? WHERE id = ?').run(background, req.params.id);
if (is_archived !== undefined) db.prepare('UPDATE boards SET is_archived = ? WHERE id = ?').run(is_archived ? 1 : 0, req.params.id);
const updated = db.prepare('SELECT * FROM boards WHERE id = ?').get(req.params.id);
res.json(updated);
});
router.delete('/:id', (req, res) => {
if (!canAccessBoard(req.user.id, req.params.id)) return res.status(403).json({ error: 'Access denied' });
db.prepare('DELETE FROM boards WHERE id = ?').run(req.params.id);
res.json({ success: true });
});
router.get('/:id/full', (req, res) => {
if (!canAccessBoard(req.user.id, req.params.id)) return res.status(403).json({ error: 'Access denied' });
const board = db.prepare('SELECT * FROM boards WHERE id = ?').get(req.params.id);
const lists = db.prepare('SELECT * FROM lists WHERE board_id = ? AND is_archived = 0 ORDER BY position').all(req.params.id);
const labels = db.prepare('SELECT * FROM labels WHERE board_id = ? ORDER BY name').all(req.params.id);
const members = db.prepare(`
SELECT u.id, u.email, u.name, u.avatar_color, bm.role as board_role
FROM board_members bm JOIN users u ON u.id = bm.user_id WHERE bm.board_id = ?
`).all(req.params.id);
const cards = db.prepare(`
SELECT c.*,
u1.name as creator_name, u1.avatar_color as creator_color,
u2.name as assignee_name, u2.avatar_color as assignee_color,
(SELECT COUNT(*) FROM checklist_items ci JOIN checklists cl ON cl.id = ci.checklist_id WHERE cl.card_id = c.id) as total_items,
(SELECT COUNT(*) FROM checklist_items ci JOIN checklists cl ON cl.id = ci.checklist_id WHERE cl.card_id = c.id AND ci.is_checked = 1) as done_items
FROM cards c
LEFT JOIN users u1 ON u1.id = c.created_by
LEFT JOIN users u2 ON u2.id = c.assigned_to
WHERE c.list_id IN (SELECT id FROM lists WHERE board_id = ? AND is_archived = 0)
ORDER BY c.position
`).all(req.params.id);
const cardLabels = db.prepare(`
SELECT cl.card_id, l.id as label_id, l.name, l.color
FROM card_labels cl JOIN labels l ON l.id = cl.label_id
WHERE l.board_id = ?
`).all(req.params.id);
const cardsById = {};
cards.forEach(c => { c.labels = []; cardsById[c.id] = c; });
cardLabels.forEach(cl => { if (cardsById[cl.card_id]) cardsById[cl.card_id].labels.push({ id: cl.label_id, name: cl.name, color: cl.color }); });
const listsWithCards = lists.map(l => ({
...l,
cards: cards.filter(c => c.list_id === l.id),
}));
res.json({ ...board, lists: listsWithCards, labels, members });
});
router.post('/:id/members', (req, res) => {
if (!canAccessBoard(req.user.id, req.params.id)) return res.status(403).json({ error: 'Access denied' });
const { user_id, role } = req.body;
if (!user_id) return res.status(400).json({ error: 'User ID required' });
const existing = db.prepare('SELECT 1 FROM board_members WHERE board_id = ? AND user_id = ?').get(req.params.id, user_id);
if (existing) return res.status(400).json({ error: 'Already a member' });
db.prepare('INSERT INTO board_members (board_id, user_id, role) VALUES (?, ?, ?)').run(req.params.id, user_id, role || 'member');
const member = db.prepare(`SELECT u.id, u.email, u.name, u.avatar_color, bm.role as board_role
FROM board_members bm JOIN users u ON u.id = bm.user_id WHERE bm.board_id = ? AND bm.user_id = ?`).get(req.params.id, user_id);
res.json(member);
});
router.delete('/:id/members/:userId', (req, res) => {
if (!canAccessBoard(req.user.id, req.params.id)) return res.status(403).json({ error: 'Access denied' });
db.prepare('DELETE FROM board_members WHERE board_id = ? AND user_id = ?').run(req.params.id, req.params.userId);
res.json({ success: true });
});
export default router;

296
server/routes/cards.js Normal file
View File

@@ -0,0 +1,296 @@
import { Router } from 'express';
import db from '../db.js';
import { authMiddleware } from '../middleware/auth.js';
import { sendMail, buildCardNotificationHtml, buildCardUrl, getEmailConfig } from '../email/transporter.js';
import { v4 as uuidv4 } from 'uuid';
const router = Router();
router.use(authMiddleware);
function canAccessCard(userId, cardId) {
const card = db.prepare(`
SELECT 1 FROM cards c
JOIN lists l ON l.id = c.list_id
JOIN board_members bm ON bm.board_id = l.board_id AND bm.user_id = ?
WHERE c.id = ?
`).get(userId, cardId);
return !!card;
}
function getBoardIdForCard(cardId) {
const r = db.prepare(`SELECT l.board_id FROM cards c JOIN lists l ON l.id = c.list_id WHERE c.id = ?`).get(cardId);
return r?.board_id;
}
async function notifyCard({ cardId, userId, action, comment }) {
const card = db.prepare(`
SELECT c.*, l.board_id, b.title as board_title,
u.name as user_name, u2.email as assignee_email, u2.name as assignee_name
FROM cards c
JOIN lists l ON l.id = c.list_id
JOIN boards b ON b.id = l.board_id
LEFT JOIN users u ON u.id = ?
LEFT JOIN users u2 ON u2.id = c.assigned_to
WHERE c.id = ?
`).get(userId, cardId);
if (!card || !card.assignee_email) return;
if (card.assigned_to === userId) return;
const config = getEmailConfig();
if (!config) return;
const token = uuidv4();
db.prepare('INSERT INTO email_tokens (card_id, user_id, token, expires_at) VALUES (?, ?, ?, datetime("now", "+30 days"))')
.run(cardId, card.assigned_to, token);
db.prepare(`INSERT INTO notifications (user_id, type, title, message, card_id, board_id) VALUES (?, ?, ?, ?, ?, ?)`)
.run(card.assigned_to, action, card.title, `${card.user_name} ${action}`, cardId, card.board_id);
try {
await sendMail({
to: card.assignee_email,
subject: `[TeamFlow] ${card.user_name} ${action}${card.title}`,
html: buildCardNotificationHtml({
userName: card.user_name,
boardTitle: card.board_title,
cardTitle: card.title,
action,
cardUrl: buildCardUrl(cardId, card.board_id),
comment,
}),
replyTo: config.email,
});
} catch {}
}
// Lists
router.post('/:boardId/lists', (req, res) => {
const { title, position } = req.body;
if (!title) return res.status(400).json({ error: 'Title required' });
const maxPos = db.prepare('SELECT MAX(position) as p FROM lists WHERE board_id = ?').get(req.params.boardId);
const result = db.prepare('INSERT INTO lists (board_id, title, position) VALUES (?, ?, ?)')
.run(req.params.boardId, title, position ?? (maxPos.p || 0) + 65536);
const list = db.prepare('SELECT * FROM lists WHERE id = ?').get(result.lastInsertRowid);
res.json(list);
});
router.put('/lists/:id/reorder', (req, res) => {
const { position } = req.body;
db.prepare('UPDATE lists SET position = ? WHERE id = ?').run(position, req.params.id);
res.json({ success: true });
});
router.put('/lists/:id', (req, res) => {
const { title, is_archived } = req.body;
if (title !== undefined) db.prepare('UPDATE lists SET title = ? WHERE id = ?').run(title, req.params.id);
if (is_archived !== undefined) db.prepare('UPDATE lists SET is_archived = ? WHERE id = ?').run(is_archived ? 1 : 0, req.params.id);
res.json({ success: true });
});
// Cards
router.post('/lists/:listId/cards', async (req, res) => {
const { title, description, due_date, priority, assigned_to, labels } = req.body;
if (!title) return res.status(400).json({ error: 'Title required' });
const maxPos = db.prepare('SELECT MAX(position) as p FROM cards WHERE list_id = ?').get(req.params.listId);
const result = db.prepare('INSERT INTO cards (list_id, title, description, position, due_date, priority, assigned_to, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
.run(req.params.listId, title, description || '', (maxPos.p || 0) + 65536, due_date || null, priority || 'none', assigned_to || null, req.user.id);
if (labels?.length) {
const ins = db.prepare('INSERT OR IGNORE INTO card_labels (card_id, label_id) VALUES (?, ?)');
labels.forEach(lid => ins.run(result.lastInsertRowid, lid));
}
db.prepare('INSERT INTO card_activity (card_id, user_id, action) VALUES (?, ?, ?)').run(result.lastInsertRowid, req.user.id, 'created');
const card = db.prepare('SELECT c.*, u.name as creator_name FROM cards c LEFT JOIN users u ON u.id = c.created_by WHERE c.id = ?').get(result.lastInsertRowid);
if (assigned_to) notifyCard({ cardId: result.lastInsertRowid, userId: req.user.id, action: 'assigned you to a card' });
res.json(card);
});
router.get('/cards/:id', (req, res) => {
if (!canAccessCard(req.user.id, req.params.id)) return res.status(403).json({ error: 'Access denied' });
const card = db.prepare(`
SELECT c.*, u1.name as creator_name, u2.name as assignee_name, u2.avatar_color as assignee_color
FROM cards c
LEFT JOIN users u1 ON u1.id = c.created_by
LEFT JOIN users u2 ON u2.id = c.assigned_to
WHERE c.id = ?
`).get(req.params.id);
const labels = db.prepare(`SELECT l.* FROM card_labels cl JOIN labels l ON l.id = cl.label_id WHERE cl.card_id = ?`).all(req.params.id);
const comments = db.prepare(`
SELECT cc.*, u.name, u.avatar_color FROM card_comments cc
LEFT JOIN users u ON u.id = cc.user_id WHERE cc.card_id = ? ORDER BY cc.created_at
`).all(req.params.id);
const activity = db.prepare(`
SELECT ca.*, u.name, u.avatar_color FROM card_activity ca
LEFT JOIN users u ON u.id = ca.user_id WHERE ca.card_id = ? ORDER BY ca.created_at DESC LIMIT 50
`).all(req.params.id);
const checklists = db.prepare(`
SELECT cl.*,
(SELECT COUNT(*) FROM checklist_items WHERE checklist_id = cl.id) as total_items,
(SELECT COUNT(*) FROM checklist_items WHERE checklist_id = cl.id AND is_checked = 1) as done_items
FROM checklists cl WHERE cl.card_id = ? ORDER BY cl.position
`).all(req.params.id);
const checklistItems = {};
for (const cl of checklists) {
checklistItems[cl.id] = db.prepare('SELECT * FROM checklist_items WHERE checklist_id = ? ORDER BY position').all(cl.id);
}
res.json({ ...card, labels, comments, activity, checklists, checklistItems });
});
router.put('/cards/:id', async (req, res) => {
if (!canAccessCard(req.user.id, req.params.id)) return res.status(403).json({ error: 'Access denied' });
const { title, description, due_date, priority, color, assigned_to, list_id, position, estimated_hours, time_spent } = req.body;
const old = db.prepare('SELECT * FROM cards WHERE id = ?').get(req.params.id);
if (!old) return res.status(404).json({ error: 'Not found' });
const updates = [];
const params = [];
if (title !== undefined) { updates.push('title = ?'); params.push(title); }
if (description !== undefined) { updates.push('description = ?'); params.push(description); }
if (due_date !== undefined) { updates.push('due_date = ?'); params.push(due_date); }
if (priority !== undefined) { updates.push('priority = ?'); params.push(priority); }
if (color !== undefined) { updates.push('color = ?'); params.push(color); }
if (assigned_to !== undefined) { updates.push('assigned_to = ?'); params.push(assigned_to); }
if (list_id !== undefined) { updates.push('list_id = ?'); params.push(list_id); }
if (position !== undefined) { updates.push('position = ?'); params.push(position); }
if (estimated_hours !== undefined) { updates.push('estimated_hours = ?'); params.push(estimated_hours); }
if (time_spent !== undefined) { updates.push('time_spent = ?'); params.push(time_spent); }
updates.push("updated_at = datetime('now')");
params.push(req.params.id);
if (updates.length > 1) {
db.prepare(`UPDATE cards SET ${updates.join(', ')} WHERE id = ?`).run(...params);
}
if (assigned_to && assigned_to !== old.assigned_to) {
db.prepare('INSERT INTO card_activity (card_id, user_id, action, details) VALUES (?, ?, ?, ?)')
.run(req.params.id, req.user.id, 'assigned', `Assigned to ${assigned_to}`);
notifyCard({ cardId: req.params.id, userId: req.user.id, action: 'assigned you to a card' });
}
if (list_id && list_id !== old.list_id) {
const newList = db.prepare('SELECT title FROM lists WHERE id = ?').get(list_id);
db.prepare('INSERT INTO card_activity (card_id, user_id, action, details) VALUES (?, ?, ?, ?)')
.run(req.params.id, req.user.id, 'moved', `Moved to ${newList?.title || 'Unknown'}`);
const assignee = db.prepare('SELECT assigned_to FROM cards WHERE id = ?').get(req.params.id);
if (assignee?.assigned_to && assignee.assigned_to !== req.user.id) {
notifyCard({ cardId: req.params.id, userId: req.user.id, action: 'moved a card' });
}
}
const card = db.prepare('SELECT * FROM cards WHERE id = ?').get(req.params.id);
res.json(card);
});
router.delete('/cards/:id', (req, res) => {
if (!canAccessCard(req.user.id, req.params.id)) return res.status(403).json({ error: 'Access denied' });
db.prepare('DELETE FROM cards WHERE id = ?').run(req.params.id);
res.json({ success: true });
});
// Card reorder (batch)
router.put('/cards/reorder', (req, res) => {
const { cards } = req.body;
if (!Array.isArray(cards)) return res.status(400).json({ error: 'Cards array required' });
const update = db.prepare('UPDATE cards SET list_id = ?, position = ? WHERE id = ?');
const moveActivity = db.prepare('INSERT INTO card_activity (card_id, user_id, action, details) VALUES (?, ?, ?, ?)');
for (const c of cards) {
update.run(c.list_id, c.position, c.id);
if (c.moved && c.old_list_id !== c.list_id) {
const listName = db.prepare('SELECT title FROM lists WHERE id = ?').get(c.list_id)?.title || '';
moveActivity.run(c.id, req.user.id, 'moved', `Moved to ${listName}`);
}
}
res.json({ success: true });
});
// Comments
router.post('/cards/:id/comments', async (req, res) => {
if (!canAccessCard(req.user.id, req.params.id)) return res.status(403).json({ error: 'Access denied' });
const { content } = req.body;
if (!content) return res.status(400).json({ error: 'Content required' });
const result = db.prepare('INSERT INTO card_comments (card_id, user_id, content) VALUES (?, ?, ?)').run(req.params.id, req.user.id, content);
db.prepare('INSERT INTO card_activity (card_id, user_id, action, details) VALUES (?, ?, ?, ?)').run(req.params.id, req.user.id, 'commented', '');
notifyCard({ cardId: req.params.id, userId: req.user.id, action: 'commented on a card', comment: content });
const comment = db.prepare('SELECT cc.*, u.name, u.avatar_color FROM card_comments cc LEFT JOIN users u ON u.id = cc.user_id WHERE cc.id = ?').get(result.lastInsertRowid);
res.json(comment);
});
// Labels
router.post('/:boardId/labels', (req, res) => {
const { name, color } = req.body;
if (!name || !color) return res.status(400).json({ error: 'Name and color required' });
const result = db.prepare('INSERT INTO labels (board_id, name, color) VALUES (?, ?, ?)').run(req.params.boardId, name, color);
const label = db.prepare('SELECT * FROM labels WHERE id = ?').get(result.lastInsertRowid);
res.json(label);
});
router.put('/labels/:id', (req, res) => {
const { name, color } = req.body;
if (name !== undefined) db.prepare('UPDATE labels SET name = ? WHERE id = ?').run(name, req.params.id);
if (color !== undefined) db.prepare('UPDATE labels SET color = ? WHERE id = ?').run(color, req.params.id);
const label = db.prepare('SELECT * FROM labels WHERE id = ?').get(req.params.id);
res.json(label);
});
router.delete('/labels/:id', (req, res) => {
db.prepare('DELETE FROM labels WHERE id = ?').run(req.params.id);
res.json({ success: true });
});
router.put('/cards/:id/labels', (req, res) => {
if (!canAccessCard(req.user.id, req.params.id)) return res.status(403).json({ error: 'Access denied' });
const { labels } = req.body;
db.prepare('DELETE FROM card_labels WHERE card_id = ?').run(req.params.id);
if (labels?.length) {
const ins = db.prepare('INSERT OR IGNORE INTO card_labels (card_id, label_id) VALUES (?, ?)');
labels.forEach(lid => ins.run(req.params.id, lid));
}
res.json({ success: true });
});
// Checklists
router.post('/cards/:id/checklists', (req, res) => {
if (!canAccessCard(req.user.id, req.params.id)) return res.status(403).json({ error: 'Access denied' });
const { title } = req.body;
const maxPos = db.prepare('SELECT MAX(position) as p FROM checklists WHERE card_id = ?').get(req.params.id);
const result = db.prepare('INSERT INTO checklists (card_id, title, position) VALUES (?, ?, ?)')
.run(req.params.id, title || 'Checklist', (maxPos?.p || 0) + 65536);
const cl = db.prepare('SELECT * FROM checklists WHERE id = ?').get(result.lastInsertRowid);
res.json({ ...cl, items: [] });
});
router.post('/checklists/:id/items', (req, res) => {
const checklist = db.prepare('SELECT * FROM checklists WHERE id = ?').get(req.params.id);
if (!checklist) return res.status(404).json({ error: 'Not found' });
if (!canAccessCard(req.user.id, checklist.card_id)) return res.status(403).json({ error: 'Access denied' });
const { text } = req.body;
if (!text) return res.status(400).json({ error: 'Text required' });
const maxPos = db.prepare('SELECT MAX(position) as p FROM checklist_items WHERE checklist_id = ?').get(req.params.id);
const result = db.prepare('INSERT INTO checklist_items (checklist_id, text, position) VALUES (?, ?, ?)')
.run(req.params.id, text, (maxPos?.p || 0) + 65536);
const item = db.prepare('SELECT * FROM checklist_items WHERE id = ?').get(result.lastInsertRowid);
res.json(item);
});
router.put('/checklist-items/:id', (req, res) => {
const item = db.prepare('SELECT ci.*, cl.card_id FROM checklist_items ci JOIN checklists cl ON cl.id = ci.checklist_id WHERE ci.id = ?').get(req.params.id);
if (!item) return res.status(404).json({ error: 'Not found' });
if (!canAccessCard(req.user.id, item.card_id)) return res.status(403).json({ error: 'Access denied' });
const { text, is_checked } = req.body;
if (text !== undefined) db.prepare('UPDATE checklist_items SET text = ? WHERE id = ?').run(text, req.params.id);
if (is_checked !== undefined) db.prepare('UPDATE checklist_items SET is_checked = ? WHERE id = ?').run(is_checked ? 1 : 0, req.params.id);
const updated = db.prepare('SELECT * FROM checklist_items WHERE id = ?').get(req.params.id);
res.json(updated);
});
router.delete('/checklist-items/:id', (req, res) => {
const item = db.prepare('SELECT ci.*, cl.card_id FROM checklist_items ci JOIN checklists cl ON cl.id = ci.checklist_id WHERE ci.id = ?').get(req.params.id);
if (!item) return res.status(404).json({ error: 'Not found' });
if (!canAccessCard(req.user.id, item.card_id)) return res.status(403).json({ error: 'Access denied' });
db.prepare('DELETE FROM checklist_items WHERE id = ?').run(req.params.id);
res.json({ success: true });
});
router.delete('/checklists/:id', (req, res) => {
const cl = db.prepare('SELECT * FROM checklists WHERE id = ?').get(req.params.id);
if (!cl) return res.status(404).json({ error: 'Not found' });
if (!canAccessCard(req.user.id, cl.card_id)) return res.status(403).json({ error: 'Access denied' });
db.prepare('DELETE FROM checklists WHERE id = ?').run(req.params.id);
res.json({ success: true });
});
export default router;

37
server/routes/email.js Normal file
View File

@@ -0,0 +1,37 @@
import { Router } from 'express';
import db from '../db.js';
import { authMiddleware, adminOnly } from '../middleware/auth.js';
import { getEmailConfig } from '../email/transporter.js';
import { pollInbox } from '../email/imap.js';
const router = Router();
router.use(authMiddleware);
router.get('/config', adminOnly, (req, res) => {
const config = getEmailConfig();
if (!config) return res.status(404).json({ error: 'Not configured' });
res.json({ ...config, app_password: '••••••••' });
});
router.get('/log', adminOnly, (req, res) => {
const logs = db.prepare('SELECT * FROM email_log ORDER BY created_at DESC LIMIT 100').all();
res.json(logs);
});
router.post('/poll', adminOnly, async (req, res) => {
try {
await pollInbox();
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/stats', adminOnly, (req, res) => {
const sent = db.prepare("SELECT COUNT(*) as c FROM email_log WHERE direction = 'sent'").get().c;
const received = db.prepare("SELECT COUNT(*) as c FROM email_log WHERE direction = 'received'").get().c;
const failed = db.prepare("SELECT COUNT(*) as c FROM email_log WHERE status = 'failed'").get().c;
res.json({ sent, received, failed });
});
export default router;

View File

@@ -0,0 +1,34 @@
import { Router } from 'express';
import db from '../db.js';
import { authMiddleware } from '../middleware/auth.js';
const router = Router();
router.use(authMiddleware);
router.get('/', (req, res) => {
const notifs = db.prepare(`
SELECT n.* FROM notifications n
WHERE n.user_id = ?
ORDER BY n.is_read ASC, n.created_at DESC
LIMIT 50
`).all(req.user.id);
const unread = db.prepare('SELECT COUNT(*) as c FROM notifications WHERE user_id = ? AND is_read = 0').get(req.user.id).c;
res.json({ notifications: notifs, unread });
});
router.put('/:id/read', (req, res) => {
db.prepare('UPDATE notifications SET is_read = 1 WHERE id = ? AND user_id = ?').run(req.params.id, req.user.id);
res.json({ success: true });
});
router.put('/read-all', (req, res) => {
db.prepare('UPDATE notifications SET is_read = 1 WHERE user_id = ?').run(req.user.id);
res.json({ success: true });
});
router.delete('/:id', (req, res) => {
db.prepare('DELETE FROM notifications WHERE id = ? AND user_id = ?').run(req.params.id, req.user.id);
res.json({ success: true });
});
export default router;

83
server/routes/setup.js Normal file
View File

@@ -0,0 +1,83 @@
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import db from '../db.js';
import { generateToken } from '../middleware/auth.js';
import { testConnection, initTransporter } from '../email/transporter.js';
const router = Router();
router.get('/status', (req, res) => {
const hasEmail = !!db.prepare('SELECT 1 FROM email_config WHERE id = 1').get();
const userCount = db.prepare('SELECT COUNT(*) as c FROM users').get().c;
const setupComplete = hasEmail && userCount > 0;
res.json({ hasEmail, userCount, setupComplete });
});
router.post('/email', async (req, res) => {
try {
const { smtp_host, smtp_port, email, app_password } = req.body;
if (!email || !app_password) return res.status(400).json({ error: 'Email and app password required' });
const host = smtp_host || 'smtp.gmail.com';
const port = parseInt(smtp_port) || 587;
await testConnection(host, port, email, app_password);
db.prepare(`INSERT OR REPLACE INTO email_config (id, smtp_host, smtp_port, email, app_password)
VALUES (1, ?, ?, ?, ?)`).run(host, port, email, app_password);
await initTransporter();
res.json({ success: true });
} catch (err) {
res.status(400).json({ error: err.message });
}
});
router.post('/email/test', async (req, res) => {
try {
const { to } = req.body;
const config = db.prepare('SELECT * FROM email_config WHERE id = 1').get();
if (!config) return res.status(400).json({ error: 'Email not configured' });
const { sendMail } = await import('../email/transporter.js');
await sendMail({
to,
subject: 'TeamFlow — Test Email',
html: `<div style="padding:32px;font-family:sans-serif">
<h2 style="color:#6366f1">✅ TeamFlow Email Working!</h2>
<p>Your email integration is configured correctly.</p>
</div>`,
});
res.json({ success: true });
} catch (err) {
res.status(400).json({ error: err.message });
}
});
router.post('/admin', async (req, res) => {
const hasEmail = !!db.prepare('SELECT 1 FROM email_config WHERE id = 1').get();
if (!hasEmail) return res.status(400).json({ error: 'Configure email first' });
const existing = db.prepare('SELECT COUNT(*) as c FROM users').get();
if (existing.c > 0) return res.status(400).json({ error: 'Admin already exists' });
const { email, name, password } = req.body;
if (!email || !name || !password) return res.status(400).json({ error: 'All fields required' });
if (password.length < 6) return res.status(400).json({ error: 'Password must be at least 6 characters' });
const hash = await bcrypt.hash(password, 12);
const result = db.prepare('INSERT INTO users (email, name, password, role) VALUES (?, ?, ?, ?)').run(email, name, hash);
const user = db.prepare('SELECT id, email, name, avatar_color, role FROM users WHERE id = ?').get(result.lastInsertRowid);
const token = generateToken(user);
res.json({ user, token });
});
router.put('/email/inbound', async (req, res) => {
const config = db.prepare('SELECT * FROM email_config WHERE id = 1').get();
if (!config) return res.status(400).json({ error: 'Email not configured' });
const { enabled, folder, prefix } = req.body;
db.prepare(`UPDATE email_config SET inbound_enabled = ?, inbound_folder = ?, board_email_prefix = ? WHERE id = 1`)
.run(enabled ? 1 : 0, folder || 'INBOX', prefix || 'tf-');
if (enabled) {
const { startImapPolling } = await import('../email/imap.js');
startImapPolling();
} else {
const { stopImapPolling } = await import('../email/imap.js');
stopImapPolling();
}
res.json({ success: true });
});
export default router;

77
server/routes/users.js Normal file
View File

@@ -0,0 +1,77 @@
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import db from '../db.js';
import { authMiddleware, adminOnly, generateToken } from '../middleware/auth.js';
import { sendMail, buildCardNotificationHtml, buildCardUrl } from '../email/transporter.js';
const router = Router();
router.use(authMiddleware);
router.get('/', (req, res) => {
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' });
const users = db.prepare('SELECT id, email, name, avatar_color, role, is_active, created_at FROM users ORDER BY created_at').all();
res.json(users);
});
router.post('/', adminOnly, async (req, res) => {
const { email, name, password, role } = req.body;
if (!email || !name || !password) return res.status(400).json({ error: 'All fields required' });
if (password.length < 6) return res.status(400).json({ error: 'Password must be at least 6 characters' });
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
if (existing) return res.status(400).json({ error: 'Email already exists' });
const hash = await bcrypt.hash(password, 12);
const colors = ['#ef4444','#f97316','#eab308','#22c55e','#14b8a6','#3b82f6','#6366f1','#a855f7','#ec4899'];
const color = colors[Math.floor(Math.random() * colors.length)];
const result = db.prepare('INSERT INTO users (email, name, password, role, avatar_color) VALUES (?, ?, ?, ?, ?)')
.run(email, name, hash, role || 'member', color);
const user = db.prepare('SELECT id, email, name, avatar_color, role FROM users WHERE id = ?').get(result.lastInsertRowid);
try {
await sendMail({
to: email,
subject: 'Welcome to TeamFlow! 🚀',
html: `<div style="padding:32px;font-family:sans-serif;max-width:480px;margin:0 auto">
<h2 style="color:#6366f1">Welcome to TeamFlow, ${name}!</h2>
<p>Your account has been created. Log in to get started.</p>
<p><strong>Email:</strong> ${email}</p>
<p><strong>Password:</strong> (set by your admin)</p>
<a href="${process.env.APP_URL || 'http://localhost:5173'}" style="display:inline-block;background:#6366f1;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;margin-top:16px">Go to TeamFlow</a>
</div>`,
});
} catch {}
res.json(user);
});
router.put('/:id', adminOnly, (req, res) => {
const { name, role, is_active } = req.body;
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
if (user.id === req.user.id) return res.status(400).json({ error: 'Cannot modify yourself' });
if (name !== undefined) db.prepare('UPDATE users SET name = ? WHERE id = ?').run(name, user.id);
if (role !== undefined) db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, user.id);
if (is_active !== undefined) db.prepare('UPDATE users SET is_active = ? WHERE id = ?').run(is_active ? 1 : 0, user.id);
const updated = db.prepare('SELECT id, email, name, avatar_color, role, is_active FROM users WHERE id = ?').get(user.id);
res.json(updated);
});
router.put('/:id/reset-password', adminOnly, async (req, res) => {
const { password } = req.body;
if (!password || password.length < 6) return res.status(400).json({ error: 'Password must be at least 6 characters' });
const user = db.prepare('SELECT id, email, name FROM users WHERE id = ?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
const hash = await bcrypt.hash(password, 12);
db.prepare('UPDATE users SET password = ? WHERE id = ?').run(hash, user.id);
res.json({ success: true });
});
router.get('/board/:boardId', (req, res) => {
const members = db.prepare(`
SELECT u.id, u.email, u.name, u.avatar_color, u.role, bm.role as board_role
FROM board_members bm
JOIN users u ON u.id = bm.user_id
WHERE bm.board_id = ?
`).all(req.params.boardId);
res.json(members);
});
export default router;