feat: Implement user authentication and database migration system

- Refactored AuthService to use UserRepository for user authentication.
- Added .env file for environment configuration.
- Created migration system with Migrator and ConnectionFactory classes.
- Added database migration files for creating users table.
- Implemented settings controller for managing database migrations.
- Developed user repository for user data handling.
- Created users controller for user management interface.
- Added frontend standards and migration documentation.
- Introduced reusable UI components and jQuery alerts module.
This commit is contained in:
2026-02-21 17:51:34 +01:00
parent 92bbe82614
commit b67542d159
35 changed files with 2733 additions and 213 deletions

14
.env Normal file
View File

@@ -0,0 +1,14 @@
APP_NAME=orderPRO
APP_ENV=local
APP_DEBUG=true
APP_URL=https://orderpro.projectpro.pl
SESSION_NAME=orderpro_session
DB_CONNECTION=mysql
DB_HOST=localhost
DB_HOST_REMOTE=host700513.hostido.net.pl
DB_PORT=3306
DB_DATABASE=host700513_orderpro
DB_USERNAME=host700513_orderpro
DB_PASSWORD=hrDNtUBg9grwZ7syN77S
DB_CHARSET=utf8mb4

View File

@@ -4,5 +4,10 @@ APP_DEBUG=true
APP_URL=http://localhost:8000 APP_URL=http://localhost:8000
SESSION_NAME=orderpro_session SESSION_NAME=orderpro_session
ADMIN_EMAIL=admin@orderpro.local DB_CONNECTION=mysql
ADMIN_PASSWORD_HASH=$2y$10$1eRQmrhEUWgKRZpG08dKOenG4eZrvLQnLdCUfKHrZ/5dzLvxpmRYC DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=orderpro
DB_USERNAME=root
DB_PASSWORD=
DB_CHARSET=utf8mb4

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
# Frontend Standards
## 1) Wspolne style UI
- Powtarzalne elementy (np. przyciski, tabele, paginacja, alerty, pola formularzy) trzymamy w:
- `resources/scss/shared/_ui-components.scss`
- Widoki maja korzystac z klas wspolnych (`btn`, `table`, `pagination`, `form-control`, `alert`) zamiast duplikowania stylu lokalnie.
## 2) Moduly JS wielokrotnego uzycia
- Kazdy modul przenoszalny trzymamy w oddzielnym folderze:
- `resources/modules/<module-name>/`
- Minimalny zestaw plikow:
- `resources/modules/<module-name>/<module-name>.js`
- `resources/modules/<module-name>/<module-name>.scss`
- Modul ma byc niezalezny od logiki projektu (brak hardcoded sciezek i zaleznosci biznesowych).
## 3) Przyklad
- Referencyjny modul: `resources/modules/jquery-alerts/`

19
DOCS/MIGRATIONS.md Normal file
View File

@@ -0,0 +1,19 @@
# Migracje bazy danych
## Zasada
- Kazda zmiana schematu bazy to nowy plik `.sql` w `database/migrations`.
- Pliki sa wykonywane rosnaco po nazwie.
- Wykonane migracje sa zapisywane w tabeli `migrations`.
## Nazewnictwo plikow
- Format: `YYYYMMDD_HHMMSS_opis.sql`
- Przyklad: `20260221_000001_create_users_table.sql`
## Uruchamianie
- CLI: `php bin/migrate.php` lub `composer migrate`
- Panel: `Ustawienia > Aktualizacja bazy danych > Wykonaj aktualizacje`
## Kolejne migracje
1. Dodaj nowy plik SQL w `database/migrations`.
2. Wrzuc plik na serwer.
3. Uruchom aktualizacje z panelu albo z CLI.

48
bin/migrate.php Normal file
View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
use App\Core\Database\ConnectionFactory;
use App\Core\Database\Migrator;
use App\Core\Support\Env;
$basePath = dirname(__DIR__);
$vendorAutoload = $basePath . '/vendor/autoload.php';
if (is_file($vendorAutoload)) {
require $vendorAutoload;
} else {
spl_autoload_register(static function (string $class) use ($basePath): void {
$prefix = 'App\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$relative = substr($class, strlen($prefix));
$file = $basePath . '/src/' . str_replace('\\', '/', $relative) . '.php';
if (is_file($file)) {
require $file;
}
});
}
Env::load($basePath . '/.env');
/** @var array<string, mixed> $dbConfig */
$dbConfig = require $basePath . '/config/database.php';
$pdo = ConnectionFactory::make($dbConfig);
$migrator = new Migrator($pdo, $basePath . '/database/migrations');
try {
$status = $migrator->status();
echo 'Pending migrations: ' . (string) ($status['pending'] ?? 0) . PHP_EOL;
$result = $migrator->runPending();
foreach ((array) ($result['logs'] ?? []) as $line) {
echo (string) $line . PHP_EOL;
}
echo 'Migrations complete.' . PHP_EOL;
} catch (Throwable $exception) {
fwrite(STDERR, '[error] ' . $exception->getMessage() . PHP_EOL);
exit(1);
}

View File

@@ -28,7 +28,7 @@ Env::load($basePath . '/.env');
$config = [ $config = [
'app' => require $basePath . '/config/app.php', 'app' => require $basePath . '/config/app.php',
'auth' => require $basePath . '/config/auth.php', 'database' => require $basePath . '/config/database.php',
]; ];
$app = new Application($basePath, $config); $app = new Application($basePath, $config);

View File

@@ -12,6 +12,7 @@
} }
}, },
"scripts": { "scripts": {
"serve": "php -S localhost:8000 -t public public/index.php" "serve": "php -S localhost:8000 -t public public/index.php",
"migrate": "php bin/migrate.php"
} }
} }

View File

@@ -16,4 +16,5 @@ return [
'view_path' => dirname(__DIR__) . '/resources/views', 'view_path' => dirname(__DIR__) . '/resources/views',
'lang_path' => dirname(__DIR__) . '/resources/lang', 'lang_path' => dirname(__DIR__) . '/resources/lang',
'log_path' => dirname(__DIR__) . '/storage/logs/app.log', 'log_path' => dirname(__DIR__) . '/storage/logs/app.log',
'migrations_path' => dirname(__DIR__) . '/database/migrations',
]; ];

View File

@@ -1,12 +0,0 @@
<?php
declare(strict_types=1);
use App\Core\Support\Env;
return [
'admin_email' => Env::get('ADMIN_EMAIL', 'admin@orderpro.local'),
'admin_password_hash' => Env::get(
'ADMIN_PASSWORD_HASH',
'$2y$10$1eRQmrhEUWgKRZpG08dKOenG4eZrvLQnLdCUfKHrZ/5dzLvxpmRYC'
),
];

14
config/database.php Normal file
View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
use App\Core\Support\Env;
return [
'driver' => Env::get('DB_CONNECTION', 'mysql'),
'host' => Env::get('DB_HOST', '127.0.0.1'),
'port' => (int) Env::get('DB_PORT', '3306'),
'database' => Env::get('DB_DATABASE', 'orderpro'),
'username' => Env::get('DB_USERNAME', 'root'),
'password' => Env::get('DB_PASSWORD', ''),
'charset' => Env::get('DB_CHARSET', 'utf8mb4'),
];

View File

