Created ability for Admins to review deleted tasks and restore them if needed
This commit is contained in:
+8
-14
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' && (
|
||||
<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
|
||||
user={merge(tab)} tasks={frontendTasks} density={t.density}
|
||||
onOpen={(task) => setOpenTaskId(task.id)}
|
||||
|
||||
+24
-3
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
+38
@@ -120,6 +120,7 @@ function TopBar({ me, dbUsers = [], isAdmin, tab, setTab, onAdd, onLogs, onLogou
|
||||
{dbUsers.map(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>
|
||||
|
||||
<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, {
|
||||
LoginScreen, TopBar, OverviewScreen, UserScreen, AddTaskModal, Modal, TaskDetail, AuditScreen, HeadsUp, BrandMark,
|
||||
SettingsScreen,
|
||||
DeletedScreen,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user