diff --git a/server/routes/auth.js b/server/routes/auth.js index fa7d88d..f5a882a 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -76,4 +76,59 @@ router.get('/users', async (_req, res) => { } }); +// POST /api/auth/users — Admin-create a new user (manager/cto/ceo) +router.post('/users', async (req, res) => { + try { + const { name, email, password, role, dept } = req.body; + if (!name || !email || !password) { + return res.status(400).json({ error: 'Name, email and password required' }); + } + + const [existing] = await pool.query('SELECT id FROM users WHERE email = ?', [email]); + if (existing.length > 0) { + return res.status(409).json({ error: 'Email already registered' }); + } + + const id = randomUUID(); + const password_hash = await bcrypt.hash(password, 10); + const avatar = name.split(' ').map(w => w[0]).join('').substring(0, 2).toUpperCase(); + const colors = ['#818cf8', '#f59e0b', '#34d399', '#f472b6', '#fb923c', '#60a5fa', '#a78bfa']; + const color = colors[Math.floor(Math.random() * colors.length)]; + + await pool.query( + 'INSERT INTO users (id, name, role, email, password_hash, color, avatar, dept) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [id, name, role || 'employee', email, password_hash, color, avatar, dept || ''] + ); + + res.status(201).json({ id, name, role: role || 'employee', email, color, avatar, dept: dept || '' }); + } catch (err) { + console.error('Create user error:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// DELETE /api/auth/users/:id — Delete a user (unassign their tasks first) +router.delete('/users/:id', async (req, res) => { + try { + const { id } = req.params; + + const [user] = await pool.query('SELECT id FROM users WHERE id = ?', [id]); + if (user.length === 0) { + return res.status(404).json({ error: 'User not found' }); + } + + // Unassign tasks assigned to or reported by this user + await pool.query('UPDATE tasks SET assignee_id = NULL WHERE assignee_id = ?', [id]); + await pool.query('UPDATE tasks SET reporter_id = NULL WHERE reporter_id = ?', [id]); + + // Delete the user (cascading will handle comments, etc. via ON DELETE SET NULL) + await pool.query('DELETE FROM users WHERE id = ?', [id]); + + res.json({ success: true, id }); + } catch (err) { + console.error('Delete user error:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + export default router; diff --git a/src/App.tsx b/src/App.tsx index 0514437..9bce370 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { apiFetchTasks, apiFetchUsers, apiCreateTask, apiUpdateTask, apiAddActivity, apiAddDependency, apiToggleDependency, apiRemoveDependency } from './api'; +import { apiFetchTasks, apiFetchUsers, apiCreateTask, apiUpdateTask, apiAddActivity, apiAddDependency, apiToggleDependency, apiRemoveDependency, apiCreateUser, apiDeleteUser } from './api'; import type { Task, User, Status } from './data'; import { STATUS_LABELS } from './data'; import { LoginPage } from './Login'; @@ -186,6 +186,18 @@ export default function App() { } catch (err) { console.error('Failed to remove dependency:', err); } }; + const handleAddUser = async (data: { name: string; email: string; password: string; role: string; dept: string }) => { + const newUser = await apiCreateUser(data); + setUsers(prev => [...prev, newUser]); + }; + + const handleDeleteUser = async (id: string) => { + await apiDeleteUser(id); + setUsers(prev => prev.filter(u => u.id !== id)); + // Unassign tasks locally too + setTasks(prev => prev.map(t => t.assignee === id ? { ...t, assignee: '' } : t).map(t => t.reporter === id ? { ...t, reporter: '' } : t)); + }; + const displayPage = VIEW_PAGES.includes(activePage) ? activeView : activePage; const filteredMyTasks = tasks.filter(t => t.assignee === currentUser.id); @@ -229,7 +241,7 @@ export default function App() { )} {displayPage === 'teamtasks' && } {displayPage === 'reports' && } - {displayPage === 'members' && } + {displayPage === 'members' && } diff --git a/src/Pages.tsx b/src/Pages.tsx index 6bf4162..9523321 100644 --- a/src/Pages.tsx +++ b/src/Pages.tsx @@ -42,18 +42,55 @@ export function TeamTasksPage({ tasks, users }: { tasks: Task[]; currentUser: Us ); } -export function MembersPage({ tasks, users }: { tasks: Task[]; users: User[] }) { +export function MembersPage({ tasks, users, currentUser, onAddUser, onDeleteUser }: { tasks: Task[]; users: User[]; currentUser: User; onAddUser: (data: { name: string; email: string; password: string; role: string; dept: string }) => Promise; onDeleteUser: (id: string) => Promise }) { const [expanded, setExpanded] = useState(null); - const [showInvite, setShowInvite] = useState(false); + const [showAdd, setShowAdd] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(null); + const [addForm, setAddForm] = useState({ name: '', email: '', password: '', role: 'employee', dept: '' }); + const [addError, setAddError] = useState(''); + const [addLoading, setAddLoading] = useState(false); + const [deleteLoading, setDeleteLoading] = useState(false); + + const canManage = ['ceo', 'cto', 'manager'].includes(currentUser.role); + + const handleAdd = async () => { + if (!addForm.name.trim() || !addForm.email.trim() || !addForm.password.trim()) { + setAddError('Name, email and password are required'); + return; + } + setAddLoading(true); + setAddError(''); + try { + await onAddUser(addForm); + setShowAdd(false); + setAddForm({ name: '', email: '', password: '', role: 'employee', dept: '' }); + } catch (err: any) { + setAddError(err.message || 'Failed to add employee'); + } finally { + setAddLoading(false); + } + }; + + const handleDelete = async (id: string) => { + setDeleteLoading(true); + try { + await onDeleteUser(id); + setConfirmDelete(null); + } catch (err: any) { + console.error('Delete failed:', err); + } finally { + setDeleteLoading(false); + } + }; return (