@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(120) NOT NULL,
email VARCHAR(190) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL,
UNIQUE KEY users_email_unique (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -6,6 +6,8 @@
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"build:css": "sass --style=compressed --no-source-map resources/scss/app.scss public/assets/css/app.css && sass --style=compressed --no-source-map resources/scss/login.scss public/assets/css/login.css", "build:css": "sass --style=compressed --no-source-map resources/scss/app.scss public/assets/css/app.css && sass --style=compressed --no-source-map resources/scss/login.scss public/assets/css/login.css",
"build:modules": "sass --style=compressed --no-source-map resources/modules/jquery-alerts/jquery-alerts.scss public/assets/css/modules/jquery-alerts.css && copy /Y resources\\modules\\jquery-alerts\\jquery-alerts.js public\\assets\\js\\modules\\jquery-alerts.js",
"build:assets": "npm run build:css && npm run build:modules",
"watch:css": "sass --watch --style=expanded --no-source-map resources/scss/app.scss:public/assets/css/app.css resources/scss/login.scss:public/assets/css/login.css" "watch:css": "sass --watch --style=expanded --no-source-map resources/scss/app.scss:public/assets/css/app.css resources/scss/login.scss:public/assets/css/login.css"
}, },
"keywords": [], "keywords": [],

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
:root{--c-primary: #6690f4;--c-primary-dark: #3164db;--c-bg: #f4f6f9;--c-surface: #ffffff;--c-text: #4e5e6a;--c-text-strong: #2d3748;--c-border: #e2e8f0;--c-muted: #718096;--c-danger: #cc0000;--focus-ring: 0 0 0 3px rgba(102, 144, 244, 0.15);--shadow-card: 0 20px 50px rgba(22, 34, 58, 0.14)}*{box-sizing:border-box}html,body{min-height:100%}body{margin:0;font-family:"Roboto","Segoe UI",sans-serif;color:var(--c-text);background:var(--c-bg);overflow-x:hidden}.bg-orb{position:fixed;width:460px;height:460px;border-radius:999px;filter:blur(28px);z-index:0;opacity:.45;pointer-events:none}.bg-orb-left{top:-200px;left:-180px;background:radial-gradient(circle, rgba(102, 144, 244, 0.48) 0%, rgba(102, 144, 244, 0) 70%)}.bg-orb-right{right:-200px;bottom:-220px;background:radial-gradient(circle, rgba(30, 42, 58, 0.36) 0%, rgba(30, 42, 58, 0) 70%)}.login-page{min-height:100vh;display:grid;place-items:center;padding:32px 20px;position:relative;z-index:1}.login-card{width:100%;max-width:430px;background:var(--c-surface);border:1px solid var(--c-border);border-radius:12px;box-shadow:var(--shadow-card);padding:34px 30px 28px;animation:card-enter 420ms ease-out}.login-header{margin-bottom:24px}.login-badge{display:inline-block;margin:0 0 14px;padding:5px 12px;border-radius:999px;border:1px solid #d9e2ff;background:#eef2ff;color:#3f5faf;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.06em}h1{margin:0;color:var(--c-text-strong);font-size:clamp(1.6rem,2.5vw,1.9rem);line-height:1.15;font-weight:700}.login-subtitle{margin:10px 0 0;font-size:15px;line-height:1.55;color:var(--c-muted)}.alert-error{margin-bottom:18px;padding:12px 14px;border-radius:8px;border:1px solid #fed7d7;background:#fff5f5;color:var(--c-danger);font-size:13px;min-height:44px}.alert-error-placeholder{opacity:.56}.login-form{display:grid;gap:16px}.form-field{display:grid;gap:7px}.field-label{color:var(--c-text-strong);font-size:13px;font-weight:600}input[type=email],input[type=password]{width:100%;height:46px;border:2px solid var(--c-border);border-radius:8px;padding:0 14px;font:inherit;color:var(--c-text-strong);background:#fff;transition:border-color .2s ease,box-shadow .2s ease}input[type=email]::placeholder,input[type=password]::placeholder{color:#cbd5e0}input[type=email]:focus,input[type=password]:focus{outline:none;border-color:var(--c-primary);box-shadow:var(--focus-ring)}.submit-btn{margin-top:2px;height:48px;border:0;border-radius:8px;font:inherit;font-size:15px;font-weight:600;color:#fff;background:var(--c-primary);cursor:pointer;transition:background-color .2s ease,transform .1s ease}.submit-btn:hover{background:var(--c-primary-dark)}.submit-btn:active{transform:translateY(1px)}.submit-btn:focus-visible{outline:none;box-shadow:var(--focus-ring)}@keyframes card-enter{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}@media(max-width: 640px){.login-page{padding:18px 14px}.login-card{padding:24px 20px 20px}h1{font-size:1.55rem}} :root{--c-primary: #6690f4;--c-primary-dark: #3164db;--c-bg: #f4f6f9;--c-surface: #ffffff;--c-text: #4e5e6a;--c-text-strong: #2d3748;--c-muted: #718096;--c-border: #e2e8f0;--c-danger: #cc0000;--focus-ring: 0 0 0 3px rgba(102, 144, 244, 0.15);--shadow-card: 0 1px 4px rgba(0, 0, 0, 0.06)}.btn{display:inline-flex;align-items:center;justify-content:center;min-height:38px;padding:8px 16px;border:1px solid rgba(0,0,0,0);border-radius:8px;font:inherit;font-weight:600;text-decoration:none;cursor:pointer;transition:background-color .2s ease,border-color .2s ease,color .2s ease,transform .1s ease}.btn--primary{color:#fff;background:var(--c-primary)}.btn--primary:hover{background:var(--c-primary-dark)}.btn--secondary{color:var(--c-text-strong);border-color:var(--c-border);background:var(--c-surface)}.btn--secondary:hover{border-color:#cbd5e0;background:#f8fafc}.btn--block{width:100%}.btn:active{transform:translateY(1px)}.btn:focus-visible{outline:none;box-shadow:var(--focus-ring);border-color:var(--c-primary)}.form-control{width:100%;min-height:38px;border:1px solid var(--c-border);border-radius:8px;padding:7px 12px;font:inherit;color:var(--c-text-strong);background:#fff;transition:border-color .2s ease,box-shadow .2s ease}.form-control:focus{outline:none;border-color:var(--c-primary);box-shadow:var(--focus-ring)}.alert{padding:12px 14px;border-radius:8px;border:1px solid rgba(0,0,0,0);font-size:13px;min-height:44px}.alert--danger{border-color:#fed7d7;background:#fff5f5;color:var(--c-danger)}.alert--success{border-color:#b7ebcf;background:#f0fff6;color:#0f6b39}.alert--warning{border-color:#f7dd8b;background:#fff8e8;color:#815500}.form-field{display:grid;gap:7px}.field-label{color:var(--c-text-strong);font-size:13px;font-weight:600}.table-wrap{width:100%;overflow-x:auto}.table{width:100%;border-collapse:collapse;background:var(--c-surface)}.table th,.table td{padding:10px 12px;border-bottom:1px solid var(--c-border);text-align:left}.table th{color:var(--c-text-strong);font-weight:700;background:#f8fafc}.pagination{display:flex;align-items:center;flex-wrap:wrap;gap:8px}.pagination__item{display:inline-flex;align-items:center;justify-content:center;min-width:36px;height:36px;padding:0 10px;border-radius:8px;border:1px solid var(--c-border);color:var(--c-text-strong);background:var(--c-surface);text-decoration:none;font-weight:600}.pagination__item:hover{border-color:#cbd5e0;background:#f8fafc}.pagination__item.is-active{border-color:var(--c-primary);color:var(--c-primary);background:#edf2ff}:root{--shadow-card: 0 20px 50px rgba(22, 34, 58, 0.14)}*{box-sizing:border-box}html,body{min-height:100%}body{margin:0;font-family:"Roboto","Segoe UI",sans-serif;color:var(--c-text);background:var(--c-bg);overflow-x:hidden}.bg-orb{position:fixed;width:460px;height:460px;border-radius:999px;filter:blur(28px);z-index:0;opacity:.45;pointer-events:none}.bg-orb-left{top:-200px;left:-180px;background:radial-gradient(circle, rgba(102, 144, 244, 0.48) 0%, rgba(102, 144, 244, 0) 70%)}.bg-orb-right{right:-200px;bottom:-220px;background:radial-gradient(circle, rgba(30, 42, 58, 0.36) 0%, rgba(30, 42, 58, 0) 70%)}.login-page{min-height:100vh;display:grid;place-items:center;padding:32px 20px;position:relative;z-index:1}.login-card{width:100%;max-width:430px;background:var(--c-surface);border:1px solid var(--c-border);border-radius:12px;box-shadow:var(--shadow-card);padding:34px 30px 28px;animation:card-enter 420ms ease-out}.login-header{margin-bottom:24px}.login-badge{display:inline-block;margin:0 0 14px;padding:5px 12px;border-radius:999px;border:1px solid #d9e2ff;background:#eef2ff;color:#3f5faf;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.06em}h1{margin:0;color:var(--c-text-strong);font-size:clamp(1.6rem,2.5vw,1.9rem);line-height:1.15;font-weight:700}.login-subtitle{margin:10px 0 0;font-size:15px;line-height:1.55;color:var(--c-muted)}.login-alert{margin-bottom:18px}.login-alert-placeholder{opacity:.56}.login-form{display:grid;gap:16px}.form-field{display:grid;gap:7px}.field-label{color:var(--c-text-strong);font-size:13px;font-weight:600}.login-form .form-control{min-height:46px;padding:0 14px;border-width:2px}.login-form .form-control::placeholder{color:#cbd5e0}.login-submit{margin-top:2px;font-size:15px;min-height:48px}@keyframes card-enter{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}@media(max-width: 640px){.login-page{padding:18px 14px}.login-card{padding:24px 20px 20px}h1{font-size:1.55rem}}

View File

@@ -0,0 +1 @@
.jq-alert{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:12px;padding:12px 14px;border:1px solid rgba(0,0,0,0);border-radius:8px;font-size:14px;opacity:0;transform:translateY(4px);transition:opacity .18s ease,transform .18s ease}.jq-alert.is-visible{opacity:1;transform:translateY(0)}.jq-alert--info{color:#1e3a8a;background:#eff6ff;border-color:#bfdbfe}.jq-alert--success{color:#065f46;background:#ecfdf5;border-color:#a7f3d0}.jq-alert--warning{color:#92400e;background:#fffbeb;border-color:#fde68a}.jq-alert--error{color:#991b1b;background:#fef2f2;border-color:#fecaca}.jq-alert__content{flex:1}.jq-alert__close{appearance:none;border:0;padding:0;line-height:1;font-size:18px;cursor:pointer;color:inherit;background:rgba(0,0,0,0)}

View File

@@ -0,0 +1,72 @@
"use strict";
(function (factory) {
if (typeof module === "object" && module.exports) {
module.exports = factory;
return;
}
if (typeof window.jQuery !== "undefined") {
factory(window.jQuery);
}
})(function ($) {
if (!$ || !$.fn) {
return;
}
const DEFAULTS = {
type: "info",
dismissible: true,
timeout: 0,
classPrefix: "jq-alert",
};
function removeAlert($el) {
$el.removeClass("is-visible");
window.setTimeout(function () {
$el.remove();
}, 180);
}
$.fn.orderProAlert = function (options) {
const settings = $.extend({}, DEFAULTS, options);
return this.each(function () {
const $host = $(this);
const $alert = $("<div>", {
class: settings.classPrefix + " " + settings.classPrefix + "--" + settings.type + " is-visible",
role: "alert",
});
const $content = $("<div>", {
class: settings.classPrefix + "__content",
text: String(settings.message || ""),
});
$alert.append($content);
if (settings.dismissible) {
const $close = $("<button>", {
class: settings.classPrefix + "__close",
type: "button",
"aria-label": "Close alert",
text: "x",
});
$close.on("click", function () {
removeAlert($alert);
});
$alert.append($close);
}
$host.append($alert);
if (settings.timeout > 0) {
window.setTimeout(function () {
removeAlert($alert);
}, settings.timeout);
}
});
};
});

View File

@@ -16,6 +16,12 @@ return [
'login' => 'Zaloguj sie', 'login' => 'Zaloguj sie',
'logout' => 'Wyloguj', 'logout' => 'Wyloguj',
], ],
'navigation' => [
'main_menu' => 'Menu glowne',
'users' => 'Uzytkownicy',
'dashboard' => 'Dashboard',
'settings' => 'Ustawienia',
],
'auth' => [ 'auth' => [
'login' => [ 'login' => [
'title' => 'Logowanie', 'title' => 'Logowanie',
@@ -38,4 +44,59 @@ return [
'description' => 'Szkielet panelu jest gotowy. Kolejny krok: lista zamowien.', 'description' => 'Szkielet panelu jest gotowy. Kolejny krok: lista zamowien.',
'active_user_label' => 'Aktywny uzytkownik:', 'active_user_label' => 'Aktywny uzytkownik:',
], ],
'users' => [
'title' => 'Zarzadzanie uzytkownikami',
'description' => 'Dodawaj konta dostepowe dla zespolu i zarzadzaj dostepem do panelu.',
'create_title' => 'Dodaj nowego uzytkownika',
'list_title' => 'Lista uzytkownikow',
'empty' => 'Brak uzytkownikow. Dodaj pierwsze konto.',
'fields' => [
'name' => 'Imie i nazwisko',
'email' => 'Email',
'password' => 'Haslo',
'created_at' => 'Data utworzenia',
],
'actions' => [
'add_user' => 'Dodaj uzytkownika',
],
'flash' => [
'created' => 'Uzytkownik zostal dodany.',
],
'validation' => [
'name_min' => 'Imie i nazwisko musi miec co najmniej 2 znaki.',
'email_invalid' => 'Podaj poprawny adres email.',
'email_taken' => 'Ten adres email jest juz zajety.',
'password_min' => 'Haslo musi miec co najmniej 8 znakow.',
],
],
'settings' => [
'title' => 'Ustawienia',
'description' => 'Konfiguracja i narzedzia administracyjne systemu.',
'submenu_label' => 'Sekcje ustawien',
'database' => [
'title' => 'Aktualizacja bazy danych',
'state' => [
'needs_update' => 'Wykryto oczekujace migracje. Wymagana aktualizacja bazy.',
'up_to_date' => 'Baza danych jest aktualna.',
],
'actions' => [
'run_update' => 'Wykonaj aktualizacje',
],
'stats' => [
'total' => 'Wszystkie migracje',
'applied' => 'Wykonane',
'pending' => 'Do wykonania',
],
'fields' => [
'filename' => 'Plik migracji',
],
'pending_files_title' => 'Oczekujace migracje',
'pending_files_empty' => 'Brak oczekujacych plikow migracji.',
'last_run_logs' => 'Log ostatniej aktualizacji',
'flash' => [
'updated' => 'Aktualizacja zakonczona. Wykonane: :executed, pominiete: :skipped.',
'failed' => 'Nie udalo sie wykonac migracji. Sprawdz log i polaczenie bazy.',
],
],
],
]; ];

View File

@@ -0,0 +1,72 @@
"use strict";
(function (factory) {
if (typeof module === "object" && module.exports) {
module.exports = factory;
return;
}
if (typeof window.jQuery !== "undefined") {
factory(window.jQuery);
}
})(function ($) {
if (!$ || !$.fn) {
return;
}
const DEFAULTS = {
type: "info",
dismissible: true,
timeout: 0,
classPrefix: "jq-alert",
};
function removeAlert($el) {
$el.removeClass("is-visible");
window.setTimeout(function () {
$el.remove();
}, 180);
}
$.fn.orderProAlert = function (options) {
const settings = $.extend({}, DEFAULTS, options);
return this.each(function () {
const $host = $(this);
const $alert = $("<div>", {
class: settings.classPrefix + " " + settings.classPrefix + "--" + settings.type + " is-visible",
role: "alert",
});
const $content = $("<div>", {
class: settings.classPrefix + "__content",
text: String(settings.message || ""),
});
$alert.append($content);
if (settings.dismissible) {
const $close = $("<button>", {
class: settings.classPrefix + "__close",
type: "button",
"aria-label": "Close alert",
text: "x",
});
$close.on("click", function () {
removeAlert($alert);
});
$alert.append($close);
}
$host.append($alert);
if (settings.timeout > 0) {
window.setTimeout(function () {
removeAlert($alert);
}, settings.timeout);
}
});
};
});

View File

@@ -0,0 +1,58 @@
.jq-alert {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 12px;
padding: 12px 14px;
border: 1px solid transparent;
border-radius: 8px;
font-size: 14px;
opacity: 0;
transform: translateY(4px);
transition: opacity 0.18s ease, transform 0.18s ease;
}
.jq-alert.is-visible {
opacity: 1;
transform: translateY(0);
}
.jq-alert--info {
color: #1e3a8a;
background: #eff6ff;
border-color: #bfdbfe;
}
.jq-alert--success {
color: #065f46;
background: #ecfdf5;
border-color: #a7f3d0;
}
.jq-alert--warning {
color: #92400e;
background: #fffbeb;
border-color: #fde68a;
}
.jq-alert--error {
color: #991b1b;
background: #fef2f2;
border-color: #fecaca;
}
.jq-alert__content {
flex: 1;
}
.jq-alert__close {
appearance: none;
border: 0;
padding: 0;
line-height: 1;
font-size: 18px;
cursor: pointer;
color: inherit;
background: transparent;
}

View File

@@ -1,16 +1,4 @@
:root { @use "shared/ui-components";
--c-primary: #6690f4;
--c-primary-dark: #3164db;
--c-bg: #f4f6f9;
--c-surface: #ffffff;
--c-text: #4e5e6a;
--c-text-strong: #2d3748;
--c-muted: #718096;
--c-border: #e2e8f0;
--c-danger: #cc0000;
--shadow-card: 0 1px 4px rgba(0, 0, 0, 0.06);
--focus-ring: 0 0 0 3px rgba(102, 144, 244, 0.15);
}
* { * {
box-sizing: border-box; box-sizing: border-box;
@@ -33,6 +21,58 @@ a {
color: var(--c-primary); color: var(--c-primary);
} }
.app-shell {
min-height: 100vh;
display: grid;
grid-template-columns: 260px 1fr;
}
.sidebar {
border-right: 1px solid var(--c-border);
border-right-color: #243041;
background: #111a28;
padding: 18px 14px;
}
.sidebar__brand {
margin: 4px 10px 16px;
color: #e9f0ff;
font-size: 24px;
font-weight: 300;
letter-spacing: -0.02em;
}
.sidebar__brand strong {
font-weight: 700;
}
.sidebar__nav {
display: grid;
gap: 6px;
}
.sidebar__link {
border-radius: 8px;
padding: 10px 12px;
text-decoration: none;
color: #cbd5e1;
font-weight: 600;
}
.sidebar__link:hover {
color: #f8fafc;
background: #1b2a3f;
}
.sidebar__link.is-active {
color: #ffffff;
background: #2e4f93;
}
.app-main {
min-width: 0;
}
.topbar { .topbar {
height: 56px; height: 56px;
border-bottom: 1px solid var(--c-border); border-bottom: 1px solid var(--c-border);
@@ -57,30 +97,6 @@ a {
font-weight: 700; font-weight: 700;
} }
.logout-btn {
min-height: 38px;
border: 1px solid var(--c-border);
border-radius: 8px;
padding: 0 14px;
font: inherit;
font-weight: 600;
color: var(--c-text-strong);
background: var(--c-surface);
cursor: pointer;
transition: border-color 0.2s ease, color 0.2s ease, background-color 0.2s ease;
}
.logout-btn:hover {
border-color: #cbd5e0;
background: #f8fafc;
}
.logout-btn:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
border-color: var(--c-primary);
}
.container { .container {
max-width: 1120px; max-width: 1120px;
margin: 24px auto; margin: 24px auto;
@@ -110,54 +126,117 @@ a {
font-weight: 600; font-weight: 600;
} }
.btn { .users-form {
min-height: 38px; display: grid;
padding: 8px 16px; gap: 14px;
border: 0; max-width: 460px;
border-radius: 8px; }
color: #ffffff;
background: var(--c-primary); .section-title {
font: inherit; margin: 0;
font-weight: 600; color: var(--c-text-strong);
font-size: 20px;
font-weight: 700;
}
.mt-12 {
margin-top: 12px;
}
.mt-16 {
margin-top: 16px;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.settings-nav {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.settings-nav__link {
text-decoration: none; text-decoration: none;
cursor: pointer;
transition: background-color 0.2s ease;
}
.btn:hover {
background: var(--c-primary-dark);
}
.btn:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.form-control {
width: 100%;
min-height: 38px;
border: 1px solid var(--c-border); border: 1px solid var(--c-border);
border-radius: 8px; border-radius: 8px;
padding: 7px 12px; padding: 8px 12px;
font: inherit;
color: var(--c-text-strong); color: var(--c-text-strong);
background: #ffffff; font-weight: 600;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
} }
.form-control:focus { .settings-nav__link:hover {
outline: none; background: #f8fafc;
}
.settings-nav__link.is-active {
border-color: var(--c-primary); border-color: var(--c-primary);
box-shadow: var(--focus-ring); color: var(--c-primary);
background: #edf2ff;
}
.settings-stat {
border: 1px solid var(--c-border);
border-radius: 8px;
padding: 12px;
background: #f8fafc;
}
.settings-stat__label {
display: block;
color: var(--c-muted);
font-size: 12px;
margin-bottom: 4px;
}
.settings-stat__value {
color: var(--c-text-strong);
font-size: 20px;
}
.settings-logs {
margin: 0;
padding: 12px;
border-radius: 8px;
border: 1px solid var(--c-border);
background: #0b1220;
color: #d1d5db;
font-size: 12px;
line-height: 1.5;
overflow: auto;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.topbar { .app-shell {
padding: 0 14px; grid-template-columns: 1fr;
} }
.brand { .sidebar {
font-size: 20px; border-right: 0;
border-bottom: 1px solid #243041;
padding: 14px;
}
.sidebar__brand {
margin: 0 0 10px;
font-size: 22px;
}
.sidebar__nav {
display: flex;
gap: 8px;
overflow-x: auto;
}
.sidebar__link {
white-space: nowrap;
}
.topbar {
padding: 0 14px;
} }
.container { .container {
@@ -165,6 +244,10 @@ a {
padding: 0 14px 18px; padding: 0 14px 18px;
} }
.settings-grid {
grid-template-columns: 1fr;
}
.card { .card {
padding: 18px; padding: 18px;
} }

View File

@@ -1,14 +1,6 @@
@use "shared/ui-components";
:root { :root {
--c-primary: #6690f4;
--c-primary-dark: #3164db;
--c-bg: #f4f6f9;
--c-surface: #ffffff;
--c-text: #4e5e6a;
--c-text-strong: #2d3748;
--c-border: #e2e8f0;
--c-muted: #718096;
--c-danger: #cc0000;
--focus-ring: 0 0 0 3px rgba(102, 144, 244, 0.15);
--shadow-card: 0 20px 50px rgba(22, 34, 58, 0.14); --shadow-card: 0 20px 50px rgba(22, 34, 58, 0.14);
} }
@@ -105,18 +97,11 @@ h1 {
color: var(--c-muted); color: var(--c-muted);
} }
.alert-error { .login-alert {
margin-bottom: 18px; margin-bottom: 18px;
padding: 12px 14px;
border-radius: 8px;
border: 1px solid #fed7d7;
background: #fff5f5;
color: var(--c-danger);
font-size: 13px;
min-height: 44px;
} }
.alert-error-placeholder { .login-alert-placeholder {
opacity: 0.56; opacity: 0.56;
} }
@@ -136,56 +121,20 @@ h1 {
font-weight: 600; font-weight: 600;
} }
input[type="email"], .login-form .form-control {
input[type="password"] { min-height: 46px;
width: 100%;
height: 46px;
border: 2px solid var(--c-border);
border-radius: 8px;
padding: 0 14px; padding: 0 14px;
font: inherit; border-width: 2px;
color: var(--c-text-strong);
background: #ffffff;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
} }
input[type="email"]::placeholder, .login-form .form-control::placeholder {
input[type="password"]::placeholder {
color: #cbd5e0; color: #cbd5e0;
} }
input[type="email"]:focus, .login-submit {
input[type="password"]:focus {
outline: none;
border-color: var(--c-primary);
box-shadow: var(--focus-ring);
}
.submit-btn {
margin-top: 2px; margin-top: 2px;
height: 48px;
border: 0;
border-radius: 8px;
font: inherit;
font-size: 15px; font-size: 15px;
font-weight: 600; min-height: 48px;
color: #ffffff;
background: var(--c-primary);
cursor: pointer;
transition: background-color 0.2s ease, transform 0.1s ease;
}
.submit-btn:hover {
background: var(--c-primary-dark);
}
.submit-btn:active {
transform: translateY(1px);
}
.submit-btn:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
} }
@keyframes card-enter { @keyframes card-enter {

View File

@@ -0,0 +1,174 @@
:root {
--c-primary: #6690f4;
--c-primary-dark: #3164db;
--c-bg: #f4f6f9;
--c-surface: #ffffff;
--c-text: #4e5e6a;
--c-text-strong: #2d3748;
--c-muted: #718096;
--c-border: #e2e8f0;
--c-danger: #cc0000;
--focus-ring: 0 0 0 3px rgba(102, 144, 244, 0.15);
--shadow-card: 0 1px 4px rgba(0, 0, 0, 0.06);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 38px;
padding: 8px 16px;
border: 1px solid transparent;
border-radius: 8px;
font: inherit;
font-weight: 600;
text-decoration: none;
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.1s ease;
}
.btn--primary {
color: #ffffff;
background: var(--c-primary);
}
.btn--primary:hover {
background: var(--c-primary-dark);
}
.btn--secondary {
color: var(--c-text-strong);
border-color: var(--c-border);
background: var(--c-surface);
}
.btn--secondary:hover {
border-color: #cbd5e0;
background: #f8fafc;
}
.btn--block {
width: 100%;
}
.btn:active {
transform: translateY(1px);
}
.btn:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
border-color: var(--c-primary);
}
.form-control {
width: 100%;
min-height: 38px;
border: 1px solid var(--c-border);
border-radius: 8px;
padding: 7px 12px;
font: inherit;
color: var(--c-text-strong);
background: #ffffff;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.form-control:focus {
outline: none;
border-color: var(--c-primary);
box-shadow: var(--focus-ring);
}
.alert {
padding: 12px 14px;
border-radius: 8px;
border: 1px solid transparent;
font-size: 13px;
min-height: 44px;
}
.alert--danger {
border-color: #fed7d7;
background: #fff5f5;
color: var(--c-danger);
}
.alert--success {
border-color: #b7ebcf;
background: #f0fff6;
color: #0f6b39;
}
.alert--warning {
border-color: #f7dd8b;
background: #fff8e8;
color: #815500;
}
.form-field {
display: grid;
gap: 7px;
}
.field-label {
color: var(--c-text-strong);
font-size: 13px;
font-weight: 600;
}
.table-wrap {
width: 100%;
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
background: var(--c-surface);
}
.table th,
.table td {
padding: 10px 12px;
border-bottom: 1px solid var(--c-border);
text-align: left;
}
.table th {
color: var(--c-text-strong);
font-weight: 700;
background: #f8fafc;
}
.pagination {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.pagination__item {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 36px;
height: 36px;
padding: 0 10px;
border-radius: 8px;
border: 1px solid var(--c-border);
color: var(--c-text-strong);
background: var(--c-surface);
text-decoration: none;
font-weight: 600;
}
.pagination__item:hover {
border-color: #cbd5e0;
background: #f8fafc;
}
.pagination__item.is-active {
border-color: var(--c-primary);
color: var(--c-primary);
background: #edf2ff;
}

View File

@@ -6,11 +6,11 @@
</header> </header>
<?php if (!empty($errorMessage)): ?> <?php if (!empty($errorMessage)): ?>
<div class="alert-error" role="alert"> <div class="alert alert--danger login-alert" role="alert">
<?= $e($errorMessage) ?> <?= $e($errorMessage) ?>
</div> </div>
<?php else: ?> <?php else: ?>
<div class="alert-error alert-error-placeholder" aria-hidden="true"> <div class="alert alert--danger login-alert login-alert-placeholder" aria-hidden="true">
<?= $e($t('auth.login.error_placeholder')) ?> <?= $e($t('auth.login.error_placeholder')) ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
@@ -22,6 +22,7 @@
<span class="field-label"><?= $e($t('auth.login.email_label')) ?></span> <span class="field-label"><?= $e($t('auth.login.email_label')) ?></span>
<input <input
type="email" type="email"
class="form-control"
name="email" name="email"
autocomplete="email" autocomplete="email"
required required
@@ -34,6 +35,7 @@
<span class="field-label"><?= $e($t('auth.login.password_label')) ?></span> <span class="field-label"><?= $e($t('auth.login.password_label')) ?></span>
<input <input
type="password" type="password"
class="form-control"
name="password" name="password"
autocomplete="current-password" autocomplete="current-password"
required required
@@ -41,6 +43,6 @@
> >
</label> </label>
<button type="submit" class="submit-btn"><?= $e($t('actions.login')) ?></button> <button type="submit" class="btn btn--primary btn--block login-submit"><?= $e($t('actions.login')) ?></button>
</form> </form>
</section> </section>

View File

@@ -10,16 +10,39 @@
<link rel="stylesheet" href="/assets/css/app.css"> <link rel="stylesheet" href="/assets/css/app.css">
</head> </head>
<body> <body>
<header class="topbar"> <?php $currentMenu = (string) ($activeMenu ?? ''); ?>
<div class="brand"><?= $e($t('brand.name_prefix')) ?><strong><?= $e($t('brand.name_suffix')) ?></strong></div> <div class="app-shell">
<form action="/logout" method="post"> <aside class="sidebar">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>"> <div class="sidebar__brand"><?= $e($t('brand.name_prefix')) ?><strong><?= $e($t('brand.name_suffix')) ?></strong></div>
<button type="submit" class="logout-btn"><?= $e($t('actions.logout')) ?></button>
</form>
</header>
<main class="container"> <nav class="sidebar__nav" aria-label="<?= $e($t('navigation.main_menu')) ?>">
<?= $content ?> <a class="sidebar__link<?= $currentMenu === 'dashboard' ? ' is-active' : '' ?>" href="/dashboard">
</main> <?= $e($t('navigation.dashboard')) ?>
</a>
<a class="sidebar__link<?= $currentMenu === 'users' ? ' is-active' : '' ?>" href="/users">
<?= $e($t('navigation.users')) ?>
</a>
<a class="sidebar__link<?= $currentMenu === 'settings' ? ' is-active' : '' ?>" href="/settings/database">
<?= $e($t('navigation.settings')) ?>
</a>
</nav>
</aside>
<div class="app-main">
<header class="topbar">
<div>
<strong><?= $e((string) (($user['name'] ?? '') !== '' ? $user['name'] : ($user['email'] ?? ''))) ?></strong>
</div>
<form action="/logout" method="post">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<button type="submit" class="btn btn--secondary"><?= $e($t('actions.logout')) ?></button>
</form>
</header>
<main class="container">
<?= $content ?>
</main>
</div>
</div>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,95 @@
<?php
$migrationStatus = is_array($status ?? null) ? $status : [];
$pending = (int) ($migrationStatus['pending'] ?? 0);
$total = (int) ($migrationStatus['total'] ?? 0);
$applied = (int) ($migrationStatus['applied'] ?? 0);
$pendingFiles = (array) ($migrationStatus['pending_files'] ?? []);
$logs = (array) ($runLogs ?? []);
?>
<section class="card">
<h1><?= $e($t('settings.title')) ?></h1>
<p class="muted"><?= $e($t('settings.description')) ?></p>
<nav class="settings-nav mt-16" aria-label="<?= $e($t('settings.submenu_label')) ?>">
<a class="settings-nav__link is-active" href="/settings/database"><?= $e($t('settings.database.title')) ?></a>
</nav>
</section>
<section class="card mt-16">
<h2 class="section-title"><?= $e($t('settings.database.title')) ?></h2>
<?php if (!empty($errorMessage)): ?>
<div class="alert alert--danger mt-12" role="alert">
<?= $e((string) $errorMessage) ?>
</div>
<?php endif; ?>
<?php if (!empty($successMessage)): ?>
<div class="alert alert--success mt-12" role="status">
<?= $e((string) $successMessage) ?>
</div>
<?php endif; ?>
<div class="settings-grid mt-16">
<div class="settings-stat">
<span class="settings-stat__label"><?= $e($t('settings.database.stats.total')) ?></span>
<strong class="settings-stat__value"><?= $e((string) $total) ?></strong>
</div>
<div class="settings-stat">
<span class="settings-stat__label"><?= $e($t('settings.database.stats.applied')) ?></span>
<strong class="settings-stat__value"><?= $e((string) $applied) ?></strong>
</div>
<div class="settings-stat">
<span class="settings-stat__label"><?= $e($t('settings.database.stats.pending')) ?></span>
<strong class="settings-stat__value"><?= $e((string) $pending) ?></strong>
</div>
</div>
<?php if ($pending > 0): ?>
<div class="alert alert--warning mt-16" role="status">
<?= $e($t('settings.database.state.needs_update')) ?>
</div>
<form class="mt-16" action="/settings/database/migrate" method="post">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<button type="submit" class="btn btn--primary"><?= $e($t('settings.database.actions.run_update')) ?></button>
</form>
<?php else: ?>
<div class="alert alert--success mt-16" role="status">
<?= $e($t('settings.database.state.up_to_date')) ?>
</div>
<?php endif; ?>
</section>
<section class="card mt-16">
<h2 class="section-title"><?= $e($t('settings.database.pending_files_title')) ?></h2>
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th><?= $e($t('settings.database.fields.filename')) ?></th>
</tr>
</thead>
<tbody>
<?php if (empty($pendingFiles)): ?>
<tr>
<td class="muted"><?= $e($t('settings.database.pending_files_empty')) ?></td>
</tr>
<?php else: ?>
<?php foreach ($pendingFiles as $filename): ?>
<tr>
<td><?= $e((string) $filename) ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
<?php if (!empty($logs)): ?>
<section class="card mt-16">
<h2 class="section-title"><?= $e($t('settings.database.last_run_logs')) ?></h2>
<pre class="settings-logs mt-12"><?php foreach ($logs as $line): ?><?= $e((string) $line) . "\n" ?><?php endforeach; ?></pre>
</section>
<?php endif; ?>

View File

@@ -0,0 +1,72 @@
<section class="card">
<h1><?= $e($t('users.title')) ?></h1>
<p class="muted"><?= $e($t('users.description')) ?></p>
</section>
<section class="card mt-16">
<h2 class="section-title"><?= $e($t('users.create_title')) ?></h2>
<?php if (!empty($errorMessage)): ?>
<div class="alert alert--danger mt-12" role="alert">
<?= $e($errorMessage) ?>
</div>
<?php endif; ?>
<?php if (!empty($successMessage)): ?>
<div class="alert alert--success mt-12" role="status">
<?= $e($successMessage) ?>
</div>
<?php endif; ?>
<form class="users-form mt-16" action="/users" method="post" novalidate>
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<label class="form-field">
<span class="field-label"><?= $e($t('users.fields.name')) ?></span>
<input class="form-control" type="text" name="name" required value="<?= $e($oldName ?? '') ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('users.fields.email')) ?></span>
<input class="form-control" type="email" name="email" required value="<?= $e($oldEmail ?? '') ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('users.fields.password')) ?></span>
<input class="form-control" type="password" name="password" required>
</label>
<button type="submit" class="btn btn--primary"><?= $e($t('users.actions.add_user')) ?></button>
</form>
</section>
<section class="card mt-16">
<h2 class="section-title"><?= $e($t('users.list_title')) ?></h2>
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th><?= $e($t('users.fields.name')) ?></th>
<th><?= $e($t('users.fields.email')) ?></th>
<th><?= $e($t('users.fields.created_at')) ?></th>
</tr>
</thead>
<tbody>
<?php if (empty($users)): ?>
<tr>
<td colspan="3" class="muted"><?= $e($t('users.empty')) ?></td>
</tr>
<?php else: ?>
<?php foreach ($users as $row): ?>
<tr>
<td><?= $e((string) ($row['name'] ?? '')) ?></td>
<td><?= $e((string) ($row['email'] ?? '')) ?></td>
<td><?= $e((string) ($row['created_at'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</section>

View File

@@ -7,6 +7,8 @@ use App\Core\Http\Response;
use App\Core\Security\Csrf; use App\Core\Security\Csrf;
use App\Modules\Auth\AuthController; use App\Modules\Auth\AuthController;
use App\Modules\Auth\AuthMiddleware; use App\Modules\Auth\AuthMiddleware;
use App\Modules\Settings\SettingsController;
use App\Modules\Users\UsersController;
return static function (Application $app): void { return static function (Application $app): void {
$router = $app->router(); $router = $app->router();
@@ -15,6 +17,8 @@ return static function (Application $app): void {
$translator = $app->translator(); $translator = $app->translator();
$authController = new AuthController($template, $auth, $translator); $authController = new AuthController($template, $auth, $translator);
$usersController = new UsersController($template, $translator, $auth, $app->users());
$settingsController = new SettingsController($template, $translator, $auth, $app->migrator());
$authMiddleware = new AuthMiddleware($auth); $authMiddleware = new AuthMiddleware($auth);
$router->get('/health', static fn (Request $request): Response => Response::json([ $router->get('/health', static fn (Request $request): Response => Response::json([
@@ -37,10 +41,16 @@ return static function (Application $app): void {
$user = $auth->user(); $user = $auth->user();
$html = $template->render('dashboard/index', [ $html = $template->render('dashboard/index', [
'title' => $translator->get('dashboard.title'), 'title' => $translator->get('dashboard.title'),
'activeMenu' => 'dashboard',
'user' => $user, 'user' => $user,
'csrfToken' => Csrf::token(), 'csrfToken' => Csrf::token(),
], 'layouts/app'); ], 'layouts/app');
return Response::html($html); return Response::html($html);
}, [$authMiddleware]); }, [$authMiddleware]);
$router->get('/users', [$usersController, 'index'], [$authMiddleware]);
$router->post('/users', [$usersController, 'store'], [$authMiddleware]);
$router->get('/settings/database', [$settingsController, 'database'], [$authMiddleware]);
$router->post('/settings/database/migrate', [$settingsController, 'migrate'], [$authMiddleware]);
}; };

View File

@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace App\Core; namespace App\Core;
use App\Core\Database\ConnectionFactory;
use App\Core\Database\Migrator;
use App\Core\Http\Request; use App\Core\Http\Request;
use App\Core\Http\Response; use App\Core\Http\Response;
use App\Core\I18n\Translator; use App\Core\I18n\Translator;
@@ -11,6 +13,8 @@ use App\Core\Support\Logger;
use App\Core\Support\Session; use App\Core\Support\Session;
use App\Core\View\Template; use App\Core\View\Template;
use App\Modules\Auth\AuthService; use App\Modules\Auth\AuthService;
use App\Modules\Users\UserRepository;
use PDO;
use Throwable; use Throwable;
final class Application final class Application
@@ -18,6 +22,9 @@ final class Application
private Router $router; private Router $router;
private Template $template; private Template $template;
private AuthService $authService; private AuthService $authService;
private UserRepository $userRepository;
private Migrator $migrator;
private PDO $db;
private Logger $logger; private Logger $logger;
private Translator $translator; private Translator $translator;
@@ -34,7 +41,13 @@ final class Application
(string) $this->config('app.locale', 'pl') (string) $this->config('app.locale', 'pl')
); );
$this->template = new Template((string) $this->config('app.view_path'), $this->translator); $this->template = new Template((string) $this->config('app.view_path'), $this->translator);
$this->authService = new AuthService((array) $this->config('auth', [])); $this->db = ConnectionFactory::make((array) $this->config('database', []));
$this->userRepository = new UserRepository($this->db);
$this->migrator = new Migrator(
$this->db,
(string) $this->config('app.migrations_path', $this->basePath('database/migrations'))
);
$this->authService = new AuthService($this->userRepository);
$this->logger = new Logger((string) $this->config('app.log_path')); $this->logger = new Logger((string) $this->config('app.log_path'));
} }
@@ -84,6 +97,21 @@ final class Application
return $this->logger; return $this->logger;
} }
public function users(): UserRepository
{
return $this->userRepository;
}
public function db(): PDO
{
return $this->db;
}
public function migrator(): Migrator
{
return $this->migrator;
}
public function translator(): Translator public function translator(): Translator
{ {
return $this->translator; return $this->translator;

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Core\Database;
use PDO;
use RuntimeException;
final class ConnectionFactory
{
/**
* @param array<string, mixed> $config
*/
public static function make(array $config): PDO
{
$driver = (string) ($config['driver'] ?? 'mysql');
if ($driver !== 'mysql') {
throw new RuntimeException('Unsupported database driver: ' . $driver);
}
$host = (string) ($config['host'] ?? '127.0.0.1');
$port = (int) ($config['port'] ?? 3306);
$database = (string) ($config['database'] ?? '');
$charset = (string) ($config['charset'] ?? 'utf8mb4');
if ($database === '') {
throw new RuntimeException('Database name is required.');
}
$dsn = sprintf('mysql:host=%s;port=%d;dbname=%s;charset=%s', $host, $port, $database, $charset);
return new PDO($dsn, (string) ($config['username'] ?? ''), (string) ($config['password'] ?? ''), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
}

View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Core\Database;
use PDO;
use RuntimeException;
use Throwable;
final class Migrator
{
public function __construct(
private readonly PDO $pdo,
private readonly string $migrationsPath
) {
}
/**
* @return array{
* total:int,
* applied:int,
* pending:int,
* pending_files:array<int, string>
* }
*/
public function status(): array
{
$this->ensureMigrationsTable();
$allFiles = $this->migrationFiles();
$appliedFiles = $this->appliedFilenames();
$pendingFiles = array_values(array_diff($allFiles, $appliedFiles));
return [
'total' => count($allFiles),
'applied' => count($appliedFiles),
'pending' => count($pendingFiles),
'pending_files' => $pendingFiles,
];
}
/**
* @return array{
* executed:int,
* skipped:int,
* logs:array<int, string>
* }
*/
public function runPending(): array
{
$this->acquireLock();
try {
$this->ensureMigrationsTable();
$allFiles = $this->migrationFiles();
$appliedFiles = $this->appliedFilenames();
$pendingFiles = array_values(array_diff($allFiles, $appliedFiles));
$insert = $this->pdo->prepare(
'INSERT INTO migrations (filename, executed_at) VALUES (:filename, :executed_at)'
);
$executed = 0;
$skipped = 0;
$logs = [];
foreach ($pendingFiles as $filename) {
$fullPath = rtrim($this->migrationsPath, '/\\') . DIRECTORY_SEPARATOR . $filename;
$sql = file_get_contents($fullPath);
if ($sql === false || trim($sql) === '') {
$skipped++;
$logs[] = '[skip-empty] ' . $filename;
continue;
}
$this->pdo->beginTransaction();
try {
$this->pdo->exec($sql);
$insert->execute([
'filename' => $filename,
'executed_at' => date('Y-m-d H:i:s'),
]);
$this->pdo->commit();
$executed++;
$logs[] = '[ok] ' . $filename;
} catch (Throwable $exception) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
$logs[] = '[error] ' . $filename . ' - ' . $exception->getMessage();
throw $exception;
}
}
return [
'executed' => $executed,
'skipped' => $skipped,
'logs' => $logs,
];
} finally {
$this->releaseLock();
}
}
private function ensureMigrationsTable(): void
{
$this->pdo->exec(
'CREATE TABLE IF NOT EXISTS migrations (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
filename VARCHAR(190) NOT NULL,
executed_at DATETIME NOT NULL,
UNIQUE KEY migrations_filename_unique (filename)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
);
}
private function acquireLock(): void
{
$statement = $this->pdo->query("SELECT GET_LOCK('orderpro_migrations_lock', 10)");
$value = $statement !== false ? $statement->fetchColumn() : false;
if ((string) $value !== '1') {
throw new RuntimeException('Nie mozna uzyskac blokady migracji. Sprobuj ponownie za chwile.');
}
}
private function releaseLock(): void
{
$this->pdo->query("DO RELEASE_LOCK('orderpro_migrations_lock')");
}
/**
* @return array<int, string>
*/
private function migrationFiles(): array
{
$files = glob(rtrim($this->migrationsPath, '/\\') . DIRECTORY_SEPARATOR . '*.sql');
if (!is_array($files)) {
return [];
}
$filenames = array_map(static fn (string $path): string => basename($path), $files);
sort($filenames);
return $filenames;
}
/**
* @return array<int, string>
*/
private function appliedFilenames(): array
{
$statement = $this->pdo->query('SELECT filename FROM migrations ORDER BY filename');
$rows = $statement->fetchAll(PDO::FETCH_COLUMN);
if (!is_array($rows)) {
return [];
}
return array_values(array_map(static fn (mixed $value): string => (string) $value, $rows));
}
}

View File

@@ -4,39 +4,34 @@ declare(strict_types=1);
namespace App\Modules\Auth; namespace App\Modules\Auth;
use App\Core\Support\Session; use App\Core\Support\Session;
use App\Modules\Users\UserRepository;
final class AuthService final class AuthService
{ {
private const SESSION_USER_KEY = 'auth_user'; private const SESSION_USER_KEY = 'auth_user';
/** public function __construct(private readonly UserRepository $users)
* @param array<string, mixed> $config
*/
public function __construct(private readonly array $config)
{ {
} }
public function attempt(string $email, string $password): bool public function attempt(string $email, string $password): bool
{ {
$storedEmail = strtolower((string) ($this->config['admin_email'] ?? '')); $storedUser = $this->users->findByEmail($email);
$storedHash = (string) ($this->config['admin_password_hash'] ?? ''); if ($storedUser === null) {
if ($storedEmail === '' || $storedHash === '') {
return false; return false;
} }
if (strtolower($email) !== $storedEmail) { $storedHash = (string) ($storedUser['password_hash'] ?? '');
return false; if ($storedHash === '' || !password_verify($password, $storedHash)) {
}
if (!password_verify($password, $storedHash)) {
return false; return false;
} }
Session::regenerate(); Session::regenerate();
$_SESSION[self::SESSION_USER_KEY] = [ $_SESSION[self::SESSION_USER_KEY] = [
'email' => $storedEmail, 'id' => (int) ($storedUser['id'] ?? 0),
'name' => (string) ($storedUser['name'] ?? ''),
'email' => strtolower((string) ($storedUser['email'] ?? '')),
'login_at' => date(DATE_ATOM), 'login_at' => date(DATE_ATOM),
]; ];

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Database\Migrator;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\I18n\Translator;
use App\Core\Security\Csrf;
use App\Core\Support\Flash;
use App\Core\View\Template;
use App\Modules\Auth\AuthService;
use Throwable;
final class SettingsController
{
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly Migrator $migrator
) {
}
public function database(Request $request): Response
{
$status = $this->migrator->status();
$html = $this->template->render('settings/database', [
'title' => $this->translator->get('settings.database.title'),
'activeMenu' => 'settings',
'activeSettings' => 'database',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'status' => $status,
'errorMessage' => (string) Flash::get('settings_error', ''),
'successMessage' => (string) Flash::get('settings_success', ''),
'runLogs' => (array) Flash::get('settings_migrate_logs', []),
], 'layouts/app');
return Response::html($html);
}
public function migrate(Request $request): Response
{
$csrfToken = (string) $request->input('_token', '');
if (!Csrf::validate($csrfToken)) {
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect('/settings/database');
}
try {
$result = $this->migrator->runPending();
$executed = (int) ($result['executed'] ?? 0);
$skipped = (int) ($result['skipped'] ?? 0);
$logs = (array) ($result['logs'] ?? []);
Flash::set(
'settings_success',
$this->translator->get('settings.database.flash.updated', [
'executed' => (string) $executed,
'skipped' => (string) $skipped,
])
);
Flash::set('settings_migrate_logs', $logs);
} catch (Throwable $exception) {
Flash::set(
'settings_error',
$this->translator->get('settings.database.flash.failed') . ' ' . $exception->getMessage()
);
Flash::set('settings_migrate_logs', ['[error] ' . $exception->getMessage()]);
}
return Response::redirect('/settings/database');
}
}

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Modules\Users;
use PDO;
final class UserRepository
{
public function __construct(private readonly PDO $pdo)
{
}
/**
* @return array<int, array<string, mixed>>
*/
public function all(): array
{
$statement = $this->pdo->query('SELECT id, name, email, created_at FROM users ORDER BY id DESC');
$rows = $statement->fetchAll();
if (!is_array($rows)) {
return [];
}
return array_map(
static fn (array $row): array => [
'id' => (int) ($row['id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'email' => (string) ($row['email'] ?? ''),
'created_at' => (string) ($row['created_at'] ?? ''),
],
$rows
);
}
public function hasAny(): bool
{
$statement = $this->pdo->query('SELECT 1 FROM users LIMIT 1');
return $statement->fetchColumn() !== false;
}
/**
* @return array<string, mixed>|null
*/
public function findByEmail(string $email): ?array
{
$normalizedEmail = strtolower(trim($email));
if ($normalizedEmail === '') {
return null;
}
$statement = $this->pdo->prepare(
'SELECT id, name, email, password_hash, created_at FROM users WHERE email = :email LIMIT 1'
);
$statement->execute(['email' => $normalizedEmail]);
$row = $statement->fetch();
if (!is_array($row)) {
return null;
}
return [
'id' => (int) ($row['id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'email' => (string) ($row['email'] ?? ''),
'password_hash' => (string) ($row['password_hash'] ?? ''),
'created_at' => (string) ($row['created_at'] ?? ''),
];
}
/**
* @return array<string, mixed>
*/
public function create(string $name, string $email, string $passwordHash): array
{
$createdAt = date('Y-m-d H:i:s');
$normalizedName = trim($name);
$normalizedEmail = strtolower(trim($email));
$statement = $this->pdo->prepare(
'INSERT INTO users (name, email, password_hash, created_at) VALUES (:name, :email, :password_hash, :created_at)'
);
$statement->execute([
'name' => $normalizedName,
'email' => $normalizedEmail,
'password_hash' => $passwordHash,
'created_at' => $createdAt,
]);
$id = (int) $this->pdo->lastInsertId();
$record = [
'id' => $id,
'name' => $normalizedName,
'email' => $normalizedEmail,
'password_hash' => $passwordHash,
'created_at' => $createdAt,
];
return $record;
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Modules\Users;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\I18n\Translator;
use App\Core\Security\Csrf;
use App\Core\Support\Flash;
use App\Core\View\Template;
use App\Modules\Auth\AuthService;
final class UsersController
{
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly UserRepository $users
) {
}
public function index(Request $request): Response
{
$records = array_map(
static fn (array $user): array => [
'id' => (int) ($user['id'] ?? 0),
'name' => (string) ($user['name'] ?? ''),
'email' => (string) ($user['email'] ?? ''),
'created_at' => (string) ($user['created_at'] ?? ''),
],
$this->users->all()
);
$html = $this->template->render('users/index', [
'title' => $this->translator->get('users.title'),
'activeMenu' => 'users',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'users' => $records,
'errorMessage' => (string) Flash::get('users_error', ''),
'successMessage' => (string) Flash::get('users_success', ''),
'oldName' => (string) Flash::get('users_old_name', ''),
'oldEmail' => (string) Flash::get('users_old_email', ''),
], 'layouts/app');
return Response::html($html);
}
public function store(Request $request): Response
{
$csrfToken = (string) $request->input('_token', '');
if (!Csrf::validate($csrfToken)) {
Flash::set('users_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect('/users');
}
$name = trim((string) $request->input('name', ''));
$email = strtolower(trim((string) $request->input('email', '')));
$password = (string) $request->input('password', '');
Flash::set('users_old_name', $name);
Flash::set('users_old_email', $email);
if (mb_strlen($name) < 2) {
Flash::set('users_error', $this->translator->get('users.validation.name_min'));
return Response::redirect('/users');
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
Flash::set('users_error', $this->translator->get('users.validation.email_invalid'));
return Response::redirect('/users');
}
if (strlen($password) < 8) {
Flash::set('users_error', $this->translator->get('users.validation.password_min'));
return Response::redirect('/users');
}
if ($this->users->findByEmail($email) !== null) {
Flash::set('users_error', $this->translator->get('users.validation.email_taken'));
return Response::redirect('/users');
}
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
$this->users->create($name, $email, $passwordHash);
Flash::set('users_success', $this->translator->get('users.flash.created'));
Flash::set('users_old_name', '');
Flash::set('users_old_email', '');
return Response::redirect('/users');
}
}