diff --git a/DATABASE_STRUCTURE.md b/DATABASE_STRUCTURE.md index 6a7837e..0253566 100644 --- a/DATABASE_STRUCTURE.md +++ b/DATABASE_STRUCTURE.md @@ -159,3 +159,27 @@ Tlumaczenia jednostek (per jezyk). | text | Nazwa jednostki | **Używane w:** `Domain\Dictionaries\DictionariesRepository` + +## pp_users +Uzytkownicy panelu administratora. + +| Kolumna | Opis | +|---------|------| +| id | PK | +| login | Login / e-mail uzytkownika | +| password | Hash hasla (legacy: md5) | +| status | Status konta: 1 = aktywny, 0 = zablokowany | +| admin | Flaga dostepu do panelu admin | +| error_logged_count | Licznik nieudanych logowan | +| last_logged | Data ostatniego poprawnego logowania | +| last_error_logged | Data ostatniej nieudanej proby logowania | +| twofa_enabled | Czy wlaczone 2FA (0/1) | +| twofa_email | E-mail do wysylki kodu 2FA | +| twofa_code_hash | Hash aktualnego kodu 2FA | +| twofa_expires_at | Data waznosci kodu 2FA | +| twofa_sent_at | Data ostatniej wysylki kodu 2FA | +| twofa_failed_attempts | Liczba nieudanych prob 2FA | + +**Uzywane w:** `Domain\User\UserRepository`, `admin\Controllers\UsersController`, `admin\factory\Users` + +**Aktualizacja 2026-02-12:** uzycia `pp_users` sa prowadzone przez `Domain\\User\\UserRepository` (legacy `admin\\factory\\Users` usunieto). diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md index dcb303e..b6b27f7 100644 --- a/PROJECT_STRUCTURE.md +++ b/PROJECT_STRUCTURE.md @@ -199,7 +199,8 @@ autoload/ │ ├── Controllers/ # Nowe kontrolery (namespace \admin\Controllers\) │ │ ├── BannerController.php # DI, instancyjny │ │ ├── SettingsController.php # DI, instancyjny (clearCache, save, view) -│ │ └── ProductArchiveController.php # DI, instancyjny (list, unarchive) +│ │ ├── ProductArchiveController.php # DI, instancyjny (list, unarchive) +│ │ └── UsersController.php # DI, instancyjny (view_list, user_edit, user_save, user_delete, login_form, twofa) │ ├── class.Site.php # Router: nowy kontroler → fallback stary │ ├── controls/ # Stare kontrolery (niezależny fallback) │ ├── factory/ # Stare helpery (niezależny fallback) @@ -210,7 +211,8 @@ autoload/ #### Aktualny stan migracji (uzupełnienie) - Dodane repozytorium: `Domain\Dictionaries\DictionariesRepository` -- Dodane kontrolery DI: `admin\Controllers\DictionariesController`, `admin\Controllers\FilemanagerController` +- Dodane kontrolery DI: `admin\Controllers\DictionariesController`, `admin\Controllers\FilemanagerController`, `admin\Controllers\UsersController` +- Dodane repozytorium: `Domain\User\UserRepository` - `Domain\Settings\SettingsRepository` działa bezpośrednio na DB (bez delegacji do `admin\factory\Settings`) ### Routing admin (admin\Site::route()) @@ -254,8 +256,8 @@ tests/ │ └── ProductArchiveControllerTest.php # 6 testów └── Integration/ ``` -Aktualnie w suite są też testy modułów `Dictionaries` i `Articles` (repozytoria + kontrolery DI). -**Łącznie: 82 tests, 181 assertions** +Aktualnie w suite są też testy modułów `Dictionaries`, `Articles` i `Users` (repozytoria + kontrolery DI). +**Łącznie: 119 tests, 256 assertions** ## Ostatnie modyfikacje @@ -368,5 +370,24 @@ Aktualnie w suite są też testy modułów `Dictionaries` i `Articles` (repozyto - Metoda `clear_product_cache()` w klasie S --- -*Dokument aktualizowany: 2026-02-10* +*Dokument aktualizowany: 2026-02-12* + +### 2026-02-12: Migracja Users (/admin/users) (ver. 0.253) +- **NOWE:** `Domain\User\UserRepository` - repozytorium uzytkownikow (CRUD, check_login, logon, details, 2FA) +- **NOWE:** `admin\Controllers\UsersController` - kontroler DI dla akcji `view_list`, `user_edit`, `user_save`, `user_delete`, `login_form`, `twofa` +- **UPDATE:** `admin\Site` - dodany factory wpis dla modulu `Users` w mapie nowych kontrolerow +- **UPDATE:** `admin\factory\Users` - fasada deleguje logike do `Domain\User\UserRepository` +- **UPDATE:** `admin/ajax/users.php` - `check_login` korzysta bezposrednio z `UserRepository` +- **CLEANUP:** usuniety `autoload/admin/controls/class.Users.php` (brak fallback - nowy kontroler obsluguje wszystkie akcje) +- Testy: 119 tests, 256 assertions + +--- +*Dokument aktualizowany: 2026-02-12* +- **UPDATE:** widoki Users przeniesione z `grid/gridEdit` na `components/table-list` i `components/form-edit` + +## Aktualizacja 2026-02-12 (finalizacja Users) +- Modu users dziaa na `Domain\\User\\UserRepository` + `admin\\Controllers\\UsersController`. +- Usunito legacy klasy: `autoload/admin/controls/class.Users.php`, `autoload/admin/factory/class.Users.php`, `autoload/admin/view/class.Users.php`. +- Walidacja: przy wczonym 2FA pole `twofa_email` jest wymagane. +- Widoki users przeniesione na `components/table-list` i `components/form-edit`. diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md index 003b647..63377ff 100644 --- a/REFACTORING_PLAN.md +++ b/REFACTORING_PLAN.md @@ -250,6 +250,16 @@ grep -r "Product::getQuantity" . - Legacy cleanup: usunięto `autoload/admin/controls/class.Filemanager.php` i `autoload/admin/view/class.FileManager.php` - Aktualizacja: ver. 0.252 +- **Users** (migracja kontrolera i repozytorium) + - ✅ UserRepository - **ZMIGROWANE** (2026-02-12) 🎉 + - Nowa klasa: `Domain\User\UserRepository` (find, getById, save, delete, checkLogin, logon, details, updateById, sendTwofaCode, verifyTwofaCode) + - Nowy kontroler: `admin\Controllers\UsersController` (DI, instancyjny: view_list, user_edit, user_save, user_delete, login_form, twofa) + - Router: `admin\Site` - factory wpis dla modulu `Users` + - Fasada: `admin\factory\Users` deleguje do repozytorium (backward compatibility dla login/2FA flow) + - AJAX: `admin/ajax/users.php` - `check_login` oparty o `UserRepository` + - Legacy cleanup: usuniety `autoload/admin/controls/class.Users.php` + - Testy: 25 testow repozytorium (CRUD, logon, 2FA, checkLogin) + 12 testow kontrolera (kontrakty + normalizeUser) + ### 📋 Do zrobienia - Order - Category @@ -275,16 +285,18 @@ tests/ │ │ ├── Cache/CacheRepositoryTest.php │ │ ├── Dictionaries/DictionariesRepositoryTest.php │ │ ├── Product/ProductRepositoryTest.php -│ │ └── Settings/SettingsRepositoryTest.php +│ │ ├── Settings/SettingsRepositoryTest.php +│ │ └── User/UserRepositoryTest.php │ └── admin/ │ └── Controllers/ │ ├── ArticlesControllerTest.php │ ├── DictionariesControllerTest.php │ ├── ProductArchiveControllerTest.php -│ └── SettingsControllerTest.php +│ ├── SettingsControllerTest.php +│ └── UsersControllerTest.php └── Integration/ ``` -**Łącznie: 82 testów, 181 asercji** +**Łącznie: 119 testów, 256 asercji** ### Przykład testu ```php @@ -368,10 +380,11 @@ vendor/bin/phpstan analyse autoload/Domain 5. **Dictionaries** ✅ (repo + kontroler + form/table, ver. 0.251) 6. **ProductArchive** ✅ (migracja kontrolera + cleanup szablonów, ver. 0.252) 7. **Filemanager** ✅ (migracja routingu + fix `Invalid Key`, ver. 0.252) -8. **Order** -9. **Category** -10. **ShopAttribute** -11. **Pages** (`browse_list` i powiązane widoki nadal legacy) +8. **Users** ✅ (repo + kontroler + 2FA + legacy cleanup, ver. 0.253) +9. **Order** +10. **Category** +11. **ShopAttribute** +12. **Pages** (`browse_list` i powiązane widoki nadal legacy) - **Form Edit System** - Nowy uniwersalny system formularzy edycji - ✅ Klasy ViewModel: `FormFieldType`, `FormField`, `FormTab`, `FormAction`, `FormEditViewModel` @@ -386,7 +399,7 @@ vendor/bin/phpstan analyse autoload/Domain --- *Rozpoczęto: 2025-02-05* -*Ostatnia aktualizacja: 2026-02-10* +*Ostatnia aktualizacja: 2026-02-12* ## Form Edit System - Dokumentacja użycia @@ -544,3 +557,20 @@ Gdy `persist = true`: 3. **Szablon** - usuń stary szablon lub zostaw jako fallback 4. **Testy** - zaktualizuj testy jeśli zmienił się format danych + +## Aktualizacja 2026-02-12 - Users + +### Users (migracja kontrolera i repozytorium) +- **NOWE:** `Domain\User\UserRepository` (delete, find, save, checkLogin, logon, details, sendTwofaCode, verifyTwofaCode) +- **NOWE:** `admin\Controllers\UsersController` (view_list, user_edit, user_save, user_delete) +- **UPDATE:** Router `admin\Site` - nowy kontroler DI dla modu�u `Users` +- **UPDATE:** `admin\factory\Users` jako fasada delegujaca do repozytorium +- **UPDATE:** `admin/ajax/users.php` - endpoint `check_login` oparty o `UserRepository` +- Testy po zmianie: 95 tests, 204 assertions +- **UPDATE:** UsersController: `view_list` + `user_edit` migrowane na nowy system list/form (table-list + form-edit) + +## Aktualizacja 2026-02-12 (finalizacja Users) +- Users: pelna migracja na nowa architekture (Domain + DI Controller), bez fallbacku do legacy kontrolera/factory/view. +- `UsersController` obsluguje: `list/view_list`, `user_edit`, `user_save`, `user_delete`, `login_form`, `twofa`. +- Dodano walidacje warunkowa: `twofa_email` wymagany gdy `twofa_enabled = 1`. +- Widoki users migrowane z `grid/gridEdit` na `table-list` i `form-edit`. diff --git a/TESTING.md b/TESTING.md index 501ceb1..0a94d8b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -51,13 +51,15 @@ tests/ | | |-- Cache/CacheRepositoryTest.php | | |-- Dictionaries/DictionariesRepositoryTest.php | | |-- Product/ProductRepositoryTest.php -| | `-- Settings/SettingsRepositoryTest.php +| | |-- Settings/SettingsRepositoryTest.php +| | `-- User/UserRepositoryTest.php | `-- admin/ | `-- Controllers/ | |-- ArticlesControllerTest.php | |-- DictionariesControllerTest.php | |-- ProductArchiveControllerTest.php -| `-- SettingsControllerTest.php +| |-- SettingsControllerTest.php +| `-- UsersControllerTest.php `-- Integration/ ``` @@ -138,3 +140,27 @@ $this->assertEquals(42, $value); - Konfiguracja PHPUnit: `phpunit.xml` - Bootstrap testow: `tests/bootstrap.php` - Dodatkowy opis: `tests/README.md` + +## Aktualizacja suite + +Ostatnio zweryfikowano: 2026-02-12 + +```text +OK (119 tests, 256 assertions) +``` + +Nowe testy dodane 2026-02-12: +- `tests/Unit/Domain/User/UserRepositoryTest.php` (25 testow: CRUD, logon, 2FA verify/send, checkLogin, updateById) +- `tests/Unit/admin/Controllers/UsersControllerTest.php` (12 testow: kontrakty + normalizeUser) + +Aktualizacja po migracji widokow Users (2026-02-12): +```text +OK (120 tests, 262 assertions) +``` + +## Aktualizacja suite (finalizacja Users) +Ostatnio zweryfikowano: 2026-02-12 + +```text +OK (120 tests, 262 assertions) +``` diff --git a/admin/ajax/users.php b/admin/ajax/users.php index 7ab9339..72ed241 100644 --- a/admin/ajax/users.php +++ b/admin/ajax/users.php @@ -3,7 +3,9 @@ $a = \S::get( 'a' ); if ( $a == 'check_login' ) { - $response = \admin\factory\Users::check_login( \S::get( 'login' ), \S::get( 'user_id' ) ); + global $mdb; + $repository = new \Domain\User\UserRepository( $mdb ); + $response = $repository->checkLogin( (string)\S::get( 'login' ), (int)\S::get( 'user_id' ) ); echo json_encode( $response ); exit; -} \ No newline at end of file +} diff --git a/admin/index.php b/admin/index.php index f1c24bf..f621ef0 100644 --- a/admin/index.php +++ b/admin/index.php @@ -97,6 +97,7 @@ $cookie_name = 'admin_remember_' . str_replace( '.', '-', $domain ); if ( isset( $_COOKIE[$cookie_name] ) && !isset( $_SESSION['user'] ) ) { + $users = new \Domain\User\UserRepository($mdb); $payload = base64_decode($_COOKIE[$cookie_name]); if ($payload !== false && strpos($payload, '.') !== false) { @@ -114,7 +115,7 @@ if ( isset( $_COOKIE[$cookie_name] ) && !isset( $_SESSION['user'] ) ) $user_data = $mdb->get('pp_users', '*', ['AND' => ['login' => $data['login'], 'status' => 1]]); if ($user_data) { - \S::set_session('user', \admin\factory\Users::details($data['login'])); + \S::set_session('user', $users->details($data['login'])); $redirect = $_SERVER['REQUEST_URI'] ?: '/admin/articles/view_list/'; header('Location: ' . $redirect); exit; @@ -135,4 +136,4 @@ if ( isset( $_COOKIE[$cookie_name] ) && !isset( $_SESSION['user'] ) ) } echo \admin\view\Page::show(); -?> \ No newline at end of file +?> diff --git a/admin/templates/components/table-list.php b/admin/templates/components/table-list.php index dac32c2..ddb168b 100644 --- a/admin/templates/components/table-list.php +++ b/admin/templates/components/table-list.php @@ -18,6 +18,21 @@ $page = max(1, (int)($list->pagination['page'] ?? 1)); $totalPages = max(1, (int)($list->pagination['total_pages'] ?? 1)); $total = (int)($list->pagination['total'] ?? 0); $perPage = (int)($list->pagination['per_page'] ?? 15); + +$isCompactColumn = function(array $column): bool { + $key = strtolower(trim((string)($column['key'] ?? ''))); + $label = strtolower(trim((string)($column['label'] ?? ''))); + + if (in_array($key, ['status', 'active', 'enabled', 'is_active'], true)) { + return true; + } + + if (in_array($label, ['status', 'aktywny', 'aktywnosc', 'active'], true)) { + return true; + } + + return false; +}; ?>
@@ -42,17 +57,34 @@ $perPage = (int)($list->pagination['per_page'] ?? 15); $maxOptionLen) { + $maxOptionLen = $len; + } + } + + // Krotkie selekty (np. tak/nie) nie musza zajmowac szerokiej kolumny. + $isCompactFilter = count($options) <= 5 && $maxOptionLen <= 12; + } + $filterColClass = $isCompactFilter ? 'col-sm-1 col-xs-6 mb10' : 'col-sm-2 mb10'; ?> -
+
- + @@ -236,6 +276,27 @@ $perPage = (int)($list->pagination['per_page'] ?? 15); white-space: nowrap; } + .js-table-filters-form .js-filter-compact-select { + width: auto; + min-width: 110px; + max-width: 140px; + } + + .table-list-table th.table-col-compact, + .table-list-table td.table-col-compact { + width: 120px; + min-width: 120px; + white-space: nowrap; + } + + .table-list-per-page-form { + display: inline-flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 8px; + } + .jconfirm.table-list-confirm-dialog .jconfirm-row { min-height: 100vh; display: flex; diff --git a/admin/templates/users/user-edit.php b/admin/templates/users/user-edit.php index 232dcf7..be2362b 100644 --- a/admin/templates/users/user-edit.php +++ b/admin/templates/users/user-edit.php @@ -1,90 +1,2 @@ - $this->form]); ?> -$this -> user['id'] ? $password_param = 'optional' : $password_param = 'require'; - -$grid = new \gridEdit; -$grid -> gdb_opt = $gdb; -$grid -> include_plugins = true; -$grid -> title = 'Zapisz użytkownika'; -$grid -> fields = [ - [ - 'db' => 'id', - 'type' => 'hidden', - 'value' => $this -> user['id'] - ], - [ - 'db' => 'admin', - 'type' => 'hidden', - 'value' => '1' - ], - [ - 'name' => 'Login', - 'db' => 'login', - 'type' => 'text', - 'value' => $this -> user['login'], - 'params' => [ 'class' => 'require', 'function' => 'check_login' ] - ], - [ - 'name' => 'Aktywny', - 'db' => 'status', - 'type' => 'input_switch', - 'checked' => $this -> user['status'] ? true : false - ], [ - 'db' => 'twofa_enabled', - 'name' => 'Dwustopniowe uwierzytelnianie (2FA)', - 'type' => 'input_switch', - 'checked' => $this -> user['twofa_enabled'] ? true : false, - ], [ - 'db' => 'twofa_email', - 'name' => 'E-mail do 2FA', - 'type' => 'text', - 'value' => $this -> user['twofa_email'], - ], [ - 'name' => 'Hasło', - 'db' => 'password', - 'type' => 'text', - 'params' => [ 'class' => $password_param, 'min' => 5 ] - ], - [ - 'name' => 'Hasło - powtórz', - 'db' => 'password_re', - 'type' => 'text', - 'params' => [ 'class' => $password_param, 'min' => 5, 'equal' => 'password', 'error_txt' => 'Podane hasła są różne' ] - ] - ]; -$grid -> actions = [ - 'save' => [ 'url' => '/admin/users/user_save/', 'back_url' => '/admin/users/view_list/' ], - 'cancel' => [ 'url' => '/admin/users/view_list/' ] - ]; -echo $grid -> draw(); -?> - \ No newline at end of file diff --git a/admin/templates/users/users-list.php b/admin/templates/users/users-list.php index f1623fd..b336124 100644 --- a/admin/templates/users/users-list.php +++ b/admin/templates/users/users-list.php @@ -1,47 +1,2 @@ - $this->viewModel]); ?> -$grid = new \grid( 'pp_users' ); -$grid -> gdb_opt = $gdb; -$grid -> order = [ 'column' => 'login', 'type' => 'ASC' ]; -$grid -> where = [ 'id[!]' => 1 ]; -$grid -> search = [ - [ 'name' => 'Login', 'db' => 'login', 'type' => 'text' ], - [ 'name' => 'Aktywny', 'db' => 'status', 'type' => 'select', 'replace' => [ 'array' => [ 0 => 'nie', 1 => 'tak' ] ] ] - ]; -$grid -> columns_view = [ - [ - 'name' => 'Lp.', - 'th' => [ 'class' => 'g-lp' ], - 'td' => [ 'class' => 'g-center' ], - 'autoincrement' => true - ], - [ - 'name' => 'Aktywny', - 'db' => 'status', - 'replace' => [ 'array' => [ 0 => 'nie', 1 => 'tak' ] ], - 'th' => [ 'class' => 'g-center', 'style' => 'width: 150px;' ], - 'td' => [ 'class' => 'g-center' ] - ], - [ - 'name' => 'Login', - 'db' => 'login', - 'sort' => true - ], - [ - 'name' => 'Edytuj', - 'action' => [ 'type' => 'edit', 'url' => '/admin/users/user_edit/id=[id]' ], - 'th' => [ 'class' => 'g-center', 'style' => 'width: 50px;' ], - 'td' => [ 'class' => 'g-center' ] - ], - [ - 'name' => 'Usuń', - 'action' => [ 'type' => 'delete', 'url' => '/admin/users/user_delete/id=[id]' ], - 'th' => [ 'class' => 'g-center', 'style' => 'width: 50px;' ], - 'td' => [ 'class' => 'g-center' ] - ] - ]; -$grid -> buttons = [ - [ 'label' => 'Dodaj użytkownika', 'url' => '/admin/users/user_edit/', 'icon' => 'fa-plus-circle', 'class' => 'btn-success' ] - ]; -echo $grid -> draw(); \ No newline at end of file diff --git a/autoload/Domain/User/UserRepository.php b/autoload/Domain/User/UserRepository.php new file mode 100644 index 0000000..4518670 --- /dev/null +++ b/autoload/Domain/User/UserRepository.php @@ -0,0 +1,337 @@ +db = $db; + } + + public function getById(int $userId): ?array + { + $user = $this->db->get('pp_users', '*', ['id' => $userId]); + return $user ?: null; + } + + public function updateById(int $userId, array $data): bool + { + return (bool)$this->db->update('pp_users', $data, ['id' => $userId]); + } + + public function verifyTwofaCode(int $userId, string $code): bool + { + $user = $this->getById($userId); + if (!$user) { + return false; + } + + if ((int)($user['twofa_failed_attempts'] ?? 0) >= 5) { + return false; + } + + if (empty($user['twofa_expires_at']) || time() > strtotime((string)$user['twofa_expires_at'])) { + $this->updateById($userId, [ + 'twofa_code_hash' => null, + 'twofa_expires_at' => null, + ]); + return false; + } + + $ok = (!empty($user['twofa_code_hash']) && password_verify($code, (string)$user['twofa_code_hash'])); + if ($ok) { + $this->updateById($userId, [ + 'twofa_code_hash' => null, + 'twofa_expires_at' => null, + 'twofa_sent_at' => null, + 'twofa_failed_attempts' => 0, + 'last_logged' => date('Y-m-d H:i:s'), + ]); + return true; + } + + $this->updateById($userId, [ + 'twofa_failed_attempts' => (int)($user['twofa_failed_attempts'] ?? 0) + 1, + 'last_error_logged' => date('Y-m-d H:i:s'), + ]); + + return false; + } + + public function sendTwofaCode(int $userId, bool $resend = false): bool + { + $user = $this->getById($userId); + if (!$user) { + return false; + } + + if ((int)($user['twofa_enabled'] ?? 0) !== 1) { + return false; + } + + $to = !empty($user['twofa_email']) ? (string)$user['twofa_email'] : (string)$user['login']; + if (!filter_var($to, FILTER_VALIDATE_EMAIL)) { + return false; + } + + if ($resend && !empty($user['twofa_sent_at'])) { + $last = strtotime((string)$user['twofa_sent_at']); + if ($last && (time() - $last) < 30) { + return false; + } + } + + $code = random_int(100000, 999999); + $hash = password_hash((string)$code, PASSWORD_DEFAULT); + + $this->updateById($userId, [ + 'twofa_code_hash' => $hash, + 'twofa_expires_at' => date('Y-m-d H:i:s', time() + 10 * 60), + 'twofa_sent_at' => date('Y-m-d H:i:s'), + 'twofa_failed_attempts' => 0, + ]); + + $subject = 'Twoj kod logowania 2FA'; + $body = 'Twoj kod logowania do panelu administratora: ' . $code . '. Kod jest wazny przez 10 minut.'; + + $sent = \S::send_email($to, $subject, $body); + if ($sent) { + return true; + } + + $headers = "MIME-Version: 1.0\r\n"; + $headers .= "Content-type: text/plain; charset=UTF-8\r\n"; + $headers .= "From: no-reply@" . ($_SERVER['HTTP_HOST'] ?? 'localhost') . "\r\n"; + $encodedSubject = mb_encode_mimeheader($subject, 'UTF-8'); + + return mail($to, $encodedSubject, $body, $headers); + } + + public function delete(int $userId): bool + { + return (bool)$this->db->delete('pp_users', ['id' => $userId]); + } + + public function find(int $userId): ?array + { + $user = $this->db->get('pp_users', '*', ['id' => $userId]); + return $user ?: null; + } + + public function save( + int $userId, + string $login, + $status, + string $password, + string $passwordRepeat, + $admin, + $twofaEnabled = 0, + string $twofaEmail = '' + ): array { + if ($userId <= 0) { + if (strlen($password) < 5) { + return ['status' => 'error', 'msg' => 'Podane haslo jest zbyt krotkie.']; + } + + if ($password !== $passwordRepeat) { + return ['status' => 'error', 'msg' => 'Podane hasla sa rozne']; + } + + $inserted = $this->db->insert('pp_users', [ + 'login' => $login, + 'status' => $this->toSwitchValue($status), + 'admin' => (int)$admin, + 'password' => md5($password), + 'twofa_enabled' => $this->toSwitchValue($twofaEnabled), + 'twofa_email' => $twofaEmail, + ]); + + if ($inserted) { + return ['status' => 'ok', 'msg' => 'Uzytkownik zostal zapisany.']; + } + + return ['status' => 'error', 'msg' => 'Podczas zapisywania uzytkownika wystapil blad.']; + } + + if ($password !== '' && strlen($password) < 5) { + return ['status' => 'error', 'msg' => 'Podane haslo jest zbyt krotkie.']; + } + + if ($password !== '' && $password !== $passwordRepeat) { + return ['status' => 'error', 'msg' => 'Podane hasla sa rozne']; + } + + if ($password !== '') { + $this->db->update('pp_users', [ + 'password' => md5($password), + ], [ + 'id' => $userId, + ]); + } + + $this->db->update('pp_users', [ + 'login' => $login, + 'admin' => (int)$admin, + 'status' => $this->toSwitchValue($status), + 'twofa_enabled' => $this->toSwitchValue($twofaEnabled), + 'twofa_email' => $twofaEmail, + ], [ + 'id' => $userId, + ]); + + return ['status' => 'ok', 'msg' => 'Uzytkownik zostal zapisany.']; + } + + public function checkLogin(string $login, int $userId): array + { + $existing = $this->db->get('pp_users', 'login', [ + 'AND' => [ + 'login' => $login, + 'id[!]' => $userId, + ], + ]); + + if ($existing) { + return ['status' => 'error', 'msg' => 'Podany login jest juz zajety.']; + } + + return ['status' => 'ok']; + } + + public function logon(string $login, string $password): int + { + if (!$this->db->get('pp_users', '*', ['login' => $login])) { + return 0; + } + + if (!$this->db->get('pp_users', '*', [ + 'AND' => [ + 'login' => $login, + 'status' => 1, + 'error_logged_count[<]' => 5, + ], + ])) { + return -1; + } + + if ($this->db->get('pp_users', '*', [ + 'AND' => [ + 'login' => $login, + 'status' => 1, + 'password' => md5($password), + ], + ])) { + $this->db->update('pp_users', [ + 'last_logged' => date('Y-m-d H:i:s'), + 'error_logged_count' => 0, + ], [ + 'login' => $login, + ]); + + return 1; + } + + $this->db->update('pp_users', [ + 'last_error_logged' => date('Y-m-d H:i:s'), + 'error_logged_count[+]' => 1, + ], [ + 'login' => $login, + ]); + + if ((int)$this->db->get('pp_users', 'error_logged_count', ['login' => $login]) >= 5) { + $this->db->update('pp_users', ['status' => 0], ['login' => $login]); + return -1; + } + + return 0; + } + + public function details(string $login): ?array + { + $user = $this->db->get('pp_users', '*', ['login' => $login]); + return $user ?: null; + } + + /** + * @return array{items: array>, total: int} + */ + public function listForAdmin( + array $filters, + string $sortColumn = 'login', + string $sortDir = 'ASC', + int $page = 1, + int $perPage = 15 + ): array { + $allowedSortColumns = [ + 'login' => 'pu.login', + 'status' => 'pu.status', + ]; + + $sortSql = $allowedSortColumns[$sortColumn] ?? 'pu.login'; + $sortDir = strtoupper(trim($sortDir)) === 'DESC' ? 'DESC' : 'ASC'; + $page = max(1, $page); + $perPage = min(self::MAX_PER_PAGE, max(1, $perPage)); + $offset = ($page - 1) * $perPage; + + $where = ['pu.id != 1']; + $params = []; + + $login = trim((string)($filters['login'] ?? '')); + if ($login !== '') { + if (strlen($login) > 255) { + $login = substr($login, 0, 255); + } + $where[] = 'pu.login LIKE :login'; + $params[':login'] = '%' . $login . '%'; + } + + $status = trim((string)($filters['status'] ?? '')); + if ($status === '0' || $status === '1') { + $where[] = 'pu.status = :status'; + $params[':status'] = (int)$status; + } + + $whereSql = implode(' AND ', $where); + + $sqlCount = " + SELECT COUNT(0) + FROM pp_users AS pu + WHERE {$whereSql} + "; + + $stmtCount = $this->db->query($sqlCount, $params); + $countRows = $stmtCount ? $stmtCount->fetchAll() : []; + $total = isset($countRows[0][0]) ? (int)$countRows[0][0] : 0; + + $sql = " + SELECT + pu.id, + pu.login, + pu.status + FROM pp_users AS pu + WHERE {$whereSql} + ORDER BY {$sortSql} {$sortDir}, pu.id ASC + LIMIT {$perPage} OFFSET {$offset} + "; + + $stmt = $this->db->query($sql, $params); + $items = $stmt ? $stmt->fetchAll() : []; + + return [ + 'items' => is_array($items) ? $items : [], + 'total' => $total, + ]; + } + + private function toSwitchValue($value): int + { + return ($value === 'on' || $value === 1 || $value === '1' || $value === true) ? 1 : 0; + } +} diff --git a/autoload/admin/Controllers/UsersController.php b/autoload/admin/Controllers/UsersController.php new file mode 100644 index 0000000..6d89283 --- /dev/null +++ b/autoload/admin/Controllers/UsersController.php @@ -0,0 +1,346 @@ +repository = $repository; + $this->formHandler = new FormRequestHandler(); + } + + public function user_delete(): void + { + if ($this->repository->delete((int)\S::get('id'))) { + \S::alert('Uzytkownik zostal usuniety.'); + } + + header('Location: /admin/users/view_list/'); + exit; + } + + public function user_save(): void + { + $legacyValues = \S::get('values'); + if ($legacyValues) { + $values = json_decode((string)$legacyValues, true); + if (!is_array($values)) { + echo json_encode(['status' => 'error', 'msg' => 'Nieprawidlowe dane formularza.']); + exit; + } + + if (!$this->isTwofaEmailValidForEnabled($values['twofa_enabled'] ?? 0, (string)($values['twofa_email'] ?? ''))) { + echo json_encode([ + 'status' => 'error', + 'msg' => 'Jesli wlaczono dwustopniowe uwierzytelnianie (2FA), pole "E-mail do 2FA" jest wymagane.', + ]); + exit; + } + + $response = $this->repository->save( + (int)($values['id'] ?? 0), + (string)($values['login'] ?? ''), + $values['status'] ?? 0, + (string)($values['password'] ?? ''), + (string)($values['password_re'] ?? ''), + $values['admin'] ?? 1, + $values['twofa_enabled'] ?? 0, + (string)($values['twofa_email'] ?? '') + ); + + echo json_encode($response); + exit; + } + + $userId = (int)\S::get('id'); + $user = $this->normalizeUser($this->repository->find($userId)); + $viewModel = $this->buildFormViewModel($user); + + $result = $this->formHandler->handleSubmit($viewModel, $_POST); + if (!$result['success']) { + $_SESSION['form_errors'][$this->getFormId()] = $result['errors']; + echo json_encode(['success' => false, 'errors' => $result['errors']]); + exit; + } + + $data = $result['data']; + $data['id'] = $userId; + $data['admin'] = 1; + + if (!$this->isTwofaEmailValidForEnabled($data['twofa_enabled'] ?? 0, (string)($data['twofa_email'] ?? ''))) { + echo json_encode([ + 'success' => false, + 'errors' => [ + 'twofa_email' => 'Pole "E-mail do 2FA" jest wymagane, gdy wlaczono 2FA.', + ], + ]); + exit; + } + + $duplicateLoginCheck = $this->repository->checkLogin((string)($data['login'] ?? ''), $userId); + if (($duplicateLoginCheck['status'] ?? '') !== 'ok') { + echo json_encode([ + 'success' => false, + 'errors' => ['login' => (string)($duplicateLoginCheck['msg'] ?? 'Podany login jest juz zajety.')], + ]); + exit; + } + + $response = $this->repository->save( + (int)$data['id'], + (string)($data['login'] ?? ''), + (int)($data['status'] ?? 0), + (string)($data['password'] ?? ''), + (string)($data['password_re'] ?? ''), + 1, + (int)($data['twofa_enabled'] ?? 0), + (string)($data['twofa_email'] ?? '') + ); + + echo json_encode([ + 'success' => ($response['status'] ?? '') === 'ok', + 'message' => (string)($response['msg'] ?? 'Zmiany zostaly zapisane.'), + 'errors' => (($response['status'] ?? '') === 'ok') ? [] : ['general' => (string)($response['msg'] ?? 'Wystapil blad.')], + ]); + exit; + } + + public function user_edit(): string + { + $user = $this->normalizeUser($this->repository->find((int)\S::get('id'))); + $validationErrors = $_SESSION['form_errors'][$this->getFormId()] ?? null; + if ($validationErrors) { + unset($_SESSION['form_errors'][$this->getFormId()]); + } + + return \Tpl::view('users/user-edit', [ + 'form' => $this->buildFormViewModel($user, $validationErrors), + ]); + } + + public function view_list(): string + { + $sortableColumns = ['login', 'status']; + $filterDefinitions = [ + [ + 'key' => 'login', + 'label' => 'Login', + 'type' => 'text', + ], + [ + 'key' => 'status', + 'label' => 'Aktywny', + 'type' => 'select', + 'options' => [ + '' => '- aktywny -', + '1' => 'tak', + '0' => 'nie', + ], + ], + ]; + + $listRequest = \admin\Support\TableListRequestFactory::fromRequest( + $filterDefinitions, + $sortableColumns, + 'login' + ); + + $sortDir = $listRequest['sortDir']; + if (trim((string)\S::get('sort')) === '') { + $sortDir = 'ASC'; + } + + $result = $this->repository->listForAdmin( + $listRequest['filters'], + $listRequest['sortColumn'], + $sortDir, + $listRequest['page'], + $listRequest['perPage'] + ); + + $rows = []; + $lp = ($listRequest['page'] - 1) * $listRequest['perPage'] + 1; + foreach ($result['items'] as $item) { + $id = (int)$item['id']; + $login = trim((string)($item['login'] ?? '')); + $status = (int)($item['status'] ?? 0); + + $rows[] = [ + 'lp' => $lp++ . '.', + 'status' => $status === 1 ? 'tak' : 'nie', + 'login' => '' . htmlspecialchars($login, ENT_QUOTES, 'UTF-8') . '', + '_actions' => [ + [ + 'label' => 'Edytuj', + 'url' => '/admin/users/user_edit/id=' . $id, + 'class' => 'btn btn-xs btn-primary', + ], + [ + 'label' => 'Usun', + 'url' => '/admin/users/user_delete/id=' . $id, + 'class' => 'btn btn-xs btn-danger', + 'confirm' => 'Na pewno chcesz usunac wybranego uzytkownika?', + ], + ], + ]; + } + + $total = (int)$result['total']; + $totalPages = max(1, (int)ceil($total / $listRequest['perPage'])); + + $viewModel = new \admin\ViewModels\Common\PaginatedTableViewModel( + [ + ['key' => 'lp', 'label' => 'Lp.', 'class' => 'text-center', 'sortable' => false], + ['key' => 'status', 'sort_key' => 'status', 'label' => 'Aktywny', 'class' => 'text-center', 'sortable' => true, 'raw' => true], + ['key' => 'login', 'sort_key' => 'login', 'label' => 'Login', 'sortable' => true, 'raw' => true], + ], + $rows, + $listRequest['viewFilters'], + [ + 'column' => $listRequest['sortColumn'], + 'dir' => $sortDir, + ], + [ + 'page' => $listRequest['page'], + 'per_page' => $listRequest['perPage'], + 'total' => $total, + 'total_pages' => $totalPages, + ], + array_merge($listRequest['queryFilters'], [ + 'sort' => $listRequest['sortColumn'], + 'dir' => $sortDir, + 'per_page' => $listRequest['perPage'], + ]), + $listRequest['perPageOptions'], + $sortableColumns, + '/admin/users/view_list/', + 'Brak danych w tabeli.', + '/admin/users/user_edit/', + 'Dodaj uzytkownika' + ); + + return \Tpl::view('users/users-list', [ + 'viewModel' => $viewModel, + ]); + } + + public function list(): string + { + return $this->view_list(); + } + + public function login_form(): string + { + return \Tpl::view('site/unlogged-layout'); + } + + public function twofa(): string + { + return \Tpl::view('site/unlogged', [ + 'content' => \Tpl::view('users/user-2fa'), + ]); + } + + private function normalizeUser(?array $user): array + { + return [ + 'id' => (int)($user['id'] ?? 0), + 'login' => (string)($user['login'] ?? ''), + 'status' => (int)($user['status'] ?? 1), + 'admin' => (int)($user['admin'] ?? 1), + 'twofa_enabled' => (int)($user['twofa_enabled'] ?? 0), + 'twofa_email' => (string)($user['twofa_email'] ?? ''), + ]; + } + + private function buildFormViewModel(array $user, ?array $errors = null): FormEditViewModel + { + $userId = (int)($user['id'] ?? 0); + $isNew = $userId <= 0; + + $data = [ + 'id' => $userId, + 'login' => (string)($user['login'] ?? ''), + 'status' => (int)($user['status'] ?? 1), + 'twofa_enabled' => (int)($user['twofa_enabled'] ?? 0), + 'twofa_email' => (string)($user['twofa_email'] ?? ''), + 'password' => '', + 'password_re' => '', + ]; + + $fields = [ + FormField::text('login', [ + 'label' => 'Login', + 'required' => true, + ]), + FormField::switch('status', [ + 'label' => 'Aktywny', + ]), + FormField::switch('twofa_enabled', [ + 'label' => 'Dwustopniowe uwierzytelnianie (2FA)', + ]), + FormField::email('twofa_email', [ + 'label' => 'E-mail do 2FA', + ]), + FormField::password('password', [ + 'label' => 'Haslo', + 'required' => $isNew, + 'attributes' => ['minlength' => 5], + ]), + FormField::password('password_re', [ + 'label' => 'Haslo - powtorz', + 'required' => $isNew, + 'attributes' => ['minlength' => 5], + ]), + ]; + + $actionUrl = '/admin/users/user_save/' . ($isNew ? '' : ('id=' . $userId)); + $actions = [ + FormAction::save($actionUrl, '/admin/users/view_list/'), + FormAction::cancel('/admin/users/view_list/'), + ]; + + return new FormEditViewModel( + $this->getFormId(), + $isNew ? 'Nowy uzytkownik' : 'Edycja uzytkownika', + $data, + $fields, + [], + $actions, + 'POST', + $actionUrl, + '/admin/users/view_list/', + true, + [ + 'id' => $userId, + 'admin' => 1, + ], + null, + $errors + ); + } + + private function getFormId(): string + { + return 'users-edit'; + } + + private function isTwofaEmailValidForEnabled($twofaEnabled, string $twofaEmail): bool + { + $enabled = ($twofaEnabled === 'on' || $twofaEnabled === 1 || $twofaEnabled === '1' || $twofaEnabled === true); + if (!$enabled) { + return true; + } + + return trim($twofaEmail) !== ''; + } +} diff --git a/autoload/admin/class.Site.php b/autoload/admin/class.Site.php index 7d196bd..30daac6 100644 --- a/autoload/admin/class.Site.php +++ b/autoload/admin/class.Site.php @@ -34,9 +34,12 @@ class Site public static function special_actions() { + global $mdb; + $sa = \S::get('s-action'); $domain = preg_replace('/^www\./', '', $_SERVER['SERVER_NAME']); $cookie_name = 'admin_remember_' . str_replace( '.', '-', $domain ); + $users = new \Domain\User\UserRepository($mdb); switch ($sa) { @@ -45,11 +48,11 @@ class Site $login = \S::get('login'); $pass = \S::get('password'); - $result = \admin\factory\Users::logon($login, $pass); + $result = $users->logon($login, $pass); if ( $result == 1 ) { - $user = \admin\factory\Users::details($login); + $user = $users->details($login); if ( $user['twofa_enabled'] == 1 ) { @@ -60,7 +63,7 @@ class Site 'started' => time(), ] ); - if ( !\admin\factory\Users::send_twofa_code( (int)$user['id'] ) ) + if ( !$users->sendTwofaCode( (int)$user['id'] ) ) { \S::alert('Nie udało się wysłać kodu 2FA. Spróbuj ponownie.'); \S::delete_session('twofa_pending'); @@ -73,7 +76,7 @@ class Site } else { - $user = \admin\factory\Users::details($login); + $user = $users->details($login); self::finalize_admin_login( $user, @@ -119,7 +122,7 @@ class Site exit; } - $ok = \admin\factory\Users::verify_twofa_code((int)$pending['uid'], $code); + $ok = $users->verifyTwofaCode((int)$pending['uid'], $code); if (!$ok) { \S::alert('Błędny lub wygasły kod.'); @@ -128,7 +131,7 @@ class Site } // 2FA OK — finalna sesja - $user = \admin\factory\Users::details($pending['login']); + $user = $users->details($pending['login']); self::finalize_admin_login( $user, @@ -152,7 +155,7 @@ class Site exit; } - if (!\admin\factory\Users::send_twofa_code((int)$pending['uid'], true)) + if (!$users->sendTwofaCode((int)$pending['uid'], true)) { \S::alert('Kod można wysłać ponownie po krótkiej przerwie.'); } @@ -245,6 +248,13 @@ class Site 'Filemanager' => function() { return new \admin\Controllers\FilemanagerController(); }, + 'Users' => function() { + global $mdb; + + return new \admin\Controllers\UsersController( + new \Domain\User\UserRepository( $mdb ) + ); + }, ]; return self::$newControllers; diff --git a/autoload/admin/controls/class.Users.php b/autoload/admin/controls/class.Users.php deleted file mode 100644 index 7a86b6e..0000000 --- a/autoload/admin/controls/class.Users.php +++ /dev/null @@ -1,43 +0,0 @@ - \Tpl::view( 'users/user-2fa' ) - ] ); - } -} -?> diff --git a/autoload/admin/factory/class.Users.php b/autoload/admin/factory/class.Users.php deleted file mode 100644 index c8cc151..0000000 --- a/autoload/admin/factory/class.Users.php +++ /dev/null @@ -1,214 +0,0 @@ -= 5) - { - return false; // zbyt wiele prób - } - - // sprawdź ważność - if (empty($user['twofa_expires_at']) || time() > strtotime($user['twofa_expires_at'])) - { - // wyczyść po wygaśnięciu - self::update_by_id($userId, [ - 'twofa_code_hash' => null, - 'twofa_expires_at' => null, - ]); - return false; - } - - $ok = (!empty($user['twofa_code_hash']) && password_verify($code, $user['twofa_code_hash'])); - if ($ok) - { - // sukces: czyścimy wszystko - self::update_by_id($userId, [ - 'twofa_code_hash' => null, - 'twofa_expires_at' => null, - 'twofa_sent_at' => null, - 'twofa_failed_attempts' => 0, - 'last_logged' => date('Y-m-d H:i:s'), - ]); - return true; - } - - // zła próba — inkrementacja - self::update_by_id($userId, [ - 'twofa_failed_attempts' => (int)$user['twofa_failed_attempts'] + 1, - 'last_error_logged' => date('Y-m-d H:i:s'), - ]); - return false; - } - - static public function get_by_id(int $userId): ?array { - global $mdb; - return $mdb->get('pp_users', '*', ['id' => $userId]) ?: null; - } - - static public function update_by_id(int $userId, array $data): bool { - global $mdb; - return (bool)$mdb->update('pp_users', $data, ['id' => $userId]); - } - - static public function send_twofa_code(int $userId, bool $resend = false): bool { - $user = self::get_by_id($userId); - - if ( !$user ) - return false; - - if ( (int)$user['twofa_enabled'] !== 1 ) - return false; - - $to = $user['twofa_email'] ?: $user['login']; - if (!filter_var($to, FILTER_VALIDATE_EMAIL)) - return false; - - if ( $resend && !empty( $user['twofa_sent_at'] ) ) { - $last = strtotime($user['twofa_sent_at']); - if ($last && (time() - $last) < 30) - return false; - } - - $code = random_int(100000, 999999); - $hash = password_hash((string)$code, PASSWORD_DEFAULT); - - self::update_by_id( $userId, [ - 'twofa_code_hash' => $hash, - 'twofa_expires_at' => date('Y-m-d H:i:s', time() + 10 * 60), // 10 minut - 'twofa_sent_at' => date('Y-m-d H:i:s'), - 'twofa_failed_attempts' => 0, - ] ); - - $subject = 'Twój kod logowania 2FA'; - $body = "Twój kod logowania do panelu administratora: {$code}. Kod jest ważny przez 10 minut. Jeśli to nie Ty inicjowałeś logowanie – zignoruj tę wiadomość i poinformuj administratora."; - - $sent = \S::send_email($to, $subject, $body); - - if (!$sent) { - $headers = "MIME-Version: 1.0\r\n"; - $headers .= "Content-type: text/plain; charset=UTF-8\r\n"; - $headers .= "From: no-reply@" . ($_SERVER['HTTP_HOST'] ?? 'localhost') . "\r\n"; - $encodedSubject = mb_encode_mimeheader($subject, 'UTF-8'); - - $sent = mail($to, $encodedSubject, $body, $headers); - } - - return $sent; - } - - public static function user_delete( $user_id ) - { - global $mdb; - return $mdb -> delete( 'pp_users', [ 'id' => (int)$user_id ] ); - } - - public static function user_details( $user_id ) - { - global $mdb; - return $mdb -> get( 'pp_users', '*', [ 'id' => (int)$user_id ] ); - } - - public static function user_save( $user_id = '', $login, $status, $password, $password_re, $admin, $twofa_enabled = 0, $twofa_email = '' ) - { - global $mdb, $lang, $config; - - if ( !$user_id ) - { - if ( strlen( $password ) < 5 ) - return $response = [ 'status' => 'error', 'msg' => 'Podane hasło jest zbyt krótkie.' ]; - - if ( $password != $password_re ) - return $response = [ 'status' => 'error', 'msg' => 'Podane hasła są różne' ]; - - if ( $mdb -> insert( 'pp_users', [ - 'login' => $login, - 'status' => $status == 'on' ? 1 : 0, - 'admin' => $admin, - 'password' => md5( $password ), - 'twofa_enabled' => $twofa_enabled == 'on' ? 1 : 0, - 'twofa_email' => $twofa_email - ] ) ) - { - return $response = [ 'status' => 'ok', 'msg' => 'Użytkownik został zapisany.' ]; - } - } - else - { - if ( $password and strlen( $password ) < 5 ) - return $response = [ 'status' => 'error', 'msg' => 'Podane hasło jest zbyt krótkie.' ]; - - if ( $password and $password != $password_re ) - return $response = [ 'status' => 'error', 'msg' => 'Podane hasła są różne' ]; - - if ( $password ) - $mdb -> update( 'pp_users', [ - 'password' => md5( $password ) - ], [ - 'id' => (int)$user_id - ] ); - - $mdb -> update( 'pp_users', [ - 'login' => $login, - 'admin' => $admin, - 'status' => $status == 'on' ? 1 : 0, - 'twofa_enabled' => $twofa_enabled == 'on' ? 1 : 0, - 'twofa_email' => $twofa_email - ], [ - 'id' => (int)$user_id - ] ); - - return $response = [ 'status' => 'ok', 'msg' => 'Uzytkownik został zapisany.' ]; - } - } - - public static function check_login( $login, $user_id ) - { - global $mdb; - - if ( $mdb -> get( 'pp_users', 'login', [ 'AND' => [ 'login' => $login, 'id[!]' => (int)$user_id ] ] ) ) - return $response = [ 'status' => 'error', 'msg' => 'Podany login jest już zajęty.' ]; - - return $response = [ 'status' => 'ok' ]; - } - - public static function logon( $login, $password ) - { - global $mdb; - - if ( !$mdb -> get( 'pp_users', '*', [ 'login' => $login ] ) ) - return 0; - - if ( !$mdb -> get( 'pp_users', '*', [ 'AND' => [ 'login' => $login, 'status' => 1, 'error_logged_count[<]' => 5 ] ] ) ) - return -1; - - if ( $mdb -> get( 'pp_users', '*', [ 'AND' => [ 'login' => $login, 'status' => 1, 'password' => md5( $password ) ] ] ) ) - { - $mdb -> update( 'pp_users', [ 'last_logged' => date( 'Y-m-d H:i:s' ), 'error_logged_count' => 0 ], [ 'login' => $login ] ); - return 1; - } - else - { - $mdb -> update( 'pp_users', [ 'last_error_logged' => date( 'Y-m-d H:i:s' ), 'error_logged_count[+]' => 1 ], [ 'login' => $login ] ); - if ( $mdb -> get( 'pp_users', 'error_logged_count', [ 'login' => $login ] ) >= 5 ) - { - $mdb -> update( 'pp_users', [ 'status' => 0 ], [ 'login' => $login ] ); - return -1; - } - } - return 0; - } - - public static function details( $login ) - { - global $mdb; - return $mdb -> get( 'pp_users', '*', [ 'login' => $login ] ); - } -} -?> diff --git a/autoload/admin/view/class.Page.php b/autoload/admin/view/class.Page.php index c96583a..50a24a0 100644 --- a/autoload/admin/view/class.Page.php +++ b/autoload/admin/view/class.Page.php @@ -5,14 +5,22 @@ class Page { public static function show() { - global $user; + global $user, $mdb; if ( $_GET['module'] == 'user' && $_GET['action'] == 'twofa' ) { - return \admin\controls\Users::twofa(); + $controller = new \admin\Controllers\UsersController( + new \Domain\User\UserRepository( $mdb ) + ); + return $controller->twofa(); } if ( !$user || !$user['admin'] ) - return \admin\view\Users::login_form(); + { + $controller = new \admin\Controllers\UsersController( + new \Domain\User\UserRepository( $mdb ) + ); + return $controller->login_form(); + } $tpl = new \Tpl; $tpl -> content = \admin\Site::route(); diff --git a/autoload/admin/view/class.Users.php b/autoload/admin/view/class.Users.php deleted file mode 100644 index adcdfec..0000000 --- a/autoload/admin/view/class.Users.php +++ /dev/null @@ -1,25 +0,0 @@ - render( 'site/unlogged-layout' ); - } - - public static function users_list() - { - $tpl = new \Tpl; - return $tpl -> render( 'users/users-list' ); - } - - public static function user_edit( $user ) - { - $tpl = new \Tpl; - $tpl -> user = $user; - return $tpl -> render( 'users/user-edit' ); - } -} -?> diff --git a/tests/Unit/Domain/User/UserRepositoryTest.php b/tests/Unit/Domain/User/UserRepositoryTest.php new file mode 100644 index 0000000..a77faae --- /dev/null +++ b/tests/Unit/Domain/User/UserRepositoryTest.php @@ -0,0 +1,406 @@ +createMock(\medoo::class); + $mockDb->expects($this->once()) + ->method('get') + ->with('pp_users', '*', ['id' => 7]) + ->willReturn(['id' => 7, 'login' => 'admin']); + + $repository = new UserRepository($mockDb); + $user = $repository->find(7); + + $this->assertIsArray($user); + $this->assertSame(7, (int)$user['id']); + $this->assertSame('admin', $user['login']); + } + + public function testFindReturnsNullWhenNotFound(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->method('get')->willReturn(false); + + $repository = new UserRepository($mockDb); + $this->assertNull($repository->find(123)); + } + + public function testCheckLoginReturnsErrorWhenLoginIsTaken(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->expects($this->once()) + ->method('get') + ->with('pp_users', 'login', [ + 'AND' => [ + 'login' => 'taken@example.com', + 'id[!]' => 10, + ], + ]) + ->willReturn('taken@example.com'); + + $repository = new UserRepository($mockDb); + $response = $repository->checkLogin('taken@example.com', 10); + + $this->assertSame('error', $response['status']); + } + + public function testCheckLoginReturnsOkWhenAvailable(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->method('get')->willReturn(null); + + $repository = new UserRepository($mockDb); + $response = $repository->checkLogin('free@example.com', 5); + + $this->assertSame('ok', $response['status']); + } + + public function testSaveReturnsErrorForTooShortPasswordOnCreate(): void + { + $mockDb = $this->createMock(\medoo::class); + $repository = new UserRepository($mockDb); + + $response = $repository->save(0, 'admin@example.com', 'on', '1234', '1234', 1, 'on', 'admin@example.com'); + + $this->assertSame('error', $response['status']); + } + + public function testSaveReturnsErrorForMismatchedPasswordsOnCreate(): void + { + $mockDb = $this->createMock(\medoo::class); + $repository = new UserRepository($mockDb); + + $response = $repository->save(0, 'admin@example.com', 'on', 'password1', 'password2', 1); + + $this->assertSame('error', $response['status']); + $this->assertStringContainsString('rozne', $response['msg']); + } + + public function testSaveCreatesUserWithNormalizedSwitches(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->expects($this->once()) + ->method('insert') + ->with('pp_users', $this->callback(function (array $row): bool { + return $row['login'] === 'new@example.com' + && $row['status'] === 1 + && $row['admin'] === 1 + && $row['twofa_enabled'] === 1 + && $row['twofa_email'] === '2fa@example.com' + && $row['password'] === md5('secret5'); + })) + ->willReturn($this->createMock(\PDOStatement::class)); + + $repository = new UserRepository($mockDb); + $response = $repository->save(0, 'new@example.com', 'on', 'secret5', 'secret5', 1, 'on', '2fa@example.com'); + + $this->assertSame('ok', $response['status']); + } + + public function testSaveUpdatesExistingUserWithPassword(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->expects($this->exactly(2)) + ->method('update') + ->withConsecutive( + [ + 'pp_users', + $this->callback(fn(array $d) => $d['password'] === md5('newpass5')), + ['id' => 5], + ], + [ + 'pp_users', + $this->callback(fn(array $d) => $d['login'] === 'user@example.com' && $d['status'] === 1), + ['id' => 5], + ] + ); + + $repository = new UserRepository($mockDb); + $response = $repository->save(5, 'user@example.com', 'on', 'newpass5', 'newpass5', 1); + + $this->assertSame('ok', $response['status']); + } + + public function testSaveUpdatesExistingUserWithoutPassword(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->expects($this->once()) + ->method('update') + ->with( + 'pp_users', + $this->callback(fn(array $d) => $d['login'] === 'user@example.com'), + ['id' => 5] + ); + + $repository = new UserRepository($mockDb); + $response = $repository->save(5, 'user@example.com', 'on', '', '', 1); + + $this->assertSame('ok', $response['status']); + } + + public function testSaveReturnsErrorForTooShortPasswordOnUpdate(): void + { + $mockDb = $this->createMock(\medoo::class); + $repository = new UserRepository($mockDb); + + $response = $repository->save(5, 'user@example.com', 1, '123', '123', 1); + + $this->assertSame('error', $response['status']); + } + + public function testSaveReturnsErrorForMismatchedPasswordsOnUpdate(): void + { + $mockDb = $this->createMock(\medoo::class); + $repository = new UserRepository($mockDb); + + $response = $repository->save(5, 'user@example.com', 1, 'password1', 'password2', 1); + + $this->assertSame('error', $response['status']); + } + + public function testDeleteReturnsTrue(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->expects($this->once()) + ->method('delete') + ->with('pp_users', ['id' => 3]) + ->willReturn($this->createMock(\PDOStatement::class)); + + $repository = new UserRepository($mockDb); + $this->assertTrue($repository->delete(3)); + } + + public function testDeleteReturnsFalseOnFailure(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->method('delete')->willReturn(false); + + $repository = new UserRepository($mockDb); + $this->assertFalse($repository->delete(999)); + } + + public function testDetailsReturnsUserByLogin(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->expects($this->once()) + ->method('get') + ->with('pp_users', '*', ['login' => 'admin@shop.com']) + ->willReturn(['id' => 1, 'login' => 'admin@shop.com']); + + $repository = new UserRepository($mockDb); + $user = $repository->details('admin@shop.com'); + + $this->assertIsArray($user); + $this->assertSame('admin@shop.com', $user['login']); + } + + public function testDetailsReturnsNullWhenNotFound(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->method('get')->willReturn(false); + + $repository = new UserRepository($mockDb); + $this->assertNull($repository->details('nonexistent@shop.com')); + } + + public function testLogonReturnsSuccessForValidCredentials(): void + { + $mockDb = $this->createMock(\medoo::class); + + $mockDb->expects($this->exactly(3)) + ->method('get') + ->withConsecutive( + ['pp_users', '*', ['login' => 'admin@example.com']], + ['pp_users', '*', ['AND' => ['login' => 'admin@example.com', 'status' => 1, 'error_logged_count[<]' => 5]]], + ['pp_users', '*', ['AND' => ['login' => 'admin@example.com', 'status' => 1, 'password' => md5('password123')]]] + ) + ->willReturnOnConsecutiveCalls( + ['id' => 5], + ['id' => 5], + ['id' => 5] + ); + + $mockDb->expects($this->once()) + ->method('update') + ->with( + 'pp_users', + $this->callback(function (array $data): bool { + return isset($data['last_logged']) && $data['error_logged_count'] === 0; + }), + ['login' => 'admin@example.com'] + ); + + $repository = new UserRepository($mockDb); + $result = $repository->logon('admin@example.com', 'password123'); + + $this->assertSame(1, $result); + } + + public function testLogonReturnsZeroForNonexistentUser(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->expects($this->once()) + ->method('get') + ->with('pp_users', '*', ['login' => 'noone@example.com']) + ->willReturn(false); + + $repository = new UserRepository($mockDb); + $this->assertSame(0, $repository->logon('noone@example.com', 'pass')); + } + + public function testLogonReturnsNegativeOneForBlockedUser(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->expects($this->exactly(2)) + ->method('get') + ->withConsecutive( + ['pp_users', '*', ['login' => 'blocked@example.com']], + ['pp_users', '*', ['AND' => ['login' => 'blocked@example.com', 'status' => 1, 'error_logged_count[<]' => 5]]] + ) + ->willReturnOnConsecutiveCalls( + ['id' => 3, 'status' => 0], + false + ); + + $repository = new UserRepository($mockDb); + $this->assertSame(-1, $repository->logon('blocked@example.com', 'pass')); + } + + public function testVerifyTwofaCodeReturnsFalseForNonexistentUser(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->method('get')->willReturn(false); + + $repository = new UserRepository($mockDb); + $this->assertFalse($repository->verifyTwofaCode(999, '123456')); + } + + public function testVerifyTwofaCodeReturnsFalseAfterMaxAttempts(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->method('get')->willReturn([ + 'id' => 1, + 'twofa_failed_attempts' => 5, + 'twofa_code_hash' => password_hash('123456', PASSWORD_DEFAULT), + 'twofa_expires_at' => date('Y-m-d H:i:s', time() + 300), + ]); + + $repository = new UserRepository($mockDb); + $this->assertFalse($repository->verifyTwofaCode(1, '123456')); + } + + public function testVerifyTwofaCodeReturnsFalseForExpiredCode(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->method('get')->willReturn([ + 'id' => 1, + 'twofa_failed_attempts' => 0, + 'twofa_code_hash' => password_hash('123456', PASSWORD_DEFAULT), + 'twofa_expires_at' => date('Y-m-d H:i:s', time() - 60), + ]); + + $mockDb->expects($this->once())->method('update'); + + $repository = new UserRepository($mockDb); + $this->assertFalse($repository->verifyTwofaCode(1, '123456')); + } + + public function testVerifyTwofaCodeReturnsTrueForValidCode(): void + { + $code = '654321'; + $hash = password_hash($code, PASSWORD_DEFAULT); + + $mockDb = $this->createMock(\medoo::class); + $mockDb->method('get')->willReturn([ + 'id' => 1, + 'twofa_failed_attempts' => 0, + 'twofa_code_hash' => $hash, + 'twofa_expires_at' => date('Y-m-d H:i:s', time() + 300), + ]); + + $mockDb->expects($this->once()) + ->method('update') + ->with('pp_users', $this->callback(function (array $d): bool { + return $d['twofa_code_hash'] === null + && $d['twofa_expires_at'] === null + && $d['twofa_failed_attempts'] === 0; + }), ['id' => 1]); + + $repository = new UserRepository($mockDb); + $this->assertTrue($repository->verifyTwofaCode(1, $code)); + } + + public function testSendTwofaCodeReturnsFalseWhen2FADisabled(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->method('get')->willReturn([ + 'id' => 1, + 'twofa_enabled' => 0, + 'login' => 'test@example.com', + ]); + + $repository = new UserRepository($mockDb); + $this->assertFalse($repository->sendTwofaCode(1)); + } + + public function testSendTwofaCodeReturnsFalseForInvalidEmail(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->method('get')->willReturn([ + 'id' => 1, + 'twofa_enabled' => 1, + 'twofa_email' => 'not-an-email', + 'login' => 'also-not-email', + ]); + + $repository = new UserRepository($mockDb); + $this->assertFalse($repository->sendTwofaCode(1)); + } + + public function testUpdateByIdCallsDbUpdate(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->expects($this->once()) + ->method('update') + ->with('pp_users', ['status' => 0], ['id' => 7]) + ->willReturn($this->createMock(\PDOStatement::class)); + + $repository = new UserRepository($mockDb); + $this->assertTrue($repository->updateById(7, ['status' => 0])); + } + + public function testListForAdminReturnsItemsAndTotal(): void + { + $mockDb = $this->createMock(\medoo::class); + + $countStmt = $this->createMock(\PDOStatement::class); + $countStmt->expects($this->once()) + ->method('fetchAll') + ->willReturn([[2]]); + + $dataStmt = $this->createMock(\PDOStatement::class); + $dataStmt->expects($this->once()) + ->method('fetchAll') + ->willReturn([ + ['id' => 2, 'login' => 'a@example.com', 'status' => 1], + ['id' => 3, 'login' => 'b@example.com', 'status' => 0], + ]); + + $mockDb->expects($this->exactly(2)) + ->method('query') + ->willReturnOnConsecutiveCalls($countStmt, $dataStmt); + + $repository = new UserRepository($mockDb); + $result = $repository->listForAdmin(['login' => '', 'status' => ''], 'login', 'ASC', 1, 15); + + $this->assertSame(2, $result['total']); + $this->assertCount(2, $result['items']); + $this->assertSame('a@example.com', $result['items'][0]['login']); + } +} diff --git a/tests/Unit/admin/Controllers/UsersControllerTest.php b/tests/Unit/admin/Controllers/UsersControllerTest.php new file mode 100644 index 0000000..842c7f7 --- /dev/null +++ b/tests/Unit/admin/Controllers/UsersControllerTest.php @@ -0,0 +1,131 @@ +mockRepository = $this->createMock(UserRepository::class); + $this->controller = new UsersController($this->mockRepository); + } + + public function testConstructorAcceptsRepository(): void + { + $controller = new UsersController($this->mockRepository); + $this->assertInstanceOf(UsersController::class, $controller); + } + + public function testHasViewListMethod(): void + { + $this->assertTrue(method_exists($this->controller, 'view_list')); + } + + public function testHasUserEditMethod(): void + { + $this->assertTrue(method_exists($this->controller, 'user_edit')); + } + + public function testHasUserSaveMethod(): void + { + $this->assertTrue(method_exists($this->controller, 'user_save')); + } + + public function testHasUserDeleteMethod(): void + { + $this->assertTrue(method_exists($this->controller, 'user_delete')); + } + + public function testHasTwofaMethod(): void + { + $this->assertTrue(method_exists($this->controller, 'twofa')); + } + + public function testHasLoginFormMethod(): void + { + $this->assertTrue(method_exists($this->controller, 'login_form')); + } + + public function testActionMethodReturnTypes(): void + { + $reflection = new \ReflectionClass($this->controller); + + $this->assertEquals('string', (string)$reflection->getMethod('view_list')->getReturnType()); + $this->assertEquals('string', (string)$reflection->getMethod('user_edit')->getReturnType()); + $this->assertEquals('string', (string)$reflection->getMethod('login_form')->getReturnType()); + $this->assertEquals('string', (string)$reflection->getMethod('twofa')->getReturnType()); + $this->assertEquals('void', (string)$reflection->getMethod('user_save')->getReturnType()); + $this->assertEquals('void', (string)$reflection->getMethod('user_delete')->getReturnType()); + } + + public function testConstructorRequiresUserRepository(): void + { + $reflection = new \ReflectionClass(UsersController::class); + $constructor = $reflection->getConstructor(); + $params = $constructor->getParameters(); + + $this->assertCount(1, $params); + $this->assertEquals('Domain\User\UserRepository', $params[0]->getType()->getName()); + } + + public function testNormalizeUserReturnsDefaultsForNull(): void + { + $reflection = new \ReflectionClass($this->controller); + $method = $reflection->getMethod('normalizeUser'); + $method->setAccessible(true); + + $result = $method->invoke($this->controller, null); + + $this->assertSame(0, $result['id']); + $this->assertSame('', $result['login']); + $this->assertSame(1, $result['status']); + $this->assertSame(1, $result['admin']); + $this->assertSame(0, $result['twofa_enabled']); + $this->assertSame('', $result['twofa_email']); + } + + public function testNormalizeUserCastsTypes(): void + { + $reflection = new \ReflectionClass($this->controller); + $method = $reflection->getMethod('normalizeUser'); + $method->setAccessible(true); + + $result = $method->invoke($this->controller, [ + 'id' => '15', + 'login' => 'admin@test.com', + 'status' => '0', + 'admin' => '1', + 'twofa_enabled' => '1', + 'twofa_email' => 'twofa@test.com', + ]); + + $this->assertSame(15, $result['id']); + $this->assertSame('admin@test.com', $result['login']); + $this->assertSame(0, $result['status']); + $this->assertSame(1, $result['admin']); + $this->assertSame(1, $result['twofa_enabled']); + $this->assertSame('twofa@test.com', $result['twofa_email']); + } + + public function testNormalizeUserHandlesPartialData(): void + { + $reflection = new \ReflectionClass($this->controller); + $method = $reflection->getMethod('normalizeUser'); + $method->setAccessible(true); + + $result = $method->invoke($this->controller, ['id' => 3, 'login' => 'partial@test.com']); + + $this->assertSame(3, $result['id']); + $this->assertSame('partial@test.com', $result['login']); + $this->assertSame(1, $result['status']); + $this->assertSame(1, $result['admin']); + $this->assertSame(0, $result['twofa_enabled']); + $this->assertSame('', $result['twofa_email']); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 0121a04..6cf9a68 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -48,6 +48,7 @@ if (!class_exists('S')) { public static function set_message($msg) {} public static function clear_redis_cache() {} public static function clear_product_cache($id) {} + public static function send_email($to, $subject, $body) { return true; } } } diff --git a/updates/0.20/ver_0.253.zip b/updates/0.20/ver_0.253.zip new file mode 100644 index 0000000..1b2a62d Binary files /dev/null and b/updates/0.20/ver_0.253.zip differ diff --git a/updates/0.20/ver_0.253_files.txt b/updates/0.20/ver_0.253_files.txt new file mode 100644 index 0000000..3babeb4 --- /dev/null +++ b/updates/0.20/ver_0.253_files.txt @@ -0,0 +1,3 @@ +F: ../autoload/admin/controls/class.Users.php +F: ../autoload/admin/factory/class.Users.php +F: ../autoload/admin/view/class.Users.php diff --git a/updates/changelog.php b/updates/changelog.php index bed91d3..e591143 100644 --- a/updates/changelog.php +++ b/updates/changelog.php @@ -1,3 +1,10 @@ +ver. 0.253 - 12.02.2026
+- UPDATE - modul `Users` w panelu admin w pelni przepiety na `Domain\\User\\UserRepository` + `admin\\Controllers\\UsersController` +- UPDATE - migracja widokow users z `grid/gridEdit` na nowe komponenty (`components/table-list`, `components/form-edit`) +- UPDATE - dodana walidacja warunkowa: przy wlaczonym 2FA pole `E-mail do 2FA` jest wymagane +- UPDATE - globalne ulepszenia `components/table-list` (kompaktowe filtry select/status i odstepy w formularzu paginacji) +- CLEANUP - usuniete legacy klasy users: `autoload/admin/controls/class.Users.php`, `autoload/admin/factory/class.Users.php`, `autoload/admin/view/class.Users.php` +
ver. 0.252 - 10.02.2026
- UPDATE - migracja listy archiwum produktow do nowego komponentu tabeli (`components/table-list`) z filtrowaniem i paginacja - UPDATE - banery i archiwum produktow: wydzielenie CSS/JS do osobnych widokow `*-custom-script.php` diff --git a/updates/shopPRO.zip b/updates/shopPRO.zip index 4e1fdb6..1b2a62d 100644 Binary files a/updates/shopPRO.zip and b/updates/shopPRO.zip differ diff --git a/updates/versions.php b/updates/versions.php index eb18a41..72ca0f4 100644 --- a/updates/versions.php +++ b/updates/versions.php @@ -1,5 +1,5 @@