refactor users module to domain/controller and release 0.253 update package

This commit is contained in:
2026-02-12 21:44:40 +01:00
parent 336891276d
commit 2ecc4fd9be
24 changed files with 1452 additions and 453 deletions

View File

@@ -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).

View File

@@ -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`.

View File

@@ -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`.

View File

@@ -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)
```

View File

@@ -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;
}
}

View File

@@ -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();
?>
?>

View File

@@ -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;

View File

@@ -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>

View File

@@ -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();

View 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;
}
}

View 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) !== '';
}
}

View File

@@ -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;

View File

@@ -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' )
] );
}
}
?>

View File

@@ -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 ] );
}
}
?>

View File

@@ -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();

View File

@@ -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' );
}
}
?>

View 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']);
}
}

View 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']);
}
}

View File

@@ -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

Binary file not shown.

View 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

View File

@@ -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.

View File

@@ -1,5 +1,5 @@
<?
$current_ver = 252;
$current_ver = 253;
for ($i = 1; $i <= $current_ver; $i++)
{