Team Members

- + {canManage && }
- + {canManage && } {users.map(u => { const ut = tasks.filter(t => t.assignee === u.id); @@ -70,9 +107,16 @@ export function MembersPage({ tasks, users }: { tasks: Task[]; users: User[] }) + {canManage && ( + + )} {expanded === u.id && ( -
AvatarFull NameRoleDeptAssignedDoneActive
AvatarFull NameRoleDeptAssignedDoneActiveActions
{ut.length} {done} {active} e.stopPropagation()}> + {u.id !== currentUser.id && ( + + )} +
+
{ut.map(t => (
@@ -91,18 +135,45 @@ export function MembersPage({ tasks, users }: { tasks: Task[]; users: User[] })
- {showInvite && ( -
setShowInvite(false)}> + {/* Add Employee Modal */} + {showAdd && ( +
setShowAdd(false)}>
e.stopPropagation()}> -

Invite Member

+

Add Employee

-
-
- - +
{ setAddForm(f => ({ ...f, name: e.target.value })); setAddError(''); }} />
+
{ setAddForm(f => ({ ...f, email: e.target.value })); setAddError(''); }} />
+
{ setAddForm(f => ({ ...f, password: e.target.value })); setAddError(''); }} />
+
+
+
setAddForm(f => ({ ...f, dept: e.target.value }))} />
+ {addError &&

{addError}

} +
+
+ + +
+
+
+ )} + + {/* Delete Confirmation Modal */} + {confirmDelete && ( +
setConfirmDelete(null)}> +
e.stopPropagation()} style={{ maxWidth: 400 }}> +

Confirm Delete

+
+

Are you sure you want to delete {users.find(u => u.id === confirmDelete)?.name}? Their tasks will be unassigned.

+
+
+ +
-
)} diff --git a/src/api.ts b/src/api.ts index b52b564..f2e73f8 100644 --- a/src/api.ts +++ b/src/api.ts @@ -33,6 +33,21 @@ export async function apiFetchUsers() { return request('/auth/users'); } +export async function apiCreateUser(data: { + name: string; email: string; password: string; role?: string; dept?: string; +}) { + return request('/auth/users', { + method: 'POST', + body: JSON.stringify(data), + }); +} + +export async function apiDeleteUser(id: string) { + return request(`/auth/users/${id}`, { + method: 'DELETE', + }); +} + // Tasks export async function apiFetchTasks() { return request('/tasks'); diff --git a/src/index.css b/src/index.css index 6b6c99a..f0d30b7 100644 --- a/src/index.css +++ b/src/index.css @@ -1655,7 +1655,44 @@ body { background: var(--accent-hover); } -/* DEPENDENCIES */ +.btn-primary:disabled, +.btn-danger:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-danger { + padding: 8px 18px; + background: #ef4444; + border: none; + border-radius: 8px; + color: #fff; + font-size: 13px; + font-weight: 700; + cursor: pointer; + font-family: inherit; + transition: background 0.15s; +} + +.btn-danger:hover { + background: #dc2626; +} + +.btn-danger-sm { + padding: 4px 8px; + background: rgba(239, 68, 68, 0.12); + border: 1px solid rgba(239, 68, 68, 0.25); + border-radius: 6px; + color: #ef4444; + font-size: 13px; + cursor: pointer; + transition: all 0.15s; +} + +.btn-danger-sm:hover { + background: rgba(239, 68, 68, 0.25); + border-color: #ef4444; +} .dep-unresolved-badge { background: rgba(239, 68, 68, 0.15); color: #ef4444;