Appearance
Admin Task Management
Flow ID: AD-55 | Module(s): auth | Complexity: Medium Last Updated: 2026-09-29 — admin_menu.php/config.php citations re-anchored (4.124.0 resync); 2026-09-14 — routes.php citation moved again to
:482-487after commit 61064a9f7c (#633) added 5 lines to theblog/blog_comments_adminroutes above the auth block; route definitions themselves remain unchanged. (Previously: 2026-08-31 — Advisable-com/ecommercen#703:application/config/routes.phpcitation moved to:477-482after the legacy/api/priceTrackingroutes were restored above the auth block; route definitions themselves are unchanged)
Business Context
The admin task management system provides a simple internal to-do feature for logged-in admin users. It lives entirely inside the auth HMVC module and is exposed through three list views (all tasks, assigned to me, created by me), an add/edit form, and a Vue "bell" component in the admin header that surfaces a badge count of overdue tasks.
The feature is meant as a lightweight internal coordination tool — tasks are not visible to storefront customers, are not linked by foreign key to any other business entity (no orders, no customers, no products), and the upstream platform does not ship any notification, reminder, or recurrence mechanism for them. Bulk product import jobs repurpose the table as a cheap notification queue so that, once an import finishes, the admin bell lights up with a summary row.
API Reference
REST Endpoints
No REST API. There is no modern domain layer or REST controller for tasks — a grep of src/Domains/ and src/Rest/ returns only the unrelated DeferredTaskRunner (a post-response work queue). All task CRUD is served by the legacy admin interface.
Legacy Admin Routes
Routes are registered in application/config/routes.php:482-487:
php
$route['auth'] = 'auth';
$route['auth/tasks/(:num)'] = 'auth/tasks/$1';
$route['auth/(.+)'] = 'auth/$1';
$route['(\w{2})/auth'] = 'auth';
$route['(\w{2})/auth/tasks/(:num)'] = 'auth/tasks/$2';
$route['(\w{2})/auth/(.+)'] = 'auth/$2';The dedicated numeric-offset route for tasks exists so that the (:num) match does not collide with the catch-all (.+) rewrite. All endpoints live on Adv_auth in ecommercen/auth/controllers/Adv_auth.php; the application-level application/modules/auth/controllers/Auth.php:1-7 is an empty subclass.
| URL | Controller method | File:line | HTTP | Description |
|---|---|---|---|---|
auth/tasks[/{offset}] | tasks($offset = 0) | Adv_auth.php:196-228 | GET (POST for filter) | All-tasks list. Supervisor-only (ADVISABLE/ADMIN). NOT filtered to current user. |
auth/myTasks[/{offset}] | myTasks($offset = 0) | Adv_auth.php:230-267 | GET (POST for filter) | Tasks assigned to me (forces assignee_id = uid). Open to any admin; self-scoped. |
auth/tasksTo[/{offset}] | tasksTo($offset = 0) | Adv_auth.php:269-306 | GET (POST for filter) | Tasks created by me (forces creator_id = uid). Open to any admin; self-scoped. |
auth/addTask | addTask() | Adv_auth.php:308-351 | GET form / POST save | Fields: assigneeId, dueDate, title, description. |
auth/editTask/{id} | editTask($taskId) | Adv_auth.php:353-401 | GET form / POST save | Ownership check via canModifyTask() before serving or saving the form. |
auth/taskDelete/{id} | taskDelete($taskId) | Adv_auth.php:403-414 | POST | Inline form + taskCsrf token. authorizeTaskMutation() guard. |
auth/taskCompleted/{id} | taskCompleted($taskId) | Adv_auth.php:416-427 | POST | Inline form + taskCsrf token. authorizeTaskMutation() guard. |
auth/taskUncompleted/{id} | taskUncompleted($taskId) | Adv_auth.php:429-440 | POST | Inline form + taskCsrf token. authorizeTaskMutation() guard. |
auth/resetTasksIndex | resetTasksIndex() | Adv_auth.php:470-475 | GET | Clears the tskSearch session key and redirects. |
auth/getUserTasks | getUserTasks() | Adv_auth.php:477-483 | GET (AJAX, JSON) | Bell-count endpoint; capped at 20 results. |
The current stub used to describe /auth/tasks as "tasks created by the current user" — that is wrong. tasks is the all-tasks list, tasksTo is "tasks I created", and myTasks is "tasks assigned to me".
Code Flow
Listing (all three views)
All three list endpoints share the same view (application/views/admin/auth/tasks_list.php) and the same filter pipeline:
Adv_auth::tasks() | myTasks() | tasksTo()
|
+--> tasks(): allowRole([ADVISABLE, ADMIN]) → error_401() (Adv_auth.php:200-202)
|
+--> setTaskRedirectUrl('auth/<view>', $offset) (Adv_auth.php:560-566)
+--> setSearchTerms() (Adv_auth.php:485-558)
| |
| +--> reads/writes session key tskSearch
|
+--> myTasks: force $where['assignee_id'] = uid
| tasksTo: force $where['creator_id'] = uid
| tasks: no ownership filter (supervisor-only gate at top)
|
+--> Adv_tasks_model::getAll* ($where, $limit, $offset)
+--> Adv_tasks_model::countAll* (used for pagination)
+--> taskCsrfToken() → $this->render['taskCsrf'] (Adv_auth.php:599-608)
+--> Render admin/auth/tasks_listCreating a task (addTask, Adv_auth.php:308-351)
addTask()
|
+--> form_validation rules from addTaskValidation() (Adv_auth.php:621-629)
| |
| +--> only `title` is `required`; all other fields `trim`-only
|
+--> if invalid -> render admin/auth/addTask with errors
+--> if valid -> build task data:
| assignee_id = post('assigneeId') ?: session uid
| creator_id = session uid
| created_at = now()
| due_date = post('dueDate') ?: null
| title = post('title')
| description = post('description')
|
+--> Adv_tasks_model::addTask($taskData)
+--> afterAddTask() (Adv_auth.php:683-686 — empty hook)
+--> redirect to tskPageUrlEditing (editTask, Adv_auth.php:353-401)
Loads task by id, checks ownership via canModifyTask($task) (Adv_auth.php:363-365) — returns error_401() if the check fails. On POST runs editTaskValidation (Adv_auth.php:638-646), saves via Adv_tasks_model::updateTask($taskId, $taskData), then invokes the empty afterEditTask hook. Supervisor roles (ADVISABLE/ADMIN) may edit any task; other admins may only edit tasks where creator_id or assignee_id matches their session uid.
Complete / uncomplete / delete
All three now require a POST submission carrying the per-session taskCsrf token. They share the authorizeTaskMutation($taskId) guard (Adv_auth.php:448-468) before reaching the model:
authorizeTaskMutation($taskId) (Adv_auth.php:448-468)
|
+--> verifyTaskRequest() (Adv_auth.php:615-619)
| +--> method == 'post' AND hash_equals(session taskCsrf, post taskCsrf)
| +--> failure → error_401(), return null
|
+--> tasks_model->getBy('id', $taskId)
| +--> not found → set error, redirect, return null
|
+--> canModifyTask($task) (Adv_auth.php:581-590)
| +--> supervisor (ADVISABLE/ADMIN) → true
| +--> uid == creator_id || uid == assignee_id → true
| +--> otherwise → error_401(), return null
|
+--> return $task| Method | File:line | Model call | Hook fired |
|---|---|---|---|
taskDelete | Adv_auth.php:403-414 | deleteTask($taskId) | afterTaskDelete |
taskCompleted | Adv_auth.php:416-427 | completeTask($taskId, now) | afterTaskCompleted |
taskUncompleted | Adv_auth.php:429-440 | unCompleteTask($taskId) | afterTaskUncompleted |
Model operations (ecommercen/auth/models/Adv_tasks_model.php)
| Method | Description |
|---|---|
getAll($where, $limit, $offset) | All tasks with search/filter. |
getAllByAssignee($assigneeId, $where, $limit, $offset) | Tasks assigned to a specific user. |
getAllByCreated($creatorId, $where, $limit, $offset) | Tasks created by a specific user. |
addTask($taskData) | Insert. |
updateTask($taskId, $taskData) | Update. |
deleteTask($taskId) | Hard delete. |
completeTask($taskId, $completedAt) | Stamps completed_at. |
unCompleteTask($taskId) | Clears completed_at. |
countAll($where) | Counts via count_all_results(), no row materialisation (see Known Issues #13). |
fixOrder() | Ordering (see Task Lifecycle). |
fixWhere($where) | Where-clause translation (see Search & Filter). |
Domain Layer
None. No src/Domains/**/Task* entity, repository, or service. No src/Rest/**/Task* controller or resource. The only match for "Task" under src/ is src/DeferredTask/DeferredTaskRunner.php, which is unrelated (a post-response work queue). application/config/container/deferred_task.php references that runner, not the admin task list.
Architecture
| Component | Path | Purpose |
|---|---|---|
Adv_auth | ecommercen/auth/controllers/Adv_auth.php | Hosts all task methods (:196, :230, :269, :308, :353, :403, :416, :429, :470, :477). |
Auth (subclass) | application/modules/auth/controllers/Auth.php:1-7 | Empty override shell. |
Adv_tasks_model | ecommercen/auth/models/Adv_tasks_model.php | 168-line CRUD model, table tasks, extends Adv_base_model. Loaded in Adv_auth::__construct at Adv_auth.php:8. |
Tasks_model (subclass) | application/modules/auth/models/Tasks_model.php:1-7 | Empty override shell. |
| List view | application/views/admin/auth/tasks_list.php | Shared by all three list endpoints. |
| Add view | application/views/admin/auth/addTask.php | Datepicker + TinyMCE description + chosen-select assignee. |
| Edit view | application/views/admin/auth/updateTask.php | Same layout as add form. |
| Routes | application/config/routes.php:482-487 | Route definitions. |
| Admin menu | application/config/admin_menu.php:378-398 | Menu entries (see Security gap #2). |
| Controller base | application/core/Admin_c.php → ecommercen/core/Adv_admin_controller.php:27-72 | Enforces "must be logged-in admin" in constructor. |
Data Model
Defined only in database/initial/initial.sql:2239-2251. There is no Phinx migration for this table — the misleadingly-named database/migrations/20250324121720_migration_task.php is actually a chmod helper for cache/delete.sh and has nothing to do with tasks.
sql
CREATE TABLE `tasks` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`creator_id` int(11) NOT NULL,
`assignee_id` int(11) NOT NULL,
`created_at` datetime NOT NULL,
`completed_at` datetime DEFAULT NULL,
`due_date` datetime DEFAULT NULL,
`title` text NOT NULL,
`description` text DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
KEY `creator_id` (`creator_id`) USING BTREE,
KEY `assignee_id` (`assignee_id`) USING BTREE
) ENGINE = InnoDB DEFAULT CHARSET = utf8;| Column | Type | Null | Default | Notes |
|---|---|---|---|---|
id | INT(11) AUTO_INCREMENT | No | — | Primary key. |
creator_id | INT(11) | No | — | Implicit FK to users.id (no DB constraint). |
assignee_id | INT(11) | No | — | Implicit FK to users.id. NOT NULL at schema level, but the controller accepts an empty POST and falls back to the session uid. |
created_at | DATETIME | No | — | Set explicitly by the app on insert. |
completed_at | DATETIME | Yes | NULL | NULL = open, any datetime = done. |
due_date | DATETIME | Yes | NULL | Optional deadline. |
title | TEXT | No | — | Stored as TEXT (not VARCHAR). |
description | TEXT | Yes | NULL | Plain text / raw HTML from TinyMCE. |
Indexes: PRIMARY on id, non-unique creator_id, non-unique assignee_id. No composite indexes on hot paths (completed_at, due_date).
Companion tables: none. There is no task_assignees, no task_comments, no task_attachments, no tasks_mui. Tasks are single-assignee, single-table, no translations.
Foreign keys: none at the database level. creator_id and assignee_id are bare indexed integer columns; deleting a user from users leaves orphan tasks behind.
Audit trail: none. There is no updated_at, no completed_by_id, no deleted_at, no record of who mutated the row. (The previous version of this document listed an updated_at column — that was incorrect.)
Task Lifecycle
States are binary. A task is either open (completed_at IS NULL) or done (completed_at IS NOT NULL). There is no "in progress", no priority field, no status enum, no assignee change history.
Status filter (inverted!)
Adv_tasks_model::fixWhere() (Adv_tasks_model.php:51-57) translates the session filter value into SQL:
tskSearch['status'] | SQL applied | Meaning in UI dropdown (tasks_list.php:59-60) |
|---|---|---|
'true' | completed_at IS NULL | Uncompleted / open |
'false' | completed_at IS NOT NULL | Completed / done |
'all' or unset | no filter | All statuses |
The inversion is intentional and matches the view dropdown, but it is a trap for anyone reading the model directly.
Overdue detection
- List view (
tasks_list.php:137-142): row is rendered withbg-warningifdue_date IS NOT NULL AND now() > due_date AND completed_at IS NULL. Completed tasks getbg-successand win. - Vue bell: uses a much looser rule — see TasksBell Vue Widget.
Recurrence / reminders
None. No recurrence_rule column, no cron sweep of the table, no reminder emails, no webhook fired at the due date. Once completed, the row is frozen with a completed_at timestamp.
Ordering
Adv_tasks_model::fixOrder() (Adv_tasks_model.php:24-31) applies:
completed_at IS NOT NULLASC — open tasks float above closed ones.completed_at DESC— most recently completed first among closed.created_at DESC— newest first among open.
Pagination is 20 rows per page (Adv_base_controller::$limit = 20).
Search & Filter
Filter criteria are session-persisted in the key tskSearch via Adv_auth::setSearchTerms() (Adv_auth.php:485-558). The helper is reused across tasks, myTasks, tasksTo, and the Vue bell endpoint getUserTasks — see Bell Count Quirks.
The model translates tskSearch into SQL in Adv_tasks_model::fixWhere() (Adv_tasks_model.php:33-85):
| UI field | tskSearch key | Column | Model behavior |
|---|---|---|---|
searchTitle | title | title | LIKE %value% |
| (hidden, not exposed) | description | description | LIKE %value% |
creatorId select | creator_id | creator_id | Exact match |
assigneeId select | assignee_id | assignee_id | Exact match |
searchStatus dropdown | status | completed_at | Inverted (see lifecycle) |
createdDate datepicker | created_at | created_at | Full-day match |
endDate datepicker | completed_at | completed_at | Full-day match |
dueDate datepicker | due_date | due_date | Full-day match |
Context rules:
- On
myTasks, theassigneeIdselect is rendered disabled (tasks_list.php:27) and theassignee_idfilter is locked to the current user. - On
tasksTo, thecreatorIdselect is disabled (tasks_list.php:40) andcreator_idis locked. - The Reset action hits
/auth/resetTasksIndex(Adv_auth.php:470-475), which unsets the session key and redirects to the last list URL (tracked viasetTaskRedirectUrl,Adv_auth.php:560-566, session keytskPageUrl).
Views
List view
application/views/admin/auth/tasks_list.php— shared bytasks,myTasks,tasksTo. A single POST form at the top holds all filters. Tasks render in a Bootstrap table with a details modal per row.Per-row actions (complete, uncomplete, delete) are inline POST forms (
tasks_list.php:246-272) with ataskCsrfhidden field (tasks_list.php:244), matching the modal controls (tasks_list.php:182-209). The edit button is a plain<a>GET anchor (tasks_list.php:262-266), which is correct — edit is a safe GET that redirects to the edit form with its own POST submission.The modal delete button (
tasks_list.php:182-188) and complete/uncomplete buttons (tasks_list.php:191-209) are also inline POST forms with the sametaskCsrftoken. No action in the list view reaches the destructive endpoints via GET.Add form
application/views/admin/auth/addTask.php—dueDate(datepicker),title,description(TinyMCEmceEditor),assigneeId(chosen-selectpopulated viaallowedUsers($users, $roles)fromecommercen/helpers/auth_helper.php:213-225).Edit form
application/views/admin/auth/updateTask.php— identical layout.
The description and title fields are escaped on output with html_escape() at four sites: title as HTML text at tasks_list.php:154 (modal heading) and :229 (row link), and description via content_shorten($task->description, 400) at :179 (modal body) and again in the title attribute at :226 — the latter was the actual exploitable sink. See security gap #5.
TasksBell Vue Widget
The admin header ships a small Vue 2 + Vuex widget that polls the same backend for a badge count of "overdue" tasks.
Component — assets/admin/js/tasks/TasksBell.vue
vue
<template>
<a :href="getMyTasksUrl()">
<i :class="getBellClass()">
<span>{{ getUserTasksWithDueDateExpired.length }}</span>
</i>
</a>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'TasksBell',
props: { siteUrl: String },
computed: {
...mapGetters(['getUserTasksWithDueDateExpired'])
},
async mounted () {
await this.$store.dispatch('fetchAllUserTasks')
},
methods: {
getBellClass () {
if (this.getUserTasksWithDueDateExpired.length > 0) {
return 'mdi mdi-bell-ring'
}
return 'mdi mdi-bell'
},
getMyTasksUrl () { return this.siteUrl + 'auth/myTasks' }
}
}
</script>Vuex store — assets/admin/js/tasks/tasks.js
js
const store = new Vuex.Store({
state: { allUserTasks: {}, namespaced: { namespace: 'tasks' } },
getters: {
getAllUserTasks (state) { return state.allUserTasks },
getUserTasksWithDueDateExpired (state) {
return filter(state.allUserTasks, e => e.due_date && !e.completed_at && new Date(e.due_date).getTime() < Date.now())
}
},
mutations: { setAllUserTasks (state, tasksData) { state.allUserTasks = tasksData } },
actions: {
async fetchAllUserTasks ({ commit }) {
try {
axios.get('/auth/getUserTasks', {}).then((res) => {
const data = res.data
commit('setAllUserTasks', data)
}, (error) => {
console.log(error)
})
} catch (error) {
console.log(error)
}
}
}
})
new Vue({ el: '#tasks', store, config, locale, components: { TasksBell } })Mount point — application/views/admin/head.php:80-86
html
<li class="eshopAdminUrl adminTasksUrl admin-user-item" id="tasks" title="My Tasks">
<div class="dropdown-head">
<button class="dropbtn-head">
<tasks-bell :site-url="'<?= site_url(); ?>'"></tasks-bell>
</button>
</div>
</li>Bundle registration
webpack.mix.admin.js:165—.js('assets/admin/js/tasks/tasks.js', 'public/ui/admin/dist/').vue()application/views/admin/footer_js.php:2994— bundle<script>include.
Backend endpoint it talks to
Adv_auth::getUserTasks() at Adv_auth.php:477-483 returns JSON. It calls Adv_tasks_model::getAllByAssignee($uid, $where, 20, 0) — hardcoded page size of 20, offset 0, and $where is whatever setSearchTerms() pulled from the tskSearch session key.
Bell Count Quirks
The Vue bell widget had three interacting behavioral quirks that made the badge count unreliable, enumerated as bugs 9-11 in Known Issues & Security Gaps. Bug 9 (completed tasks counted as overdue) is now fixed (#71); two quirks remain open — the hard cap of 20 (bug 10, tracked as #104) and pollution from the last list view's session filter (bug 11, tracked as #72).
The bell also only fetches once, in mounted(). No polling, no push, no websocket — the count only refreshes on a full page navigation.
There is no dropdown preview; clicking the bell simply hard-redirects to /auth/myTasks via siteUrl + 'auth/myTasks'.
Known Issues & Security Gaps
These gaps are serious enough to treat as a dedicated section rather than a TODO list.
[FIXED #21] Per-endpoint RBAC — all-tasks list now gated;
myTasks/tasksTointentionally open but self-scoped. Before commit0260023895, the only gate on any task endpoint was the constructor "must be logged-in admin" check.tasks()now opens withallowRole([AUTH_ROLE_ADVISABLE, AUTH_ROLE_ADMIN], ...) → error_401()(Adv_auth.php:200-202), matching the admin-menu gating.myTasksandtasksToremain open to any admin role — they are self-scoped to the session uid by forcingassignee_id/creator_idfilters, so they cannot expose another user's data. Related open tracker: #66 (menu gating — the menu entry hide forauth/tasksis still cosmetic security even though the endpoint is now gated; a direct URL still reaches it for non-ADMIN roles, which is the intended new behaviour, but the menu logic and the endpoint RBAC still do not perfectly mirror each other).Menu-level gating is cosmetic security.
application/config/admin_menu.php:378-398showsauth/tasksonly to[AUTH_ROLE_ADVISABLE, AUTH_ROLE_ADMIN]. The menu and the endpoint now agree on who can access the all-tasks list. However,auth/myTasksandauth/tasksToare still exposed to everyone in the menu (empty roles array) and the endpoint, which is correct by design (self-scoped). Menu gating is not a security boundary in any case — see #66.[FIXED #22] Ownership check on edit, delete, complete, and uncomplete. Before commit
0260023895, none of these endpoints comparedcreator_id/assignee_idto the sessionuid.editTasknow callscanModifyTask($task)(Adv_auth.php:363-365) immediately after loading the task, returningerror_401()on failure.taskDelete,taskCompleted, andtaskUncompletedall reachcanModifyTaskthrough the sharedauthorizeTaskMutation()guard (Adv_auth.php:448-468). A supervisor role (ADVISABLE/ADMIN) may modify any task; other admins are restricted to tasks where their uid matchescreator_idorassignee_id(Adv_auth.php:581-590).[FIXED #23] Destructive actions now require POST + per-session CSRF token. Before commit
0260023895,taskDelete,taskCompleted, andtaskUncompletedwere GET routes reachable via plain<a>anchor tags — a single<img src="/auth/taskDelete/42">was enough to destroy a task. These three methods now pass throughauthorizeTaskMutation(), which callsverifyTaskRequest()(Adv_auth.php:615-619): the request must be POST and carry ataskCsrffield matching the per-session token generated bytaskCsrfToken()(Adv_auth.php:599-608). The token is lazily generated asbin2hex(random_bytes(16)), stored in session keytaskCsrf, and passed to views as$this->render['taskCsrf']. All three list views render it as a hidden field in inline forms. Note:csrf_protectionremains globallyfalseinapplication/config/config.php:143; thetaskCsrfmechanism is a task-specific synchronizer token, not a framework-level CSRF guard.[FIXED #24] Stored XSS in
descriptionandtitle— closed by escaping on output, not on input. The controller still savespost('description')andpost('title')untouched — storage stays raw soapplication/views/admin/auth/updateTask.php:28'sset_value('description', $task->description)keeps round-tripping pre-existing TinyMCE HTML in the edit form (CI3'sset_value()already escapes for that context). The list view's actual sink was never:179—content_shorten()declared atapplication/helpers/MY_text_helper.php:97callsstrip_tags()as its first transformation, so markup never reached the browser there. The real sink wastasks_list.php:226: the rawdescriptionwas interpolated into a double-quotedtitle="..."HTML attribute, and a bare"in the stored value broke out of it (e.g." onmouseover="alert(1)). A second sink was the rawtitlecolumn, echoed as HTML text content at:154(modal heading) and:229(row link) — a<script>alert(1)</script>title executed directly, a simpler exploit than the attribute break-out; this sink was never named in the original issue and was folded into the same fix. All four sites now escape withhtml_escape(), with:179gaining it as defence-in-depth on top of the pre-existingstrip_tags.:179passes the literal…character ascontent_shorten()'s third argument rather than relying on the helper's'…'entity default, sohtml_escape()can't double-encode it into a visible…; the truncation marker renders unchanged, and the shared helper's default is untouched for every other caller. The two description sites (:179,:226) passhtml_escape($value, false)—double_encode = false— becausedescriptionis stored as HTML source from TinyMCE (&, , etc.) and re-encoding those entities would render them as literal visible text instead of the character they represent; the two title sites (:154,:229) keep the bare single-argument form, becausetitleis stored as literal text from a plain<input name="title">, sodouble_encode = truecorrectly shows a literally-typedA&Bas-is rather than silently decoding it toA&B. Security is unaffected either way:double_encodeonly changes how an already-valid entity sequence's&is handled, and every literal<,>,",'is still escaped identically under both settings. No sanitizer dependency was added (HTMLPurifier/voku/anti-xss— neither is incomposer.json/composer.lock): no render site displays the description as HTML (:179strips tags,:226is an attribute), so sanitizing on input would have bought nothing and the editor round-trip was already safe viaset_value(). The blast radius is cross-role, not self-XSS:auth/myTasksandauth/tasksTocarry'roles' => [](application/config/admin_menu.php:375and:382), open to every admin role, and the POSTedassigneeIdis not server-validated (tracked separately as #67) — so a low-privilege admin can assign a payload-carrying task to a supervisor and have it execute in that supervisor's authenticated session. Fixing #67 narrows this but does not close it. Invariant — the task tooltip attasks_list.php:226must stayhtml: false. Bootstrap reads the tooltip content via$e.attr('title'), which returns the entity-decoded string, so underdouble_encode = falsea description containing<img src=x onerror=alert(1)>(the normal way TinyMCE stores an author-typed<img ...>) reaches JavaScript as a live<img src=x onerror=alert(1)>. That is safe today only because the tooltip initializes with no options —$('[data-toggle="tooltip"]').tooltip()atapplication/views/admin/footer_js.php:3847— and the bundledpublic/ui/plugins/bootstrap/3.3.7/js/bootstrap.min.jsdefaults tooltips tohtml: false, which injects the content via.text()rather than.html()regardless of how the attribute decoded; the anchor at:226carries nodata-htmloverride, so it inherits that default. Bootstrap 3 honours a per-elementdata-html="true"override, and this codebase already uses it in three places —application/views/admin/orders/create.php:312,edit.php:292,repeat.php:285(all<span>s witht()-translated titles) — so addingdata-html="true"to the task tooltip, or switching its initialisation tohtml: true, would make entity-encoded markup in a description live again and reopen this stored XSS.NOT NULL violations possible under edge cases.
addTaskwritesassignee_id = (!empty(post('assigneeId'))) ? post('assigneeId') : session->userdata('uid')(Adv_auth.php:325-327).assignee_idis NOT NULL at the schema level, but if the sessionuidis missing and the POST field is empty, the insert attempts to write an empty string into a NOT NULL INT column. There is no server-side check that the POSTedassigneeIdcorresponds to an existing user.No audit trail, no soft delete. There is no
updated_at, nocompleted_by_id, nodeleted_at, no log of who mutated the task. Once a task is deleted it is gone with no trace of which admin clicked the button.No notifications of any kind. Assignment changes send no email, fire no in-app notification, and do not update the target user's bell until the target's browser performs a full page navigation. The
afterAddTask,afterEditTask,afterTaskDelete,afterTaskCompleted, andafterTaskUncompletedhooks atAdv_auth.php:678-701are empty stubs explicitly designed for client repos to override — the upstream platform ships zero notification implementation.[FIXED #71] Bell: completed tasks no longer count. The getter
getUserTasksWithDueDateExpired(assets/admin/js/tasks/tasks.js:25-27) checked onlydue_dateagainstDate.now(), so a completed task whose due date had passed still contributed to the badge count until the next full page navigation dropped it out of the top 20. The getter now adds a!e.completed_atguard alongside the existinge.due_dateguard (the #70 fix, preserved unchanged), so a completed task is excluded from the overdue count regardless of its due date.TasksBell.vueneeded no change — it only renders the getter's length and picks the bell icon from it, so the shared getter fix corrects both the count and the icon.Bell: hard cap of 20 silently clamps. The JSON endpoint
getUserTaskscallsgetAllByAssignee(uid, where, 20, 0)(Adv_auth.php:477-483) — the bell can never show more than 20 even if the user has hundreds of overdue tasks.Bell: polluted by the last list filter.
getUserTasksroutes through the samesetSearchTerms()helper as the three list views, so thetskSearchsession key is shared (Adv_auth.php:485-558). If the admin just browsedmyTaskswith status filter set to "completed", the bell query inherits that filter and counts only completed tasks.[FIXED #73] Copy-paste hook bug:
taskCompletedandtaskUncompletedfireafterTaskDelete.taskCompleted(Adv_auth.php:416-427) andtaskUncompleted(Adv_auth.php:429-440) now call dedicatedafterTaskCompleted($taskId)andafterTaskUncompleted($taskId)hooks (Adv_auth.php:693-701) instead of the sharedafterTaskDelete.taskUncompleted's flash message was also corrected tot('auth.task.success.uncompleted', [$taskId])(previously it incorrectly reused the "completed" message). Both hooks are empty stubs, matching the pattern of the other task/user lifecycle hooks.[FIXED #74]
countAllnow counts viaCOUNT(*)instead ofnum_rows().Adv_tasks_model::countAll()(Adv_tasks_model.php:98-103) previously loaded the full filtered result set into PHP just to callnum_rows()on it and discard the rows. It now keeps the samefixJoins()/fixWhere($where)calls — so the counted set is still structurally identical to the onegetAll()pages over — and returns$this->db->count_all_results($this->table), which compiles aCOUNT(*)query honouring those joins and where clauses (and, per CI3, drops anyORDER BY) instead of selecting and materialising every matching row.[FIXED #552] Ten more unescaped sinks — session-persisted search-term XSS — but the underlying CSRF gap is only now inert, not closed. #24 escaped only
title/description; ten other sinks in the same view were explicitly out of scope for that fix and remained raw: the search term (tasks_list.php:21, a double-quotedvalueattribute), the assignee/creator filter<option>usernames (:31,:44), the three date-filtervalueattributes (:73,:81,:89), and the creator/assignee usernames in the modal (:171,:175) and table row (:232,:233). All ten now wrap the value in barehtml_escape()— these are literal-text values (search terms, usernames, dates), not stored HTML likedescription, so thedouble_encode = falsetreatment #24 used fordescriptiondoes not apply here. The search term was the serious one because it is session-persisted, not merely reflected:Adv_auth.php:493captures the raw POST,:524writes it into thetskSearchsession key,:487re-reads it on every later request, and:529hands it to the view; it is cleared only viaauth/resetTasksIndex(:473). A planted payload therefore fired on every subsequent task-list load until the admin explicitly reset the search. Residual gap — not closed by #552: the search POST form still carries notaskCsrftoken (unlike the destructive-action forms fixed by #23), and bothcsrf_protectionandglobal_xss_filteringarefalse(application/config/config.php), so an attacker-hosted auto-submitting cross-origin POST can still plant an arbitrary value into a visiting admin'stskSearchsession key. The planted value is now inert on render (escaped on every read site), but the session remains plantable — adding a CSRF token to the search form was explicitly out of scope for #552 and needs its own follow-up issue.
Form validation
addTaskValidation() (Adv_auth.php:621-629) and editTaskValidation() (Adv_auth.php:638-646) mark only title as required; everything else is trim-only. There is no min/max length check, no assignee-existence check, and no due-date format validation.
Open trackers
- #66 — menu gating vs endpoint RBAC alignment
- #311 — split task management out of
Adv_authinto its own controller - No tracker yet — CSRF token for the task-list search POST, to close the residual plantability gap left open by #552 (see security gap #14)
Bulk Import Jobs as Notification Queue
Outside the admin UI, four bulk product import jobs write rows directly into the tasks table and use it as a poor-man's notification queue. In every case creator_id = assignee_id = customerId and due_date = date('Y-m-d H:i:s') (i.e. now), so the bell lights up the instant the job finishes.
| Job file | Line | Title/description source |
|---|---|---|
ecommercen/job/libraries/AdvInsertProductsFromFile.php | :49-56 | Import summary (counts of inserted/failed products). |
ecommercen/job/libraries/AdvUpdateProductsFromFileByBarcode.php | :49 | Same pattern. |
ecommercen/job/libraries/AdvUpdateProductsFromFileByProductCode.php | :47 | Same pattern. |
ecommercen/job/libraries/AdvUploadImagesFromZipFile.php | :33-40 | Hardcoded Greek title/description at :30-31 — not translated. |
Because the generated due_date is always "now", these rows feed directly into the bell's overdue count the moment they land.
Dead load: ecommercen/audit/controllers/Adv_audit.php:10 loads tasks_model in its constructor but never calls it — leftover copy-paste, not an actual integration point.
Cross-References to Business Entities
Tasks are not linked to any other business entity. There is no FK (or implicit column) pointing at orders, customers, products, categories, or suppliers. Tasks cannot be used as "follow-up reminders for order X" or "review this product" — the feature is strictly a freeform to-do list for admin users. If a client needs task-to-entity linking it must be added via a migration in the client repo.
Configuration
- Routes:
application/config/routes.php:482-487. - Admin menu:
application/config/admin_menu.php:378-398— three entries ("All Tasks"auth/tasks:379, "My Tasks"auth/myTasks:386, "Tasks I Created"auth/tasksTo:393). See security gap #2 for why the per-entry role arrays are now mostly aligned but still not a security boundary. - Required roles:
tasks()requires ADVISABLE or ADMIN role (Adv_auth.php:200-202).myTasks()andtasksTo()are open to any authenticated admin but self-scope to the session uid. Destructive mutations (taskDelete,taskCompleted,taskUncompleted) additionally require POST +taskCsrftoken and pass an ownership check. - Session keys used by task endpoints:
tskSearch(filter criteria),tskPageUrl(redirect target),taskCsrf(per-session mutation token,Adv_auth.php:599-608).
Client Extension Points
- Override controller: extend
Authinapplication/modules/auth/controllers/Auth.php(main repo) or a client-repo equivalent. The empty subclass atapplication/modules/auth/controllers/Auth.php:1-7exists specifically for this. - Override model: extend
Tasks_modelatapplication/modules/auth/models/Tasks_model.php:1-7. - Post-mutation hooks: override
afterAddTask,afterEditTask,afterTaskDelete,afterTaskCompleted,afterTaskUncompletedfromAdv_auth.php:678-701. A fork that previously relied onafterTaskDeletefiring for completions and un-completions (the pre-#73 copy-paste bug) must now override the two new hooks,afterTaskCompletedandafterTaskUncompleted, to keep that behaviour —afterTaskDeletefires only ontaskDeletegoing forward. canModifyTask/verifyTaskRequest: both areprotectedmethods onAdv_auth(Adv_auth.php:581-590and:615-619). A client subclass may override them to apply stricter or different ownership rules.
Business Rules
- Three views, one template. All three list endpoints share
tasks_list.php; only the forced where-clause and disabled filter dropdown differ. - Binary lifecycle. Open (
completed_at IS NULL) or done (completed_at IS NOT NULL). No priority, no in-progress state. - Session-persisted filters. Search/filter criteria live in the session key
tskSearch, shared across the three list views and the Vue bell endpoint. - Session-persisted redirect.
tskPageUrlremembers the last list URL so write endpoints return the admin to the correct paginated view. myTasksandtasksToboth require a valid session uid. Both methods (Adv_auth.php:232-236, :271-275) redirect with an error if the sessionuidis missing.- Bell badge only refreshes on full navigation. The Vue widget fetches once on
mounted()— there is no polling or push. - Supervisor override. A user with role ADVISABLE or ADMIN may edit or delete any task, regardless of
creator_id/assignee_id. Any other admin role is restricted to tasks they created or are assigned to (Adv_auth.php:581-590). - Destructive actions require POST + token.
taskDelete,taskCompleted, andtaskUncompletedreject GET requests and requests with a missing or mismatchedtaskCsrffield (Adv_auth.php:615-619).
Tests
tests/Legacy/Auth/AdvAuthTaskSecurityTest.php covers the two new security predicates added in commit 0260023895 (#21/#22/#23). Tests use reflection on a constructor-less Adv_auth instance with injected stub session and input objects.
canModifyTask() cases (#22)
| Test | Scenario | Expected |
|---|---|---|
supervisor_can_modify_any_task | uid=99, role=ADMIN, task creator=1/assignee=2 | true |
creator_can_modify_own_task | uid=5, non-supervisor role, creator_id=5 | true |
assignee_can_modify_own_task | uid=7, non-supervisor role, assignee_id=7 | true |
unrelated_admin_cannot_modify_task | uid=8, non-supervisor role, creator=1/assignee=2 | false |
verifyTaskRequest() cases (#23)
| Test | Scenario | Expected |
|---|---|---|
verify_passes_for_post_with_matching_token | POST, session token = posted token | true |
verify_fails_for_get_request | GET, token present but method wrong | false |
verify_fails_for_post_with_wrong_token | POST, token mismatch | false |
verify_fails_for_post_with_no_token | POST, no taskCsrf in body | false |
Coverage gaps
- The
tasks()RBAC gate (Adv_auth.php:200-202) is not unit-tested — it exercisesallowRole()+error_401()interaction that requires a more integrated harness. authorizeTaskMutation()orchestration (verifyTaskRequest → load task → canModifyTask) is not unit-tested as a whole; only its constituent predicates are covered.
Related Flows
- AD-01 Admin Auth — admin authentication and the
uid/role model that every task endpoint reads from.
No other flow is related — tasks have no connection to orders, customers, products, or any other business domain.