🌊 TeamFlow — Modern Trello alternative with email integration

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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