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.
|
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.
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+38
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user