Created ability for Admins to review deleted tasks and restore them if needed

This commit is contained in:
NPS Agent
2026-05-12 09:53:51 +09:30
parent 60a1cf1b67
commit 62cfeb0da4
8 changed files with 115 additions and 24 deletions
+8 -14
View File
@@ -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. 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. 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. 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. 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: 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.
- Added the missing `onDeleteTask` prop to the `TaskDetail` component signature in `screens.jsx`. 14. **Password Management:** Made the "Change password" flow real with backend password hashing and current-password verification.
- Added the missing `deleteTask` handler in `app.jsx` (calls `api.deleteTask`, writes an audit entry, and closes the modal). 15. **Real Login Authentication:** Fixed a security bug where the login screen accepted any password. implemented proper 401 handling.
- Restarted the FastAPI backend so the previously-added `DELETE /tasks/{id}` route was loaded into the running process (was returning 405 prior to restart). 16. **Network Hardening:** Configured the frontend to use a relative `/api` path via an Nginx SSL reverse proxy.
14. **Password Management:** Made the "Change password" flow real (previously a placeholder UI). 17. **API Authentication Enforcement:** Applied JWT Bearer token validation to all sensitive routes.
- **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. 18. **Persistent Workspace Settings:** Added a `Workspace` database model to persist global dashboard settings like Name and Timezone.
- **API client:** Added `api.changePassword(id, oldPwd, newPwd)` that surfaces the backend's `detail` message inline rather than just the HTTP status text. 19. **Dynamic UI Integration:** Completely refactored the navigation and boards to build columns and tabs dynamically from the live database user list.
- **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.
### Phase 3: Advanced Features ### Phase 3: Advanced Features
- **Real-time Notifications:** Explore WebSockets for task assignments. - **Real-time Notifications:** Explore WebSockets for task assignments.
+12
View File
@@ -126,6 +126,10 @@ class ApiService {
return this.request('/tasks'); return this.request('/tasks');
} }
async getDeletedTasks() {
return this.request('/tasks/deleted');
}
async getAudit() { async getAudit() {
return this.request('/audit'); return this.request('/audit');
} }
@@ -148,6 +152,14 @@ class ApiService {
return data; return data;
} }
async restoreTask(id) {
const data = await this.request(`/tasks/${id}/restore`, {
method: 'POST',
});
this.notify();
return data;
}
async deleteTask(id) { async deleteTask(id) {
const data = await this.request(`/tasks/${id}`, { const data = await this.request(`/tasks/${id}`, {
method: 'DELETE', method: 'DELETE',
+31 -7
View File
@@ -10,7 +10,7 @@ const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
const ACCENTS = ['#2A6FDB', '#1F8A5B', '#D97757', '#7A5AF8']; const ACCENTS = ['#2A6FDB', '#1F8A5B', '#D97757', '#7A5AF8'];
function useApiData(authed) { 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); const [loading, setLoading] = React.useState(true);
React.useEffect(() => { React.useEffect(() => {
@@ -19,14 +19,15 @@ function useApiData(authed) {
let mounted = true; let mounted = true;
const load = async () => { const load = async () => {
try { try {
const [tasks, users, audit, workspace] = await Promise.all([ const [tasks, users, audit, workspace, deletedTasks] = await Promise.all([
api.getTasks(), api.getTasks(),
api.getUsers(), api.getUsers(),
api.getAudit(), api.getAudit(),
api.getWorkspace() api.getWorkspace(),
api.getDeletedTasks().catch(() => []) // Catch if not admin or error
]); ]);
if (mounted) { if (mounted) {
setData({ tasks, users, audit, workspace }); setData({ tasks, users, audit, workspace, deletedTasks });
setLoading(false); setLoading(false);
} }
} catch (e) { } catch (e) {
@@ -56,7 +57,7 @@ function App() {
return 'rod'; 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'); const [tab, setTab] = React.useState('overview');
React.useEffect(() => { React.useEffect(() => {
@@ -170,7 +171,7 @@ function App() {
try { try {
const t = tasks.find(x => x.id === taskId); const t = tasks.find(x => x.id === taskId);
await api.deleteTask(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); setOpenTaskId(null);
} catch(e) { } catch(e) {
console.error(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 dismissHU = (id) => setHeadsUp(h => h.filter(x => x.id !== id));
const openTaskFromAnywhere = (id) => { setOpenTaskId(id); setShowLogs(false); }; const openTaskFromAnywhere = (id) => { setOpenTaskId(id); setShowLogs(false); };
@@ -227,7 +239,19 @@ function App() {
onMoveTask={moveTask} onMoveTask={moveTask}
/> />
)} )}
{tab !== 'overview' && ( {tab === 'deleted' && (
<DeletedScreen
tasks={deletedTasks.map(t => ({
...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' && (
<UserScreen <UserScreen
user={merge(tab)} tasks={frontendTasks} density={t.density} user={merge(tab)} tasks={frontendTasks} density={t.density}
onOpen={(task) => setOpenTaskId(task.id)} onOpen={(task) => setOpenTaskId(task.id)}
+24 -3
View File
@@ -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]) @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)): 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) @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)): 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: if not db_task:
raise HTTPException(status_code=404, detail="Task not found") 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() 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) @app.get("/workspace", response_model=schemas.Workspace)
def read_workspace(db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): def read_workspace(db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)):
ws = db.query(models.Workspace).first() ws = db.query(models.Workspace).first()
+1
View File
@@ -42,6 +42,7 @@ class Task(Base):
added_at = Column(DateTime(timezone=True), server_default=func.now()) added_at = Column(DateTime(timezone=True), server_default=func.now())
due_at = Column(DateTime(timezone=True)) due_at = Column(DateTime(timezone=True))
reminder_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]) assignee = relationship("User", back_populates="tasks", foreign_keys=[assignee_id])
tags = relationship("Tag", secondary=task_tags, back_populates="tasks") tags = relationship("Tag", secondary=task_tags, back_populates="tasks")
+1
View File
@@ -67,6 +67,7 @@ class TaskBase(BaseModel):
status: str = "open" status: str = "open"
due_at: Optional[datetime] = None due_at: Optional[datetime] = None
reminder_at: Optional[datetime] = None reminder_at: Optional[datetime] = None
deleted_at: Optional[datetime] = None
class TaskCreate(TaskBase): class TaskCreate(TaskBase):
id: Optional[str] = None id: Optional[str] = None
BIN
View File
Binary file not shown.
+38
View File
@@ -120,6 +120,7 @@ function TopBar({ me, dbUsers = [], isAdmin, tab, setTab, onAdd, onLogs, onLogou
{dbUsers.map(u => ( {dbUsers.map(u => (
<Tab key={u.id} id={u.id} label={u.name} tab={tab} setTab={setTab} user={u} /> <Tab key={u.id} id={u.id} label={u.name} tab={tab} setTab={setTab} user={u} />
))} ))}
{isAdmin && <Tab id="deleted" label="Deleted" tab={tab} setTab={setTab} />}
</nav> </nav>
<div className="topbar__right"> <div className="topbar__right">
@@ -1048,7 +1049,44 @@ function WorkspaceTab({ user, isAdmin, dbUsers = [], onSwitchUser, onCreateUser,
); );
} }
function DeletedScreen({ tasks, onRestore }) {
return (
<div className="audit">
<header className="audit__head">
<div>
<h1 className="audit__title">Trash</h1>
<p className="audit__sub">Recently deleted tasks · <span className="mono">Admins only</span></p>
</div>
</header>
<div className="audit__list">
{tasks.length === 0 ? (
<div className="column__empty" style={{ marginTop: '4rem' }}>
<span className="mono">— trash is empty —</span>
</div>
) : (
<div className="user-view__grid" style={{ padding: '0 2rem' }}>
{tasks.map(t => (
<div key={t.id} style={{ position: 'relative' }}>
<TaskCard task={t} />
<button
className="btn btn--primary btn--sm"
style={{ position: 'absolute', top: '1rem', right: '1rem', zIndex: 10 }}
onClick={() => onRestore(t.id)}
>
Restore
</button>
</div>
))}
</div>
)}
</div>
</div>
);
}
Object.assign(window, { Object.assign(window, {
LoginScreen, TopBar, OverviewScreen, UserScreen, AddTaskModal, Modal, TaskDetail, AuditScreen, HeadsUp, BrandMark, LoginScreen, TopBar, OverviewScreen, UserScreen, AddTaskModal, Modal, TaskDetail, AuditScreen, HeadsUp, BrandMark,
SettingsScreen, SettingsScreen,
DeletedScreen,
}); });