diff --git a/.vscode/ftp-kr.sync.cache.json b/.vscode/ftp-kr.sync.cache.json
index 7e50840..e35e7e6 100644
--- a/.vscode/ftp-kr.sync.cache.json
+++ b/.vscode/ftp-kr.sync.cache.json
@@ -13,7 +13,36 @@
"lmtime": 0,
"modified": true
},
- "autoload": {},
+ "autoload": {
+ "Domain": {
+ "Tasks": {
+ "class.WorkTimeRepository.php": {
+ "type": "-",
+ "size": 3269,
+ "lmtime": 0,
+ "modified": false
+ },
+ "MailToTaskImporter.php": {
+ "type": "-",
+ "size": 24511,
+ "lmtime": 1770587036920,
+ "modified": false
+ },
+ "TaskAttachmentRepository.php": {
+ "type": "-",
+ "size": 8411,
+ "lmtime": 0,
+ "modified": false
+ },
+ "WorkTimeRepository.php": {
+ "type": "-",
+ "size": 3269,
+ "lmtime": 0,
+ "modified": false
+ }
+ }
+ }
+ },
"ceidg.php": {
"type": "-",
"size": 3950,
@@ -30,8 +59,8 @@
},
"config.php": {
"type": "-",
- "size": 729,
- "lmtime": 1770583610342,
+ "size": 1249,
+ "lmtime": 1770587027872,
"modified": false
},
"cron.php": {
diff --git a/CODE_INDEX.md b/CODE_INDEX.md
new file mode 100644
index 0000000..70c0dff
--- /dev/null
+++ b/CODE_INDEX.md
@@ -0,0 +1,462 @@
+# CODE INDEX
+
+Generated: 2026-02-09 17:02:20
+
+Scope: root `*.php`, `autoload/**/*.php`, `tests/**/*.php`.
+Excluded: `libraries/**`, `templates/**`.
+
+## Summary
+- Files with declarations: 41
+- Classes/interfaces/traits: 33
+- Functions/methods: 333
+
+## Declarations By File
+
+### `ajax.php`
+- Function: `__autoload_my_classes()` (line 3)
+
+### `api.php`
+- Function: `__autoload_my_classes()` (line 4)
+
+### `autoload/class.Cache.php`
+- Class: `Cache` (line 2)
+- Function: `store()` (line 4)
+- Function: `get_file_name()` (line 9)
+- Function: `fetch()` (line 20)
+
+### `autoload/class.Chunk.php`
+- Class: `Chunk` (line 2)
+- Function: `__construct()` (line 62)
+- Function: `__destruct()` (line 108)
+- Function: `read()` (line 122)
+
+### `autoload/class.Cron.php`
+- Class: `Cron` (line 3)
+- Function: `recursive_tasks()` (line 5)
+- Function: `import_tasks_from_email()` (line 121)
+- Function: `tasks_emails()` (line 132)
+
+### `autoload/class.DbModel.php`
+- Class: `DbModel` (line 2)
+- Function: `__construct()` (line 8)
+- Function: `__get()` (line 20)
+- Function: `__set()` (line 26)
+- Function: `save()` (line 31)
+- Function: `delete()` (line 52)
+
+### `autoload/class.Excel.php`
+- Class: `Excel` (line 10)
+- Function: `filename()` (line 21)
+- Function: `__construct()` (line 33)
+- Function: `headers()` (line 45)
+- Function: `send_to_file()` (line 53)
+- Function: `send()` (line 59)
+- Function: `bofMarker()` (line 70)
+- Function: `eofMarker()` (line 79)
+- Function: `left()` (line 88)
+- Function: `right()` (line 101)
+- Function: `up()` (line 111)
+- Function: `down()` (line 124)
+- Function: `top()` (line 133)
+- Function: `home()` (line 141)
+- Function: `number()` (line 151)
+- Function: `label()` (line 162)
+
+### `autoload/class.Html.php`
+- Class: `Html` (line 3)
+- Function: `form_text()` (line 5)
+- Function: `input_switch()` (line 12)
+- Function: `select()` (line 19)
+- Function: `textarea()` (line 26)
+- Function: `input_icon()` (line 39)
+- Function: `input()` (line 52)
+- Function: `button()` (line 65)
+- Function: `panel()` (line 78)
+
+### `autoload/class.S.php`
+- Class: `S` (line 2)
+- Function: `array_unique_multi()` (line 4)
+- Function: `number_display()` (line 16)
+- Function: `prepar_request()` (line 21)
+- Function: `seo()` (line 33)
+- Function: `no_pl_excel()` (line 48)
+- Function: `noPL()` (line 63)
+- Function: `alert()` (line 100)
+- Function: `hash()` (line 105)
+- Function: `sort_array_of_array()` (line 121)
+- Function: `json_to_array()` (line 130)
+- Function: `get_session()` (line 149)
+- Function: `del_session()` (line 154)
+- Function: `set_session()` (line 158)
+- Function: `get()` (line 163)
+- Function: `pre()` (line 184)
+- Function: `email_check()` (line 200)
+- Function: `send_email()` (line 205)
+
+### `autoload/class.Tpl.php`
+- Class: `Tpl` (line 2)
+- Function: `__construct()` (line 7)
+- Function: `view()` (line 13)
+- Function: `secureHTML()` (line 21)
+- Function: `render()` (line 31)
+- Function: `__set()` (line 64)
+- Function: `__get()` (line 69)
+
+### `autoload/Controllers/TasksController.php`
+- Class: `TasksController` (line 4)
+- Function: `workTime()` (line 8)
+- Function: `workTimeViewModel()` (line 20)
+- Function: `resolveTaskStatusForForm()` (line 28)
+- Function: `resolveTaskStatusForSave()` (line 38)
+- Function: `taskChangeStatus()` (line 48)
+- Function: `shouldStopTimerOnStatus()` (line 74)
+- Function: `shouldSendStatusChangeEmail()` (line 79)
+- Function: `sendEmailTaskChangeStatus()` (line 84)
+
+### `autoload/controls/class.BackendSites.php`
+- Class: `BackendSites` (line 3)
+- Function: `topic_delete()` (line 5)
+- Function: `topic_accept()` (line 21)
+- Function: `topic_unaccept()` (line 31)
+- Function: `topic_save()` (line 41)
+- Function: `topic_edit()` (line 58)
+- Function: `topics()` (line 65)
+- Function: `collective_topics()` (line 70)
+- Function: `collective_topic_edit()` (line 75)
+- Function: `collective_topic_save()` (line 82)
+
+### `autoload/controls/class.Crm.php`
+- Class: `Crm` (line 14)
+- Function: `client_delete()` (line 17)
+- Function: `client_save()` (line 32)
+- Function: `client_edit()` (line 51)
+- Function: `main_view()` (line 63)
+
+### `autoload/controls/class.Cron.php`
+- Class: `Cron` (line 3)
+- Function: `main_view()` (line 5)
+
+### `autoload/controls/class.Finances.php`
+- Class: `Finances` (line 3)
+- Function: `category_delete()` (line 5)
+- Function: `operation_save()` (line 18)
+- Function: `operation_delete()` (line 39)
+- Function: `operation_edit()` (line 54)
+- Function: `category_save()` (line 71)
+- Function: `category_edit()` (line 90)
+- Function: `operations_list()` (line 103)
+- Function: `main_view()` (line 137)
+
+### `autoload/controls/class.Projects.php`
+- Class: `Projects` (line 3)
+- Function: `project_save()` (line 5)
+- Function: `project_edit()` (line 25)
+- Function: `main_view()` (line 42)
+- Function: `task_order_save()` (line 51)
+- Function: `action_mark_as_done()` (line 63)
+- Function: `action_edit()` (line 77)
+- Function: `task_update()` (line 91)
+- Function: `task_text_update()` (line 98)
+- Function: `task_text_new()` (line 104)
+- Function: `project_delete()` (line 110)
+- Function: `task_change_status()` (line 126)
+- Function: `ajax_user_tasks()` (line 137)
+- Function: `task_delete()` (line 177)
+- Function: `open_task_details()` (line 193)
+- Function: `task_details()` (line 201)
+- Function: `project_default()` (line 226)
+- Function: `tasks()` (line 235)
+
+### `autoload/controls/class.Site.php`
+- Class: `Site` (line 3)
+- Function: `route()` (line 5)
+
+### `autoload/controls/class.Tasks.php`
+- Class: `Tasks` (line 3)
+- Function: `task_change_dates()` (line 5)
+- Function: `task_delete()` (line 17)
+- Function: `main_view_by_ajax()` (line 34)
+- Function: `main_view()` (line 96)
+- Function: `action_change_status()` (line 183)
+- Function: `comment_delete()` (line 201)
+- Function: `comment_save()` (line 218)
+- Function: `action_delete()` (line 240)
+- Function: `action_save()` (line 259)
+- Function: `tasks_order_save()` (line 282)
+- Function: `send_email_task_change_status()` (line 292)
+- Function: `task_change_project()` (line 309)
+- Function: `task_change_client()` (line 320)
+- Function: `task_change_priority()` (line 334)
+- Function: `task_change_status()` (line 348)
+- Function: `task_end()` (line 353)
+- Function: `task_start()` (line 364)
+- Function: `task_edit()` (line 375)
+- Function: `task_save()` (line 398)
+- Function: `task_popup()` (line 423)
+- Function: `task_attachment_upload()` (line 449)
+- Function: `normalize_uploads_array()` (line 496)
+- Function: `task_attachment_delete()` (line 519)
+- Function: `task_attachment_rename()` (line 539)
+- Function: `filtr_save_form()` (line 559)
+- Function: `filtr_save()` (line 571)
+- Function: `filtr_update()` (line 589)
+- Function: `work_time()` (line 609)
+- Function: `change_task_work_date_start()` (line 614)
+- Function: `change_task_work_date_end()` (line 619)
+- Function: `work_delete()` (line 624)
+- Function: `filtr_set_default()` (line 633)
+- Function: `filtr_get()` (line 650)
+
+### `autoload/controls/class.Users.php`
+- Class: `Users` (line 4)
+- Function: `permissions()` (line 7)
+- Function: `logout()` (line 46)
+- Function: `settings_save()` (line 57)
+- Function: `settings()` (line 71)
+- Function: `login()` (line 85)
+- Function: `login_form()` (line 117)
+
+### `autoload/controls/class.Wiki.php`
+- Class: `Wiki` (line 4)
+- Function: `category_delete()` (line 7)
+- Function: `category_save()` (line 20)
+- Function: `category_edit()` (line 34)
+- Function: `category_preview()` (line 47)
+- Function: `main_view()` (line 59)
+
+### `autoload/Domain/Tasks/MailToTaskImporter.php`
+- Class: `MailToTaskImporter` (line 4)
+- Function: `__construct()` (line 14)
+- Function: `importFromImap()` (line 27)
+- Function: `buildMailbox()` (line 197)
+- Function: `resolveClientIdBySenderDomain()` (line 214)
+- Function: `parseEmailsField()` (line 241)
+- Function: `extractDomainFromEmail()` (line 254)
+- Function: `parseReceivedDate()` (line 265)
+- Function: `extractSender()` (line 274)
+- Function: `decodeHeaderValue()` (line 289)
+- Function: `messageKey()` (line 306)
+- Function: `isMessageFinalized()` (line 318)
+- Function: `getImportStatus()` (line 324)
+- Function: `saveImportLog()` (line 329)
+- Function: `ensureImportTable()` (line 346)
+- Function: `extractMessageContent()` (line 365)
+- Function: `flattenParts()` (line 410)
+- Function: `parseSinglePart()` (line 441)
+- Function: `partParams()` (line 485)
+- Function: `decodePartBody()` (line 510)
+- Function: `mimeType()` (line 521)
+- Function: `htmlToText()` (line 540)
+- Function: `cleanBodyText()` (line 557)
+- Function: `prepareImportedTaskText()` (line 601)
+- Function: `shouldImportAttachment()` (line 622)
+- Function: `extractReferencedCidValues()` (line 647)
+- Function: `normalizeContentId()` (line 668)
+- Function: `parseWithAI()` (line 678)
+
+### `autoload/Domain/Tasks/TaskAttachmentRepository.php`
+- Class: `TaskAttachmentRepository` (line 4)
+- Function: `__construct()` (line 11)
+- Function: `listByTaskId()` (line 25)
+- Function: `upload()` (line 46)
+- Function: `uploadFromContent()` (line 76)
+- Function: `rename()` (line 100)
+- Function: `delete()` (line 112)
+- Function: `purgeByTaskId()` (line 137)
+- Function: `effectiveTitle()` (line 159)
+- Function: `sanitizeFileName()` (line 165)
+- Function: `ensureStorage()` (line 173)
+- Function: `ensureTable()` (line 185)
+- Function: `storeMeta()` (line 209)
+- Function: `buildPublicUrl()` (line 230)
+- Function: `formatSize()` (line 235)
+- Function: `resolveFilePath()` (line 247)
+
+### `autoload/Domain/Tasks/WorkTimeRepository.php`
+- Class: `WorkTimeRepository` (line 4)
+- Function: `__construct()` (line 9)
+- Function: `getClientsWithUnsettledTasks()` (line 20)
+- Function: `buildClientTasksByMonth()` (line 44)
+- Function: `getClientTaskRows()` (line 74)
+- Function: `getUnsettledTaskStatuses()` (line 91)
+- Function: `getTaskTotalTimeByMonth()` (line 96)
+
+### `autoload/factory/class.BackendSites.php`
+- Class: `BackendSites` (line 3)
+- Function: `topic_delete()` (line 6)
+- Function: `topic_unaccept()` (line 12)
+- Function: `topic_accept()` (line 18)
+- Function: `topic_save()` (line 24)
+- Function: `topic()` (line 57)
+- Function: `collective_topic()` (line 63)
+- Function: `collective_topic_save()` (line 69)
+
+### `autoload/factory/class.Crm.php`
+- Class: `Crm` (line 14)
+- Function: `settings()` (line 19)
+- Function: `get_client_name()` (line 31)
+- Function: `get_client_list()` (line 36)
+- Function: `client_delete()` (line 41)
+- Function: `client_details()` (line 47)
+- Function: `client_save()` (line 52)
+
+### `autoload/factory/class.Cron.php`
+- Class: `Cron` (line 3)
+- Function: `remove_points_history()` (line 5)
+- Function: `update_points()` (line 18)
+- Function: `send_push()` (line 31)
+- Function: `send_emails()` (line 291)
+
+### `autoload/factory/class.Finances.php`
+- Class: `Finances` (line 3)
+- Function: `first_operation_date()` (line 5)
+- Function: `get_operation_tags()` (line 11)
+- Function: `client_name()` (line 25)
+- Function: `clients_list_by_dates()` (line 31)
+- Function: `clients_list()` (line 37)
+- Function: `category_delete()` (line 43)
+- Function: `default_group()` (line 48)
+- Function: `groups_list()` (line 54)
+- Function: `operation_delete()` (line 60)
+- Function: `tags_json()` (line 66)
+- Function: `tags_list()` (line 72)
+- Function: `operations_list()` (line 91)
+- Function: `operation_details()` (line 105)
+- Function: `operation_save()` (line 115)
+- Function: `category_details()` (line 171)
+- Function: `category_save()` (line 177)
+- Function: `wallet_expenses_this_month()` (line 203)
+- Function: `wallet_income_this_month()` (line 214)
+- Function: `wallet_summary_this_month()` (line 225)
+- Function: `wallet_summary()` (line 236)
+- Function: `operations()` (line 244)
+- Function: `categories()` (line 273)
+
+### `autoload/factory/class.Projects.php`
+- Class: `Projects` (line 3)
+- Function: `projects_list()` (line 5)
+- Function: `count_open_subtasks()` (line 12)
+- Function: `task_text_new()` (line 18)
+- Function: `task_text_update()` (line 29)
+- Function: `task_total_time()` (line 40)
+- Function: `send_email_task_change_status()` (line 63)
+- Function: `task_order_save()` (line 90)
+- Function: `action_mark_as_done()` (line 104)
+- Function: `action_name()` (line 112)
+- Function: `send_email_notification()` (line 118)
+- Function: `get_task_name()` (line 127)
+- Function: `set_project_as_default()` (line 133)
+- Function: `task_update()` (line 139)
+- Function: `project_delete()` (line 151)
+- Function: `project_name()` (line 157)
+- Function: `open_task()` (line 163)
+- Function: `task_change_status()` (line 176)
+- Function: `task_delete()` (line 247)
+- Function: `project_save()` (line 257)
+- Function: `project_user_id()` (line 329)
+- Function: `task_user_id()` (line 335)
+- Function: `project_details()` (line 341)
+- Function: `tasks_without_project()` (line 351)
+- Function: `get_project_name()` (line 365)
+- Function: `user_projects()` (line 371)
+- Function: `get_unassigned_tasks()` (line 402)
+- Function: `get_closed_tasks()` (line 431)
+- Function: `get_toreview_tasks()` (line 476)
+- Function: `get_inprogress_tasks()` (line 509)
+- Function: `user_tasks()` (line 555)
+
+### `autoload/factory/class.Tasks.php`
+- Class: `Tasks` (line 4)
+- Function: `filtr_details()` (line 11)
+- Function: `get_priorities()` (line 17)
+- Function: `task_change_dates()` (line 22)
+- Function: `parent_tasks()` (line 37)
+- Function: `get_tasks_gantt()` (line 59)
+- Function: `work_delete()` (line 117)
+- Function: `change_task_work_date_end()` (line 122)
+- Function: `change_task_work_date_start()` (line 128)
+- Function: `task_works()` (line 134)
+- Function: `get_statuses()` (line 140)
+- Function: `clear_task_opened()` (line 150)
+- Function: `set_task_opened_by_user()` (line 156)
+- Function: `is_taks_is_opened_by_user()` (line 169)
+- Function: `get_filtrs()` (line 175)
+- Function: `filtr_update()` (line 180)
+- Function: `filtr_save()` (line 190)
+- Function: `action_change_status()` (line 208)
+- Function: `comment_delete()` (line 213)
+- Function: `comment_save()` (line 218)
+- Function: `action_delete()` (line 233)
+- Function: `action_save()` (line 239)
+- Function: `get_tasks()` (line 248)
+- Function: `get_open_task_id()` (line 295)
+- Function: `task_start()` (line 301)
+- Function: `task_end()` (line 340)
+- Function: `is_work_duration_too_short()` (line 368)
+- Function: `task_details()` (line 379)
+- Function: `task_total_time()` (line 394)
+- Function: `is_task_open()` (line 417)
+- Function: `work_time_clients()` (line 427)
+- Function: `task_save()` (line 434)
+- Function: `task_delete()` (line 532)
+- Function: `task_first_id()` (line 540)
+- Function: `task_delete_all()` (line 549)
+- Function: `task_delete_from_db()` (line 567)
+- Function: `filtr_set_default()` (line 582)
+- Function: `get_default_filtr()` (line 590)
+
+### `autoload/factory/class.Users.php`
+- Class: `Users` (line 3)
+- Function: `user_details()` (line 5)
+- Function: `get_default_project()` (line 18)
+- Function: `get_user_email()` (line 24)
+- Function: `user_name()` (line 30)
+- Function: `users_list()` (line 39)
+- Function: `settings_save()` (line 62)
+- Function: `login()` (line 73)
+
+### `autoload/factory/class.Wiki.php`
+- Class: `Wiki` (line 4)
+- Function: `category_delete()` (line 6)
+- Function: `category_save()` (line 11)
+- Function: `category_details()` (line 45)
+- Function: `category_users()` (line 52)
+- Function: `get_categories()` (line 58)
+
+### `autoload/view/class.Cron.php`
+- Class: `Cron` (line 4)
+- Function: `main_view()` (line 6)
+
+### `autoload/view/class.Projects.php`
+- Class: `Projects` (line 3)
+
+### `autoload/view/class.Site.php`
+- Class: `Site` (line 3)
+- Function: `show()` (line 5)
+
+### `autoload/view/class.Users.php`
+- Class: `Users` (line 3)
+- Function: `points_history()` (line 5)
+- Function: `settings()` (line 12)
+
+### `ceidg.php`
+- Function: `__autoload_my_classes()` (line 3)
+- Function: `memory_get_process_usage()` (line 114)
+
+### `cron.php`
+- Function: `__autoload_my_classes()` (line 4)
+
+### `index.php`
+- Function: `__autoload_my_classes()` (line 3)
+
+### `tests/Controllers/TasksControllerTest.php`
+- Function: `assert_same()` (line 7)
+- Function: `run_tasks_controller_tests()` (line 13)
+
+### `tests/Domain/Tasks/TaskAttachmentRepositoryTest.php`
+- Function: `run_task_attachment_repository_tests()` (line 7)
+
+### `tests/Domain/Tasks/WorkTimeRepositoryTest.php`
+- Function: `assert_true()` (line 7)
+- Function: `run_work_time_repository_tests()` (line 13)
+
diff --git a/autoload/Controllers/UsersController.php b/autoload/Controllers/UsersController.php
new file mode 100644
index 0000000..aec2585
--- /dev/null
+++ b/autoload/Controllers/UsersController.php
@@ -0,0 +1,133 @@
+ all()
+ ) );
+ }
+
+ public static function loginAs()
+ {
+ global $user;
+
+ if ( !$user )
+ return \controls\Users::login_form();
+
+ $impersonator_user = self::getImpersonatorUser();
+ if ( !self::canManageUsers( $user, $impersonator_user ) )
+ self::forbiddenRedirect();
+
+ $target_user_id = (int)\S::get( 'user_id' );
+ $users_repository = new \Domain\Users\UserRepository();
+ $target_user = $users_repository -> byId( $target_user_id );
+
+ if ( !$target_user )
+ {
+ \S::alert( 'Nie znaleziono wskazanego uzytkownika.' );
+ header( 'Location: /users/main_view/' );
+ exit;
+ }
+
+ $new_session_state = self::impersonationStateAfterLoginAs( $user, $target_user, $impersonator_user );
+
+ \S::set_session( 'user', $new_session_state['user'] );
+ \S::set_session( self::IMPERSONATOR_SESSION_KEY, $new_session_state['impersonator_user'] );
+
+ \S::alert( 'Zalogowano jako: ' . $target_user['name'] . ' ' . $target_user['surname'] . '.' );
+ header( 'Location: /' );
+ exit;
+ }
+
+ public static function switchBackToAdmin()
+ {
+ $impersonator_user = self::getImpersonatorUser();
+
+ if ( !$impersonator_user or !isset( $impersonator_user['id'] ) or (int)$impersonator_user['id'] !== self::ADMIN_USER_ID )
+ {
+ \S::alert( 'Brak aktywnej sesji podszywania.' );
+ header( 'Location: /' );
+ exit;
+ }
+
+ \S::set_session( 'user', $impersonator_user );
+ \S::del_session( self::IMPERSONATOR_SESSION_KEY );
+
+ \S::alert( 'Powrot do konta administratora.' );
+ header( 'Location: /users/main_view/' );
+ exit;
+ }
+
+ public static function canManageUsers( $current_user, $impersonator_user = null )
+ {
+ if ( !is_array( $current_user ) )
+ return false;
+
+ if ( isset( $current_user['id'] ) and (int)$current_user['id'] === self::ADMIN_USER_ID )
+ return true;
+
+ if ( is_array( $impersonator_user ) and isset( $impersonator_user['id'] ) and (int)$impersonator_user['id'] === self::ADMIN_USER_ID )
+ return true;
+
+ return false;
+ }
+
+ public static function buildMainViewModel( $current_user, $impersonator_user, array $users )
+ {
+ return [
+ 'current_user' => $current_user,
+ 'impersonator_user' => $impersonator_user,
+ 'users' => $users,
+ 'can_switch_back' => is_array( $impersonator_user ) and isset( $impersonator_user['id'] ) and (int)$impersonator_user['id'] === self::ADMIN_USER_ID
+ ];
+ }
+
+ public static function impersonationStateAfterLoginAs( $current_user, $target_user, $existing_impersonator_user = null )
+ {
+ $impersonator_user = $existing_impersonator_user;
+
+ if ( !is_array( $impersonator_user ) )
+ $impersonator_user = ( is_array( $current_user ) and isset( $current_user['id'] ) and (int)$current_user['id'] === self::ADMIN_USER_ID ) ? $current_user : null;
+
+ return [
+ 'user' => $target_user,
+ 'impersonator_user' => $impersonator_user
+ ];
+ }
+
+ private static function getImpersonatorUser()
+ {
+ $session_value = \S::get_session( self::IMPERSONATOR_SESSION_KEY );
+
+ if ( is_array( $session_value ) )
+ return $session_value;
+
+ return null;
+ }
+
+ private static function forbiddenRedirect()
+ {
+ \S::alert( 'Brak uprawnien do zarzadzania uzytkownikami.' );
+ header( 'Location: /' );
+ exit;
+ }
+}
diff --git a/autoload/Domain/Users/UserRepository.php b/autoload/Domain/Users/UserRepository.php
new file mode 100644
index 0000000..c67efe5
--- /dev/null
+++ b/autoload/Domain/Users/UserRepository.php
@@ -0,0 +1,33 @@
+ mdb = $mdb;
+ else if ( isset( $GLOBALS['mdb'] ) )
+ $this -> mdb = $GLOBALS['mdb'];
+ else
+ $this -> mdb = null;
+ }
+
+ public function all()
+ {
+ if ( !$this -> mdb )
+ return [];
+
+ return $this -> mdb -> select( 'users', '*', [ 'ORDER' => [ 'id' => 'ASC' ] ] );
+ }
+
+ public function byId( $user_id )
+ {
+ if ( !$this -> mdb )
+ return false;
+
+ return $this -> mdb -> get( 'users', '*', [ 'id' => (int)$user_id ] );
+ }
+}
diff --git a/autoload/controls/class.Users.php b/autoload/controls/class.Users.php
index a4185c6..8ac4935 100644
--- a/autoload/controls/class.Users.php
+++ b/autoload/controls/class.Users.php
@@ -119,4 +119,28 @@ class Users
return \Tpl::view( 'users/login-form' );
}
-}
\ No newline at end of file
+ /**
+ * @deprecated Use \Controllers\UsersController::mainView() instead.
+ */
+ public static function main_view()
+ {
+ return \Controllers\UsersController::mainView();
+ }
+
+ /**
+ * @deprecated Use \Controllers\UsersController::loginAs() instead.
+ */
+ public static function login_as()
+ {
+ return \Controllers\UsersController::loginAs();
+ }
+
+ /**
+ * @deprecated Use \Controllers\UsersController::switchBackToAdmin() instead.
+ */
+ public static function back_to_admin()
+ {
+ return \Controllers\UsersController::switchBackToAdmin();
+ }
+
+}
diff --git a/autoload/factory/class.Users.php b/autoload/factory/class.Users.php
index 96a44df..69bbbc6 100644
--- a/autoload/factory/class.Users.php
+++ b/autoload/factory/class.Users.php
@@ -1,4 +1,5 @@
= $this -> user[ 'email' ];?>
+ $impersonator_user = \S::get_session( 'impersonator_user' );?>
+ if ( is_array( $impersonator_user ) and isset( $impersonator_user['id'] ) and (int)$impersonator_user['id'] === 1 ):?>
+ -
+ Powrot do admina
+
+
+ endif;?>
-
Ustawienia
@@ -51,6 +58,7 @@
+ $can_manage_users = (int)$this -> user['id'] === 1;?>
if ( \controls\Users::permissions( $this -> user[ 'id' ], 'projects' ) ):?>
-
Zadania
@@ -81,6 +89,11 @@
Wiki
endif;?>
+ if ( $can_manage_users ):?>
+ -
+ Użytkownicy
+
+ endif;?>
if ( \controls\Users::permissions( $this -> user[ 'id' ], 'zaplecze' ) ):?>
-
Zaplecze
diff --git a/templates/site/layout-logged.php b/templates/site/layout-logged.php
index ecfc89b..d76d2e9 100644
--- a/templates/site/layout-logged.php
+++ b/templates/site/layout-logged.php
@@ -45,6 +45,13 @@
= $this -> user[ 'email' ];?>
+ $impersonator_user = \S::get_session( 'impersonator_user' );?>
+ if ( is_array( $impersonator_user ) and isset( $impersonator_user['id'] ) and (int)$impersonator_user['id'] === 1 ):?>
+ -
+ Powrot do admina
+
+
+ endif;?>
-
Ustawienia
@@ -57,6 +64,7 @@
+ $can_manage_users = (int)$this -> user['id'] === 1;?>
if ( \controls\Users::permissions( $this -> user[ 'id' ], 'tasks' ) ):?>
-
Zadania
@@ -87,6 +95,11 @@
Wiki
endif;?>
+ if ( $can_manage_users ):?>
+ -
+ Użytkownicy
+
+ endif;?>
if ( \controls\Users::permissions( $this -> user[ 'id' ], 'zaplecze' ) ):?>
-
Zaplecze - tematy zbiorcze
diff --git a/templates/users/main-view.php b/templates/users/main-view.php
new file mode 100644
index 0000000..fa56672
--- /dev/null
+++ b/templates/users/main-view.php
@@ -0,0 +1,55 @@
+
diff --git a/tests/Controllers/UsersControllerTest.php b/tests/Controllers/UsersControllerTest.php
new file mode 100644
index 0000000..bb1fe61
--- /dev/null
+++ b/tests/Controllers/UsersControllerTest.php
@@ -0,0 +1,32 @@
+ 1, 'name' => 'Admin', 'surname' => 'One' ];
+ $regular_user = [ 'id' => 3, 'name' => 'Jan', 'surname' => 'Kowalski' ];
+ $target_user = [ 'id' => 5, 'name' => 'Anna', 'surname' => 'Nowak' ];
+
+ assert_users_controller_same( true, UsersController::canManageUsers( $admin_user, null ), 'Expected admin to manage users.' );
+ assert_users_controller_same( false, UsersController::canManageUsers( $regular_user, null ), 'Expected regular user to not manage users.' );
+ assert_users_controller_same( true, UsersController::canManageUsers( $regular_user, $admin_user ), 'Expected impersonated admin session to manage users.' );
+
+ $state = UsersController::impersonationStateAfterLoginAs( $admin_user, $target_user, null );
+ assert_users_controller_same( 5, (int)$state['user']['id'], 'Expected impersonation to switch current user to target user.' );
+ assert_users_controller_same( 1, (int)$state['impersonator_user']['id'], 'Expected impersonator to be preserved as admin user.' );
+
+ $state_with_existing = UsersController::impersonationStateAfterLoginAs( $regular_user, $target_user, $admin_user );
+ assert_users_controller_same( 1, (int)$state_with_existing['impersonator_user']['id'], 'Expected existing impersonator to stay unchanged.' );
+
+ $view_model = UsersController::buildMainViewModel( $target_user, $admin_user, [ $admin_user, $regular_user, $target_user ] );
+ assert_users_controller_same( true, $view_model['can_switch_back'], 'Expected can_switch_back to be true when impersonator is admin.' );
+}
diff --git a/tests/Domain/Users/UserRepositoryTest.php b/tests/Domain/Users/UserRepositoryTest.php
new file mode 100644
index 0000000..c2ca8b2
--- /dev/null
+++ b/tests/Domain/Users/UserRepositoryTest.php
@@ -0,0 +1,53 @@
+ rows = $rows;
+ }
+
+ public function select( $table, $columns, $where )
+ {
+ $this -> last_select = [ 'table' => $table, 'columns' => $columns, 'where' => $where ];
+ return $this -> rows;
+ }
+
+ public function get( $table, $columns, $where )
+ {
+ $this -> last_get = [ 'table' => $table, 'columns' => $columns, 'where' => $where ];
+ foreach ( $this -> rows as $row )
+ {
+ if ( (int)$row['id'] === (int)$where['id'] )
+ return $row;
+ }
+ return false;
+ }
+}
+
+function run_user_repository_tests()
+{
+ $rows = [
+ [ 'id' => 1, 'email' => 'admin@example.com', 'name' => 'Admin', 'surname' => 'User' ],
+ [ 'id' => 3, 'email' => 'user@example.com', 'name' => 'Normal', 'surname' => 'User' ]
+ ];
+
+ $fake_mdb = new FakeUsersMdb( $rows );
+ $repository = new UserRepository( $fake_mdb );
+
+ $all = $repository -> all();
+ assert_true( count( $all ) === 2, 'Expected all() to return all users.' );
+ assert_true( $fake_mdb -> last_select['table'] === 'users', 'Expected all() to query users table.' );
+
+ $user = $repository -> byId( 3 );
+ assert_true( (int)$user['id'] === 3, 'Expected byId() to return matching user.' );
+ assert_true( (int)$fake_mdb -> last_get['where']['id'] === 3, 'Expected byId() to query requested id.' );
+}
diff --git a/tests/run.php b/tests/run.php
index bc961aa..e0a7937 100644
--- a/tests/run.php
+++ b/tests/run.php
@@ -2,12 +2,16 @@
require_once __DIR__ . '/Domain/Tasks/WorkTimeRepositoryTest.php';
require_once __DIR__ . '/Domain/Tasks/TaskAttachmentRepositoryTest.php';
+require_once __DIR__ . '/Domain/Users/UserRepositoryTest.php';
require_once __DIR__ . '/Controllers/TasksControllerTest.php';
+require_once __DIR__ . '/Controllers/UsersControllerTest.php';
$tests = [
'run_work_time_repository_tests',
'run_task_attachment_repository_tests',
- 'run_tasks_controller_tests'
+ 'run_user_repository_tests',
+ 'run_tasks_controller_tests',
+ 'run_users_controller_tests'
];
$failed = 0;