refactor users module to domain/controller and release 0.253 update package
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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<64> users dzia<69>a na `Domain\\User\\UserRepository` + `admin\\Controllers\\UsersController`.
|
||||
- Usuni<6E>to legacy klasy: `autoload/admin/controls/class.Users.php`, `autoload/admin/factory/class.Users.php`, `autoload/admin/view/class.Users.php`.
|
||||
- Walidacja: przy w<><77>czonym 2FA pole `twofa_email` jest wymagane.
|
||||
- Widoki users przeniesione na `components/table-list` i `components/form-edit`.
|
||||
|
||||
@@ -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<64>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`.
|
||||
|
||||
30
TESTING.md
30
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)
|
||||
```
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
?>
|
||||
?>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
?>
|
||||
|
||||
<div class="panel">
|
||||
@@ -42,17 +57,34 @@ $perPage = (int)($list->pagination['per_page'] ?? 15);
|
||||
<?php
|
||||
$filterKey = (string)($filter['key'] ?? '');
|
||||
$inputId = 'filter_' . preg_replace('/[^a-zA-Z0-9_]+/', '_', $filterKey);
|
||||
$filterType = (string)($filter['type'] ?? '');
|
||||
$isCompactFilter = false;
|
||||
if ($filterType === 'select') {
|
||||
$options = (array)($filter['options'] ?? []);
|
||||
$maxOptionLen = 0;
|
||||
foreach ($options as $optionLabel) {
|
||||
$len = strlen(trim((string)$optionLabel));
|
||||
if ($len > $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';
|
||||
?>
|
||||
<div class="col-sm-2 mb10">
|
||||
<div class="<?= htmlspecialchars($filterColClass, ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<label for="<?= htmlspecialchars($inputId, ENT_QUOTES, 'UTF-8'); ?>" class="control-label">
|
||||
<?= htmlspecialchars((string)($filter['label'] ?? ''), ENT_QUOTES, 'UTF-8'); ?>
|
||||
</label>
|
||||
|
||||
<?php if (($filter['type'] ?? '') === 'select'): ?>
|
||||
<?php if ($filterType === 'select'): ?>
|
||||
<select
|
||||
id="<?= htmlspecialchars($inputId, ENT_QUOTES, 'UTF-8'); ?>"
|
||||
name="<?= htmlspecialchars($filter['key'], ENT_QUOTES, 'UTF-8'); ?>"
|
||||
class="form-control input-sm"
|
||||
class="form-control input-sm<?= $isCompactFilter ? ' js-filter-compact-select' : ''; ?>"
|
||||
<?= $isCompactFilter ? 'data-compact-filter="1"' : ''; ?>
|
||||
title="<?= htmlspecialchars($filter['label'], ENT_QUOTES, 'UTF-8'); ?>"
|
||||
>
|
||||
<?php foreach (($filter['options'] ?? []) as $value => $label): ?>
|
||||
@@ -110,8 +142,12 @@ $perPage = (int)($list->pagination['per_page'] ?? 15);
|
||||
'dir' => $nextDir,
|
||||
'page' => 1,
|
||||
]);
|
||||
$headerClass = trim((string)($column['class'] ?? ''));
|
||||
if ($isCompactColumn($column)) {
|
||||
$headerClass = trim($headerClass . ' table-col-compact');
|
||||
}
|
||||
?>
|
||||
<th class="<?= htmlspecialchars((string)($column['class'] ?? ''), ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<th class="<?= htmlspecialchars($headerClass, ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<?php if ($isSortable): ?>
|
||||
<a href="<?= htmlspecialchars($sortUrl, ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<?= htmlspecialchars((string)($column['label'] ?? ''), ENT_QUOTES, 'UTF-8'); ?>
|
||||
@@ -138,8 +174,12 @@ $perPage = (int)($list->pagination['per_page'] ?? 15);
|
||||
$key = $column['key'] ?? '';
|
||||
$raw = !empty($column['raw']);
|
||||
$value = $row[$key] ?? '';
|
||||
$cellClass = trim((string)($column['class'] ?? ''));
|
||||
if ($isCompactColumn($column)) {
|
||||
$cellClass = trim($cellClass . ' table-col-compact');
|
||||
}
|
||||
?>
|
||||
<td class="<?= htmlspecialchars((string)($column['class'] ?? ''), ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<td class="<?= htmlspecialchars($cellClass, ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<?php if ($raw): ?>
|
||||
<?= (string)$value; ?>
|
||||
<?php else: ?>
|
||||
@@ -209,7 +249,7 @@ $perPage = (int)($list->pagination['per_page'] ?? 15);
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-sm-6 text-right">
|
||||
<form method="get" action="<?= htmlspecialchars($list->basePath, ENT_QUOTES, 'UTF-8'); ?>" class="form-inline">
|
||||
<form method="get" action="<?= htmlspecialchars($list->basePath, ENT_QUOTES, 'UTF-8'); ?>" class="form-inline table-list-per-page-form">
|
||||
<?php foreach ($list->query as $key => $value): ?>
|
||||
<?php if ($key !== 'per_page' && $key !== 'page'): ?>
|
||||
<input type="hidden" name="<?= htmlspecialchars((string)$key, ENT_QUOTES, 'UTF-8'); ?>" value="<?= htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8'); ?>" />
|
||||
@@ -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;
|
||||
|
||||
@@ -1,90 +1,2 @@
|
||||
<?php
|
||||
global $gdb, $config;
|
||||
<?= \Tpl::view('components/form-edit', ['form' => $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();
|
||||
?>
|
||||
<script type="text/javascript">
|
||||
$( function()
|
||||
{
|
||||
disable_menu();
|
||||
});
|
||||
|
||||
function check_login()
|
||||
{
|
||||
var response = null;
|
||||
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
cache: false,
|
||||
async: false,
|
||||
url: '/admin/ajax.php',
|
||||
data:
|
||||
{
|
||||
a: 'check_login',
|
||||
login: $.trim( $( '#login' ).val() ),
|
||||
user_id: <?= (int)$this -> user['id'];?>
|
||||
},
|
||||
success: function( data )
|
||||
{
|
||||
response = $.parseJSON( data );
|
||||
}
|
||||
});
|
||||
return response;
|
||||
}
|
||||
</script>
|
||||
@@ -1,47 +1,2 @@
|
||||
<?php
|
||||
global $gdb;
|
||||
<?= \Tpl::view('components/table-list', ['list' => $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 => '<span style="color: #FF0000;">nie</span>', 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();
|
||||
337
autoload/Domain/User/UserRepository.php
Normal file
337
autoload/Domain/User/UserRepository.php
Normal file
@@ -0,0 +1,337 @@
|
||||
<?php
|
||||
namespace Domain\User;
|
||||
|
||||
/**
|
||||
* Repository odpowiedzialny za dostep do danych uzytkownikow admina.
|
||||
*/
|
||||
class UserRepository
|
||||
{
|
||||
private const MAX_PER_PAGE = 100;
|
||||
|
||||
private $db;
|
||||
|
||||
public function __construct($db)
|
||||
{
|
||||
$this->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<int, array<string, mixed>>, 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;
|
||||
}
|
||||
}
|
||||
346
autoload/admin/Controllers/UsersController.php
Normal file
346
autoload/admin/Controllers/UsersController.php
Normal file
@@ -0,0 +1,346 @@
|
||||
<?php
|
||||
namespace admin\Controllers;
|
||||
|
||||
use Domain\User\UserRepository;
|
||||
use admin\ViewModels\Forms\FormAction;
|
||||
use admin\ViewModels\Forms\FormEditViewModel;
|
||||
use admin\ViewModels\Forms\FormField;
|
||||
use admin\Support\Forms\FormRequestHandler;
|
||||
|
||||
class UsersController
|
||||
{
|
||||
private UserRepository $repository;
|
||||
private FormRequestHandler $formHandler;
|
||||
|
||||
public function __construct(UserRepository $repository)
|
||||
{
|
||||
$this->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' : '<span style="color: #FF0000;">nie</span>',
|
||||
'login' => '<a href="/admin/users/user_edit/id=' . $id . '">' . htmlspecialchars($login, ENT_QUOTES, 'UTF-8') . '</a>',
|
||||
'_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) !== '';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
<?php
|
||||
namespace admin\controls;
|
||||
|
||||
class Users
|
||||
{
|
||||
public static function user_delete()
|
||||
{
|
||||
if ( \admin\factory\Users::user_delete( \S::get( 'id' ) ) )
|
||||
\S::alert( 'Użytkownik został usunięty.' );
|
||||
header( 'Location: /admin/users/view_list/' );
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function user_save()
|
||||
{
|
||||
$values = json_decode( \S::get( 'values' ), true );
|
||||
|
||||
$response = \admin\factory\Users::user_save( $values['id'], $values['login'], $values['status'], $values['password'], $values['password_re'], $values['admin'], $values['twofa_enabled'], $values['twofa_email'] );
|
||||
echo json_encode( $response );
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function user_edit()
|
||||
{
|
||||
return \admin\view\Users::user_edit(
|
||||
\admin\factory\Users::user_details(
|
||||
\S::get( 'id' )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public static function view_list()
|
||||
{
|
||||
return \admin\view\Users::users_list();
|
||||
}
|
||||
|
||||
static public function twofa() {
|
||||
return \Tpl::view( 'site/unlogged', [
|
||||
'content' => \Tpl::view( 'users/user-2fa' )
|
||||
] );
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -1,214 +0,0 @@
|
||||
<?php
|
||||
namespace admin\factory;
|
||||
|
||||
class Users
|
||||
{
|
||||
|
||||
static public function verify_twofa_code(int $userId, string $code): bool
|
||||
{
|
||||
$user = self::get_by_id( $userId );
|
||||
if (!$user) return false;
|
||||
|
||||
if ((int)$user['twofa_failed_attempts'] >= 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 ] );
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -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();
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
namespace admin\view;
|
||||
|
||||
class Users
|
||||
{
|
||||
public static function login_form()
|
||||
{
|
||||
$tpl = new \Tpl;
|
||||
return $tpl -> 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' );
|
||||
}
|
||||
}
|
||||
?>
|
||||
406
tests/Unit/Domain/User/UserRepositoryTest.php
Normal file
406
tests/Unit/Domain/User/UserRepositoryTest.php
Normal file
@@ -0,0 +1,406 @@
|
||||
<?php
|
||||
namespace Tests\Unit\Domain\User;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Domain\User\UserRepository;
|
||||
|
||||
class UserRepositoryTest extends TestCase
|
||||
{
|
||||
public function testFindReturnsUserWhenExists(): void
|
||||
{
|
||||
$mockDb = $this->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']);
|
||||
}
|
||||
}
|
||||
131
tests/Unit/admin/Controllers/UsersControllerTest.php
Normal file
131
tests/Unit/admin/Controllers/UsersControllerTest.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
namespace Tests\Unit\admin\Controllers;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use admin\Controllers\UsersController;
|
||||
use Domain\User\UserRepository;
|
||||
|
||||
class UsersControllerTest extends TestCase
|
||||
{
|
||||
private $mockRepository;
|
||||
private $controller;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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']);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
BIN
updates/0.20/ver_0.253.zip
Normal file
BIN
updates/0.20/ver_0.253.zip
Normal file
Binary file not shown.
3
updates/0.20/ver_0.253_files.txt
Normal file
3
updates/0.20/ver_0.253_files.txt
Normal file
@@ -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
|
||||
@@ -1,3 +1,10 @@
|
||||
<b>ver. 0.253 - 12.02.2026</b><br />
|
||||
- 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`
|
||||
<hr>
|
||||
<b>ver. 0.252 - 10.02.2026</b><br />
|
||||
- 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`
|
||||
|
||||
Binary file not shown.
@@ -1,5 +1,5 @@
|
||||
<?
|
||||
$current_ver = 252;
|
||||
$current_ver = 253;
|
||||
|
||||
for ($i = 1; $i <= $current_ver; $i++)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user