🌊 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:
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
PORT=3001
|
||||||
|
JWT_SECRET=change-me-in-production
|
||||||
|
APP_URL=http://localhost:5173
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
server/data/
|
||||||
|
*.db
|
||||||
|
*.pem
|
||||||
234
README.md
Normal file
234
README.md
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
# 🌊 TeamFlow
|
||||||
|
|
||||||
|
### The Modern Trello Alternative for Small Teams
|
||||||
|
|
||||||
|
**Kanban boards. Integrated email. Zero friction.**
|
||||||
|
|
||||||
|
[](LICENSE)
|
||||||
|
[](https://nodejs.org)
|
||||||
|
[](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
13
client/index.html
Normal 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
2922
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
client/package.json
Normal file
28
client/package.json
Normal 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
6
client/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
58
client/src/App.jsx
Normal file
58
client/src/App.jsx
Normal 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
85
client/src/api.js
Normal 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'),
|
||||||
|
},
|
||||||
|
};
|
||||||
301
client/src/components/CardModal.jsx
Normal file
301
client/src/components/CardModal.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
client/src/components/Layout.jsx
Normal file
80
client/src/components/Layout.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
client/src/components/NotificationPanel.jsx
Normal file
35
client/src/components/NotificationPanel.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
157
client/src/components/SetupWizard.jsx
Normal file
157
client/src/components/SetupWizard.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
client/src/components/Sidebar.jsx
Normal file
57
client/src/components/Sidebar.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
client/src/context/AuthContext.jsx
Normal file
51
client/src/context/AuthContext.jsx
Normal 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
27
client/src/index.css
Normal 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
6
client/src/main.jsx
Normal 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 />);
|
||||||
288
client/src/pages/BoardView.jsx
Normal file
288
client/src/pages/BoardView.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
client/src/pages/Dashboard.jsx
Normal file
111
client/src/pages/Dashboard.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
176
client/src/pages/EmailSettings.jsx
Normal file
176
client/src/pages/EmailSettings.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
client/src/pages/Login.jsx
Normal file
54
client/src/pages/Login.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
188
client/src/pages/UserManagement.jsx
Normal file
188
client/src/pages/UserManagement.jsx
Normal 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
16
client/tailwind.config.js
Normal 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
15
client/vite.config.js
Normal 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
372
package-lock.json
generated
Normal 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
18
package.json
Normal 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
182
server/db.js
Normal 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
155
server/email/imap.js
Normal 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
110
server/email/transporter.js
Normal 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
66
server/index.js
Normal 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
34
server/middleware/auth.js
Normal 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
1945
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
server/package.json
Normal file
21
server/package.json
Normal 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
36
server/routes/auth.js
Normal 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
129
server/routes/boards.js
Normal 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
296
server/routes/cards.js
Normal 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
37
server/routes/email.js
Normal 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;
|
||||||
34
server/routes/notifications.js
Normal file
34
server/routes/notifications.js
Normal 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
83
server/routes/setup.js
Normal 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
77
server/routes/users.js
Normal 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;
|
||||||
Reference in New Issue
Block a user