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:
14
.env
Normal file
14
.env
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
1299
.vscode/ftp-kr.sync.cache.json
vendored
1299
.vscode/ftp-kr.sync.cache.json
vendored
File diff suppressed because it is too large
Load Diff
17
DOCS/FRONTEND_STANDARDS.md
Normal file
17
DOCS/FRONTEND_STANDARDS.md
Normal 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
19
DOCS/MIGRATIONS.md
Normal 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
48
bin/migrate.php
Normal 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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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
14
config/database.php
Normal 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'),
|
||||||
|
];
|
||||||
@@ -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;
|
||||||
@@ -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
@@ -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}}
|
||||||
|
|||||||
1
public/assets/css/modules/jquery-alerts.css
Normal file
1
public/assets/css/modules/jquery-alerts.css
Normal 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)}
|
||||||
72
public/assets/js/modules/jquery-alerts.js
vendored
Normal file
72
public/assets/js/modules/jquery-alerts.js
vendored
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -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.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
72
resources/modules/jquery-alerts/jquery-alerts.js
vendored
Normal file
72
resources/modules/jquery-alerts/jquery-alerts.js
vendored
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
58
resources/modules/jquery-alerts/jquery-alerts.scss
Normal file
58
resources/modules/jquery-alerts/jquery-alerts.scss
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
174
resources/scss/shared/_ui-components.scss
Normal file
174
resources/scss/shared/_ui-components.scss
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
95
resources/views/settings/database.php
Normal file
95
resources/views/settings/database.php
Normal 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; ?>
|
||||||
72
resources/views/users/index.php
Normal file
72
resources/views/users/index.php
Normal 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>
|
||||||
@@ -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]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
38
src/Core/Database/ConnectionFactory.php
Normal file
38
src/Core/Database/ConnectionFactory.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
162
src/Core/Database/Migrator.php
Normal file
162
src/Core/Database/Migrator.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
77
src/Modules/Settings/SettingsController.php
Normal file
77
src/Modules/Settings/SettingsController.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
103
src/Modules/Users/UserRepository.php
Normal file
103
src/Modules/Users/UserRepository.php
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
95
src/Modules/Users/UsersController.php
Normal file
95
src/Modules/Users/UsersController.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user