🌊 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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user