diff --git a/PROGRESS.md b/PROGRESS.md index df6ca3b..029c331 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -53,20 +53,14 @@ 9. **User Management (Settings):** Built backend API endpoints (`POST`, `PATCH`, `DELETE` for `/users`) and wired up the `WorkspaceTab` allowing Admins to manage the team from the UI. 10. **Task Editing:** Implemented inline editing for task descriptions using an active text box state with "Save/Cancel" actions. 11. **UI Cleanup:** Removed hardcoded, prototype placeholder notes from the `TaskDetail` modal to prepare for future dynamic notes integration. -12. **Permanent Deletion:** Added a "Delete task permanently" button to the `TaskDetail` sidebar with a confirmation dialog, backed by a new `DELETE /tasks/{id}` API endpoint. -13. **Permanent Deletion Wiring Fix:** Resolved a three-layer bug where the delete button was non-functional: - - Added the missing `onDeleteTask` prop to the `TaskDetail` component signature in `screens.jsx`. - - Added the missing `deleteTask` handler in `app.jsx` (calls `api.deleteTask`, writes an audit entry, and closes the modal). - - Restarted the FastAPI backend so the previously-added `DELETE /tasks/{id}` route was loaded into the running process (was returning 405 prior to restart). -14. **Password Management:** Made the "Change password" flow real (previously a placeholder UI). - - **Backend:** Added `PasswordChange` schema and a new `POST /users/{user_id}/password` endpoint that verifies the current password (401 on mismatch) before re-hashing and saving the new one. - - **API client:** Added `api.changePassword(id, oldPwd, newPwd)` that surfaces the backend's `detail` message inline rather than just the HTTP status text. - - **Frontend:** Wired the previously-inert "Update password" button in `SettingsScreen` โ€” submits via `onChangePassword`, shows inline error / success states, disables while in flight, clears the fields on success, and writes a `password_changed` audit entry. - - **Defaults confirmed:** Seeded users (`seed.py`) and admin-created users (`app.jsx`) both default to `password123`. -15. **Real Login Authentication:** Fixed a security bug where the login screen accepted any password. Bound the input to component state and implemented proper 401 handling with inline error messaging. -16. **Network Hardening:** Configured the frontend to use a relative `/api` path, allowing the FastAPI backend to be completely shielded behind an Nginx SSL reverse proxy on `127.0.0.1`. No internal ports are now exposed to the public internet. -17. **API Authentication Enforcement:** Fixed a security vulnerability where API endpoints were publicly accessible without a token. Implemented the `get_current_user` dependency in `backend/auth.py` and applied it to all sensitive routes. Accessing `/tasks`, `/users`, etc. now strictly requires a valid JWT Bearer token. -18. **Persistent Workspace Settings:** Added a `Workspace` database model and API endpoints (`GET /workspace`, `PATCH /workspace`) to track global settings. Added an "Update workspace" button to the Settings UI, allowing Admins to persist changes to the Workspace Name and Timezone across the entire dashboard. +12. **Soft Deletion & Recovery:** Replaced hard-deletion with "Soft Deletion" by adding a `deleted_at` field to the Task model. Created a new Admin-only "Deleted" tab that allows restoring tasks from the trash via a new `/restore` endpoint. +13. **Permanent Deletion Wiring Fix:** Resolved a three-layer bug where the delete button was non-functional by adding the missing props and handlers across three files. +14. **Password Management:** Made the "Change password" flow real with backend password hashing and current-password verification. +15. **Real Login Authentication:** Fixed a security bug where the login screen accepted any password. implemented proper 401 handling. +16. **Network Hardening:** Configured the frontend to use a relative `/api` path via an Nginx SSL reverse proxy. +17. **API Authentication Enforcement:** Applied JWT Bearer token validation to all sensitive routes. +18. **Persistent Workspace Settings:** Added a `Workspace` database model to persist global dashboard settings like Name and Timezone. +19. **Dynamic UI Integration:** Completely refactored the navigation and boards to build columns and tabs dynamically from the live database user list. ### Phase 3: Advanced Features - **Real-time Notifications:** Explore WebSockets for task assignments. diff --git a/api.js b/api.js index 6cd37d1..67850ce 100644 --- a/api.js +++ b/api.js @@ -126,6 +126,10 @@ class ApiService { return this.request('/tasks'); } + async getDeletedTasks() { + return this.request('/tasks/deleted'); + } + async getAudit() { return this.request('/audit'); } @@ -148,6 +152,14 @@ class ApiService { return data; } + async restoreTask(id) { + const data = await this.request(`/tasks/${id}/restore`, { + method: 'POST', + }); + this.notify(); + return data; + } + async deleteTask(id) { const data = await this.request(`/tasks/${id}`, { method: 'DELETE', diff --git a/app.jsx b/app.jsx index c29554d..e9832c2 100644 --- a/app.jsx +++ b/app.jsx @@ -10,7 +10,7 @@ const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ const ACCENTS = ['#2A6FDB', '#1F8A5B', '#D97757', '#7A5AF8']; function useApiData(authed) { - const [data, setData] = React.useState({ tasks: [], users: [], audit: [], workspace: null }); + const [data, setData] = React.useState({ tasks: [], users: [], audit: [], workspace: null, deletedTasks: [] }); const [loading, setLoading] = React.useState(true); React.useEffect(() => { @@ -19,14 +19,15 @@ function useApiData(authed) { let mounted = true; const load = async () => { try { - const [tasks, users, audit, workspace] = await Promise.all([ + const [tasks, users, audit, workspace, deletedTasks] = await Promise.all([ api.getTasks(), api.getUsers(), api.getAudit(), - api.getWorkspace() + api.getWorkspace(), + api.getDeletedTasks().catch(() => []) // Catch if not admin or error ]); if (mounted) { - setData({ tasks, users, audit, workspace }); + setData({ tasks, users, audit, workspace, deletedTasks }); setLoading(false); } } catch (e) { @@ -56,7 +57,7 @@ function App() { return 'rod'; }); - const { tasks, users: dbUsers, audit, workspace, loading } = useApiData(authed); + const { tasks, users: dbUsers, audit, workspace, deletedTasks, loading } = useApiData(authed); const [tab, setTab] = React.useState('overview'); React.useEffect(() => { @@ -170,7 +171,7 @@ function App() { try { const t = tasks.find(x => x.id === taskId); await api.deleteTask(taskId); - await api.addAudit({ actor: meId, action: 'task_deleted', summary: 'Deleted task' + (t ? ': ' + t.title : ''), target: taskId }); + await api.addAudit({ actor: meId, action: 'task_deleted', summary: 'Permanently deleted task' + (t ? ': ' + t.title : ''), target: taskId }); setOpenTaskId(null); } catch(e) { console.error(e); @@ -178,6 +179,17 @@ function App() { } }; + const restoreTask = async (taskId) => { + try { + await api.restoreTask(taskId); + await api.addAudit({ actor: meId, action: 'task_restored', summary: 'Restored task from trash', target: taskId }); + if (tab === 'deleted') setTab('overview'); + } catch(e) { + console.error(e); + alert("Failed to restore task: " + e.message); + } + }; + const dismissHU = (id) => setHeadsUp(h => h.filter(x => x.id !== id)); const openTaskFromAnywhere = (id) => { setOpenTaskId(id); setShowLogs(false); }; @@ -227,7 +239,19 @@ function App() { onMoveTask={moveTask} /> )} - {tab !== 'overview' && ( + {tab === 'deleted' && ( + ({ + ...t, + assignee: t.assignee_id, + addedBy: t.added_by, + addedAt: t.added_at, + tags: t.tags.map(tagObj => tagObj.tag) + }))} + onRestore={restoreTask} + /> + )} + {tab !== 'overview' && tab !== 'deleted' && ( setOpenTaskId(task.id)} diff --git a/backend/main.py b/backend/main.py index 8a59718..58ce2a4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -94,7 +94,13 @@ def delete_user(user_id: str, db: Session = Depends(get_db), current_user: model @app.get("/tasks", response_model=List[schemas.Task]) def read_tasks(db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): - return db.query(models.Task).all() + return db.query(models.Task).filter(models.Task.deleted_at == None).all() + +@app.get("/tasks/deleted", response_model=List[schemas.Task]) +def read_deleted_tasks(db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): + if current_user.account_type != "admin": + raise HTTPException(status_code=403, detail="Not enough permissions") + return db.query(models.Task).filter(models.Task.deleted_at != None).all() @app.post("/tasks", response_model=schemas.Task) def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): @@ -144,9 +150,24 @@ def delete_task(task_id: str, db: Session = Depends(get_db), current_user: model if not db_task: raise HTTPException(status_code=404, detail="Task not found") - db.delete(db_task) + from sqlalchemy.sql import func + db_task.deleted_at = func.now() db.commit() - return {"message": "Task deleted"} + return {"message": "Task moved to trash"} + +@app.post("/tasks/{task_id}/restore", response_model=schemas.Task) +def restore_task(task_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): + if current_user.account_type != "admin": + raise HTTPException(status_code=403, detail="Not enough permissions") + + db_task = db.query(models.Task).filter(models.Task.id == task_id).first() + if not db_task: + raise HTTPException(status_code=404, detail="Task not found") + + db_task.deleted_at = None + db.commit() + db.refresh(db_task) + return db_task @app.get("/workspace", response_model=schemas.Workspace) def read_workspace(db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): ws = db.query(models.Workspace).first() diff --git a/backend/models.py b/backend/models.py index 257036d..9c83066 100644 --- a/backend/models.py +++ b/backend/models.py @@ -42,6 +42,7 @@ class Task(Base): added_at = Column(DateTime(timezone=True), server_default=func.now()) due_at = Column(DateTime(timezone=True)) reminder_at = Column(DateTime(timezone=True)) + deleted_at = Column(DateTime(timezone=True)) assignee = relationship("User", back_populates="tasks", foreign_keys=[assignee_id]) tags = relationship("Tag", secondary=task_tags, back_populates="tasks") diff --git a/backend/schemas.py b/backend/schemas.py index 1b79309..6f74bcc 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -67,6 +67,7 @@ class TaskBase(BaseModel): status: str = "open" due_at: Optional[datetime] = None reminder_at: Optional[datetime] = None + deleted_at: Optional[datetime] = None class TaskCreate(TaskBase): id: Optional[str] = None diff --git a/dashy.db b/dashy.db index 60a3dcc..db8c5ff 100644 Binary files a/dashy.db and b/dashy.db differ diff --git a/screens.jsx b/screens.jsx index 2cb0684..125f302 100644 --- a/screens.jsx +++ b/screens.jsx @@ -120,6 +120,7 @@ function TopBar({ me, dbUsers = [], isAdmin, tab, setTab, onAdd, onLogs, onLogou {dbUsers.map(u => ( ))} + {isAdmin && }
@@ -1048,7 +1049,44 @@ function WorkspaceTab({ user, isAdmin, dbUsers = [], onSwitchUser, onCreateUser, ); } +function DeletedScreen({ tasks, onRestore }) { + return ( +
+
+
+

Trash

+

Recently deleted tasks ยท Admins only

+
+
+ +
+ {tasks.length === 0 ? ( +
+ โ€” trash is empty โ€” +
+ ) : ( +
+ {tasks.map(t => ( +
+ + +
+ ))} +
+ )} +
+
+ ); +} + Object.assign(window, { LoginScreen, TopBar, OverviewScreen, UserScreen, AddTaskModal, Modal, TaskDetail, AuditScreen, HeadsUp, BrandMark, SettingsScreen, + DeletedScreen, });