baseline
This commit is contained in:
19
.env.example
Normal file
19
.env.example
Normal file
@@ -0,0 +1,19 @@
|
||||
APP_NAME=IMVS
|
||||
APP_SLOGAN=Immobilien Makler Verkaufs Software
|
||||
APP_URL=http://localhost:8080
|
||||
APP_ENV=dev
|
||||
APP_DEBUG=true
|
||||
APP_TIMEZONE=Europe/Berlin
|
||||
APP_STORAGE_PATH=./storage
|
||||
APP_LOCALE=de
|
||||
APP_LOCALES=de,en
|
||||
SESSION_NAME=IMVS
|
||||
|
||||
DB_HOST=db
|
||||
DB_PORT=3306
|
||||
DB_NAME=imvs
|
||||
DB_USER=imvs
|
||||
DB_PASS=imvs
|
||||
DB_ROOT_PASSWORD=imvs_root
|
||||
|
||||
CACHE_SERVERS=memcached:11211
|
||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
.vscode/
|
||||
composer.phar
|
||||
config/config.php
|
||||
.env
|
||||
/vendor/
|
||||
/web/debugger/
|
||||
/data/
|
||||
/storage/*
|
||||
!/storage/.gitkeep
|
||||
5
.htaccess
Normal file
5
.htaccess
Normal file
@@ -0,0 +1,5 @@
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine on
|
||||
RewriteRule ^$ web/ [L]
|
||||
RewriteRule (.*) web/$1 [L]
|
||||
</IfModule>
|
||||
21
LICENSE.md
Normal file
21
LICENSE.md
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Maurits van der Schee
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
41
README.md
Normal file
41
README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
<h1><img alt="MintyPHP" height="50" src="web/img/minty_square.png"> MintyPHP</h1>
|
||||
|
||||
MintyPHP aims to be a full-stack PHP 7 (or 8) framework that is:
|
||||
|
||||
- Easy to learn
|
||||
- Secure by design
|
||||
- Light-weight
|
||||
|
||||
By design, it does:
|
||||
|
||||
- … have one variable scope for all layers.
|
||||
- … require you to write SQL queries (no ORM).
|
||||
- … use PHP as a templating language.
|
||||
|
||||
Mainly to make it easy to learn for PHP developers.
|
||||
|
||||
[Download](https://mintyphp.github.io/installation/) /
|
||||
[Documentation](https://mintyphp.github.io/docs/)
|
||||
|
||||
## External links
|
||||
|
||||
- [MintyPHP v3 is released](https://tqdev.com/2022-mintyphp-v3-is-released)
|
||||
- [MintyPHP now on packagist!](https://tqdev.com/2018-mindaphp-now-on-packagist)
|
||||
|
||||
## Quickstart (Docker)
|
||||
|
||||
1. Copy `.env.example` to `.env` (defaults are fine for local dev).
|
||||
2. Build and start the stack:
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
3. Open the app at `http://localhost:8080`.
|
||||
4. Register a user, then visit `/admin` (protected route).
|
||||
5. phpMyAdmin is available at `http://localhost:8081`.
|
||||
|
||||
### Notes
|
||||
|
||||
- MintyPHP uses Memcached for its firewall cache; the compose stack includes a `memcached` service and the PHP container has the extension enabled.
|
||||
- `config/config.php` is generated and gitignored by default; adjust `.gitignore` if you want to commit it.
|
||||
33
composer.json
Normal file
33
composer.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "mintyphp/mintyphp",
|
||||
"description": "A refreshingly different PHP web framework that is easy to learn",
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Maurits van der Schee",
|
||||
"email": "maurits@vdschee.nl",
|
||||
"homepage": "https://www.tqdev.com"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.5",
|
||||
"mintyphp/core": "*",
|
||||
"phpmailer/phpmailer": "^7.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"mintyphp/tools": "*",
|
||||
"mintyphp/debugger": "*",
|
||||
"phpunit/phpunit": "*",
|
||||
"phpstan/phpstan": "^1.9"
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"MintyPHP\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"MintyPHP\\": "lib/"
|
||||
}
|
||||
}
|
||||
}
|
||||
1847
composer.lock
generated
Normal file
1847
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
69
config/config.php.example
Normal file
69
config/config.php.example
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Auth;
|
||||
use MintyPHP\Cache;
|
||||
use MintyPHP\DB;
|
||||
use MintyPHP\Debugger;
|
||||
use MintyPHP\Firewall;
|
||||
use MintyPHP\I18n;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Session;
|
||||
|
||||
define('APP_NAME', getenv('APP_NAME') ?: 'IMVS');
|
||||
define('APP_SLOGAN', getenv('APP_SLOGAN') ?: 'Immobilien Makler Verkaufs Software');
|
||||
define('APP_TIMEZONE', getenv('APP_TIMEZONE') ?: 'Europe/Berlin');
|
||||
define('APP_STORAGE_PATH', getenv('APP_STORAGE_PATH') ?: __DIR__ . '/../storage');
|
||||
$tenantScopeEnv = getenv('TENANT_SCOPE_STRICT');
|
||||
define('TENANT_SCOPE_STRICT', $tenantScopeEnv === false ? true : filter_var($tenantScopeEnv, FILTER_VALIDATE_BOOLEAN));
|
||||
if (!defined('APP_LOCALES')) {
|
||||
$locales = getenv('APP_LOCALES') ?: 'de,en';
|
||||
define('APP_LOCALES', array_values(array_filter(array_map('trim', explode(',', $locales)))));
|
||||
}
|
||||
|
||||
if (!defined('APP_PUBLIC_PATHS')) {
|
||||
define('APP_PUBLIC_PATHS', [
|
||||
'login',
|
||||
'register',
|
||||
'password/forgot',
|
||||
'password/verify',
|
||||
'password/reset',
|
||||
'lang',
|
||||
'imprint',
|
||||
'privacy',
|
||||
]);
|
||||
}
|
||||
|
||||
date_default_timezone_set(APP_TIMEZONE);
|
||||
ini_set('date.timezone', APP_TIMEZONE);
|
||||
|
||||
Router::$baseUrl = '/'; // default: '/'
|
||||
Router::$pageRoot = 'pages/'; // default: 'pages/'
|
||||
Router::$templateRoot = 'templates/'; // default: 'templates/'
|
||||
|
||||
Session::$sessionName = getenv('SESSION_NAME') ?: 'MintyPHP';
|
||||
|
||||
Firewall::$concurrency = (int) (getenv('FIREWALL_CONCURRENCY') ?: 10);
|
||||
Firewall::$spinLockSeconds = (float) (getenv('FIREWALL_SPINLOCK_SECONDS') ?: 0.15);
|
||||
Firewall::$intervalSeconds = (int) (getenv('FIREWALL_INTERVAL_SECONDS') ?: 300);
|
||||
Firewall::$cachePrefix = getenv('FIREWALL_CACHE_PREFIX') ?: 'fw_concurrency_';
|
||||
Firewall::$reverseProxy = getenv('FIREWALL_REVERSE_PROXY') === 'true';
|
||||
|
||||
Cache::$servers = getenv('CACHE_SERVERS') ?: '127.0.0.1';
|
||||
|
||||
DB::$host = getenv('DB_HOST') ?: 'db';
|
||||
DB::$username = getenv('DB_USER') ?: 'mintyphp';
|
||||
DB::$password = getenv('DB_PASS') ?: 'mintyphp';
|
||||
DB::$database = getenv('DB_NAME') ?: 'mintyphp';
|
||||
DB::$port = (int) (getenv('DB_PORT') ?: 3306);
|
||||
|
||||
Auth::$usersTable = 'users';
|
||||
Auth::$usernameField = 'email';
|
||||
Auth::$passwordField = 'password';
|
||||
Auth::$createdField = 'created';
|
||||
Auth::$totpSecretField = 'totp_secret';
|
||||
|
||||
$debugEnv = getenv('APP_DEBUG');
|
||||
Debugger::$enabled = $debugEnv === false ? true : filter_var($debugEnv, FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
I18n::$domain = getenv('APP_I18N_DOMAIN') ?: 'default';
|
||||
I18n::$locale = getenv('APP_LOCALE') ?: 'de';
|
||||
16
config/router.php
Normal file
16
config/router.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Router;
|
||||
|
||||
// Set up redirects
|
||||
|
||||
Router::addRoute('login', 'auth/login');
|
||||
Router::addRoute('register', 'auth/register');
|
||||
Router::addRoute('logout', 'auth/logout');
|
||||
Router::addRoute('profile', 'account/profile');
|
||||
Router::addRoute('password/forgot', 'auth/forgot');
|
||||
Router::addRoute('password/verify', 'auth/verify');
|
||||
Router::addRoute('password/reset', 'auth/reset');
|
||||
Router::addRoute('verify-email', 'auth/verify-email');
|
||||
Router::addRoute('imprint', 'page/imprint');
|
||||
Router::addRoute('privacy', 'page/privacy');
|
||||
8
config/settings.php
Normal file
8
config/settings.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
return array (
|
||||
'app_title' => 'IVMS',
|
||||
'app_locale' => 'de',
|
||||
'app_theme' => 'dark',
|
||||
'app_theme_user' => '1',
|
||||
'app_primary_color' => '#9b3dc7',
|
||||
);
|
||||
425
db/init/init.sql
Normal file
425
db/init/init.sql
Normal file
@@ -0,0 +1,425 @@
|
||||
-- Consolidated schema (fresh install)
|
||||
-- Includes all current tables, columns, indexes, and seed data.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `users` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`uuid` CHAR(36) NOT NULL,
|
||||
`first_name` VARCHAR(100) NOT NULL,
|
||||
`last_name` VARCHAR(100) NOT NULL,
|
||||
`email` VARCHAR(255) NOT NULL,
|
||||
`profile_description` TEXT NULL,
|
||||
`job_title` VARCHAR(160) NULL,
|
||||
`phone` VARCHAR(50) NULL,
|
||||
`mobile` VARCHAR(50) NULL,
|
||||
`short_dial` VARCHAR(20) NULL,
|
||||
`address` VARCHAR(255) NULL,
|
||||
`postal_code` VARCHAR(20) NULL,
|
||||
`city` VARCHAR(100) NULL,
|
||||
`country` VARCHAR(100) NULL,
|
||||
`region` VARCHAR(100) NULL,
|
||||
`hire_date` DATE NULL,
|
||||
`email_verified_at` DATETIME NULL DEFAULT NULL,
|
||||
`password` VARCHAR(255) NOT NULL,
|
||||
`locale` VARCHAR(10) DEFAULT NULL,
|
||||
`totp_secret` VARCHAR(255) DEFAULT NULL,
|
||||
`theme` VARCHAR(10) NOT NULL DEFAULT 'light',
|
||||
`primary_tenant_id` INT NULL,
|
||||
`current_tenant_id` INT UNSIGNED NULL,
|
||||
`created_by` INT UNSIGNED DEFAULT NULL,
|
||||
`modified_by` INT UNSIGNED DEFAULT NULL,
|
||||
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`modified` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
`active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`active_changed_at` DATETIME NULL DEFAULT NULL,
|
||||
`active_changed_by` INT UNSIGNED DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_users_uuid` (`uuid`),
|
||||
UNIQUE KEY `uniq_users_email` (`email`),
|
||||
KEY `idx_users_primary_tenant_id` (`primary_tenant_id`),
|
||||
KEY `idx_users_current_tenant_id` (`current_tenant_id`),
|
||||
KEY `idx_users_created_by` (`created_by`),
|
||||
KEY `idx_users_modified_by` (`modified_by`),
|
||||
KEY `idx_users_active_changed_by` (`active_changed_by`),
|
||||
CONSTRAINT `fk_users_created_by` FOREIGN KEY (`created_by`) REFERENCES `users` (`id`) ON DELETE SET NULL,
|
||||
CONSTRAINT `fk_users_modified_by` FOREIGN KEY (`modified_by`) REFERENCES `users` (`id`) ON DELETE SET NULL,
|
||||
CONSTRAINT `fk_users_active_changed_by` FOREIGN KEY (`active_changed_by`) REFERENCES `users` (`id`) ON DELETE SET NULL,
|
||||
CONSTRAINT `fk_users_current_tenant` FOREIGN KEY (`current_tenant_id`) REFERENCES `tenants` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `tenants` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`uuid` CHAR(36) NOT NULL,
|
||||
`description` VARCHAR(255) NOT NULL,
|
||||
`address` VARCHAR(255) NULL,
|
||||
`postal_code` VARCHAR(20) NULL,
|
||||
`city` VARCHAR(100) NULL,
|
||||
`country` VARCHAR(100) NULL,
|
||||
`region` VARCHAR(100) NULL,
|
||||
`vat_id` VARCHAR(50) NULL,
|
||||
`tax_number` VARCHAR(50) NULL,
|
||||
`phone` VARCHAR(50) NULL,
|
||||
`fax` VARCHAR(100) NULL,
|
||||
`email` VARCHAR(190) NULL,
|
||||
`support_email` VARCHAR(190) NULL,
|
||||
`support_phone` VARCHAR(50) NULL,
|
||||
`billing_email` VARCHAR(190) NULL,
|
||||
`website` VARCHAR(255) NULL,
|
||||
`privacy_url` VARCHAR(255) NULL,
|
||||
`imprint_url` VARCHAR(255) NULL,
|
||||
`primary_color` VARCHAR(7) NULL,
|
||||
`status` VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
`status_changed_at` DATETIME NULL DEFAULT NULL,
|
||||
`status_changed_by` INT UNSIGNED DEFAULT NULL,
|
||||
`created_by` INT UNSIGNED DEFAULT NULL,
|
||||
`modified_by` INT UNSIGNED DEFAULT NULL,
|
||||
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`modified` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_tenants_uuid` (`uuid`),
|
||||
KEY `idx_tenants_created_by` (`created_by`),
|
||||
KEY `idx_tenants_modified_by` (`modified_by`),
|
||||
KEY `idx_tenants_status_changed_by` (`status_changed_by`),
|
||||
CONSTRAINT `fk_tenants_created_by` FOREIGN KEY (`created_by`) REFERENCES `users` (`id`) ON DELETE SET NULL,
|
||||
CONSTRAINT `fk_tenants_modified_by` FOREIGN KEY (`modified_by`) REFERENCES `users` (`id`) ON DELETE SET NULL,
|
||||
CONSTRAINT `fk_tenants_status_changed_by` FOREIGN KEY (`status_changed_by`) REFERENCES `users` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `roles` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`uuid` CHAR(36) NOT NULL,
|
||||
`description` VARCHAR(255) NOT NULL,
|
||||
`created_by` INT UNSIGNED DEFAULT NULL,
|
||||
`modified_by` INT UNSIGNED DEFAULT NULL,
|
||||
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`modified` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_roles_uuid` (`uuid`),
|
||||
KEY `idx_roles_created_by` (`created_by`),
|
||||
KEY `idx_roles_modified_by` (`modified_by`),
|
||||
CONSTRAINT `fk_roles_created_by` FOREIGN KEY (`created_by`) REFERENCES `users` (`id`) ON DELETE SET NULL,
|
||||
CONSTRAINT `fk_roles_modified_by` FOREIGN KEY (`modified_by`) REFERENCES `users` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `permissions` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`key` VARCHAR(191) NOT NULL,
|
||||
`description` VARCHAR(255) DEFAULT NULL,
|
||||
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_permissions_key` (`key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `departments` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`uuid` CHAR(36) NOT NULL,
|
||||
`description` VARCHAR(255) NOT NULL,
|
||||
`created_by` INT UNSIGNED DEFAULT NULL,
|
||||
`modified_by` INT UNSIGNED DEFAULT NULL,
|
||||
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`modified` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_departments_uuid` (`uuid`),
|
||||
KEY `idx_departments_created_by` (`created_by`),
|
||||
KEY `idx_departments_modified_by` (`modified_by`),
|
||||
CONSTRAINT `fk_departments_created_by` FOREIGN KEY (`created_by`) REFERENCES `users` (`id`) ON DELETE SET NULL,
|
||||
CONSTRAINT `fk_departments_modified_by` FOREIGN KEY (`modified_by`) REFERENCES `users` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `pages` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`uuid` CHAR(36) NOT NULL,
|
||||
`slug` VARCHAR(190) NOT NULL,
|
||||
`created_by` INT UNSIGNED NULL,
|
||||
`modified_by` INT UNSIGNED NULL,
|
||||
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`modified` DATETIME NULL DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_pages_uuid` (`uuid`),
|
||||
UNIQUE KEY `uniq_pages_slug` (`slug`),
|
||||
KEY `idx_pages_created_by` (`created_by`),
|
||||
KEY `idx_pages_modified_by` (`modified_by`),
|
||||
CONSTRAINT `fk_pages_created_by` FOREIGN KEY (`created_by`) REFERENCES `users` (`id`) ON DELETE SET NULL,
|
||||
CONSTRAINT `fk_pages_modified_by` FOREIGN KEY (`modified_by`) REFERENCES `users` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `page_contents` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`page_id` INT UNSIGNED NOT NULL,
|
||||
`locale` VARCHAR(10) NOT NULL,
|
||||
`content` LONGTEXT NULL,
|
||||
`created_by` INT UNSIGNED NULL,
|
||||
`modified_by` INT UNSIGNED NULL,
|
||||
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`modified` DATETIME NULL DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_page_locale` (`page_id`, `locale`),
|
||||
KEY `idx_page_id` (`page_id`),
|
||||
KEY `idx_page_contents_created_by` (`created_by`),
|
||||
KEY `idx_page_contents_modified_by` (`modified_by`),
|
||||
CONSTRAINT `fk_page_contents_page` FOREIGN KEY (`page_id`) REFERENCES `pages` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_page_contents_created_by` FOREIGN KEY (`created_by`) REFERENCES `users` (`id`) ON DELETE SET NULL,
|
||||
CONSTRAINT `fk_page_contents_modified_by` FOREIGN KEY (`modified_by`) REFERENCES `users` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `settings` (
|
||||
`key` VARCHAR(64) NOT NULL,
|
||||
`value` TEXT NULL,
|
||||
`description` VARCHAR(255) NULL,
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `user_tenants` (
|
||||
`user_id` INT UNSIGNED NOT NULL,
|
||||
`tenant_id` INT UNSIGNED NOT NULL,
|
||||
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`user_id`, `tenant_id`),
|
||||
KEY `idx_user_tenants_user` (`user_id`),
|
||||
KEY `idx_user_tenants_tenant` (`tenant_id`),
|
||||
CONSTRAINT `fk_user_tenants_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_user_tenants_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `user_roles` (
|
||||
`user_id` INT UNSIGNED NOT NULL,
|
||||
`role_id` INT UNSIGNED NOT NULL,
|
||||
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`user_id`, `role_id`),
|
||||
KEY `idx_user_roles_user` (`user_id`),
|
||||
KEY `idx_user_roles_role` (`role_id`),
|
||||
CONSTRAINT `fk_user_roles_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_user_roles_role` FOREIGN KEY (`role_id`) REFERENCES `roles` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `role_permissions` (
|
||||
`role_id` INT UNSIGNED NOT NULL,
|
||||
`permission_id` INT UNSIGNED NOT NULL,
|
||||
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`role_id`, `permission_id`),
|
||||
KEY `idx_role_permissions_permission` (`permission_id`),
|
||||
CONSTRAINT `fk_role_permissions_role` FOREIGN KEY (`role_id`) REFERENCES `roles` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_role_permissions_permission` FOREIGN KEY (`permission_id`) REFERENCES `permissions` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `user_departments` (
|
||||
`user_id` INT UNSIGNED NOT NULL,
|
||||
`department_id` INT UNSIGNED NOT NULL,
|
||||
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`user_id`, `department_id`),
|
||||
KEY `idx_user_departments_user` (`user_id`),
|
||||
KEY `idx_user_departments_department` (`department_id`),
|
||||
CONSTRAINT `fk_user_departments_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_user_departments_department` FOREIGN KEY (`department_id`) REFERENCES `departments` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `tenant_departments` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`tenant_id` INT UNSIGNED NOT NULL,
|
||||
`department_id` INT UNSIGNED NOT NULL,
|
||||
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_tenant_department` (`tenant_id`, `department_id`),
|
||||
KEY `idx_tenant_departments_tenant` (`tenant_id`),
|
||||
KEY `idx_tenant_departments_department` (`department_id`),
|
||||
CONSTRAINT `fk_tenant_departments_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_tenant_departments_department` FOREIGN KEY (`department_id`) REFERENCES `departments` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `mail_log` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`to_email` VARCHAR(255) NOT NULL,
|
||||
`subject` VARCHAR(255) NOT NULL,
|
||||
`template` VARCHAR(100) NULL,
|
||||
`status` VARCHAR(20) NOT NULL DEFAULT 'queued',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`sent_at` DATETIME NULL DEFAULT NULL,
|
||||
`error_message` TEXT NULL,
|
||||
`provider_message_id` VARCHAR(255) NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_mail_log_status` (`status`),
|
||||
KEY `idx_mail_log_to_email` (`to_email`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `password_resets` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT UNSIGNED NOT NULL,
|
||||
`code_hash` VARCHAR(255) NOT NULL,
|
||||
`expires_at` DATETIME NOT NULL,
|
||||
`attempts` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Failed verification attempts',
|
||||
`used_at` DATETIME NULL DEFAULT NULL,
|
||||
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_password_resets_user` (`user_id`),
|
||||
KEY `idx_password_resets_expires` (`expires_at`),
|
||||
CONSTRAINT `fk_password_resets_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `email_verifications` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT UNSIGNED NOT NULL,
|
||||
`code_hash` VARCHAR(255) NOT NULL,
|
||||
`expires_at` DATETIME NOT NULL,
|
||||
`attempts` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Failed verification attempts',
|
||||
`used_at` DATETIME NULL DEFAULT NULL,
|
||||
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_email_verifications_user` (`user_id`),
|
||||
KEY `idx_email_verifications_expires` (`expires_at`),
|
||||
CONSTRAINT `fk_email_verifications_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `user_remember_tokens` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT UNSIGNED NOT NULL,
|
||||
`selector` CHAR(24) NOT NULL,
|
||||
`token_hash` CHAR(64) NOT NULL,
|
||||
`expires_at` DATETIME NOT NULL,
|
||||
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`last_used` DATETIME NULL DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_user_remember_selector` (`selector`),
|
||||
KEY `idx_user_remember_user` (`user_id`),
|
||||
KEY `idx_user_remember_expires` (`expires_at`),
|
||||
CONSTRAINT `fk_user_remember_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT INTO `tenants` (`uuid`, `description`, `created`)
|
||||
SELECT UUID(), 'Cronus', NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM tenants WHERE description = 'Cronus');
|
||||
|
||||
INSERT INTO `departments` (`uuid`, `description`, `created`)
|
||||
SELECT UUID(), 'IT', NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM departments WHERE description = 'IT');
|
||||
|
||||
INSERT INTO `roles` (`uuid`, `description`, `created`)
|
||||
SELECT UUID(), 'Admin', NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM roles WHERE description = 'Admin');
|
||||
|
||||
INSERT INTO `users` (
|
||||
`uuid`,
|
||||
`first_name`,
|
||||
`last_name`,
|
||||
`email`,
|
||||
`password`,
|
||||
`locale`,
|
||||
`totp_secret`,
|
||||
`theme`,
|
||||
`primary_tenant_id`,
|
||||
`created_by`,
|
||||
`created`,
|
||||
`active`,
|
||||
`active_changed_at`,
|
||||
`active_changed_by`
|
||||
)
|
||||
SELECT
|
||||
UUID(),
|
||||
'Detlef',
|
||||
'Demo',
|
||||
'demo@user.com',
|
||||
'$2y$12$KVCQvuy4Pl1aySBuzSpc7ehpZhAzYZkndDI9OaMi05E2P/Mhob5HO',
|
||||
'de',
|
||||
NULL,
|
||||
'light',
|
||||
(SELECT id FROM tenants WHERE description = 'Cronus' LIMIT 1),
|
||||
NULL,
|
||||
NOW(),
|
||||
1,
|
||||
NOW(),
|
||||
NULL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM users WHERE email = 'demo@user.com');
|
||||
|
||||
INSERT INTO `user_tenants` (`user_id`, `tenant_id`, `created`)
|
||||
SELECT u.id, t.id, NOW()
|
||||
FROM users u
|
||||
JOIN tenants t ON t.description = 'Cronus'
|
||||
WHERE u.email = 'demo@user.com'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM user_tenants ut WHERE ut.user_id = u.id AND ut.tenant_id = t.id
|
||||
);
|
||||
|
||||
INSERT INTO `user_departments` (`user_id`, `department_id`, `created`)
|
||||
SELECT u.id, d.id, NOW()
|
||||
FROM users u
|
||||
JOIN departments d ON d.description = 'IT'
|
||||
WHERE u.email = 'demo@user.com'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM user_departments ud WHERE ud.user_id = u.id AND ud.department_id = d.id
|
||||
);
|
||||
|
||||
INSERT INTO `tenant_departments` (`tenant_id`, `department_id`, `created`)
|
||||
SELECT t.id, d.id, NOW()
|
||||
FROM tenants t
|
||||
JOIN departments d ON d.description = 'IT'
|
||||
WHERE t.description = 'Cronus'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM tenant_departments td WHERE td.tenant_id = t.id AND td.department_id = d.id
|
||||
);
|
||||
|
||||
INSERT INTO `user_roles` (`user_id`, `role_id`, `created`)
|
||||
SELECT u.id, r.id, NOW()
|
||||
FROM users u
|
||||
JOIN roles r ON r.description = 'Admin'
|
||||
WHERE u.email = 'demo@user.com'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM user_roles ur WHERE ur.user_id = u.id AND ur.role_id = r.id
|
||||
);
|
||||
|
||||
INSERT INTO `pages` (`uuid`, `slug`, `created`)
|
||||
SELECT UUID(), 'impressum', NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM pages WHERE slug = 'impressum');
|
||||
|
||||
INSERT INTO `page_contents` (`page_id`, `locale`, `content`, `created`)
|
||||
SELECT p.id, 'de', NULL, p.created
|
||||
FROM pages p
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM page_contents pc WHERE pc.page_id = p.id AND pc.locale = 'de'
|
||||
);
|
||||
|
||||
INSERT INTO `permissions` (`key`, `description`)
|
||||
VALUES
|
||||
('users.view', 'Can view users'),
|
||||
('users.create', 'Can create users'),
|
||||
('users.update', 'Can update users'),
|
||||
('users.delete', 'Can delete users'),
|
||||
('users.self_update', 'Can update own user'),
|
||||
('users.update_assignments', 'Can update user assignments'),
|
||||
('address_book.view', 'Can view address book'),
|
||||
('tenants.view', 'Can view tenants'),
|
||||
('tenants.create', 'Can create tenants'),
|
||||
('tenants.update', 'Can update tenants'),
|
||||
('tenants.delete', 'Can delete tenants'),
|
||||
('departments.view', 'Can view departments'),
|
||||
('departments.create', 'Can create departments'),
|
||||
('departments.update', 'Can update departments'),
|
||||
('departments.delete', 'Can delete departments'),
|
||||
('roles.view', 'Can view roles'),
|
||||
('roles.create', 'Can create roles'),
|
||||
('roles.update', 'Can update roles'),
|
||||
('roles.delete', 'Can delete roles'),
|
||||
('permissions.view', 'Can view permissions'),
|
||||
('permissions.create', 'Can create permissions'),
|
||||
('permissions.update', 'Can update permissions'),
|
||||
('permissions.delete', 'Can delete permissions'),
|
||||
('settings.view', 'Can view settings'),
|
||||
('settings.update', 'Can update settings'),
|
||||
('mail_log.view', 'Can view mail logs'),
|
||||
('stats.view', 'Can view statistics')
|
||||
ON DUPLICATE KEY UPDATE `description` = VALUES(`description`);
|
||||
|
||||
INSERT INTO `role_permissions` (`role_id`, `permission_id`, `created`)
|
||||
SELECT r.id, p.id, NOW()
|
||||
FROM roles r
|
||||
JOIN permissions p ON p.`key` IN (
|
||||
'users.view', 'users.create', 'users.update', 'users.delete',
|
||||
'users.self_update', 'users.update_assignments',
|
||||
'address_book.view',
|
||||
'tenants.view', 'tenants.create', 'tenants.update', 'tenants.delete',
|
||||
'departments.view', 'departments.create', 'departments.update', 'departments.delete',
|
||||
'roles.view', 'roles.create', 'roles.update', 'roles.delete',
|
||||
'permissions.view', 'permissions.create', 'permissions.update', 'permissions.delete',
|
||||
'settings.view', 'settings.update',
|
||||
'mail_log.view',
|
||||
'stats.view'
|
||||
)
|
||||
WHERE r.description IN ('Admin', 'Administrator') OR r.id = 1
|
||||
ON DUPLICATE KEY UPDATE role_id = role_id;
|
||||
55
docker-compose.yml
Normal file
55
docker-compose.yml
Normal file
@@ -0,0 +1,55 @@
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:1.25-alpine
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- ./:/var/www
|
||||
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
- php
|
||||
|
||||
php:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/php/Dockerfile
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./:/var/www
|
||||
depends_on:
|
||||
- db
|
||||
- memcached
|
||||
|
||||
db:
|
||||
image: mariadb:11.4
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
||||
MARIADB_DATABASE: ${DB_NAME}
|
||||
MARIADB_USER: ${DB_USER}
|
||||
MARIADB_PASSWORD: ${DB_PASS}
|
||||
volumes:
|
||||
- db_data:/var/lib/mysql
|
||||
- ./db/init:/docker-entrypoint-initdb.d:ro
|
||||
|
||||
phpmyadmin:
|
||||
image: phpmyadmin:5
|
||||
ports:
|
||||
- "8081:80"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
PMA_HOST: db
|
||||
PMA_PORT: ${DB_PORT}
|
||||
PMA_USER: ${DB_USER}
|
||||
PMA_PASSWORD: ${DB_PASS}
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
memcached:
|
||||
image: memcached:1.6-alpine
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
BIN
docker/.DS_Store
vendored
Normal file
BIN
docker/.DS_Store
vendored
Normal file
Binary file not shown.
29
docker/nginx/default.conf
Normal file
29
docker/nginx/default.conf
Normal file
@@ -0,0 +1,29 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /var/www/web;
|
||||
index index.php;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
fastcgi_pass php:9000;
|
||||
}
|
||||
|
||||
location ~* \.mjs$ {
|
||||
default_type application/javascript;
|
||||
try_files $uri =404;
|
||||
expires 30d;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location ~* \.(css|js|png|jpg|jpeg|gif|svg|ico)$ {
|
||||
expires 30d;
|
||||
access_log off;
|
||||
}
|
||||
}
|
||||
22
docker/php/Dockerfile
Normal file
22
docker/php/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM php:8.5-fpm
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
unzip \
|
||||
libmemcached-dev \
|
||||
zlib1g-dev \
|
||||
libssl-dev \
|
||||
libjpeg-dev \
|
||||
libpng-dev \
|
||||
libwebp-dev \
|
||||
&& pecl install memcached \
|
||||
&& docker-php-ext-enable memcached \
|
||||
&& docker-php-ext-configure gd --with-jpeg --with-webp \
|
||||
&& docker-php-ext-install pdo_mysql mysqli gd \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
COPY docker/php/php.ini /usr/local/etc/php/conf.d/zzz-minty-dev.ini
|
||||
|
||||
WORKDIR /var/www
|
||||
4
docker/php/php.ini
Normal file
4
docker/php/php.ini
Normal file
@@ -0,0 +1,4 @@
|
||||
display_errors=1
|
||||
display_startup_errors=1
|
||||
error_reporting=E_ALL
|
||||
memory_limit=256M
|
||||
717
docs/ARCHITECTURE.md
Normal file
717
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,717 @@
|
||||
# IMVS - Architektur und Grundprinzipien
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
|
||||
1. [Projektübersicht](#1-projektübersicht)
|
||||
2. [Framework: MintyPHP](#2-framework-mintyphp)
|
||||
3. [Verzeichnisstruktur](#3-verzeichnisstruktur)
|
||||
4. [Datenbankarchitektur](#4-datenbankarchitektur)
|
||||
5. [PHP-Architektur](#5-php-architektur)
|
||||
6. [Frontend-Architektur](#6-frontend-architektur)
|
||||
7. [Template-System](#7-template-system)
|
||||
8. [Authentifizierung und Autorisierung](#8-authentifizierung-und-autorisierung)
|
||||
9. [Internationalisierung](#9-internationalisierung)
|
||||
10. [Best Practices](#10-best-practices)
|
||||
|
||||
---
|
||||
|
||||
## 1. Projektübersicht
|
||||
|
||||
**Projektname:** IMVS
|
||||
**Framework:** MintyPHP v3+
|
||||
**PHP-Version:** 8.5+
|
||||
**Datenbank:** MySQL 5.7+
|
||||
**Lizenz:** MIT
|
||||
|
||||
### Kernfeatures
|
||||
|
||||
- Multi-Tenant-Architektur
|
||||
- Rollen- und Berechtigungssystem
|
||||
- Mehrsprachigkeit (DE/EN)
|
||||
- Dark/Light Theme
|
||||
- E-Mail-Integration mit Templates
|
||||
|
||||
---
|
||||
|
||||
## 2. Framework: MintyPHP
|
||||
|
||||
### Philosophie
|
||||
|
||||
MintyPHP ist ein leichtgewichtiges PHP-Framework mit folgenden Prinzipien:
|
||||
|
||||
- **Easy to learn** - Einfach für PHP-Entwickler zu erlernen
|
||||
- **Secure by design** - Sicherheit als Grundprinzip
|
||||
- **Lightweight** - Minimaler Overhead
|
||||
- **Single variable scope** - Eine Variable-Scope für alle Layer
|
||||
- **SQL required** - Direktes SQL statt ORM
|
||||
- **PHP as templating** - PHP selbst als Template-Sprache
|
||||
|
||||
### Kern-Komponenten
|
||||
|
||||
| Komponente | Beschreibung |
|
||||
|------------|--------------|
|
||||
| `Router` | URL-Routing zu Pages |
|
||||
| `DB` | Datenbankzugriff mit Prepared Statements |
|
||||
| `Auth` | Basis-Authentifizierung |
|
||||
| `Session` | Session-Management |
|
||||
| `Buffer` | Output-Buffering für Templates |
|
||||
| `I18n` | Internationalisierung |
|
||||
| `Cache` | Memcached-Integration |
|
||||
| `Firewall` | DDoS-Schutz |
|
||||
|
||||
### Router-Konvention
|
||||
|
||||
```
|
||||
URL: /admin/users/edit/abc-123
|
||||
Datei: pages/admin/users/edit($id).php
|
||||
Variable: $id = 'abc-123'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Verzeichnisstruktur
|
||||
|
||||
```
|
||||
ImmobilienMaklerVerkaufssoftware/
|
||||
├── config/ # Konfigurationsdateien
|
||||
│ ├── config.php # Hauptkonfiguration
|
||||
│ ├── router.php # Route-Definitionen
|
||||
│ └── settings.php # App-Einstellungen (generiert)
|
||||
├── db/ # Datenbank
|
||||
│ └── init/init.sql # Schema und Seeds
|
||||
├── docker/ # Docker-Konfiguration
|
||||
├── docs/ # Dokumentation
|
||||
├── i18n/ # Sprachdateien
|
||||
│ ├── default_de.json
|
||||
│ └── default_en.json
|
||||
├── lib/ # Geschäftslogik
|
||||
│ ├── Http/ # HTTP-Utilities
|
||||
│ ├── Repository/ # Datenbankschicht
|
||||
│ ├── Service/ # Business-Logic
|
||||
│ └── Support/ # Helper und Guards
|
||||
├── pages/ # Seiten (Controller)
|
||||
│ ├── admin/ # Geschützte Admin-Seiten
|
||||
│ ├── auth/ # Authentifizierung
|
||||
│ ├── error/ # Fehlerseiten
|
||||
│ └── page/ # CMS-Seiten
|
||||
├── storage/ # Dateiablage
|
||||
│ ├── branding/ # Logos, Favicons
|
||||
│ ├── tenants/ # Tenant-Dateien
|
||||
│ └── users/ # User-Avatare
|
||||
├── templates/ # View-Templates
|
||||
│ ├── partials/ # Wiederverwendbare Teile
|
||||
│ └── emails/ # E-Mail-Templates
|
||||
├── tests/ # PHPUnit Tests
|
||||
├── vendor/ # Composer Dependencies
|
||||
└── web/ # Public Root
|
||||
├── css/ # Stylesheets
|
||||
├── js/ # JavaScript-Module
|
||||
├── vendor/ # Frontend-Libraries
|
||||
└── index.php # Entry Point
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Datenbankarchitektur
|
||||
|
||||
### Entity-Relationship-Diagramm
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────────┐ ┌─────────────┐
|
||||
│ USERS │────<│ USER_TENANTS │>────│ TENANTS │
|
||||
└─────────────┘ └─────────────────┘ └─────────────┘
|
||||
│ │
|
||||
│ ┌─────────────────┐ │
|
||||
└────────────<│ USER_ROLES │ │
|
||||
│ └─────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────┐ │
|
||||
│ │ ROLES │ │
|
||||
│ └─────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────────────┐ │
|
||||
│ │ROLE_PERMISSIONS │ │
|
||||
│ └─────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────┐ │
|
||||
│ │ PERMISSIONS │ │
|
||||
│ └─────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌──────┴──────┐
|
||||
└────────────<│USER_DEPARTMENTS │>────│ DEPARTMENTS │
|
||||
└─────────────────┘ └─────────────┘
|
||||
│
|
||||
┌──────┴──────┐
|
||||
│TENANT_DEPTS │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
### Haupttabellen
|
||||
|
||||
#### users
|
||||
```sql
|
||||
id INT PRIMARY KEY AUTO_INCREMENT
|
||||
uuid VARCHAR(36) UNIQUE
|
||||
first_name VARCHAR(100)
|
||||
last_name VARCHAR(100)
|
||||
email VARCHAR(255) UNIQUE
|
||||
password VARCHAR(255) -- bcrypt hash
|
||||
locale VARCHAR(10) -- 'de', 'en'
|
||||
theme VARCHAR(20) -- 'light', 'dark'
|
||||
active TINYINT DEFAULT 1
|
||||
primary_tenant_id INT FK -> tenants.id
|
||||
current_tenant_id INT FK -> tenants.id
|
||||
created_by INT FK -> users.id
|
||||
modified_by INT FK -> users.id
|
||||
created DATETIME
|
||||
modified DATETIME
|
||||
```
|
||||
|
||||
#### tenants
|
||||
```sql
|
||||
id INT PRIMARY KEY AUTO_INCREMENT
|
||||
uuid VARCHAR(36) UNIQUE
|
||||
description VARCHAR(255) -- Name/Firma
|
||||
address VARCHAR(255)
|
||||
postal_code VARCHAR(20)
|
||||
city VARCHAR(100)
|
||||
country VARCHAR(100)
|
||||
vat_id VARCHAR(50) -- MwSt-ID
|
||||
tax_number VARCHAR(50)
|
||||
phone VARCHAR(50)
|
||||
email VARCHAR(255)
|
||||
website VARCHAR(255)
|
||||
created_by INT FK -> users.id
|
||||
modified_by INT FK -> users.id
|
||||
created DATETIME
|
||||
modified DATETIME
|
||||
```
|
||||
|
||||
#### roles
|
||||
```sql
|
||||
id INT PRIMARY KEY AUTO_INCREMENT
|
||||
uuid VARCHAR(36) UNIQUE
|
||||
description VARCHAR(255) -- Rollenname
|
||||
created_by INT FK -> users.id
|
||||
modified_by INT FK -> users.id
|
||||
created DATETIME
|
||||
modified DATETIME
|
||||
```
|
||||
|
||||
#### permissions
|
||||
```sql
|
||||
id INT PRIMARY KEY AUTO_INCREMENT
|
||||
key VARCHAR(100) UNIQUE -- z.B. 'users.view'
|
||||
description VARCHAR(255)
|
||||
created DATETIME
|
||||
```
|
||||
|
||||
#### departments
|
||||
```sql
|
||||
id INT PRIMARY KEY AUTO_INCREMENT
|
||||
uuid VARCHAR(36) UNIQUE
|
||||
description VARCHAR(255) -- Abteilungsname
|
||||
created_by INT FK -> users.id
|
||||
modified_by INT FK -> users.id
|
||||
created DATETIME
|
||||
modified DATETIME
|
||||
```
|
||||
|
||||
### Verknüpfungstabellen
|
||||
|
||||
| Tabelle | Beschreibung |
|
||||
|---------|--------------|
|
||||
| `user_tenants` | User ↔ Tenant (M:N) |
|
||||
| `user_roles` | User ↔ Role (M:N) |
|
||||
| `user_departments` | User ↔ Department (M:N) |
|
||||
| `role_permissions` | Role ↔ Permission (M:N) |
|
||||
| `tenant_departments` | Tenant ↔ Department (M:N) |
|
||||
|
||||
### Vordefinierte Permissions
|
||||
|
||||
```
|
||||
users.view, users.create, users.update, users.delete
|
||||
users.self_update, users.update_assignments
|
||||
tenants.view, tenants.create, tenants.update, tenants.delete
|
||||
departments.view, departments.create, departments.update, departments.delete
|
||||
roles.view, roles.create, roles.update, roles.delete
|
||||
permissions.view, permissions.create, permissions.update, permissions.delete
|
||||
settings.view, settings.update
|
||||
mail_log.view
|
||||
stats.view
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. PHP-Architektur
|
||||
|
||||
### Schichten-Architektur
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Pages │
|
||||
│ (Controller/Actions) │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ Services │
|
||||
│ (Business Logic, Validation) │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ Repositories │
|
||||
│ (Data Access, SQL) │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ DB │
|
||||
│ (MintyPHP Database) │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Service-Layer (`lib/Service/`)
|
||||
|
||||
Services implementieren die Geschäftslogik:
|
||||
|
||||
```php
|
||||
namespace MintyPHP\Service;
|
||||
|
||||
class UserService
|
||||
{
|
||||
// Listen
|
||||
public static function list(): array
|
||||
public static function listPaged(array $options): array
|
||||
|
||||
// Finder
|
||||
public static function findByUuid(string $uuid): ?array
|
||||
public static function findById(int $id): ?array
|
||||
public static function findByEmail(string $email): ?array
|
||||
|
||||
// CRUD
|
||||
public static function createFromAdmin(array $input, int $currentUserId): array
|
||||
public static function updateFromAdmin(int $userId, array $input, int $currentUserId): array
|
||||
public static function deleteByUuid(string $uuid, int $currentUserId): array
|
||||
|
||||
// Bulk-Operationen
|
||||
public static function deleteByUuids(array $uuids, int $currentUserId): array
|
||||
public static function setActiveByUuids(array $uuids, bool $active, int $currentUserId): array
|
||||
}
|
||||
```
|
||||
|
||||
**Wichtige Services:**
|
||||
|
||||
| Service | Verantwortung |
|
||||
|---------|---------------|
|
||||
| `AuthService` | Login, Logout, Session-Management |
|
||||
| `UserService` | Benutzerverwaltung |
|
||||
| `TenantService` | Mandantenverwaltung |
|
||||
| `RoleService` | Rollenverwaltung |
|
||||
| `PermissionService` | Berechtigungsprüfung und -verwaltung |
|
||||
| `DepartmentService` | Abteilungsverwaltung |
|
||||
| `MailService` | E-Mail-Versand |
|
||||
| `SettingService` | App-Einstellungen |
|
||||
|
||||
### Repository-Layer (`lib/Repository/`)
|
||||
|
||||
Repositories kapseln Datenbankzugriffe:
|
||||
|
||||
```php
|
||||
namespace MintyPHP\Repository;
|
||||
|
||||
class UserRepository
|
||||
{
|
||||
public static function list(): array
|
||||
{
|
||||
return DB::select('SELECT * FROM users ORDER BY last_name, first_name');
|
||||
}
|
||||
|
||||
public static function find(int $id): ?array
|
||||
{
|
||||
return DB::selectOne('SELECT * FROM users WHERE id = ?', [$id]);
|
||||
}
|
||||
|
||||
public static function create(array $data): int
|
||||
{
|
||||
return DB::insert('users', $data);
|
||||
}
|
||||
|
||||
public static function update(int $id, array $data): bool
|
||||
{
|
||||
return DB::update('users', $data, ['id' => $id]) > 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Support-Layer (`lib/Support/`)
|
||||
|
||||
#### Guard - Zugriffskontrolle
|
||||
|
||||
```php
|
||||
namespace MintyPHP\Support;
|
||||
|
||||
class Guard
|
||||
{
|
||||
// Nur für angemeldete Benutzer
|
||||
public static function requireLogin(string $redirect = 'login'): void
|
||||
|
||||
// Spezifische Berechtigung erforderlich
|
||||
public static function requirePermission(string $permissionKey, string $redirect = 'admin'): void
|
||||
|
||||
// Berechtigung oder 403 (für AJAX)
|
||||
public static function requirePermissionOrForbidden(string $permissionKey): void
|
||||
}
|
||||
```
|
||||
|
||||
#### Flash - Session-Nachrichten
|
||||
|
||||
```php
|
||||
namespace MintyPHP\Support;
|
||||
|
||||
class Flash
|
||||
{
|
||||
public static function success(string $message, ?string $scope = null, ?string $key = null): string
|
||||
public static function error(string $message, ?string $scope = null, ?string $key = null): string
|
||||
public static function info(string $message, ?string $scope = null, ?string $key = null): string
|
||||
public static function peek(?string $scope = null): array
|
||||
public static function has(): bool
|
||||
}
|
||||
```
|
||||
|
||||
### Helper-Funktionen (`lib/Support/helpers/`)
|
||||
|
||||
```php
|
||||
// HTML-Escape
|
||||
e($string);
|
||||
|
||||
// Asset-URL
|
||||
asset('css/style.css');
|
||||
|
||||
// Lokalisierte URL
|
||||
lurl('admin/users');
|
||||
|
||||
// Übersetzung
|
||||
t('Hello');
|
||||
t('Welcome %s', $name);
|
||||
|
||||
// App-Einstellung
|
||||
appSetting('app_title');
|
||||
|
||||
// Berechtigung prüfen
|
||||
can('users.view');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Frontend-Architektur
|
||||
|
||||
### CSS-Struktur (`web/css/`)
|
||||
|
||||
```
|
||||
web/css/
|
||||
├── base/
|
||||
│ └── variables.css # CSS-Variablen
|
||||
├── layout/
|
||||
│ ├── app-shell.css # Haupt-Layout
|
||||
│ ├── app-topbar.css # Header
|
||||
│ ├── app-sidebar.css # Navigation
|
||||
│ └── app-aside-icon-bar.css
|
||||
├── components/
|
||||
│ ├── app-buttons.css
|
||||
│ ├── app-forms.css
|
||||
│ ├── app-list-table.css
|
||||
│ ├── app-badges.css
|
||||
│ ├── app-tabs.css
|
||||
│ ├── app-flash.css
|
||||
│ └── vendor-*.css # Third-Party
|
||||
└── pages/
|
||||
└── app-login.css
|
||||
```
|
||||
|
||||
### JavaScript-Module (`web/js/`)
|
||||
|
||||
ES-Module-Architektur:
|
||||
|
||||
```
|
||||
web/js/
|
||||
├── app-init.js # Haupt-Initialisierung
|
||||
├── core/
|
||||
│ └── app-grid-factory.js # Grid/Tabellen-Generator
|
||||
├── components/
|
||||
│ ├── app-tenant-switcher.js
|
||||
│ ├── app-theme-toggle.js
|
||||
│ ├── app-bulk-selection.js
|
||||
│ ├── app-flash-auto-dismiss.js
|
||||
│ └── ...
|
||||
└── pages/
|
||||
├── app-users-list.js
|
||||
└── app-list-utils.js
|
||||
```
|
||||
|
||||
### Modul-Pattern
|
||||
|
||||
```javascript
|
||||
// web/js/components/app-tenant-switcher.js
|
||||
const switchTenant = async (link) => {
|
||||
const tenantId = link.dataset.switchTenant;
|
||||
// ...
|
||||
};
|
||||
|
||||
const initTenantSwitcher = () => {
|
||||
const tenantLinks = document.querySelectorAll('[data-switch-tenant]');
|
||||
tenantLinks.forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
switchTenant(link);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Auto-Init bei DOM Ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initTenantSwitcher);
|
||||
} else {
|
||||
initTenantSwitcher();
|
||||
}
|
||||
```
|
||||
|
||||
### Import in app-init.js
|
||||
|
||||
```javascript
|
||||
// web/js/app-init.js
|
||||
import './components/app-tenant-switcher.js';
|
||||
import './components/app-theme-toggle.js';
|
||||
import './components/app-flash-auto-dismiss.js';
|
||||
// ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Template-System
|
||||
|
||||
### Struktur
|
||||
|
||||
```
|
||||
templates/
|
||||
├── default.phtml # Haupt-Layout
|
||||
├── login.phtml # Login-Layout
|
||||
├── error.phtml # Fehler-Layout
|
||||
├── partials/
|
||||
│ ├── app-topbar.phtml
|
||||
│ ├── app-sidebar.phtml
|
||||
│ ├── app-aside.phtml
|
||||
│ ├── app-flash.phtml
|
||||
│ └── app-footer.phtml
|
||||
└── emails/
|
||||
├── de/
|
||||
│ ├── access_info.html
|
||||
│ └── reset_code.html
|
||||
└── en/
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Template-Rendering
|
||||
|
||||
```php
|
||||
<!-- templates/default.phtml -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="<?php e(I18n::$locale); ?>">
|
||||
<head>
|
||||
<title><?php e(Buffer::get('title') ?? appTitle()); ?></title>
|
||||
<link rel="stylesheet" href="<?php e(asset('css/app.css')); ?>">
|
||||
</head>
|
||||
<body>
|
||||
<?php require 'partials/app-topbar.phtml'; ?>
|
||||
|
||||
<main>
|
||||
<?php require 'partials/app-flash.phtml'; ?>
|
||||
<?php echo Buffer::get('html'); ?>
|
||||
</main>
|
||||
|
||||
<script type="module" src="<?php e(asset('js/app-init.js')); ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### Page mit View
|
||||
|
||||
```php
|
||||
// pages/admin/users/index().php
|
||||
use MintyPHP\Buffer;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requirePermissionOrForbidden('users.view');
|
||||
|
||||
$users = UserService::list();
|
||||
|
||||
Buffer::set('title', t('Users'));
|
||||
|
||||
// View wird automatisch geladen: pages/admin/users/index(default).phtml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Authentifizierung und Autorisierung
|
||||
|
||||
### Login-Ablauf
|
||||
|
||||
```
|
||||
1. POST /login
|
||||
│
|
||||
├─> AuthService::login(email, password)
|
||||
│ ├─> Auth::login() - Passwort-Verifikation
|
||||
│ ├─> $_SESSION['user'] setzen
|
||||
│ ├─> PermissionService::getUserPermissions()
|
||||
│ │ └─> $_SESSION['permissions'] setzen
|
||||
│ └─> AuthService::loadTenantDataIntoSession()
|
||||
│ ├─> $_SESSION['available_tenants']
|
||||
│ └─> $_SESSION['current_tenant']
|
||||
│
|
||||
└─> Router::redirect('admin')
|
||||
```
|
||||
|
||||
### Berechtigungsprüfung
|
||||
|
||||
```php
|
||||
// In Page
|
||||
Guard::requirePermissionOrForbidden(PermissionService::USERS_VIEW);
|
||||
|
||||
// Im Template
|
||||
<?php if (can('users.create')): ?>
|
||||
<a href="admin/users/create">Neuer Benutzer</a>
|
||||
<?php endif; ?>
|
||||
```
|
||||
|
||||
### Session-Struktur
|
||||
|
||||
```php
|
||||
$_SESSION = [
|
||||
'user' => [
|
||||
'id' => 1,
|
||||
'uuid' => 'abc-123',
|
||||
'email' => 'user@example.com',
|
||||
'first_name' => 'Max',
|
||||
'last_name' => 'Mustermann',
|
||||
'locale' => 'de',
|
||||
'theme' => 'dark',
|
||||
],
|
||||
'permissions' => [
|
||||
'user_id' => 1,
|
||||
'keys' => ['users.view', 'users.create', ...],
|
||||
],
|
||||
'available_tenants' => [
|
||||
['id' => 1, 'uuid' => '...', 'description' => 'Tenant A'],
|
||||
['id' => 2, 'uuid' => '...', 'description' => 'Tenant B'],
|
||||
],
|
||||
'current_tenant' => [
|
||||
'id' => 1,
|
||||
'uuid' => '...',
|
||||
'description' => 'Tenant A',
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Internationalisierung
|
||||
|
||||
### Sprachdateien
|
||||
|
||||
```json
|
||||
// i18n/default_de.json
|
||||
{
|
||||
"Login": "Anmelden",
|
||||
"Users": "Benutzer",
|
||||
"At least %d characters": "Mindestens %d Zeichen"
|
||||
}
|
||||
|
||||
// i18n/default_en.json
|
||||
{
|
||||
"Login": "Login",
|
||||
"Users": "Users",
|
||||
"At least %d characters": "At least %d characters"
|
||||
}
|
||||
```
|
||||
|
||||
### Verwendung
|
||||
|
||||
```php
|
||||
// Einfache Übersetzung
|
||||
t('Login')
|
||||
|
||||
// Mit Parametern
|
||||
t('At least %d characters', 12)
|
||||
|
||||
// Im Template
|
||||
<?php e(t('Users')); ?>
|
||||
```
|
||||
|
||||
### Sprachauflösung
|
||||
|
||||
Priorität:
|
||||
1. URL-Präfix (`/de/admin`, `/en/admin`)
|
||||
2. Session (`$_SESSION['user']['locale']`)
|
||||
3. Cookie (`locale=de`)
|
||||
4. Default (`APP_LOCALE`)
|
||||
|
||||
---
|
||||
|
||||
## 10. Best Practices
|
||||
|
||||
### Service-Rückgabewerte
|
||||
|
||||
```php
|
||||
// Erfolg
|
||||
return [
|
||||
'ok' => true,
|
||||
'uuid' => $uuid,
|
||||
'id' => $id,
|
||||
];
|
||||
|
||||
// Fehler
|
||||
return [
|
||||
'ok' => false,
|
||||
'error' => 'validation_error',
|
||||
'errors' => ['email' => 'Email ist erforderlich'],
|
||||
'form' => $sanitizedInput,
|
||||
];
|
||||
```
|
||||
|
||||
### Datenbankzugriff
|
||||
|
||||
```php
|
||||
// RICHTIG - Prepared Statements
|
||||
$user = DB::selectOne('SELECT * FROM users WHERE email = ?', [$email]);
|
||||
|
||||
// FALSCH - SQL-Injection
|
||||
$user = DB::selectOne("SELECT * FROM users WHERE email = '$email'");
|
||||
```
|
||||
|
||||
### Template-Ausgabe
|
||||
|
||||
```php
|
||||
// RICHTIG - Escape
|
||||
<?php e($user['email']); ?>
|
||||
|
||||
// FALSCH - XSS-Risiko
|
||||
<?php echo $user['email']; ?>
|
||||
```
|
||||
|
||||
### Passwort-Anforderungen
|
||||
|
||||
- Mindestens 12 Zeichen
|
||||
- Mindestens 1 Großbuchstabe
|
||||
- Mindestens 1 Kleinbuchstabe
|
||||
- Mindestens 1 Ziffer
|
||||
- Mindestens 1 Sonderzeichen
|
||||
|
||||
### Code-Organisation
|
||||
|
||||
1. **Services** - Geschäftslogik und Validierung
|
||||
2. **Repositories** - Nur Datenbankzugriff, keine Logik
|
||||
3. **Guards** - Zugriffskontrolle
|
||||
4. **Helpers** - Wiederverwendbare Funktionen
|
||||
5. **Pages** - Koordination, keine Logik
|
||||
|
||||
---
|
||||
|
||||
## Weiterführende Dokumentation
|
||||
|
||||
- [MintyPHP Framework](https://github.com/mevdschee/php-crud-api)
|
||||
- [Docker Setup](../docker-compose.yml)
|
||||
- [Database Schema](../db/init/init.sql)
|
||||
25
docs/README-search.md
Normal file
25
docs/README-search.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Search + Upload TODO (Internal)
|
||||
|
||||
Goal: Unified search across DB data and file contents (PDF, DOCX), starting with MariaDB FULLTEXT and private file storage.
|
||||
|
||||
## MVP Steps
|
||||
1) Create `documents` table (uuid, original_name, mime, size, storage_path, content_text, created).
|
||||
2) Add FULLTEXT index on `content_text` (and later title/metadata fields if needed).
|
||||
3) Build `FileUploadService`:
|
||||
- Validate size/type (PDF/DOCX).
|
||||
- Store outside `/web` (e.g. `storage/uploads/{uuid}/file.ext`).
|
||||
- Extract text:
|
||||
- PDF: `pdftotext` (poppler-utils).
|
||||
- DOCX: unzip + parse `word/document.xml` (fallback: LibreOffice headless).
|
||||
- Save `content_text` + metadata.
|
||||
4) Add upload action + form (admin area).
|
||||
5) Add search endpoint:
|
||||
- Search DB entities.
|
||||
- Search `documents.content_text`.
|
||||
- Merge results into one list.
|
||||
|
||||
## Future Enhancements
|
||||
- Async extraction (queue/worker) for large files.
|
||||
- Dedicated search engine (Meilisearch/Typesense) when scale/relevance needs grow.
|
||||
- Result ranking with field weighting (title > body).
|
||||
- Optional language detection / stemming.
|
||||
17
docs/conventions.md
Normal file
17
docs/conventions.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Conventions (Short)
|
||||
|
||||
## CSS
|
||||
- All project files use `app-` prefix.
|
||||
- Vendor overrides use `vendor-` prefix.
|
||||
- Structure: `base/`, `layout/`, `components/`, `pages/`.
|
||||
|
||||
## JS
|
||||
- All project files use `app-` prefix.
|
||||
- Vendor scripts stay in `web/vendor`.
|
||||
- Structure: `core/`, `components/`, `pages/`.
|
||||
|
||||
## Partials
|
||||
- All partials use `app-` prefix (e.g. `app-footer.phtml`).
|
||||
|
||||
## PHP (Lib)
|
||||
- Services in `lib/Service`, repositories in `lib/Repository`.
|
||||
63
docs/todo-editorjs-image.md
Normal file
63
docs/todo-editorjs-image.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# TODO: Editor.js Image Tool Integration
|
||||
|
||||
## Goal
|
||||
Integrate the Editor.js **Image Tool** with proper upload endpoints, storage, and access control.
|
||||
|
||||
## Why
|
||||
- Editor.js expects uploads to respond with `{ success: 1, file: { url: "..." } }`.
|
||||
- We want private storage with a controlled proxy (like avatars) and optional resizing.
|
||||
|
||||
## Open Decisions
|
||||
- Public access? (e.g. impressum pages should be public)
|
||||
- Allowed file types (recommended: PNG/JPG/WebP; avoid SVG for security)
|
||||
- Resize policy (max width + WebP variants?)
|
||||
|
||||
## Proposed Structure
|
||||
|
||||
### Storage
|
||||
- `storage/pages/{page_uuid}/assets/{asset_uuid}/original.<ext>`
|
||||
- Optional variants: `w1280.webp`, `w640.webp`, etc.
|
||||
|
||||
### DB
|
||||
Create `page_assets` table:
|
||||
- `id`, `uuid`, `page_id`, `locale`
|
||||
- `file_name`, `mime`, `size`, `width`, `height`
|
||||
- `created_by`, `created`
|
||||
|
||||
### Endpoints
|
||||
1) **Upload by file**
|
||||
`pages/page/upload-image(none).phtml`
|
||||
- Accepts multipart (field name `image`).
|
||||
- Validates MIME/size.
|
||||
- Stores file + creates DB record.
|
||||
- Returns `{ success: 1, file: { url } }`.
|
||||
|
||||
2) **Fetch by URL** (optional)
|
||||
`pages/page/fetch-image(none).phtml`
|
||||
- Accepts JSON `{ url: "..." }`.
|
||||
- Downloads, validates, stores like upload.
|
||||
- Returns same response.
|
||||
|
||||
3) **Media proxy**
|
||||
`pages/page/media(none).phtml?uuid=...`
|
||||
- Checks permissions (public vs. logged‑in).
|
||||
- Reads storage file, sets headers, returns file.
|
||||
|
||||
### Editor.js config
|
||||
Add `image` tool to `web/js/components/page-editor.js`:
|
||||
```js
|
||||
image: {
|
||||
class: ImageTool,
|
||||
config: {
|
||||
endpoints: {
|
||||
byFile: 'page/upload-image',
|
||||
byUrl: 'page/fetch-image'
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
- Decide public vs. private access rules.
|
||||
- Implement service + migration + endpoints.
|
||||
- Add tool config and i18n messages.
|
||||
481
i18n/default_de.json
Normal file
481
i18n/default_de.json
Normal file
@@ -0,0 +1,481 @@
|
||||
{
|
||||
"Login": "Login",
|
||||
"Register": "Registrieren",
|
||||
"Password": "Passwort",
|
||||
"Forgot password": "Passwort vergessen",
|
||||
"Remember me": "Angemeldet bleiben",
|
||||
"Clear login tokens": "Login-Tokens löschen",
|
||||
"Clear all login tokens for this user?": "Alle Login-Tokens für diesen Benutzer löschen?",
|
||||
"Login tokens cleared": "Login-Tokens gelöscht",
|
||||
"Send access": "Zugang versenden",
|
||||
"Send access email to this user?": "Zugangsdaten an diesen Benutzer senden?",
|
||||
"Access email sent": "Zugangsdaten gesendet",
|
||||
"Access email failed": "Zugangsdaten konnten nicht gesendet werden",
|
||||
"Your access details": "Deine Zugangsdaten",
|
||||
"Reset password": "Passwort zurücksetzen",
|
||||
"Verification code": "Bestätigungscode",
|
||||
"Verify code": "Code prüfen",
|
||||
"Verify": "Prüfen",
|
||||
"Send code": "Code senden",
|
||||
"Send code again": "Code erneut senden",
|
||||
"Enter your email address and we will send you a verification code.": "Gib deine E-Mail-Adresse ein, wir senden dir einen Bestätigungscode.",
|
||||
"Enter the verification code we sent to your email.": "Gib den Bestätigungscode ein, den wir dir per E-Mail gesendet haben.",
|
||||
"Please enter a valid email address": "Bitte gib eine gültige E-Mail-Adresse ein",
|
||||
"Please enter the 6-digit code": "Bitte gib den 6-stelligen Code ein",
|
||||
"Invalid or expired code": "Code ist ungültig oder abgelaufen",
|
||||
"Code verified": "Code bestätigt",
|
||||
"Password reset code": "Code zum Zurücksetzen des Passworts",
|
||||
"If the email exists, a verification code has been sent.": "Falls die E-Mail existiert, wurde ein Bestätigungscode gesendet.",
|
||||
"Choose a new password for your account.": "Wähle ein neues Passwort für dein Konto.",
|
||||
"Password updated": "Passwort aktualisiert",
|
||||
"Password can not be updated": "Passwort kann nicht aktualisiert werden",
|
||||
"Reset request not found": "Zurücksetz-Anfrage nicht gefunden",
|
||||
"Reset request already used": "Zurücksetz-Anfrage wurde bereits verwendet",
|
||||
"Reset request expired": "Zurücksetz-Anfrage ist abgelaufen",
|
||||
"Username": "Benutzername",
|
||||
"Admin": "Admin",
|
||||
"Logout": "Logout",
|
||||
"Account": "Konto",
|
||||
"Please enter your credentials": "Bitte geben Sie Ihre Zugangsdaten ein",
|
||||
"Create your account": "Erstelle dein Konto",
|
||||
"First name": "Vorname",
|
||||
"Last name": "Nachname",
|
||||
"Email": "E-Mail",
|
||||
"Password (again)": "Passwort bestätigen",
|
||||
"Password requirements": "Passwort-Anforderungen",
|
||||
"At least %d characters": "Mindestens %d Zeichen",
|
||||
"At least one uppercase letter": "Mindestens ein Großbuchstabe",
|
||||
"At least one lowercase letter": "Mindestens ein Kleinbuchstabe",
|
||||
"At least one number": "Mindestens eine Zahl",
|
||||
"At least one symbol": "Mindestens ein Sonderzeichen",
|
||||
"Must not contain your email": "Darf deine E-Mail nicht enthalten",
|
||||
"Login successful": "Login erfolgreich",
|
||||
"Email/password not valid": "E-Mail oder Passwort ist ungültig",
|
||||
"First name cannot be empty": "Vorname darf nicht leer sein",
|
||||
"Last name cannot be empty": "Nachname darf nicht leer sein",
|
||||
"Email cannot be empty": "E-Mail darf nicht leer sein",
|
||||
"Email is not valid": "E-Mail ist ungültig",
|
||||
"Password cannot be empty": "Passwort darf nicht leer sein",
|
||||
"Passwords must match": "Passwörter müssen übereinstimmen",
|
||||
"Password must be at least %d characters": "Passwort muss mindestens %d Zeichen haben",
|
||||
"Password must include an uppercase letter": "Passwort muss einen Großbuchstaben enthalten",
|
||||
"Password must include a lowercase letter": "Passwort muss einen Kleinbuchstaben enthalten",
|
||||
"Password must include a number": "Passwort muss eine Zahl enthalten",
|
||||
"Password must include a symbol": "Passwort muss ein Sonderzeichen enthalten",
|
||||
"Password must not contain your email": "Passwort darf deine E-Mail nicht enthalten",
|
||||
"Email is already taken": "E-Mail ist bereits vergeben",
|
||||
"User can not be registered": "Benutzer kann nicht registriert werden",
|
||||
"Language not supported": "Sprache wird nicht unterstützt",
|
||||
"Language": "Sprache",
|
||||
"Sales": "Vertrieb",
|
||||
"Sales rooms": "Verkaufsräume",
|
||||
"Sales processes": "Verkaufsprozesse",
|
||||
"Master data": "Stammdaten",
|
||||
"Properties": "Objekte",
|
||||
"Units": "Einheiten",
|
||||
"Organization": "Organisation",
|
||||
"Users & permissions": "Benutzer & Rechte",
|
||||
"Roles & permissions": "Rollen & Rechte",
|
||||
"Permissions": "Berechtigungen",
|
||||
"Permission key": "Berechtigungs-Schlüssel",
|
||||
"Create permission": "Berechtigung erstellen",
|
||||
"Edit permission": "Berechtigung bearbeiten",
|
||||
"Delete this permission?": "Diese Berechtigung löschen?",
|
||||
"Permission created": "Berechtigung erstellt",
|
||||
"Permission updated": "Berechtigung aktualisiert",
|
||||
"Permission deleted": "Berechtigung gelöscht",
|
||||
"Permission not found": "Berechtigung nicht gefunden",
|
||||
"Permission can not be created": "Berechtigung kann nicht erstellt werden",
|
||||
"Permission can not be updated": "Berechtigung kann nicht aktualisiert werden",
|
||||
"Permission key cannot be empty": "Berechtigungs-Schlüssel darf nicht leer sein",
|
||||
"Permission key is invalid": "Berechtigungs-Schlüssel ist ungültig",
|
||||
"Permission key already exists": "Berechtigungs-Schlüssel existiert bereits",
|
||||
"Assigned permissions": "Zugewiesene Berechtigungen",
|
||||
"Select permissions": "Berechtigungen auswählen",
|
||||
"Permission denied": "Keine Berechtigung",
|
||||
"No active tenant assigned": "Kein aktiver Mandant zugewiesen",
|
||||
"Please select at least one tenant": "Bitte mindestens einen Mandanten auswählen",
|
||||
"Effective permissions": "Effektive Berechtigungen",
|
||||
"perm.users.view": "Benutzer anzeigen",
|
||||
"perm.users.create": "Benutzer erstellen",
|
||||
"perm.users.update": "Benutzer bearbeiten",
|
||||
"perm.users.delete": "Benutzer löschen",
|
||||
"perm.users.self_update": "Eigenen Benutzer bearbeiten",
|
||||
"perm.users.update_assignments": "Benutzerzuweisungen bearbeiten",
|
||||
"Permission origin": "Herkunft der Berechtigung",
|
||||
"perm.tenants.view": "Mandanten anzeigen",
|
||||
"perm.tenants.create": "Mandanten erstellen",
|
||||
"perm.tenants.update": "Mandanten bearbeiten",
|
||||
"perm.tenants.delete": "Mandanten löschen",
|
||||
"perm.departments.view": "Abteilungen anzeigen",
|
||||
"perm.departments.create": "Abteilungen erstellen",
|
||||
"perm.departments.update": "Abteilungen bearbeiten",
|
||||
"perm.departments.delete": "Abteilungen löschen",
|
||||
"perm.roles.view": "Rollen anzeigen",
|
||||
"perm.roles.create": "Rollen erstellen",
|
||||
"perm.roles.update": "Rollen bearbeiten",
|
||||
"perm.roles.delete": "Rollen löschen",
|
||||
"perm.permissions.view": "Berechtigungen anzeigen",
|
||||
"perm.permissions.create": "Berechtigungen erstellen",
|
||||
"perm.permissions.update": "Berechtigungen bearbeiten",
|
||||
"perm.permissions.delete": "Berechtigungen löschen",
|
||||
"perm.settings.view": "Einstellungen anzeigen",
|
||||
"perm.settings.update": "Einstellungen bearbeiten",
|
||||
"perm.mail_log.view": "E-Mail-Protokolle anzeigen",
|
||||
"Select row": "Zeile auswählen",
|
||||
"Select all": "Alle auswählen",
|
||||
"Activate": "Aktivieren",
|
||||
"Deactivate": "Deaktivieren",
|
||||
"Delete": "Löschen",
|
||||
"Activate users?": "Benutzer aktivieren?",
|
||||
"Deactivate users?": "Benutzer deaktivieren?",
|
||||
"Delete users?": "Benutzer löschen?",
|
||||
"Send access emails to selected users?": "Zugangsdaten an ausgewählte Benutzer senden?",
|
||||
"%d users activated": "%d Benutzer aktiviert",
|
||||
"%d users deactivated": "%d Benutzer deaktiviert",
|
||||
"%d users deleted": "%d Benutzer gelöscht",
|
||||
"Access emails sent to %d users": "Zugangsdaten an %d Benutzer gesendet",
|
||||
"Access emails sent to %d users, %f failed": "Zugangsdaten an %d Benutzer gesendet, %f fehlgeschlagen",
|
||||
"Failed to activate users": "Benutzer konnten nicht aktiviert werden",
|
||||
"Failed to deactivate users": "Benutzer konnten nicht deaktiviert werden",
|
||||
"Failed to delete users": "Benutzer konnten nicht gelöscht werden",
|
||||
"Failed to send access emails": "Zugangsdaten konnten nicht gesendet werden",
|
||||
"Read": "Gelesen",
|
||||
"Home": "Home",
|
||||
"Users": "Benutzer",
|
||||
"All users": "Alle Benutzer",
|
||||
"Avatar": "Avatar",
|
||||
"Theme": "Theme",
|
||||
"Light": "Hell",
|
||||
"Dark": "Dunkel",
|
||||
"Toggle theme": "Theme umschalten",
|
||||
"Toggle Sidebar": "Sidebar umschalten",
|
||||
"Toggle Detail Sidebar": "Detail-Seitenleiste umschalten",
|
||||
"2FA Settings": "2FA Einstellungen",
|
||||
"%d selected": "%d ausgewählt",
|
||||
"Create user": "Benutzer anlegen",
|
||||
"Add a new user account": "Neues Benutzerkonto anlegen",
|
||||
"Edit user": "Benutzer bearbeiten",
|
||||
"Update account details": "Kontodaten aktualisieren",
|
||||
"Create": "Erstellen",
|
||||
"Create & close": "Erstellen & schließen",
|
||||
"Save": "Speichern",
|
||||
"Save & close": "Speichern & schließen",
|
||||
"Cancel": "Abbrechen",
|
||||
"Active": "Aktiv",
|
||||
"Actions": "Aktionen",
|
||||
"ID": "ID",
|
||||
"Created": "Erstellt",
|
||||
"Created by": "Erstellt von",
|
||||
"Modified by": "Bearbeitet von",
|
||||
"Modified": "Aktualisiert",
|
||||
"No users found": "Keine Benutzer gefunden",
|
||||
"Yes": "Ja",
|
||||
"No": "Nein",
|
||||
"User created": "Benutzer erstellt",
|
||||
"User updated": "Benutzer aktualisiert",
|
||||
"User deleted": "Benutzer gelöscht",
|
||||
"User activated": "Benutzer aktiviert",
|
||||
"User deactivated": "Benutzer deaktiviert",
|
||||
"User not found": "Benutzer nicht gefunden",
|
||||
"User can not be updated": "Benutzer kann nicht aktualisiert werden",
|
||||
"Avatar updated": "Profilbild aktualisiert",
|
||||
"Avatar removed": "Profilbild entfernt",
|
||||
"Upload failed": "Upload fehlgeschlagen",
|
||||
"File is too large": "Datei ist zu groß",
|
||||
"Invalid image file": "Ungültige Bilddatei",
|
||||
"No file uploaded": "Keine Datei hochgeladen",
|
||||
"You cannot delete your own account": "Du kannst deinen eigenen Account nicht löschen",
|
||||
"Login required": "Login erforderlich",
|
||||
"You cannot deactivate your own account": "Du kannst dein eigenes Konto nicht deaktivieren",
|
||||
"Welcome back": "Willkommen zurück",
|
||||
"Total users": "Benutzer gesamt",
|
||||
"Open user management": "Benutzerverwaltung öffnen",
|
||||
"New password (optional)": "Neues Passwort (optional)",
|
||||
"TOTP secret": "TOTP-Secret",
|
||||
"Account is inactive": "Konto ist deaktiviert",
|
||||
"No account yet": "Noch kein Konto",
|
||||
"Inactive": "Inaktiv",
|
||||
"Filter": "Filter",
|
||||
"UUID": "UUID",
|
||||
"Profile image": "Profilbild",
|
||||
"Profile": "Profil",
|
||||
"Profile description": "Profilbeschreibung",
|
||||
"About": "Über",
|
||||
"Info": "Info",
|
||||
"Search results": "Suchergebnisse",
|
||||
"Result": "Ergebnis",
|
||||
"Type": "Typ",
|
||||
"Page": "Seite",
|
||||
"Upload image": "Bild hochladen",
|
||||
"Remove image": "Bild entfernen",
|
||||
"App logo": "App-Logo",
|
||||
"Upload logo": "Logo hochladen",
|
||||
"Remove logo": "Logo entfernen",
|
||||
"Logo updated": "Logo aktualisiert",
|
||||
"Logo removed": "Logo entfernt",
|
||||
"Allowed file types: SVG, PNG, JPG, WEBP": "Erlaubte Dateitypen: SVG, PNG, JPG, WEBP",
|
||||
"Favicon": "Favicon",
|
||||
"Upload favicon": "Favicon hochladen",
|
||||
"Remove favicon": "Favicon entfernen",
|
||||
"Favicon updated": "Favicon aktualisiert",
|
||||
"Favicon removed": "Favicon entfernt",
|
||||
"Allowed file types: PNG": "Erlaubte Dateitypen: PNG",
|
||||
"Square images are recommended (icons are center-cropped).": "Quadratische Bilder empfohlen (Icons werden zentriert zugeschnitten).",
|
||||
"Favicon preview": "Favicon Vorschau",
|
||||
"Primary color": "Primärfarbe",
|
||||
"Primary color is invalid": "Primärfarbe ist ungültig",
|
||||
"Show filters": "Filter anzeigen",
|
||||
"Hide filters": "Filter ausblenden",
|
||||
"Export CSV": "CSV exportieren",
|
||||
"Reset filters": "Filter zurücksetzen",
|
||||
"State": "Status",
|
||||
"Edit": "Bearbeiten",
|
||||
"Status": "Status",
|
||||
"All": "Alle",
|
||||
"All people": "Alle Personen",
|
||||
"Explorer": "Explorer",
|
||||
"Search": "Suche",
|
||||
"Search...": "Suchen...",
|
||||
"Created from": "Erstellt von",
|
||||
"Created to": "Erstellt bis",
|
||||
"Files": "Dateien",
|
||||
"Settings": "Einstellungen",
|
||||
"System": "System",
|
||||
"Statistics": "Statistiken",
|
||||
"Organization breakdown": "Organisations-Aufschlüsselung",
|
||||
"Department assignments": "Abteilungs-Zuweisungen",
|
||||
"Assigned users (Active/Inactive)": "Benutzer zugewiesen (Aktiv/Inaktiv)",
|
||||
"User status": "Benutzerstatus",
|
||||
"Users & roles": "Benutzer & Rollen",
|
||||
"Active users": "Aktive Benutzer",
|
||||
"Inactive users": "Inaktive Benutzer",
|
||||
"Total emails": "E-Mails gesamt",
|
||||
"Sent emails": "Gesendete E-Mails",
|
||||
"Failed emails": "Fehlgeschlagene E-Mails",
|
||||
"Queued emails": "E-Mails in Warteschlange",
|
||||
"Last 24 hours": "Letzte 24 Stunden",
|
||||
"Total (24h)": "Gesamt (24h)",
|
||||
"Sent (24h)": "Gesendet (24h)",
|
||||
"Failed (24h)": "Fehlgeschlagen (24h)",
|
||||
"Queued (24h)": "Warteschlange (24h)",
|
||||
"Sidebar sections": "Sidebar-Bereiche",
|
||||
"All files": "Alle Dateien",
|
||||
"My files": "Meine Dateien",
|
||||
"Shared with me": "Mit mir geteilt",
|
||||
"Add": "Hinzufügen",
|
||||
"Search options": "Suchoptionen",
|
||||
"New folder": "Neuer Ordner",
|
||||
"Toggle navigation": "Navigation umschalten",
|
||||
"Primary navigation": "Hauptnavigation",
|
||||
"Sort column ascending": "Spalte aufsteigend sortieren",
|
||||
"Sort column descending": "Spalte absteigend sortieren",
|
||||
"Previous": "Zurück",
|
||||
"Next": "Weiter",
|
||||
"Page %d of %d": "Seite %d von %d",
|
||||
"Page %d": "Seite %d",
|
||||
"Showing": "Zeige",
|
||||
"of": "von",
|
||||
"to": "bis",
|
||||
"results": "Ergebnisse",
|
||||
"Loading...": "Lade...",
|
||||
"No records found": "Keine Einträge gefunden",
|
||||
"An error happened while fetching the data": "Fehler beim Laden der Daten",
|
||||
"Delete this user?": "Diesen Benutzer wirklich löschen?",
|
||||
"Delete this tenant?": "Diesen Mandanten wirklich löschen?",
|
||||
"Tenant": "Mandant",
|
||||
"Default tenant": "Standard-Mandant",
|
||||
"Default role": "Standard-Rolle",
|
||||
"Default department": "Standard-Abteilung",
|
||||
"Default": "Standard",
|
||||
"Identity": "Stammdaten",
|
||||
"Access": "Zugang",
|
||||
"Access & password": "Zugang & Passwort",
|
||||
"Security": "Sicherheit",
|
||||
"Preferences": "Einstellungen",
|
||||
"Assignments": "Zuweisungen",
|
||||
"My account": "Meine Benutzereinstellungen",
|
||||
"Defaults": "Standards",
|
||||
"None": "Keine",
|
||||
"Settings updated": "Einstellungen gespeichert",
|
||||
"setting.default_tenant": "Standard-Mandant, wenn bei der Benutzeranlage keiner zugewiesen wird",
|
||||
"setting.default_role": "Standard-Rolle, wenn bei der Benutzeranlage keine Rolle zugewiesen wird",
|
||||
"setting.default_department": "Standard-Abteilung, wenn bei der Benutzeranlage keine Abteilung zugewiesen wird",
|
||||
"setting.app_title": "App-Titel für den Browser-Tab",
|
||||
"setting.app_locale": "Standard-Sprache, wenn keine Sprache gesetzt ist",
|
||||
"Default language": "Standard-Sprache",
|
||||
"setting.app_theme": "Standard-Theme, wenn der Benutzer keine Präferenz hat",
|
||||
"setting.app_theme_user": "Benutzer dürfen ihr eigenes Theme wählen",
|
||||
"setting.app_primary_color": "Überschreibt die Standard-Primärfarbe (Hex wie #2FA4A4)",
|
||||
"Default theme": "Standard-Theme",
|
||||
"Allow user theme": "Benutzer-Theme erlauben",
|
||||
"General": "Allgemein",
|
||||
"Appearance": "Darstellung",
|
||||
"Use default color": "Standardfarbe verwenden",
|
||||
"Tenants can override this color in their appearance settings.": "Mandanten können diese Farbe in ihren Darstellungseinstellungen überschreiben.",
|
||||
"Using the default color applies the global system appearance.": "Standardfarbe verwenden heißt: Es werden die globalen Systemeinstellungen für die Darstellung genutzt.",
|
||||
"Status & meta": "Status & Meta",
|
||||
"Audit": "Audit",
|
||||
"Overview": "Übersicht",
|
||||
"Quick actions": "Schnellaktionen",
|
||||
"Send email": "E-Mail senden",
|
||||
"Call phone": "Telefon anrufen",
|
||||
"Call mobile": "Mobil anrufen",
|
||||
"Choose color": "Farbe wählen",
|
||||
"Be careful - this resets the tenants color": "Achtung – das setzt die Mandantenfarbe zurück",
|
||||
"Internationalization": "Internationalisierung",
|
||||
"User creation rules": "Benutzeranlage-Regeln",
|
||||
"Branding": "Branding",
|
||||
"App title": "App-Titel",
|
||||
"Tenants": "Mandanten",
|
||||
"Create tenant": "Mandant anlegen",
|
||||
"Edit tenant": "Mandant bearbeiten",
|
||||
"Tenant created": "Mandant erstellt",
|
||||
"Tenant updated": "Mandant aktualisiert",
|
||||
"Tenant deleted": "Mandant gelöscht",
|
||||
"Tenant not found": "Mandant nicht gefunden",
|
||||
"Tenant can not be created": "Mandant kann nicht erstellt werden",
|
||||
"Tenant can not be updated": "Mandant kann nicht aktualisiert werden",
|
||||
"Tenant image": "Mandantenbild",
|
||||
"Assigned tenants": "Zugewiesene Mandanten",
|
||||
"Primary tenant": "Hauptmandant",
|
||||
"Select primary tenant": "Hauptmandant auswählen",
|
||||
"Please select a primary tenant": "Bitte einen Hauptmandanten auswählen",
|
||||
"Primary tenant must be one of the assigned tenants": "Der Hauptmandant muss einem zugewiesenen Mandanten entsprechen",
|
||||
"Structure": "Struktur",
|
||||
"Departments & roles": "Abteilungen und Rollen",
|
||||
"Department assignments cleaned: %d": "Abteilungszuweisungen bereinigt: %d",
|
||||
"Select tenants": "Mandanten auswählen",
|
||||
"Assigned roles": "Zugewiesene Rollen",
|
||||
"Select roles": "Rollen auswählen",
|
||||
"Department": "Abteilung",
|
||||
"Departments": "Abteilungen",
|
||||
"Create department": "Abteilung anlegen",
|
||||
"Edit department": "Abteilung bearbeiten",
|
||||
"Department created": "Abteilung erstellt",
|
||||
"Department updated": "Abteilung aktualisiert",
|
||||
"Department deleted": "Abteilung gelöscht",
|
||||
"Department not found": "Abteilung nicht gefunden",
|
||||
"Department can not be created": "Abteilung kann nicht erstellt werden",
|
||||
"Department can not be updated": "Abteilung kann nicht aktualisiert werden",
|
||||
"Delete this department?": "Diese Abteilung wirklich löschen?",
|
||||
"Assigned departments": "Zugewiesene Abteilungen",
|
||||
"Select departments": "Abteilungen auswählen",
|
||||
"Role": "Rolle",
|
||||
"Roles": "Rollen",
|
||||
"Create role": "Rolle anlegen",
|
||||
"Edit role": "Rolle bearbeiten",
|
||||
"Role created": "Rolle erstellt",
|
||||
"Role updated": "Rolle aktualisiert",
|
||||
"Role deleted": "Rolle gelöscht",
|
||||
"Role not found": "Rolle nicht gefunden",
|
||||
"Role can not be created": "Rolle kann nicht erstellt werden",
|
||||
"Role can not be updated": "Rolle kann nicht aktualisiert werden",
|
||||
"Delete this role?": "Diese Rolle wirklich löschen?",
|
||||
"Description": "Beschreibung",
|
||||
"Description cannot be empty": "Beschreibung darf nicht leer sein",
|
||||
"Status is invalid": "Status ist ungültig",
|
||||
"Address": "Adresse",
|
||||
"Postal code": "Postleitzahl",
|
||||
"City": "Ort",
|
||||
"Country": "Land",
|
||||
"Location": "Standort",
|
||||
"Mail logs": "E-Mail-Protokolle",
|
||||
"Mail log": "E-Mail-Protokoll",
|
||||
"View mail log": "E-Mail-Protokoll ansehen",
|
||||
"Mail log not found": "E-Mail-Protokoll nicht gefunden",
|
||||
"Email details": "E-Mail-Details",
|
||||
"Recipient": "Empfänger",
|
||||
"Subject": "Betreff",
|
||||
"Template": "Vorlage",
|
||||
"Sent": "Gesendet",
|
||||
"Failed": "Fehlgeschlagen",
|
||||
"Queued": "Warteschlange",
|
||||
"Error message": "Fehlermeldung",
|
||||
"Error information": "Fehlerinformationen",
|
||||
"Provider message ID": "Provider-Nachrichten-ID",
|
||||
"Sent at": "Gesendet am",
|
||||
"Created at": "Erstellt am",
|
||||
"Delivery information": "Versandinformationen",
|
||||
"All statuses": "Alle Status",
|
||||
"Contact": "Kontakt",
|
||||
"Support": "Support",
|
||||
"Support email": "Support-E-Mail",
|
||||
"Support phone": "Support-Telefon",
|
||||
"VAT ID": "USt-ID",
|
||||
"Tax number": "Steuernummer",
|
||||
"Billing email": "Rechnungs-E-Mail",
|
||||
"Phone": "Telefon",
|
||||
"Website": "Webseite",
|
||||
"Accounting info": "Buchhaltungsinfo",
|
||||
"Communication": "Kommunikation",
|
||||
"Legal": "Rechtliches",
|
||||
"Privacy URL": "Datenschutz-URL",
|
||||
"Imprint URL": "Impressum-URL",
|
||||
"Region": "Region",
|
||||
"Fax": "Fax",
|
||||
"Back to Login": "Zurück zum Login",
|
||||
"Back": "Zurück",
|
||||
"Access denied": "Zugriff verweigert",
|
||||
"You do not have permission to view this content.": "Du hast keine Berechtigung, diesen Inhalt zu sehen.",
|
||||
"If you believe this is an error, please contact your system administrator.": "Wenn du glaubst, dass dies ein Fehler ist, wende dich bitte an den Systemadministrator.",
|
||||
"Requested URL": "Angeforderte URL",
|
||||
"Audit": "Audit",
|
||||
"Forward": "Vorwärts",
|
||||
"Status changed": "Status geändert",
|
||||
"Status changed by": "Status geändert von",
|
||||
"Page": "Seite",
|
||||
"Requested path": "Angeforderter Pfad",
|
||||
"Page not found": "Seite nicht gefunden",
|
||||
"The page you requested does not exist or has moved.": "Die angeforderte Seite existiert nicht oder wurde verschoben.",
|
||||
"Page updated": "Seite aktualisiert",
|
||||
"Page can not be updated": "Seite kann nicht aktualisiert werden",
|
||||
"Content is invalid": "Inhalt ist ungültig",
|
||||
"You are not allowed to edit this page": "Du darfst diese Seite nicht bearbeiten",
|
||||
"Form expired, please try again": "Formular abgelaufen, bitte erneut versuchen",
|
||||
"Start writing...": "Mit dem Schreiben beginnen...",
|
||||
"View": "Ansicht",
|
||||
"Heading": "Überschrift",
|
||||
"Copy content to": "Inhalt kopieren nach",
|
||||
"Existing content in the target language will be overwritten": "Bestehender Inhalt in der Zielsprache wird überschrieben",
|
||||
"Content copied": "Inhalt kopiert",
|
||||
"Target language required": "Zielsprache erforderlich",
|
||||
"Source content not found": "Quellinhalt nicht gefunden",
|
||||
"Employment": "Arbeitsverhältnis",
|
||||
"Job title": "Position",
|
||||
"Hire date": "Eintrittsdatum",
|
||||
"German": "Deutsch",
|
||||
"English": "Englisch",
|
||||
"Switch tenant": "Mandant wechseln",
|
||||
"Copy": "Kopieren",
|
||||
"Registration successful! Please check your email for the verification code.": "Registrierung erfolgreich! Bitte prüfe deine E-Mails für den Bestätigungscode.",
|
||||
"A new verification code has been sent.": "Ein neuer Bestätigungscode wurde gesendet.",
|
||||
"Email already verified. Please login.": "E-Mail bereits bestätigt. Bitte einloggen.",
|
||||
"If the email exists, a new verification code has been sent.": "Falls die E-Mail existiert, wurde ein neuer Bestätigungscode gesendet.",
|
||||
"Email verified successfully! Please login.": "E-Mail erfolgreich bestätigt! Bitte einloggen.",
|
||||
"Too many failed attempts. Please request a new code.": "Zu viele Fehlversuche. Bitte fordere einen neuen Code an.",
|
||||
"Verify email": "E-Mail bestätigen",
|
||||
"Enter the verification code we sent to your email to complete your registration.": "Gib den Bestätigungscode ein, den wir dir per E-Mail gesendet haben, um deine Registrierung abzuschließen.",
|
||||
"Email verified": "E-Mail bestätigt",
|
||||
"Not verified": "Nicht bestätigt",
|
||||
"Verified": "Bestätigt",
|
||||
"Email settings": "E-Mail Einstellungen",
|
||||
"SMTP host": "SMTP Host",
|
||||
"SMTP port": "SMTP Port",
|
||||
"SMTP user": "SMTP Benutzer",
|
||||
"SMTP password": "SMTP Passwort",
|
||||
"SMTP security": "SMTP Sicherheit",
|
||||
"SMTP from address": "Absender-Adresse",
|
||||
"SMTP from name": "Absendername",
|
||||
"Address book": "Adressbuch",
|
||||
"All people": "Alle Personen",
|
||||
"Mobile": "Mobil",
|
||||
"Short dial": "Kurzwahl",
|
||||
"setting.smtp_host": "SMTP-Serveradresse (z.B. smtp.example.com)",
|
||||
"setting.smtp_port": "SMTP-Port (z.B. 587)",
|
||||
"setting.smtp_user": "SMTP-Benutzername",
|
||||
"setting.smtp_password": "Leer lassen, um das bestehende Passwort zu behalten",
|
||||
"setting.smtp_secure": "Verschlüsselung für SMTP",
|
||||
"setting.smtp_from": "Absender-E-Mail für ausgehende Nachrichten",
|
||||
"setting.smtp_from_name": "Absendername für ausgehende Nachrichten"
|
||||
}
|
||||
481
i18n/default_en.json
Normal file
481
i18n/default_en.json
Normal file
@@ -0,0 +1,481 @@
|
||||
{
|
||||
"Login": "Login",
|
||||
"Register": "Register",
|
||||
"Password": "Password",
|
||||
"Forgot password": "Forgot password",
|
||||
"Remember me": "Remember me",
|
||||
"Clear login tokens": "Clear login tokens",
|
||||
"Clear all login tokens for this user?": "Clear all login tokens for this user?",
|
||||
"Login tokens cleared": "Login tokens cleared",
|
||||
"Send access": "Send access",
|
||||
"Send access email to this user?": "Send access email to this user?",
|
||||
"Access email sent": "Access email sent",
|
||||
"Access email failed": "Access email failed",
|
||||
"Your access details": "Your access details",
|
||||
"Reset password": "Reset password",
|
||||
"Verification code": "Verification code",
|
||||
"Verify code": "Verify code",
|
||||
"Verify": "Verify",
|
||||
"Send code": "Send code",
|
||||
"Send code again": "Send code again",
|
||||
"Enter your email address and we will send you a verification code.": "Enter your email address and we will send you a verification code.",
|
||||
"Enter the verification code we sent to your email.": "Enter the verification code we sent to your email.",
|
||||
"Please enter a valid email address": "Please enter a valid email address",
|
||||
"Please enter the 6-digit code": "Please enter the 6-digit code",
|
||||
"Invalid or expired code": "Invalid or expired code",
|
||||
"Code verified": "Code verified",
|
||||
"Password reset code": "Password reset code",
|
||||
"If the email exists, a verification code has been sent.": "If the email exists, a verification code has been sent.",
|
||||
"Choose a new password for your account.": "Choose a new password for your account.",
|
||||
"Password updated": "Password updated",
|
||||
"Password can not be updated": "Password can not be updated",
|
||||
"Reset request not found": "Reset request not found",
|
||||
"Reset request already used": "Reset request already used",
|
||||
"Reset request expired": "Reset request expired",
|
||||
"Username": "Username",
|
||||
"Admin": "Admin",
|
||||
"Logout": "Logout",
|
||||
"Account": "Account",
|
||||
"Please enter your credentials": "Please enter your credentials",
|
||||
"Create your account": "Create your account",
|
||||
"First name": "First name",
|
||||
"Last name": "Last name",
|
||||
"Email": "Email",
|
||||
"Password (again)": "Password confirm",
|
||||
"Password requirements": "Password requirements",
|
||||
"At least %d characters": "At least %d characters",
|
||||
"At least one uppercase letter": "At least one uppercase letter",
|
||||
"At least one lowercase letter": "At least one lowercase letter",
|
||||
"At least one number": "At least one number",
|
||||
"At least one symbol": "At least one symbol",
|
||||
"Must not contain your email": "Must not contain your email",
|
||||
"Login successful": "Login successful",
|
||||
"Email/password not valid": "Email or password is invalid",
|
||||
"First name cannot be empty": "First name cannot be empty",
|
||||
"Last name cannot be empty": "Last name cannot be empty",
|
||||
"Email cannot be empty": "Email cannot be empty",
|
||||
"Email is not valid": "Email is not valid",
|
||||
"Password cannot be empty": "Password cannot be empty",
|
||||
"Passwords must match": "Passwords must match",
|
||||
"Password must be at least %d characters": "Password must be at least %d characters",
|
||||
"Password must include an uppercase letter": "Password must include an uppercase letter",
|
||||
"Password must include a lowercase letter": "Password must include a lowercase letter",
|
||||
"Password must include a number": "Password must include a number",
|
||||
"Password must include a symbol": "Password must include a symbol",
|
||||
"Password must not contain your email": "Password must not contain your email",
|
||||
"Email is already taken": "Email is already taken",
|
||||
"User can not be registered": "User can not be registered",
|
||||
"Language not supported": "Language not supported",
|
||||
"Language": "Language",
|
||||
"Sales": "Sales",
|
||||
"Sales rooms": "Sales rooms",
|
||||
"Sales processes": "Sales processes",
|
||||
"Master data": "Master data",
|
||||
"Properties": "Properties",
|
||||
"Units": "Units",
|
||||
"Organization": "Organization",
|
||||
"Users & permissions": "Users & permissions",
|
||||
"Roles & permissions": "Roles & permissions",
|
||||
"Permissions": "Permissions",
|
||||
"Permission key": "Permission key",
|
||||
"Create permission": "Create permission",
|
||||
"Edit permission": "Edit permission",
|
||||
"Delete this permission?": "Delete this permission?",
|
||||
"Permission created": "Permission created",
|
||||
"Permission updated": "Permission updated",
|
||||
"Permission deleted": "Permission deleted",
|
||||
"Permission not found": "Permission not found",
|
||||
"Permission can not be created": "Permission can not be created",
|
||||
"Permission can not be updated": "Permission can not be updated",
|
||||
"Permission key cannot be empty": "Permission key cannot be empty",
|
||||
"Permission key is invalid": "Permission key is invalid",
|
||||
"Permission key already exists": "Permission key already exists",
|
||||
"Assigned permissions": "Assigned permissions",
|
||||
"Select permissions": "Select permissions",
|
||||
"Permission denied": "Permission denied",
|
||||
"No active tenant assigned": "No active tenant assigned",
|
||||
"Please select at least one tenant": "Please select at least one tenant",
|
||||
"Effective permissions": "Effective permissions",
|
||||
"perm.users.view": "View users",
|
||||
"perm.users.create": "Create users",
|
||||
"perm.users.update": "Update users",
|
||||
"perm.users.delete": "Delete users",
|
||||
"perm.users.self_update": "Update own user",
|
||||
"perm.users.update_assignments": "Update user assignments",
|
||||
"Permission origin": "Permission origin",
|
||||
"perm.tenants.view": "View tenants",
|
||||
"perm.tenants.create": "Create tenants",
|
||||
"perm.tenants.update": "Update tenants",
|
||||
"perm.tenants.delete": "Delete tenants",
|
||||
"perm.departments.view": "View departments",
|
||||
"perm.departments.create": "Create departments",
|
||||
"perm.departments.update": "Update departments",
|
||||
"perm.departments.delete": "Delete departments",
|
||||
"perm.roles.view": "View roles",
|
||||
"perm.roles.create": "Create roles",
|
||||
"perm.roles.update": "Update roles",
|
||||
"perm.roles.delete": "Delete roles",
|
||||
"perm.permissions.view": "View permissions",
|
||||
"perm.permissions.create": "Create permissions",
|
||||
"perm.permissions.update": "Update permissions",
|
||||
"perm.permissions.delete": "Delete permissions",
|
||||
"perm.settings.view": "View settings",
|
||||
"perm.settings.update": "Update settings",
|
||||
"perm.mail_log.view": "View mail logs",
|
||||
"Select row": "Select row",
|
||||
"Select all": "Select all",
|
||||
"Activate": "Activate",
|
||||
"Deactivate": "Deactivate",
|
||||
"Delete": "Delete",
|
||||
"Activate users?": "Activate users?",
|
||||
"Deactivate users?": "Deactivate users?",
|
||||
"Delete users?": "Delete users?",
|
||||
"Send access emails to selected users?": "Send access emails to selected users?",
|
||||
"%d users activated": "%d users activated",
|
||||
"%d users deactivated": "%d users deactivated",
|
||||
"%d users deleted": "%d users deleted",
|
||||
"Access emails sent to %d users": "Access emails sent to %d users",
|
||||
"Access emails sent to %d users, %f failed": "Access emails sent to %d users, %f failed",
|
||||
"Failed to activate users": "Failed to activate users",
|
||||
"Failed to deactivate users": "Failed to deactivate users",
|
||||
"Failed to delete users": "Failed to delete users",
|
||||
"Failed to send access emails": "Failed to send access emails",
|
||||
"Read": "Read",
|
||||
"Home": "Home",
|
||||
"Users": "Users",
|
||||
"All users": "All users",
|
||||
"Avatar": "Avatar",
|
||||
"Theme": "Theme",
|
||||
"Light": "Light",
|
||||
"Dark": "Dark",
|
||||
"Toggle theme": "Toggle theme",
|
||||
"Toggle Sidebar": "Toggle Sidebar",
|
||||
"Toggle Detail Sidebar": "Toggle Detail Sidebar",
|
||||
"2FA Settings": "2FA Settings",
|
||||
"%d selected": "%d selected",
|
||||
"Create user": "Create user",
|
||||
"Add a new user account": "Add a new user account",
|
||||
"Edit user": "Edit user",
|
||||
"Update account details": "Update account details",
|
||||
"Create": "Create",
|
||||
"Create & close": "Create & close",
|
||||
"Save": "Save",
|
||||
"Save & close": "Save & close",
|
||||
"Cancel": "Cancel",
|
||||
"Active": "Active",
|
||||
"Actions": "Actions",
|
||||
"ID": "ID",
|
||||
"Created": "Created",
|
||||
"Created by": "Created by",
|
||||
"Modified by": "Modified by",
|
||||
"Modified": "Modified",
|
||||
"No users found": "No users found",
|
||||
"Yes": "Yes",
|
||||
"No": "No",
|
||||
"User created": "User created",
|
||||
"User updated": "User updated",
|
||||
"User deleted": "User deleted",
|
||||
"User activated": "User activated",
|
||||
"User deactivated": "User deactivated",
|
||||
"User not found": "User not found",
|
||||
"User can not be updated": "User can not be updated",
|
||||
"Avatar updated": "Profile image updated",
|
||||
"Avatar removed": "Profile image removed",
|
||||
"Upload failed": "Upload failed",
|
||||
"File is too large": "File is too large",
|
||||
"Invalid image file": "Invalid image file",
|
||||
"No file uploaded": "No file uploaded",
|
||||
"You cannot delete your own account": "You cannot delete your own account",
|
||||
"Login required": "Login required",
|
||||
"You cannot deactivate your own account": "You cannot deactivate your own account",
|
||||
"Welcome back": "Welcome back",
|
||||
"Total users": "Total users",
|
||||
"Open user management": "Open user management",
|
||||
"New password (optional)": "New password (optional)",
|
||||
"TOTP secret": "TOTP secret",
|
||||
"Account is inactive": "Account is inactive",
|
||||
"No account yet": "No account yet",
|
||||
"Inactive": "Inactive",
|
||||
"Filter": "Filter",
|
||||
"UUID": "UUID",
|
||||
"Profile image": "Profile image",
|
||||
"Profile": "Profile",
|
||||
"Profile description": "Profile description",
|
||||
"About": "About",
|
||||
"Info": "Info",
|
||||
"Search results": "Search results",
|
||||
"Result": "Result",
|
||||
"Type": "Type",
|
||||
"Page": "Page",
|
||||
"Upload image": "Upload image",
|
||||
"Remove image": "Remove image",
|
||||
"App logo": "App logo",
|
||||
"Upload logo": "Upload logo",
|
||||
"Remove logo": "Remove logo",
|
||||
"Logo updated": "Logo updated",
|
||||
"Logo removed": "Logo removed",
|
||||
"Allowed file types: SVG, PNG, JPG, WEBP": "Allowed file types: SVG, PNG, JPG, WEBP",
|
||||
"Favicon": "Favicon",
|
||||
"Upload favicon": "Upload favicon",
|
||||
"Remove favicon": "Remove favicon",
|
||||
"Favicon updated": "Favicon updated",
|
||||
"Favicon removed": "Favicon removed",
|
||||
"Allowed file types: PNG": "Allowed file types: PNG",
|
||||
"Square images are recommended (icons are center-cropped).": "Square images are recommended (icons are center-cropped).",
|
||||
"Favicon preview": "Favicon preview",
|
||||
"Primary color": "Primary color",
|
||||
"Primary color is invalid": "Primary color is invalid",
|
||||
"Show filters": "Show filters",
|
||||
"Hide filters": "Hide filters",
|
||||
"Export CSV": "Export CSV",
|
||||
"Reset filters": "Reset filters",
|
||||
"State": "State",
|
||||
"Edit": "Edit",
|
||||
"Status": "Status",
|
||||
"All": "All",
|
||||
"All people": "All people",
|
||||
"Explorer": "Explorer",
|
||||
"Search": "Search",
|
||||
"Search...": "Search...",
|
||||
"Created from": "Created from",
|
||||
"Created to": "Created to",
|
||||
"Files": "Files",
|
||||
"Settings": "Settings",
|
||||
"System": "System",
|
||||
"Statistics": "Statistics",
|
||||
"Organization breakdown": "Organization breakdown",
|
||||
"Department assignments": "Department assignments",
|
||||
"Assigned users (Active/Inactive)": "Assigned users (Active/Inactive)",
|
||||
"User status": "User status",
|
||||
"Users & roles": "Users & roles",
|
||||
"Active users": "Active users",
|
||||
"Inactive users": "Inactive users",
|
||||
"Total emails": "Total emails",
|
||||
"Sent emails": "Sent emails",
|
||||
"Failed emails": "Failed emails",
|
||||
"Queued emails": "Queued emails",
|
||||
"Last 24 hours": "Last 24 hours",
|
||||
"Total (24h)": "Total (24h)",
|
||||
"Sent (24h)": "Sent (24h)",
|
||||
"Failed (24h)": "Failed (24h)",
|
||||
"Queued (24h)": "Queued (24h)",
|
||||
"Sidebar sections": "Sidebar sections",
|
||||
"All files": "All files",
|
||||
"My files": "My files",
|
||||
"Shared with me": "Shared with me",
|
||||
"Add": "Add",
|
||||
"Search options": "Search options",
|
||||
"New folder": "New folder",
|
||||
"Toggle navigation": "Toggle navigation",
|
||||
"Primary navigation": "Primary navigation",
|
||||
"Sort column ascending": "Sort column ascending",
|
||||
"Sort column descending": "Sort column descending",
|
||||
"Previous": "Previous",
|
||||
"Next": "Next",
|
||||
"Page %d of %d": "Page %d of %d",
|
||||
"Page %d": "Page %d",
|
||||
"Showing": "Showing",
|
||||
"of": "of",
|
||||
"to": "to",
|
||||
"results": "results",
|
||||
"Loading...": "Loading...",
|
||||
"No records found": "No records found",
|
||||
"An error happened while fetching the data": "An error happened while fetching the data",
|
||||
"Delete this user?": "Delete this user?",
|
||||
"Delete this tenant?": "Delete this tenant?",
|
||||
"Tenant": "Tenant",
|
||||
"Default tenant": "Default tenant",
|
||||
"Default role": "Default role",
|
||||
"Default department": "Default department",
|
||||
"Default": "Default",
|
||||
"Identity": "Identity",
|
||||
"Access": "Access",
|
||||
"Access & password": "Access & password",
|
||||
"Security": "Security",
|
||||
"Preferences": "Preferences",
|
||||
"Assignments": "Assignments",
|
||||
"My account": "My account",
|
||||
"Defaults": "Defaults",
|
||||
"None": "None",
|
||||
"Settings updated": "Settings updated",
|
||||
"setting.default_tenant": "Default tenant used when no tenant is assigned on user creation",
|
||||
"setting.default_role": "Default role used when no role is assigned on user creation",
|
||||
"setting.default_department": "Default department used when no department is assigned on user creation",
|
||||
"setting.app_title": "App title shown in the browser title bar",
|
||||
"setting.app_locale": "Default language used when no user or URL locale is set",
|
||||
"Default language": "Default language",
|
||||
"setting.app_theme": "Default theme used when a user has no theme preference",
|
||||
"setting.app_theme_user": "Allow users to choose their own theme",
|
||||
"setting.app_primary_color": "Overrides the default primary color (hex like #2FA4A4)",
|
||||
"Default theme": "Default theme",
|
||||
"Allow user theme": "Allow user theme",
|
||||
"General": "General",
|
||||
"Appearance": "Appearance",
|
||||
"Use default color": "Use default color",
|
||||
"Tenants can override this color in their appearance settings.": "Tenants can override this color in their appearance settings.",
|
||||
"Using the default color applies the global system appearance.": "Using the default color applies the global system appearance.",
|
||||
"Status & meta": "Status & meta",
|
||||
"Audit": "Audit",
|
||||
"Overview": "Overview",
|
||||
"Quick actions": "Quick actions",
|
||||
"Send email": "Send email",
|
||||
"Call phone": "Call phone",
|
||||
"Call mobile": "Call mobile",
|
||||
"Choose color": "Choose color",
|
||||
"Be careful - this resets the tenants color": "Be careful - this resets the tenant color",
|
||||
"Internationalization": "Internationalization",
|
||||
"User creation rules": "User creation rules",
|
||||
"Branding": "Branding",
|
||||
"App title": "App title",
|
||||
"Tenants": "Tenants",
|
||||
"Create tenant": "Create tenant",
|
||||
"Edit tenant": "Edit tenant",
|
||||
"Tenant created": "Tenant created",
|
||||
"Tenant updated": "Tenant updated",
|
||||
"Tenant deleted": "Tenant deleted",
|
||||
"Tenant not found": "Tenant not found",
|
||||
"Tenant can not be created": "Tenant can not be created",
|
||||
"Tenant can not be updated": "Tenant can not be updated",
|
||||
"Tenant image": "Tenant image",
|
||||
"Assigned tenants": "Assigned tenants",
|
||||
"Primary tenant": "Primary tenant",
|
||||
"Select primary tenant": "Select primary tenant",
|
||||
"Please select a primary tenant": "Please select a primary tenant",
|
||||
"Primary tenant must be one of the assigned tenants": "Primary tenant must be one of the assigned tenants",
|
||||
"Structure": "Structure",
|
||||
"Departments & roles": "Departments & roles",
|
||||
"Department assignments cleaned: %d": "Department assignments cleaned: %d",
|
||||
"Select tenants": "Select tenants",
|
||||
"Assigned roles": "Assigned roles",
|
||||
"Select roles": "Select roles",
|
||||
"Department": "Department",
|
||||
"Departments": "Departments",
|
||||
"Create department": "Create department",
|
||||
"Edit department": "Edit department",
|
||||
"Department created": "Department created",
|
||||
"Department updated": "Department updated",
|
||||
"Department deleted": "Department deleted",
|
||||
"Department not found": "Department not found",
|
||||
"Department can not be created": "Department can not be created",
|
||||
"Department can not be updated": "Department can not be updated",
|
||||
"Delete this department?": "Delete this department?",
|
||||
"Assigned departments": "Assigned departments",
|
||||
"Select departments": "Select departments",
|
||||
"Role": "Role",
|
||||
"Roles": "Roles",
|
||||
"Create role": "Create role",
|
||||
"Edit role": "Edit role",
|
||||
"Role created": "Role created",
|
||||
"Role updated": "Role updated",
|
||||
"Role deleted": "Role deleted",
|
||||
"Role not found": "Role not found",
|
||||
"Role can not be created": "Role can not be created",
|
||||
"Role can not be updated": "Role can not be updated",
|
||||
"Delete this role?": "Delete this role?",
|
||||
"Description": "Description",
|
||||
"Description cannot be empty": "Description cannot be empty",
|
||||
"Status is invalid": "Status is invalid",
|
||||
"Address": "Address",
|
||||
"Postal code": "Postal code",
|
||||
"City": "City",
|
||||
"Country": "Country",
|
||||
"Location": "Location",
|
||||
"Mail logs": "Mail logs",
|
||||
"Mail log": "Mail log",
|
||||
"View mail log": "View mail log",
|
||||
"Mail log not found": "Mail log not found",
|
||||
"Email details": "Email details",
|
||||
"Recipient": "Recipient",
|
||||
"Subject": "Subject",
|
||||
"Template": "Template",
|
||||
"Sent": "Sent",
|
||||
"Failed": "Failed",
|
||||
"Queued": "Queued",
|
||||
"Error message": "Error message",
|
||||
"Error information": "Error information",
|
||||
"Provider message ID": "Provider message ID",
|
||||
"Sent at": "Sent at",
|
||||
"Created at": "Created at",
|
||||
"Delivery information": "Delivery information",
|
||||
"All statuses": "All statuses",
|
||||
"Contact": "Contact",
|
||||
"Support": "Support",
|
||||
"Support email": "Support email",
|
||||
"Support phone": "Support phone",
|
||||
"VAT ID": "VAT ID",
|
||||
"Tax number": "Tax number",
|
||||
"Billing email": "Billing email",
|
||||
"Phone": "Phone",
|
||||
"Website": "Website",
|
||||
"Accounting info": "Accounting info",
|
||||
"Communication": "Communication",
|
||||
"Legal": "Legal",
|
||||
"Privacy URL": "Privacy URL",
|
||||
"Imprint URL": "Imprint URL",
|
||||
"Region": "Region",
|
||||
"Fax": "Fax",
|
||||
"Back to Login": "Back to Login",
|
||||
"Back": "Back",
|
||||
"Access denied": "Access denied",
|
||||
"You do not have permission to view this content.": "You do not have permission to view this content.",
|
||||
"If you believe this is an error, please contact your system administrator.": "If you believe this is an error, please contact your system administrator.",
|
||||
"Requested URL": "Requested URL",
|
||||
"Audit": "Audit",
|
||||
"Forward": "Forward",
|
||||
"Status changed": "Status changed",
|
||||
"Status changed by": "Status changed by",
|
||||
"Page": "Page",
|
||||
"Requested path": "Requested path",
|
||||
"Page not found": "Page not found",
|
||||
"The page you requested does not exist or has moved.": "The page you requested does not exist or has moved.",
|
||||
"Page updated": "Page updated",
|
||||
"Page can not be updated": "Page can not be updated",
|
||||
"Content is invalid": "Content is invalid",
|
||||
"You are not allowed to edit this page": "You are not allowed to edit this page",
|
||||
"Form expired, please try again": "Form expired, please try again",
|
||||
"Start writing...": "Start writing...",
|
||||
"View": "View",
|
||||
"Heading": "Heading",
|
||||
"Copy content to": "Copy content to",
|
||||
"Existing content in the target language will be overwritten": "Existing content in the target language will be overwritten",
|
||||
"Content copied": "Content copied",
|
||||
"Target language required": "Target language required",
|
||||
"Source content not found": "Source content not found",
|
||||
"Employment": "Employment",
|
||||
"Job title": "Job title",
|
||||
"Hire date": "Hire date",
|
||||
"German": "German",
|
||||
"English": "English",
|
||||
"Switch tenant": "Switch tenant",
|
||||
"Copy": "Copy",
|
||||
"Registration successful! Please check your email for the verification code.": "Registration successful! Please check your email for the verification code.",
|
||||
"A new verification code has been sent.": "A new verification code has been sent.",
|
||||
"Email already verified. Please login.": "Email already verified. Please login.",
|
||||
"If the email exists, a new verification code has been sent.": "If the email exists, a new verification code has been sent.",
|
||||
"Email verified successfully! Please login.": "Email verified successfully! Please login.",
|
||||
"Too many failed attempts. Please request a new code.": "Too many failed attempts. Please request a new code.",
|
||||
"Verify email": "Verify email",
|
||||
"Enter the verification code we sent to your email to complete your registration.": "Enter the verification code we sent to your email to complete your registration.",
|
||||
"Email verified": "Email verified",
|
||||
"Not verified": "Not verified",
|
||||
"Verified": "Verified",
|
||||
"Email settings": "Email settings",
|
||||
"SMTP host": "SMTP host",
|
||||
"SMTP port": "SMTP port",
|
||||
"SMTP user": "SMTP user",
|
||||
"SMTP password": "SMTP password",
|
||||
"SMTP security": "SMTP security",
|
||||
"SMTP from address": "SMTP from address",
|
||||
"SMTP from name": "SMTP from name",
|
||||
"Address book": "Address book",
|
||||
"All people": "All people",
|
||||
"Mobile": "Mobile",
|
||||
"Short dial": "Short dial",
|
||||
"setting.smtp_host": "SMTP server host (e.g. smtp.example.com)",
|
||||
"setting.smtp_port": "SMTP port (e.g. 587)",
|
||||
"setting.smtp_user": "SMTP username",
|
||||
"setting.smtp_password": "Leave empty to keep the existing password",
|
||||
"setting.smtp_secure": "SMTP encryption method",
|
||||
"setting.smtp_from": "From address for outgoing emails",
|
||||
"setting.smtp_from_name": "From name for outgoing emails"
|
||||
}
|
||||
BIN
lib/.DS_Store
vendored
Normal file
BIN
lib/.DS_Store
vendored
Normal file
Binary file not shown.
96
lib/Http/AccessControl.php
Normal file
96
lib/Http/AccessControl.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
use MintyPHP\I18n;
|
||||
use MintyPHP\Router;
|
||||
|
||||
/**
|
||||
* Handles public path detection and authentication guards.
|
||||
*/
|
||||
class AccessControl
|
||||
{
|
||||
/** Paths that are always public (no auth required) */
|
||||
private const ALWAYS_PUBLIC_PATHS = [
|
||||
'login',
|
||||
'register',
|
||||
'verify-email',
|
||||
];
|
||||
|
||||
/** Prefixes that are always public */
|
||||
private const ALWAYS_PUBLIC_PREFIXES = [
|
||||
'password/',
|
||||
'branding/',
|
||||
'flash/',
|
||||
];
|
||||
|
||||
private array $configuredPublicPaths;
|
||||
|
||||
public function __construct(?array $publicPaths = null)
|
||||
{
|
||||
$this->configuredPublicPaths = $publicPaths ?? (defined('APP_PUBLIC_PATHS') ? APP_PUBLIC_PATHS : []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path is publicly accessible (no authentication required).
|
||||
*/
|
||||
public function isPublicPath(string $path): bool
|
||||
{
|
||||
$normalizedPath = $this->normalizePath($path);
|
||||
|
||||
// Root path check
|
||||
if ($normalizedPath === '/') {
|
||||
return in_array('/', $this->configuredPublicPaths, true);
|
||||
}
|
||||
|
||||
// Check configured public paths
|
||||
if (in_array($normalizedPath, $this->configuredPublicPaths, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check page/ prefix with slug lookup
|
||||
if (str_starts_with($normalizedPath, 'page/')) {
|
||||
$pageSlug = substr($normalizedPath, strlen('page/'));
|
||||
if (in_array($pageSlug, $this->configuredPublicPaths, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check always-public paths
|
||||
if (in_array($normalizedPath, self::ALWAYS_PUBLIC_PATHS, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check always-public prefixes
|
||||
foreach (self::ALWAYS_PUBLIC_PREFIXES as $prefix) {
|
||||
if (str_starts_with($normalizedPath, $prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to login if the path requires authentication and user is not logged in.
|
||||
*
|
||||
* @return bool True if access is allowed, false if redirected
|
||||
*/
|
||||
public function requireAuthOrRedirect(string $path, bool $isLoggedIn): bool
|
||||
{
|
||||
if ($this->isPublicPath($path) || $isLoggedIn) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$locale = I18n::$locale ?? I18n::$defaultLocale;
|
||||
Router::redirect(Request::withLocale('login', $locale));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function normalizePath(string $path): string
|
||||
{
|
||||
$normalized = trim($path, '/');
|
||||
return $normalized === '' ? '/' : $normalized;
|
||||
}
|
||||
}
|
||||
56
lib/Http/AssetDetector.php
Normal file
56
lib/Http/AssetDetector.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
/**
|
||||
* Detects static asset requests that should bypass locale redirects.
|
||||
*/
|
||||
class AssetDetector
|
||||
{
|
||||
/** File extensions that indicate static assets */
|
||||
private const ASSET_EXTENSIONS = [
|
||||
'css', 'js', 'mjs', 'map',
|
||||
'png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'webp',
|
||||
'woff', 'woff2', 'ttf', 'eot',
|
||||
'webmanifest',
|
||||
];
|
||||
|
||||
/** Path prefixes that indicate static asset directories */
|
||||
private const ASSET_PREFIXES = [
|
||||
'vendor/',
|
||||
'css/',
|
||||
'js/',
|
||||
'favicon/',
|
||||
'brand/',
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if the given path is a static asset request.
|
||||
*/
|
||||
public static function isAssetRequest(string $path): bool
|
||||
{
|
||||
if ($path === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check file extension
|
||||
if (self::hasAssetExtension($path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check directory prefix
|
||||
foreach (self::ASSET_PREFIXES as $prefix) {
|
||||
if (str_starts_with($path, $prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function hasAssetExtension(string $path): bool
|
||||
{
|
||||
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
return $extension !== '' && in_array($extension, self::ASSET_EXTENSIONS, true);
|
||||
}
|
||||
}
|
||||
142
lib/Http/LocaleResolver.php
Normal file
142
lib/Http/LocaleResolver.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
use MintyPHP\I18n;
|
||||
use MintyPHP\Router;
|
||||
|
||||
/**
|
||||
* Handles locale detection and URL manipulation for multi-language support.
|
||||
*/
|
||||
class LocaleResolver
|
||||
{
|
||||
private array $availableLocales;
|
||||
private string $defaultLocale;
|
||||
private string $basePath;
|
||||
|
||||
public function __construct(?array $availableLocales = null, ?string $defaultLocale = null)
|
||||
{
|
||||
$this->defaultLocale = $defaultLocale ?? I18n::$defaultLocale;
|
||||
$this->availableLocales = $availableLocales ?? (defined('APP_LOCALES') ? APP_LOCALES : [$this->defaultLocale]);
|
||||
$this->basePath = trim(parse_url(Router::getBaseUrl(), PHP_URL_PATH) ?: '/', '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a request URI and extract locale information.
|
||||
*
|
||||
* @return array{
|
||||
* locale: string,
|
||||
* pathWithoutLocale: string,
|
||||
* query: string,
|
||||
* hadLocaleInUrl: bool,
|
||||
* hadInvalidLocale: bool
|
||||
* }
|
||||
*/
|
||||
public function parseUri(string $uri): array
|
||||
{
|
||||
$path = parse_url($uri, PHP_URL_PATH) ?: '';
|
||||
$query = parse_url($uri, PHP_URL_QUERY) ?: '';
|
||||
|
||||
$relativePath = $this->stripBasePath($path);
|
||||
$segments = $relativePath === '' ? [] : explode('/', $relativePath);
|
||||
|
||||
$localeCandidate = $segments[0] ?? '';
|
||||
$hadLocaleInUrl = false;
|
||||
$hadInvalidLocale = false;
|
||||
$locale = '';
|
||||
|
||||
if ($localeCandidate !== '' && in_array($localeCandidate, $this->availableLocales, true)) {
|
||||
$locale = array_shift($segments);
|
||||
$hadLocaleInUrl = true;
|
||||
} elseif ($localeCandidate !== '' && $this->looksLikeLocale($localeCandidate)) {
|
||||
array_shift($segments);
|
||||
$hadInvalidLocale = true;
|
||||
}
|
||||
|
||||
return [
|
||||
'locale' => $locale,
|
||||
'pathWithoutLocale' => implode('/', $segments),
|
||||
'query' => $query,
|
||||
'hadLocaleInUrl' => $hadLocaleInUrl,
|
||||
'hadInvalidLocale' => $hadInvalidLocale,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective locale from multiple sources (priority order):
|
||||
* 1. URL segment (explicit)
|
||||
* 2. User session preference
|
||||
* 3. Cookie preference
|
||||
* 4. Default locale
|
||||
*/
|
||||
public function resolveLocale(string $urlLocale, ?string $userLocale = null, ?string $cookieLocale = null): string
|
||||
{
|
||||
if ($urlLocale !== '' && $this->isValidLocale($urlLocale)) {
|
||||
return $urlLocale;
|
||||
}
|
||||
|
||||
if ($userLocale !== null && $userLocale !== '' && $this->isValidLocale($userLocale)) {
|
||||
return $userLocale;
|
||||
}
|
||||
|
||||
if ($cookieLocale !== null && $cookieLocale !== '' && $this->isValidLocale($cookieLocale)) {
|
||||
return $cookieLocale;
|
||||
}
|
||||
|
||||
return $this->defaultLocale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a new REQUEST_URI without the locale segment.
|
||||
*/
|
||||
public function buildUriWithoutLocale(string $pathWithoutLocale, string $query): string
|
||||
{
|
||||
$newPath = $this->basePath !== '' ? '/' . $this->basePath : '';
|
||||
|
||||
if ($pathWithoutLocale !== '') {
|
||||
$newPath .= '/' . $pathWithoutLocale;
|
||||
} elseif ($newPath === '') {
|
||||
$newPath = '/';
|
||||
}
|
||||
|
||||
return $newPath . ($query !== '' ? '?' . $query : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a locale string is valid (exists in available locales).
|
||||
*/
|
||||
public function isValidLocale(string $locale): bool
|
||||
{
|
||||
return in_array($locale, $this->availableLocales, true);
|
||||
}
|
||||
|
||||
public function getAvailableLocales(): array
|
||||
{
|
||||
return $this->availableLocales;
|
||||
}
|
||||
|
||||
public function getDefaultLocale(): string
|
||||
{
|
||||
return $this->defaultLocale;
|
||||
}
|
||||
|
||||
private function stripBasePath(string $path): string
|
||||
{
|
||||
$relativePath = ltrim($path, '/');
|
||||
|
||||
if ($this->basePath !== '' && strpos($relativePath, $this->basePath . '/') === 0) {
|
||||
return substr($relativePath, strlen($this->basePath) + 1);
|
||||
}
|
||||
|
||||
if ($this->basePath !== '' && $relativePath === $this->basePath) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $relativePath;
|
||||
}
|
||||
|
||||
private function looksLikeLocale(string $candidate): bool
|
||||
{
|
||||
return preg_match('/^[a-z]{2}(?:-[a-z]{2})?$/i', $candidate) === 1;
|
||||
}
|
||||
}
|
||||
102
lib/Http/Request.php
Normal file
102
lib/Http/Request.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\I18n;
|
||||
|
||||
class Request
|
||||
{
|
||||
public static function path(): string
|
||||
{
|
||||
$uri = $_SERVER['REQUEST_URI'] ?? '';
|
||||
$path = parse_url($uri, PHP_URL_PATH) ?: '';
|
||||
$base = trim(Router::getBaseUrl(), '/');
|
||||
$path = ltrim($path, '/');
|
||||
|
||||
if ($base !== '' && strpos($path, $base . '/') === 0) {
|
||||
$path = substr($path, strlen($base) + 1);
|
||||
} elseif ($base !== '' && $path === $base) {
|
||||
$path = '';
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
public static function safeReturnTarget(string $returnParam = ''): string
|
||||
{
|
||||
if ($returnParam !== '') {
|
||||
$parts = parse_url($returnParam);
|
||||
if (($parts['scheme'] ?? '') === '' && ($parts['host'] ?? '') === '') {
|
||||
$path = $parts['path'] ?? '';
|
||||
$query = isset($parts['query']) ? '?' . $parts['query'] : '';
|
||||
$base = trim(Router::getBaseUrl(), '/');
|
||||
$path = ltrim($path, '/');
|
||||
if ($base !== '' && strpos($path, $base . '/') === 0) {
|
||||
$path = substr($path, strlen($base) + 1);
|
||||
} elseif ($base !== '' && $path === $base) {
|
||||
$path = '';
|
||||
}
|
||||
return $path . $query;
|
||||
}
|
||||
}
|
||||
|
||||
$referer = $_SERVER['HTTP_REFERER'] ?? '';
|
||||
if ($referer !== '') {
|
||||
$parts = parse_url($referer);
|
||||
$host = $parts['host'] ?? '';
|
||||
$currentHost = $_SERVER['HTTP_HOST'] ?? '';
|
||||
if ($host === '' || $host === $currentHost) {
|
||||
$path = $parts['path'] ?? '';
|
||||
$query = isset($parts['query']) ? '?' . $parts['query'] : '';
|
||||
$base = trim(Router::getBaseUrl(), '/');
|
||||
$path = ltrim($path, '/');
|
||||
if ($base !== '' && strpos($path, $base . '/') === 0) {
|
||||
$path = substr($path, strlen($base) + 1);
|
||||
} elseif ($base !== '' && $path === $base) {
|
||||
$path = '';
|
||||
}
|
||||
return $path . $query;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
public static function pathWithQuery(): string
|
||||
{
|
||||
$uri = $_SERVER['REQUEST_URI'] ?? '';
|
||||
$path = self::path();
|
||||
$query = parse_url($uri, PHP_URL_QUERY);
|
||||
|
||||
return $path . ($query ? '?' . $query : '');
|
||||
}
|
||||
|
||||
public static function stripLocale(string $path, ?array $locales = null): string
|
||||
{
|
||||
$locales = $locales ?? (defined('APP_LOCALES') ? APP_LOCALES : [I18n::$defaultLocale]);
|
||||
$path = ltrim($path, '/');
|
||||
if ($path === '') {
|
||||
return '';
|
||||
}
|
||||
$parts = explode('/', $path);
|
||||
while ($parts && $parts[0] !== '' && in_array($parts[0], $locales, true)) {
|
||||
array_shift($parts);
|
||||
}
|
||||
return implode('/', $parts);
|
||||
}
|
||||
|
||||
public static function withLocale(string $path = '', ?string $locale = null): string
|
||||
{
|
||||
$locale = $locale ?? (I18n::$locale ?? I18n::$defaultLocale);
|
||||
$path = ltrim($path, '/');
|
||||
return ($locale !== '' ? $locale . '/' : '') . $path;
|
||||
}
|
||||
|
||||
public static function wantsJson(): bool
|
||||
{
|
||||
$accept = $_SERVER['HTTP_ACCEPT'] ?? '';
|
||||
$requestedWith = $_SERVER['HTTP_X_REQUESTED_WITH'] ?? '';
|
||||
return stripos($accept, 'application/json') !== false || $requestedWith !== '';
|
||||
}
|
||||
}
|
||||
307
lib/Repository/DepartmentRepository.php
Normal file
307
lib/Repository/DepartmentRepository.php
Normal file
@@ -0,0 +1,307 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class DepartmentRepository
|
||||
{
|
||||
private static function unwrap(?array $row): ?array
|
||||
{
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
return $row['departments'] ?? null;
|
||||
}
|
||||
|
||||
private static function unwrapList($rows): array
|
||||
{
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$department = $row['departments'] ?? null;
|
||||
if (is_array($department)) {
|
||||
$list[] = $department;
|
||||
}
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
public static function list(): array
|
||||
{
|
||||
$rows = DB::select(
|
||||
'select id, uuid, description, created_by, modified_by, created, modified from departments order by id desc'
|
||||
);
|
||||
return self::unwrapList($rows);
|
||||
}
|
||||
|
||||
public static function listIds(): array
|
||||
{
|
||||
$rows = DB::select('select id from departments');
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['departments'] ?? $row;
|
||||
if (is_array($data) && isset($data['id'])) {
|
||||
$ids[] = (int) $data['id'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public static function listByTenantIds(array $tenantIds): array
|
||||
{
|
||||
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
|
||||
$tenantIds = array_filter($tenantIds, static fn ($id) => $id > 0);
|
||||
if (!$tenantIds) {
|
||||
return [];
|
||||
}
|
||||
$placeholders = implode(',', array_fill(0, count($tenantIds), '?'));
|
||||
$rows = DB::select(
|
||||
'select departments.id, departments.uuid, departments.description, departments.created_by, departments.modified_by, departments.created, departments.modified ' .
|
||||
'from departments departments join tenant_departments td on td.department_id = departments.id ' .
|
||||
'where td.tenant_id in (' . $placeholders . ') ' .
|
||||
'group by departments.id, departments.uuid, departments.description, departments.created_by, departments.modified_by, departments.created, departments.modified ' .
|
||||
'order by departments.description asc',
|
||||
...array_map('strval', $tenantIds)
|
||||
);
|
||||
return self::unwrapList($rows);
|
||||
}
|
||||
|
||||
public static function listByIds(array $departmentIds): array
|
||||
{
|
||||
$departmentIds = array_values(array_unique(array_map('intval', $departmentIds)));
|
||||
$departmentIds = array_filter($departmentIds, static fn ($id) => $id > 0);
|
||||
if (!$departmentIds) {
|
||||
return [];
|
||||
}
|
||||
$placeholders = implode(',', array_fill(0, count($departmentIds), '?'));
|
||||
$rows = DB::select(
|
||||
'select id, uuid, description, created_by, modified_by, created, modified from departments where id in (' . $placeholders . ') order by description asc',
|
||||
...array_map('strval', $departmentIds)
|
||||
);
|
||||
return self::unwrapList($rows);
|
||||
}
|
||||
|
||||
public static function listPaged(array $options): array
|
||||
{
|
||||
$limit = (int) ($options['limit'] ?? 10);
|
||||
if ($limit < 1) {
|
||||
$limit = 10;
|
||||
} elseif ($limit > 100) {
|
||||
$limit = 100;
|
||||
}
|
||||
|
||||
$offset = (int) ($options['offset'] ?? 0);
|
||||
if ($offset < 0) {
|
||||
$offset = 0;
|
||||
}
|
||||
|
||||
$search = trim((string) ($options['search'] ?? ''));
|
||||
$tenant = trim((string) ($options['tenant'] ?? ''));
|
||||
$order = (string) ($options['order'] ?? 'id');
|
||||
$dir = strtolower((string) ($options['dir'] ?? 'desc'));
|
||||
$allowedOrder = ['id', 'uuid', 'description', 'created', 'modified'];
|
||||
if (!in_array($order, $allowedOrder, true)) {
|
||||
$order = 'id';
|
||||
}
|
||||
if (!in_array($dir, ['asc', 'desc'], true)) {
|
||||
$dir = 'desc';
|
||||
}
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
if ($search !== '') {
|
||||
$like = '%' . $search . '%';
|
||||
$where[] = '(description like ? or uuid like ?)';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
}
|
||||
if ($tenant !== '') {
|
||||
$where[] = "exists (select 1 from tenant_departments td join tenants t on t.id = td.tenant_id and t.status = 'active' where td.department_id = departments.id and t.uuid = ?)";
|
||||
$params[] = $tenant;
|
||||
}
|
||||
if (!empty($options['tenantUserId'])) {
|
||||
$tenantUserId = (int) $options['tenantUserId'];
|
||||
if ($tenantUserId > 0) {
|
||||
if (defined('TENANT_SCOPE_STRICT') && TENANT_SCOPE_STRICT) {
|
||||
$where[] = 'exists (select 1 from tenant_departments td ' .
|
||||
"join tenants t on t.id = td.tenant_id and t.status = 'active' " .
|
||||
'join user_tenants ut on ut.tenant_id = td.tenant_id ' .
|
||||
'where ut.user_id = ? and td.department_id = departments.id)';
|
||||
$params[] = (string) $tenantUserId;
|
||||
} else {
|
||||
$where[] = '(not exists (select 1 from tenant_departments td where td.department_id = departments.id) ' .
|
||||
'or exists (select 1 from tenant_departments td ' .
|
||||
"join tenants t on t.id = td.tenant_id and t.status = 'active' " .
|
||||
'join user_tenants ut on ut.tenant_id = td.tenant_id ' .
|
||||
'where ut.user_id = ? and td.department_id = departments.id))';
|
||||
$params[] = (string) $tenantUserId;
|
||||
}
|
||||
}
|
||||
} elseif (array_key_exists('tenantIds', $options)) {
|
||||
$tenantIds = $options['tenantIds'];
|
||||
$tenantIds = is_array($tenantIds) ? array_values(array_unique(array_map('intval', $tenantIds))) : [];
|
||||
if ($tenantIds) {
|
||||
if (defined('TENANT_SCOPE_STRICT') && TENANT_SCOPE_STRICT) {
|
||||
$where[] = 'exists (select 1 from tenant_departments td where td.department_id = departments.id and td.tenant_id in (???))';
|
||||
$params[] = $tenantIds;
|
||||
} else {
|
||||
$where[] = '(not exists (select 1 from tenant_departments td where td.department_id = departments.id) ' .
|
||||
'or exists (select 1 from tenant_departments td where td.department_id = departments.id and td.tenant_id in (???)))';
|
||||
$params[] = $tenantIds;
|
||||
}
|
||||
} else {
|
||||
if (defined('TENANT_SCOPE_STRICT') && TENANT_SCOPE_STRICT) {
|
||||
$where[] = '1=0';
|
||||
} else {
|
||||
$where[] = 'not exists (select 1 from tenant_departments td where td.department_id = departments.id)';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$whereSql = $where ? (' where ' . implode(' and ', $where)) : '';
|
||||
$count = DB::selectValue('select count(*) from departments' . $whereSql, ...$params);
|
||||
$total = $count ? (int) $count : 0;
|
||||
|
||||
$query = 'select id, uuid, description, created_by, modified_by, created, modified from departments' .
|
||||
$whereSql .
|
||||
sprintf(' order by `%s` %s limit ? offset ?', $order, $dir);
|
||||
|
||||
$queryParams = array_merge($params, [(string) $limit, (string) $offset]);
|
||||
$rows = call_user_func_array(['MintyPHP\\DB', 'select'], array_merge([$query], $queryParams));
|
||||
|
||||
$list = self::unwrapList($rows);
|
||||
$ids = [];
|
||||
foreach ($list as $department) {
|
||||
if (isset($department['id'])) {
|
||||
$ids[] = (int) $department['id'];
|
||||
}
|
||||
}
|
||||
$tenantLabelsByDepartment = [];
|
||||
if ($ids) {
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||
$labelRows = DB::select(
|
||||
"select td.department_id as department_id, t.description as description from tenant_departments td join tenants t on t.id = td.tenant_id and t.status = 'active' " .
|
||||
'where td.department_id in (' . $placeholders . ') order by t.description asc',
|
||||
...array_map('strval', $ids)
|
||||
);
|
||||
$collectLabels = static function (array $rows): array {
|
||||
$labelsByDepartment = [];
|
||||
foreach ($rows as $row) {
|
||||
$departmentId = 0;
|
||||
$label = '';
|
||||
if (isset($row['td']) && is_array($row['td'])) {
|
||||
$departmentId = (int) ($row['td']['department_id'] ?? 0);
|
||||
}
|
||||
if (isset($row['t']) && is_array($row['t'])) {
|
||||
$label = (string) ($row['t']['description'] ?? '');
|
||||
}
|
||||
if ($departmentId === 0 || $label === '') {
|
||||
foreach ($row as $value) {
|
||||
if (!is_array($value)) {
|
||||
continue;
|
||||
}
|
||||
if ($departmentId === 0 && isset($value['department_id'])) {
|
||||
$departmentId = (int) $value['department_id'];
|
||||
}
|
||||
if ($label === '' && isset($value['description'])) {
|
||||
$label = (string) $value['description'];
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($departmentId === 0 && isset($row['department_id'])) {
|
||||
$departmentId = (int) $row['department_id'];
|
||||
}
|
||||
if ($label === '' && isset($row['description'])) {
|
||||
$label = (string) $row['description'];
|
||||
}
|
||||
if ($departmentId > 0 && $label !== '') {
|
||||
$labelsByDepartment[$departmentId] ??= [];
|
||||
$labelsByDepartment[$departmentId][] = $label;
|
||||
}
|
||||
}
|
||||
return $labelsByDepartment;
|
||||
};
|
||||
$tenantLabelsByDepartment = $collectLabels($labelRows);
|
||||
}
|
||||
foreach ($list as &$department) {
|
||||
$departmentId = (int) ($department['id'] ?? 0);
|
||||
$department['tenant_labels'] = $tenantLabelsByDepartment[$departmentId] ?? [];
|
||||
}
|
||||
unset($department);
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'rows' => $list,
|
||||
];
|
||||
}
|
||||
|
||||
public static function find(int $id): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, description, created_by, modified_by, created, modified from departments where id = ? limit 1',
|
||||
(string) $id
|
||||
);
|
||||
return self::unwrap($row);
|
||||
}
|
||||
|
||||
public static function findByUuid(string $uuid): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, description, created_by, modified_by, created, modified from departments where uuid = ? limit 1',
|
||||
$uuid
|
||||
);
|
||||
return self::unwrap($row);
|
||||
}
|
||||
|
||||
private static function uuidV4(): string
|
||||
{
|
||||
$data = random_bytes(16);
|
||||
$data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
|
||||
$data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
|
||||
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
|
||||
}
|
||||
|
||||
public static function create(array $data)
|
||||
{
|
||||
return DB::insert(
|
||||
'insert into departments (uuid, description, created_by, created) values (?,?,?,NOW())',
|
||||
$data['uuid'] ?? self::uuidV4(),
|
||||
$data['description'],
|
||||
$data['created_by'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
public static function update(int $id, array $data): bool
|
||||
{
|
||||
$fields = [
|
||||
'description' => $data['description'],
|
||||
];
|
||||
if (array_key_exists('modified_by', $data)) {
|
||||
$fields['modified_by'] = $data['modified_by'];
|
||||
}
|
||||
|
||||
$setParts = [];
|
||||
$params = [];
|
||||
foreach ($fields as $field => $value) {
|
||||
$setParts[] = sprintf('`%s` = ?', $field);
|
||||
$params[] = $value;
|
||||
}
|
||||
$params[] = (string) $id;
|
||||
|
||||
$query = 'update departments set ' . implode(', ', $setParts) . ' where id = ?';
|
||||
$result = DB::update($query, ...$params);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function delete(int $id): bool
|
||||
{
|
||||
$result = DB::delete('delete from departments where id = ?', (string) $id);
|
||||
return $result !== false;
|
||||
}
|
||||
}
|
||||
71
lib/Repository/EmailVerificationRepository.php
Normal file
71
lib/Repository/EmailVerificationRepository.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class EmailVerificationRepository
|
||||
{
|
||||
public static function create(int $userId, string $codeHash, string $expiresAt): ?int
|
||||
{
|
||||
$id = DB::insert(
|
||||
'insert into email_verifications (user_id, code_hash, expires_at, attempts, used_at, created) values (?,?,?,?,NULL,NOW())',
|
||||
(string) $userId,
|
||||
$codeHash,
|
||||
$expiresAt,
|
||||
0
|
||||
);
|
||||
return $id ? (int) $id : null;
|
||||
}
|
||||
|
||||
public static function invalidateForUser(int $userId): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update email_verifications set used_at = NOW() where user_id = ? and used_at is null',
|
||||
(string) $userId
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function findActiveByUserId(int $userId): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, user_id, code_hash, expires_at, attempts, used_at from email_verifications where user_id = ? and used_at is null and expires_at > UTC_TIMESTAMP() order by id desc limit 1',
|
||||
(string) $userId
|
||||
);
|
||||
if (!$row || !isset($row['email_verifications'])) {
|
||||
return null;
|
||||
}
|
||||
return $row['email_verifications'];
|
||||
}
|
||||
|
||||
public static function findById(int $id): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, user_id, code_hash, expires_at, attempts, used_at from email_verifications where id = ? limit 1',
|
||||
(string) $id
|
||||
);
|
||||
if (!$row || !isset($row['email_verifications'])) {
|
||||
return null;
|
||||
}
|
||||
return $row['email_verifications'];
|
||||
}
|
||||
|
||||
public static function incrementAttempts(int $id): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update email_verifications set attempts = attempts + 1 where id = ?',
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function markUsed(int $id): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update email_verifications set used_at = NOW() where id = ?',
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
}
|
||||
142
lib/Repository/MailLogRepository.php
Normal file
142
lib/Repository/MailLogRepository.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class MailLogRepository
|
||||
{
|
||||
public static function create(array $data): ?int
|
||||
{
|
||||
$id = DB::insert(
|
||||
'insert into mail_log (to_email, subject, template, status, created_at) values (?,?,?,?,NOW())',
|
||||
$data['to_email'],
|
||||
$data['subject'],
|
||||
$data['template'] ?? null,
|
||||
$data['status'] ?? 'queued'
|
||||
);
|
||||
return $id ? (int) $id : null;
|
||||
}
|
||||
|
||||
public static function markSent(int $id, ?string $providerMessageId = null): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update mail_log set status = ?, sent_at = NOW(), provider_message_id = ?, error_message = NULL where id = ?',
|
||||
'sent',
|
||||
$providerMessageId,
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function markFailed(int $id, string $errorMessage): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update mail_log set status = ?, error_message = ? where id = ?',
|
||||
'failed',
|
||||
$errorMessage,
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
private static function unwrap(?array $row): ?array
|
||||
{
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
return $row['mail_log'] ?? null;
|
||||
}
|
||||
|
||||
private static function unwrapList($rows): array
|
||||
{
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$mailLog = $row['mail_log'] ?? null;
|
||||
if (is_array($mailLog)) {
|
||||
$list[] = $mailLog;
|
||||
}
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
public static function listPaged(array $options): array
|
||||
{
|
||||
$limit = (int) ($options['limit'] ?? 10);
|
||||
if ($limit < 1) {
|
||||
$limit = 10;
|
||||
} elseif ($limit > 100) {
|
||||
$limit = 100;
|
||||
}
|
||||
|
||||
$offset = (int) ($options['offset'] ?? 0);
|
||||
if ($offset < 0) {
|
||||
$offset = 0;
|
||||
}
|
||||
|
||||
$search = trim((string) ($options['search'] ?? ''));
|
||||
$order = (string) ($options['order'] ?? 'created_at');
|
||||
$dir = strtolower((string) ($options['dir'] ?? 'desc'));
|
||||
$status = trim((string) ($options['status'] ?? ''));
|
||||
$createdFrom = trim((string) ($options['created_from'] ?? ''));
|
||||
$createdTo = trim((string) ($options['created_to'] ?? ''));
|
||||
|
||||
$allowedOrder = ['id', 'created_at', 'sent_at', 'to_email', 'subject', 'status'];
|
||||
if (!in_array($order, $allowedOrder, true)) {
|
||||
$order = 'created_at';
|
||||
}
|
||||
if (!in_array($dir, ['asc', 'desc'], true)) {
|
||||
$dir = 'desc';
|
||||
}
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
if ($search !== '') {
|
||||
$like = '%' . $search . '%';
|
||||
$where[] = '(to_email like ? or subject like ? or template like ?)';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
}
|
||||
if ($status !== '') {
|
||||
$where[] = 'status = ?';
|
||||
$params[] = $status;
|
||||
}
|
||||
if ($createdFrom !== '') {
|
||||
$where[] = 'created_at >= ?';
|
||||
$params[] = $createdFrom . ' 00:00:00';
|
||||
}
|
||||
if ($createdTo !== '') {
|
||||
$where[] = 'created_at <= ?';
|
||||
$params[] = $createdTo . ' 23:59:59';
|
||||
}
|
||||
|
||||
$whereSql = $where ? (' where ' . implode(' and ', $where)) : '';
|
||||
$count = DB::selectValue('select count(*) from mail_log' . $whereSql, ...$params);
|
||||
$total = $count ? (int) $count : 0;
|
||||
|
||||
$query = 'select id, to_email, subject, template, status, created_at, sent_at, error_message, provider_message_id from mail_log' .
|
||||
$whereSql .
|
||||
sprintf(' order by `%s` %s limit ? offset ?', $order, $dir);
|
||||
|
||||
$queryParams = array_merge($params, [(string) $limit, (string) $offset]);
|
||||
$rows = call_user_func_array(['MintyPHP\\DB', 'select'], array_merge([$query], $queryParams));
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'rows' => self::unwrapList($rows),
|
||||
];
|
||||
}
|
||||
|
||||
public static function find(int $id): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, to_email, subject, template, status, created_at, sent_at, error_message, provider_message_id from mail_log where id = ? limit 1',
|
||||
(string) $id
|
||||
);
|
||||
return self::unwrap($row);
|
||||
}
|
||||
}
|
||||
59
lib/Repository/PageContentRepository.php
Normal file
59
lib/Repository/PageContentRepository.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class PageContentRepository
|
||||
{
|
||||
private static function unwrap(?array $row): ?array
|
||||
{
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
return $row['page_contents'] ?? $row;
|
||||
}
|
||||
|
||||
public static function findByPageIdAndLocale(int $pageId, string $locale): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, page_id, locale, content, created_by, modified_by, created, modified from page_contents where page_id = ? and locale = ? limit 1',
|
||||
(string) $pageId,
|
||||
$locale
|
||||
);
|
||||
return self::unwrap($row);
|
||||
}
|
||||
|
||||
public static function create(array $data)
|
||||
{
|
||||
return DB::insert(
|
||||
'insert into page_contents (page_id, locale, content, created_by, created) values (?,?,?,?,NOW())',
|
||||
(string) $data['page_id'],
|
||||
$data['locale'],
|
||||
$data['content'] ?? null,
|
||||
$data['created_by'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
public static function update(int $id, array $data): bool
|
||||
{
|
||||
$fields = [
|
||||
'content' => $data['content'] ?? null,
|
||||
];
|
||||
if (array_key_exists('modified_by', $data)) {
|
||||
$fields['modified_by'] = $data['modified_by'];
|
||||
}
|
||||
|
||||
$setParts = [];
|
||||
$params = [];
|
||||
foreach ($fields as $field => $value) {
|
||||
$setParts[] = sprintf('`%s` = ?', $field);
|
||||
$params[] = $value;
|
||||
}
|
||||
$params[] = (string) $id;
|
||||
|
||||
$query = 'update page_contents set ' . implode(', ', $setParts) . ', modified = NOW() where id = ?';
|
||||
$result = DB::update($query, ...$params);
|
||||
return $result !== false;
|
||||
}
|
||||
}
|
||||
25
lib/Repository/PageRepository.php
Normal file
25
lib/Repository/PageRepository.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class PageRepository
|
||||
{
|
||||
private static function unwrap(?array $row): ?array
|
||||
{
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
return $row['pages'] ?? $row;
|
||||
}
|
||||
|
||||
public static function findBySlug(string $slug): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, slug, created_by, modified_by, created, modified from pages where slug = ? limit 1',
|
||||
$slug
|
||||
);
|
||||
return self::unwrap($row);
|
||||
}
|
||||
}
|
||||
71
lib/Repository/PasswordResetRepository.php
Normal file
71
lib/Repository/PasswordResetRepository.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class PasswordResetRepository
|
||||
{
|
||||
public static function create(int $userId, string $codeHash, string $expiresAt): ?int
|
||||
{
|
||||
$id = DB::insert(
|
||||
'insert into password_resets (user_id, code_hash, expires_at, attempts, used_at, created) values (?,?,?,?,NULL,NOW())',
|
||||
(string) $userId,
|
||||
$codeHash,
|
||||
$expiresAt,
|
||||
0
|
||||
);
|
||||
return $id ? (int) $id : null;
|
||||
}
|
||||
|
||||
public static function invalidateForUser(int $userId): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update password_resets set used_at = NOW() where user_id = ? and used_at is null',
|
||||
(string) $userId
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function findActiveByUserId(int $userId): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, user_id, code_hash, expires_at, attempts, used_at from password_resets where user_id = ? and used_at is null and expires_at > UTC_TIMESTAMP() order by id desc limit 1',
|
||||
(string) $userId
|
||||
);
|
||||
if (!$row || !isset($row['password_resets'])) {
|
||||
return null;
|
||||
}
|
||||
return $row['password_resets'];
|
||||
}
|
||||
|
||||
public static function findById(int $id): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, user_id, code_hash, expires_at, attempts, used_at from password_resets where id = ? limit 1',
|
||||
(string) $id
|
||||
);
|
||||
if (!$row || !isset($row['password_resets'])) {
|
||||
return null;
|
||||
}
|
||||
return $row['password_resets'];
|
||||
}
|
||||
|
||||
public static function incrementAttempts(int $id): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update password_resets set attempts = attempts + 1 where id = ?',
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function markUsed(int $id): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update password_resets set used_at = NOW() where id = ?',
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
}
|
||||
104
lib/Repository/PermissionRepository.php
Normal file
104
lib/Repository/PermissionRepository.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class PermissionRepository
|
||||
{
|
||||
public static function list(): array
|
||||
{
|
||||
$rows = DB::select('select id, `key`, description, created from permissions order by `key` asc');
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['permissions'] ?? $row;
|
||||
if (is_array($data)) {
|
||||
$list[] = $data;
|
||||
}
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
public static function listPaged(array $options): array
|
||||
{
|
||||
$limit = (int) ($options['limit'] ?? 10);
|
||||
$offset = (int) ($options['offset'] ?? 0);
|
||||
$search = trim((string) ($options['search'] ?? ''));
|
||||
$order = (string) ($options['order'] ?? 'key');
|
||||
$dir = strtolower((string) ($options['dir'] ?? 'asc')) === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
$allowedOrder = ['key' => '`key`', 'description' => 'description', 'created' => 'created'];
|
||||
$orderBy = $allowedOrder[$order] ?? $allowedOrder['key'];
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
if ($search !== '') {
|
||||
$where[] = '(permissions.`key` like ? or permissions.description like ?)';
|
||||
$params[] = '%' . $search . '%';
|
||||
$params[] = '%' . $search . '%';
|
||||
}
|
||||
$whereSql = $where ? ' where ' . implode(' and ', $where) : '';
|
||||
|
||||
$total = DB::selectValue('select count(*) from permissions' . $whereSql, ...$params);
|
||||
$query = 'select id, `key`, description, created from permissions' . $whereSql .
|
||||
' order by ' . $orderBy . ' ' . $dir . ' limit ? offset ?';
|
||||
$queryParams = array_merge($params, [(string) $limit, (string) $offset]);
|
||||
$rows = DB::select($query, ...$queryParams);
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['permissions'] ?? $row;
|
||||
if (is_array($data)) {
|
||||
$list[] = $data;
|
||||
}
|
||||
}
|
||||
return ['data' => $list, 'total' => (int) $total];
|
||||
}
|
||||
|
||||
public static function find(int $id): ?array
|
||||
{
|
||||
$row = DB::selectOne('select id, `key`, description, created from permissions where id = ? limit 1', (string) $id);
|
||||
$data = $row['permissions'] ?? $row;
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
|
||||
public static function findByKey(string $key): ?array
|
||||
{
|
||||
$row = DB::selectOne('select id, `key`, description, created from permissions where `key` = ? limit 1', $key);
|
||||
$data = $row['permissions'] ?? $row;
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
|
||||
public static function create(array $data): ?int
|
||||
{
|
||||
$key = trim((string) ($data['key'] ?? ''));
|
||||
$desc = trim((string) ($data['description'] ?? ''));
|
||||
$result = DB::insert(
|
||||
'insert into permissions (`key`, description, created) values (?,?,NOW())',
|
||||
$key,
|
||||
$desc !== '' ? $desc : null
|
||||
);
|
||||
return $result ? (int) $result : null;
|
||||
}
|
||||
|
||||
public static function update(int $id, array $data): bool
|
||||
{
|
||||
$key = trim((string) ($data['key'] ?? ''));
|
||||
$desc = trim((string) ($data['description'] ?? ''));
|
||||
$result = DB::update(
|
||||
'update permissions set `key` = ?, description = ? where id = ?',
|
||||
$key,
|
||||
$desc !== '' ? $desc : null,
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function delete(int $id): bool
|
||||
{
|
||||
$result = DB::delete('delete from permissions where id = ?', (string) $id);
|
||||
return (bool) $result;
|
||||
}
|
||||
}
|
||||
55
lib/Repository/RememberTokenRepository.php
Normal file
55
lib/Repository/RememberTokenRepository.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class RememberTokenRepository
|
||||
{
|
||||
public static function create(int $userId, string $selector, string $tokenHash, string $expiresAt): ?int
|
||||
{
|
||||
$id = DB::insert(
|
||||
'insert into user_remember_tokens (user_id, selector, token_hash, expires_at, created) values (?,?,?,?,NOW())',
|
||||
(string) $userId,
|
||||
$selector,
|
||||
$tokenHash,
|
||||
$expiresAt
|
||||
);
|
||||
return $id ? (int) $id : null;
|
||||
}
|
||||
|
||||
public static function findBySelector(string $selector): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, user_id, selector, token_hash, expires_at, last_used from user_remember_tokens where selector = ? limit 1',
|
||||
$selector
|
||||
);
|
||||
if (!$row || !isset($row['user_remember_tokens'])) {
|
||||
return null;
|
||||
}
|
||||
return $row['user_remember_tokens'];
|
||||
}
|
||||
|
||||
public static function updateToken(int $id, string $tokenHash, string $expiresAt): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update user_remember_tokens set token_hash = ?, expires_at = ?, last_used = NOW() where id = ?',
|
||||
$tokenHash,
|
||||
$expiresAt,
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function deleteById(int $id): bool
|
||||
{
|
||||
$result = DB::delete('delete from user_remember_tokens where id = ?', (string) $id);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function deleteByUserId(int $userId): bool
|
||||
{
|
||||
$result = DB::delete('delete from user_remember_tokens where user_id = ?', (string) $userId);
|
||||
return $result !== false;
|
||||
}
|
||||
}
|
||||
147
lib/Repository/RolePermissionRepository.php
Normal file
147
lib/Repository/RolePermissionRepository.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class RolePermissionRepository
|
||||
{
|
||||
public static function listPermissionIdsByRoleId(int $roleId): array
|
||||
{
|
||||
$rows = DB::select('select permission_id from role_permissions where role_id = ?', (string) $roleId);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['role_permissions'] ?? $row;
|
||||
if (is_array($data) && isset($data['permission_id'])) {
|
||||
$ids[] = (int) $data['permission_id'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public static function replaceForRole(int $roleId, array $permissionIds): bool
|
||||
{
|
||||
DB::delete('delete from role_permissions where role_id = ?', (string) $roleId);
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $permissionIds))));
|
||||
if (!$ids) {
|
||||
return true;
|
||||
}
|
||||
foreach ($ids as $permissionId) {
|
||||
DB::insert(
|
||||
'insert into role_permissions (role_id, permission_id, created) values (?,?,NOW())',
|
||||
(string) $roleId,
|
||||
(string) $permissionId
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function listRoleIdsByPermissionId(int $permissionId): array
|
||||
{
|
||||
$rows = DB::select('select role_id from role_permissions where permission_id = ?', (string) $permissionId);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['role_permissions'] ?? $row;
|
||||
if (is_array($data) && isset($data['role_id'])) {
|
||||
$ids[] = (int) $data['role_id'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public static function replaceForPermission(int $permissionId, array $roleIds): bool
|
||||
{
|
||||
DB::delete('delete from role_permissions where permission_id = ?', (string) $permissionId);
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $roleIds))));
|
||||
if (!$ids) {
|
||||
return true;
|
||||
}
|
||||
foreach ($ids as $roleId) {
|
||||
DB::insert(
|
||||
'insert into role_permissions (role_id, permission_id, created) values (?,?,NOW())',
|
||||
(string) $roleId,
|
||||
(string) $permissionId
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function listPermissionKeysByRoleIds(array $roleIds): array
|
||||
{
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $roleIds))));
|
||||
if (!$ids) {
|
||||
return [];
|
||||
}
|
||||
$rows = DB::select(
|
||||
'select p.`key` as `key` from role_permissions rp join permissions p on p.id = rp.permission_id ' .
|
||||
'where rp.role_id in (???)',
|
||||
array_map('strval', $ids)
|
||||
);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$keys = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['p'] ?? $row;
|
||||
if (is_array($data) && isset($data['key'])) {
|
||||
$keys[] = (string) $data['key'];
|
||||
} elseif (is_array($row) && isset($row['key'])) {
|
||||
$keys[] = (string) $row['key'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($keys));
|
||||
}
|
||||
|
||||
public static function listPermissionsWithRolesByRoleIds(array $roleIds): array
|
||||
{
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $roleIds))));
|
||||
if (!$ids) {
|
||||
return [];
|
||||
}
|
||||
$rows = DB::select(
|
||||
'select rp.permission_id as permission_id, p.`key` as `key`, p.description as `description`, r.description as role ' .
|
||||
'from role_permissions rp join permissions p on p.id = rp.permission_id ' .
|
||||
'join roles r on r.id = rp.role_id where rp.role_id in (???) ' .
|
||||
'order by p.`key` asc, r.description asc',
|
||||
array_map('strval', $ids)
|
||||
);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$permMap = [];
|
||||
foreach ($rows as $row) {
|
||||
$rowPerm = $row['p'] ?? ($row['permissions'] ?? $row);
|
||||
$rowRole = $row['r'] ?? ($row['roles'] ?? $row);
|
||||
$rowRp = $row['rp'] ?? ($row['role_permissions'] ?? $row);
|
||||
|
||||
$permId = $rowRp['permission_id'] ?? ($row['permission_id'] ?? null);
|
||||
$key = (string) (($rowPerm['key'] ?? $row['key'] ?? '') ?? '');
|
||||
if ($permId === null || $key === '') {
|
||||
continue;
|
||||
}
|
||||
if (!isset($permMap[$permId])) {
|
||||
$desc = (string) (($rowPerm['description'] ?? $row['description'] ?? '') ?? '');
|
||||
$permMap[$permId] = [
|
||||
'key' => $key,
|
||||
'description' => $desc,
|
||||
'roles' => [],
|
||||
];
|
||||
}
|
||||
$roleLabel = (string) (($rowRole['description'] ?? $rowRole['role'] ?? $row['role'] ?? '') ?? '');
|
||||
if ($roleLabel !== '') {
|
||||
$permMap[$permId]['roles'][] = $roleLabel;
|
||||
}
|
||||
}
|
||||
foreach ($permMap as &$item) {
|
||||
$item['roles'] = array_values(array_unique($item['roles'] ?? []));
|
||||
}
|
||||
unset($item);
|
||||
return array_values($permMap);
|
||||
}
|
||||
}
|
||||
170
lib/Repository/RoleRepository.php
Normal file
170
lib/Repository/RoleRepository.php
Normal file
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class RoleRepository
|
||||
{
|
||||
private static function unwrap(?array $row): ?array
|
||||
{
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
return $row['roles'] ?? null;
|
||||
}
|
||||
|
||||
private static function unwrapList($rows): array
|
||||
{
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$role = $row['roles'] ?? null;
|
||||
if (is_array($role)) {
|
||||
$list[] = $role;
|
||||
}
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
public static function list(): array
|
||||
{
|
||||
$rows = DB::select(
|
||||
'select id, uuid, description, created_by, modified_by, created, modified from roles order by id desc'
|
||||
);
|
||||
return self::unwrapList($rows);
|
||||
}
|
||||
|
||||
public static function listIds(): array
|
||||
{
|
||||
$rows = DB::select('select id from roles');
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['roles'] ?? $row;
|
||||
if (is_array($data) && isset($data['id'])) {
|
||||
$ids[] = (int) $data['id'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public static function listPaged(array $options): array
|
||||
{
|
||||
$limit = (int) ($options['limit'] ?? 10);
|
||||
if ($limit < 1) {
|
||||
$limit = 10;
|
||||
} elseif ($limit > 100) {
|
||||
$limit = 100;
|
||||
}
|
||||
|
||||
$offset = (int) ($options['offset'] ?? 0);
|
||||
if ($offset < 0) {
|
||||
$offset = 0;
|
||||
}
|
||||
|
||||
$search = trim((string) ($options['search'] ?? ''));
|
||||
$order = (string) ($options['order'] ?? 'id');
|
||||
$dir = strtolower((string) ($options['dir'] ?? 'desc'));
|
||||
$allowedOrder = ['id', 'uuid', 'description', 'created', 'modified'];
|
||||
if (!in_array($order, $allowedOrder, true)) {
|
||||
$order = 'id';
|
||||
}
|
||||
if (!in_array($dir, ['asc', 'desc'], true)) {
|
||||
$dir = 'desc';
|
||||
}
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
if ($search !== '') {
|
||||
$like = '%' . $search . '%';
|
||||
$where[] = '(description like ? or uuid like ?)';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
}
|
||||
|
||||
$whereSql = $where ? (' where ' . implode(' and ', $where)) : '';
|
||||
$count = DB::selectValue('select count(*) from roles' . $whereSql, ...$params);
|
||||
$total = $count ? (int) $count : 0;
|
||||
|
||||
$query = 'select id, uuid, description, created_by, modified_by, created, modified from roles' .
|
||||
$whereSql .
|
||||
sprintf(' order by `%s` %s limit ? offset ?', $order, $dir);
|
||||
|
||||
$queryParams = array_merge($params, [(string) $limit, (string) $offset]);
|
||||
$rows = call_user_func_array(['MintyPHP\\DB', 'select'], array_merge([$query], $queryParams));
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'rows' => self::unwrapList($rows),
|
||||
];
|
||||
}
|
||||
|
||||
public static function find(int $id): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, description, created_by, modified_by, created, modified from roles where id = ? limit 1',
|
||||
(string) $id
|
||||
);
|
||||
return self::unwrap($row);
|
||||
}
|
||||
|
||||
public static function findByUuid(string $uuid): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, description, created_by, modified_by, created, modified from roles where uuid = ? limit 1',
|
||||
$uuid
|
||||
);
|
||||
return self::unwrap($row);
|
||||
}
|
||||
|
||||
private static function uuidV4(): string
|
||||
{
|
||||
$data = random_bytes(16);
|
||||
$data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
|
||||
$data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
|
||||
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
|
||||
}
|
||||
|
||||
public static function create(array $data)
|
||||
{
|
||||
return DB::insert(
|
||||
'insert into roles (uuid, description, created_by, created) values (?,?,?,NOW())',
|
||||
$data['uuid'] ?? self::uuidV4(),
|
||||
$data['description'],
|
||||
$data['created_by'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
public static function update(int $id, array $data): bool
|
||||
{
|
||||
$fields = [
|
||||
'description' => $data['description'],
|
||||
];
|
||||
if (array_key_exists('modified_by', $data)) {
|
||||
$fields['modified_by'] = $data['modified_by'];
|
||||
}
|
||||
|
||||
$setParts = [];
|
||||
$params = [];
|
||||
foreach ($fields as $field => $value) {
|
||||
$setParts[] = sprintf('`%s` = ?', $field);
|
||||
$params[] = $value;
|
||||
}
|
||||
$params[] = (string) $id;
|
||||
|
||||
$query = 'update roles set ' . implode(', ', $setParts) . ' where id = ?';
|
||||
$result = DB::update($query, ...$params);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function delete(int $id): bool
|
||||
{
|
||||
$result = DB::delete('delete from roles where id = ?', (string) $id);
|
||||
return $result !== false;
|
||||
}
|
||||
}
|
||||
35
lib/Repository/SettingRepository.php
Normal file
35
lib/Repository/SettingRepository.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class SettingRepository
|
||||
{
|
||||
public static function find(string $key): ?array
|
||||
{
|
||||
$row = DB::selectOne('select `key`, `value`, `description`, `updated_at` from settings where `key` = ? limit 1', $key);
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
return $row['settings'] ?? $row;
|
||||
}
|
||||
|
||||
public static function getValue(string $key): ?string
|
||||
{
|
||||
$row = self::find($key);
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
return array_key_exists('value', $row) ? (string) $row['value'] : null;
|
||||
}
|
||||
|
||||
public static function set(string $key, ?string $value, ?string $description = null): bool
|
||||
{
|
||||
$query = 'insert into settings (`key`, `value`, `description`) values (?, ?, ?) '
|
||||
. 'on duplicate key update `value` = values(`value`), '
|
||||
. '`description` = ifnull(values(`description`), `description`)';
|
||||
$result = DB::update($query, $key, $value, $description);
|
||||
return $result !== false;
|
||||
}
|
||||
}
|
||||
69
lib/Repository/TenantDepartmentRepository.php
Normal file
69
lib/Repository/TenantDepartmentRepository.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class TenantDepartmentRepository
|
||||
{
|
||||
public static function listTenantIdsByDepartmentId(int $departmentId): array
|
||||
{
|
||||
$rows = DB::select('select tenant_id from tenant_departments where department_id = ?', (string) $departmentId);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['tenant_departments'] ?? $row;
|
||||
if (is_array($data) && isset($data['tenant_id'])) {
|
||||
$ids[] = (int) $data['tenant_id'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public static function listDepartmentIdsByTenantIds(array $tenantIds): array
|
||||
{
|
||||
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
|
||||
$tenantIds = array_filter($tenantIds, static fn ($id) => $id > 0);
|
||||
if (!$tenantIds) {
|
||||
return [];
|
||||
}
|
||||
$placeholders = implode(',', array_fill(0, count($tenantIds), '?'));
|
||||
$rows = DB::select(
|
||||
'select department_id from tenant_departments where tenant_id in (' . $placeholders . ')',
|
||||
...array_map('strval', $tenantIds)
|
||||
);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['tenant_departments'] ?? $row;
|
||||
if (is_array($data) && isset($data['department_id'])) {
|
||||
$ids[] = (int) $data['department_id'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public static function replaceForDepartment(int $departmentId, array $tenantIds): bool
|
||||
{
|
||||
DB::delete('delete from tenant_departments where department_id = ?', (string) $departmentId);
|
||||
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
|
||||
$tenantIds = array_filter($tenantIds, static fn ($id) => $id > 0);
|
||||
if (!$tenantIds) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($tenantIds as $tenantId) {
|
||||
DB::insert(
|
||||
'insert into tenant_departments (tenant_id, department_id, created) values (?,?,NOW())',
|
||||
(string) $tenantId,
|
||||
(string) $departmentId
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
249
lib/Repository/TenantRepository.php
Normal file
249
lib/Repository/TenantRepository.php
Normal file
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class TenantRepository
|
||||
{
|
||||
private static function unwrap(?array $row): ?array
|
||||
{
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
return $row['tenants'] ?? null;
|
||||
}
|
||||
|
||||
private static function unwrapList($rows): array
|
||||
{
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$tenant = $row['tenants'] ?? null;
|
||||
if (is_array($tenant)) {
|
||||
$list[] = $tenant;
|
||||
}
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
public static function list(): array
|
||||
{
|
||||
$rows = DB::select(
|
||||
'select id, uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants order by id desc'
|
||||
);
|
||||
return self::unwrapList($rows);
|
||||
}
|
||||
|
||||
public static function listIds(): array
|
||||
{
|
||||
$rows = DB::select('select id from tenants');
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['tenants'] ?? $row;
|
||||
if (is_array($data) && isset($data['id'])) {
|
||||
$ids[] = (int) $data['id'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public static function listActiveIdsByIds(array $tenantIds): array
|
||||
{
|
||||
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
|
||||
if (!$tenantIds) {
|
||||
return [];
|
||||
}
|
||||
$placeholders = implode(',', array_fill(0, count($tenantIds), '?'));
|
||||
$params = array_map('strval', $tenantIds);
|
||||
$rows = call_user_func_array(
|
||||
['MintyPHP\\DB', 'select'],
|
||||
array_merge(
|
||||
["select id from tenants where status = 'active' and id in ($placeholders)"],
|
||||
$params
|
||||
)
|
||||
);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['tenants'] ?? $row;
|
||||
if (is_array($data) && isset($data['id'])) {
|
||||
$ids[] = (int) $data['id'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public static function listPaged(array $options): array
|
||||
{
|
||||
$limit = (int) ($options['limit'] ?? 10);
|
||||
if ($limit < 1) {
|
||||
$limit = 10;
|
||||
} elseif ($limit > 100) {
|
||||
$limit = 100;
|
||||
}
|
||||
|
||||
$offset = (int) ($options['offset'] ?? 0);
|
||||
if ($offset < 0) {
|
||||
$offset = 0;
|
||||
}
|
||||
|
||||
$search = trim((string) ($options['search'] ?? ''));
|
||||
$order = (string) ($options['order'] ?? 'id');
|
||||
$dir = strtolower((string) ($options['dir'] ?? 'desc'));
|
||||
$allowedOrder = ['id', 'uuid', 'description', 'created', 'modified'];
|
||||
if (!in_array($order, $allowedOrder, true)) {
|
||||
$order = 'id';
|
||||
}
|
||||
if (!in_array($dir, ['asc', 'desc'], true)) {
|
||||
$dir = 'desc';
|
||||
}
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
if ($search !== '') {
|
||||
$like = '%' . $search . '%';
|
||||
$where[] = '(description like ? or uuid like ?)';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
}
|
||||
if (!empty($options['tenantUserId'])) {
|
||||
$tenantUserId = (int) $options['tenantUserId'];
|
||||
if ($tenantUserId > 0) {
|
||||
$where[] = 'exists (select 1 from user_tenants ut where ut.user_id = ? and ut.tenant_id = tenants.id)';
|
||||
$params[] = (string) $tenantUserId;
|
||||
}
|
||||
}
|
||||
|
||||
$whereSql = $where ? (' where ' . implode(' and ', $where)) : '';
|
||||
$count = DB::selectValue('select count(*) from tenants' . $whereSql, ...$params);
|
||||
$total = $count ? (int) $count : 0;
|
||||
|
||||
$query = 'select id, uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants' .
|
||||
$whereSql .
|
||||
sprintf(' order by `%s` %s limit ? offset ?', $order, $dir);
|
||||
|
||||
$queryParams = array_merge($params, [(string) $limit, (string) $offset]);
|
||||
$rows = call_user_func_array(['MintyPHP\\DB', 'select'], array_merge([$query], $queryParams));
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'rows' => self::unwrapList($rows),
|
||||
];
|
||||
}
|
||||
|
||||
public static function find(int $id): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants where id = ? limit 1',
|
||||
(string) $id
|
||||
);
|
||||
return self::unwrap($row);
|
||||
}
|
||||
|
||||
public static function findByUuid(string $uuid): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants where uuid = ? limit 1',
|
||||
$uuid
|
||||
);
|
||||
return self::unwrap($row);
|
||||
}
|
||||
|
||||
private static function uuidV4(): string
|
||||
{
|
||||
$data = random_bytes(16);
|
||||
$data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
|
||||
$data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
|
||||
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
|
||||
}
|
||||
|
||||
public static function create(array $data)
|
||||
{
|
||||
return DB::insert(
|
||||
'insert into tenants (uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, status, status_changed_at, status_changed_by, created_by, created) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW())',
|
||||
$data['uuid'] ?? self::uuidV4(),
|
||||
$data['description'],
|
||||
$data['address'] ?? null,
|
||||
$data['postal_code'] ?? null,
|
||||
$data['city'] ?? null,
|
||||
$data['country'] ?? null,
|
||||
$data['region'] ?? null,
|
||||
$data['vat_id'] ?? null,
|
||||
$data['tax_number'] ?? null,
|
||||
$data['phone'] ?? null,
|
||||
$data['fax'] ?? null,
|
||||
$data['email'] ?? null,
|
||||
$data['support_email'] ?? null,
|
||||
$data['support_phone'] ?? null,
|
||||
$data['billing_email'] ?? null,
|
||||
$data['website'] ?? null,
|
||||
$data['privacy_url'] ?? null,
|
||||
$data['imprint_url'] ?? null,
|
||||
$data['primary_color'] ?? null,
|
||||
$data['status'] ?? 'active',
|
||||
$data['status_changed_at'] ?? null,
|
||||
$data['status_changed_by'] ?? null,
|
||||
$data['created_by'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
public static function update(int $id, array $data): bool
|
||||
{
|
||||
$fields = [
|
||||
'description' => $data['description'],
|
||||
'address' => $data['address'] ?? null,
|
||||
'postal_code' => $data['postal_code'] ?? null,
|
||||
'city' => $data['city'] ?? null,
|
||||
'country' => $data['country'] ?? null,
|
||||
'region' => $data['region'] ?? null,
|
||||
'vat_id' => $data['vat_id'] ?? null,
|
||||
'tax_number' => $data['tax_number'] ?? null,
|
||||
'phone' => $data['phone'] ?? null,
|
||||
'fax' => $data['fax'] ?? null,
|
||||
'email' => $data['email'] ?? null,
|
||||
'support_email' => $data['support_email'] ?? null,
|
||||
'support_phone' => $data['support_phone'] ?? null,
|
||||
'billing_email' => $data['billing_email'] ?? null,
|
||||
'website' => $data['website'] ?? null,
|
||||
'privacy_url' => $data['privacy_url'] ?? null,
|
||||
'imprint_url' => $data['imprint_url'] ?? null,
|
||||
'primary_color' => $data['primary_color'] ?? null,
|
||||
'status' => $data['status'] ?? 'active',
|
||||
];
|
||||
if (array_key_exists('modified_by', $data)) {
|
||||
$fields['modified_by'] = $data['modified_by'];
|
||||
}
|
||||
if (array_key_exists('status_changed_at', $data)) {
|
||||
$fields['status_changed_at'] = $data['status_changed_at'];
|
||||
}
|
||||
if (array_key_exists('status_changed_by', $data)) {
|
||||
$fields['status_changed_by'] = $data['status_changed_by'];
|
||||
}
|
||||
|
||||
$setParts = [];
|
||||
$params = [];
|
||||
foreach ($fields as $field => $value) {
|
||||
$setParts[] = sprintf('`%s` = ?', $field);
|
||||
$params[] = $value;
|
||||
}
|
||||
$params[] = (string) $id;
|
||||
|
||||
$query = 'update tenants set ' . implode(', ', $setParts) . ' where id = ?';
|
||||
$result = DB::update($query, ...$params);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function delete(int $id): bool
|
||||
{
|
||||
$result = DB::delete('delete from tenants where id = ?', (string) $id);
|
||||
return $result !== false;
|
||||
}
|
||||
}
|
||||
54
lib/Repository/UserDepartmentRepository.php
Normal file
54
lib/Repository/UserDepartmentRepository.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class UserDepartmentRepository
|
||||
{
|
||||
public static function listDepartmentIdsByUserId(int $userId): array
|
||||
{
|
||||
$rows = DB::select('select department_id from user_departments where user_id = ?', (string) $userId);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['user_departments'] ?? $row;
|
||||
if (is_array($data) && isset($data['department_id'])) {
|
||||
$ids[] = (int) $data['department_id'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public static function replaceForUser(int $userId, array $departmentIds): bool
|
||||
{
|
||||
DB::delete('delete from user_departments where user_id = ?', (string) $userId);
|
||||
if (!$departmentIds) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($departmentIds as $departmentId) {
|
||||
DB::insert(
|
||||
'insert into user_departments (user_id, department_id, created) values (?,?,NOW())',
|
||||
(string) $userId,
|
||||
(string) $departmentId
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function removeInvalidForDepartment(int $departmentId): int
|
||||
{
|
||||
$query = 'delete ud from user_departments ud ' .
|
||||
'where ud.department_id = ? and not exists (' .
|
||||
'select 1 from user_tenants ut ' .
|
||||
'join tenant_departments td on td.tenant_id = ut.tenant_id ' .
|
||||
'where ut.user_id = ud.user_id and td.department_id = ud.department_id' .
|
||||
')';
|
||||
$result = DB::delete($query, (string) $departmentId);
|
||||
return $result !== false ? (int) $result : 0;
|
||||
}
|
||||
}
|
||||
534
lib/Repository/UserRepository.php
Normal file
534
lib/Repository/UserRepository.php
Normal file
@@ -0,0 +1,534 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class UserRepository
|
||||
{
|
||||
private static function unwrap(?array $row): ?array
|
||||
{
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
return $row['users'] ?? null;
|
||||
}
|
||||
|
||||
private static function unwrapList($rows): array
|
||||
{
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$user = $row['users'] ?? null;
|
||||
if (is_array($user)) {
|
||||
foreach ($row as $key => $value) {
|
||||
if ($key === 'users' || is_array($value)) {
|
||||
continue;
|
||||
}
|
||||
$user[$key] = $value;
|
||||
}
|
||||
if (isset($row['pt']) && is_array($row['pt'])) {
|
||||
$label = (string) ($row['pt']['description'] ?? '');
|
||||
if ($label !== '') {
|
||||
$user['primary_tenant_label'] = $label;
|
||||
}
|
||||
}
|
||||
$list[] = $user;
|
||||
}
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
public static function list(): array
|
||||
{
|
||||
$rows = DB::select(
|
||||
'select id, uuid, first_name, last_name, email, profile_description, job_title, phone, mobile, short_dial, theme, primary_tenant_id, created_by, modified_by, created, modified, active from users order by id desc'
|
||||
);
|
||||
return self::unwrapList($rows);
|
||||
}
|
||||
|
||||
public static function listPaged(array $options): array
|
||||
{
|
||||
$limit = (int) ($options['limit'] ?? 10);
|
||||
if ($limit < 1) {
|
||||
$limit = 10;
|
||||
} elseif ($limit > 100) {
|
||||
$limit = 100;
|
||||
}
|
||||
|
||||
$offset = (int) ($options['offset'] ?? 0);
|
||||
if ($offset < 0) {
|
||||
$offset = 0;
|
||||
}
|
||||
|
||||
$search = trim((string) ($options['search'] ?? ''));
|
||||
$active = $options['active'] ?? null;
|
||||
$createdFrom = trim((string) ($options['created_from'] ?? ''));
|
||||
$createdTo = trim((string) ($options['created_to'] ?? ''));
|
||||
$tenant = trim((string) ($options['tenant'] ?? ''));
|
||||
$tenantUuids = array_filter(array_map('trim', explode(',', (string) ($options['tenants'] ?? ''))));
|
||||
$roleIds = self::normalizeIdList($options['roles'] ?? []);
|
||||
$departmentIds = self::normalizeIdList($options['departments'] ?? []);
|
||||
$emailVerified = $options['email_verified'] ?? null;
|
||||
$order = (string) ($options['order'] ?? 'id');
|
||||
$dir = strtolower((string) ($options['dir'] ?? 'desc'));
|
||||
$allowedOrder = ['id', 'uuid', 'first_name', 'last_name', 'email', 'created', 'modified', 'active'];
|
||||
if (!in_array($order, $allowedOrder, true)) {
|
||||
$order = 'id';
|
||||
}
|
||||
if (!in_array($dir, ['asc', 'desc'], true)) {
|
||||
$dir = 'desc';
|
||||
}
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
if ($search !== '') {
|
||||
$like = '%' . $search . '%';
|
||||
$where[] = '(users.first_name like ? or users.last_name like ? or users.email like ?)';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
}
|
||||
|
||||
$activeFilter = null;
|
||||
if ($active !== null && $active !== '' && $active !== 'all') {
|
||||
$activeValue = strtolower((string) $active);
|
||||
if (in_array($activeValue, ['1', 'true', 'active'], true)) {
|
||||
$activeFilter = 1;
|
||||
} elseif (in_array($activeValue, ['0', 'false', 'inactive'], true)) {
|
||||
$activeFilter = 0;
|
||||
}
|
||||
}
|
||||
if ($activeFilter !== null) {
|
||||
$where[] = 'users.active = ?';
|
||||
$params[] = (string) $activeFilter;
|
||||
}
|
||||
|
||||
if ($createdFrom !== '') {
|
||||
$where[] = 'users.created >= ?';
|
||||
$params[] = $createdFrom . ' 00:00:00';
|
||||
}
|
||||
if ($createdTo !== '') {
|
||||
$where[] = 'users.created <= ?';
|
||||
$params[] = $createdTo . ' 23:59:59';
|
||||
}
|
||||
if ($tenantUuids) {
|
||||
$where[] = 'exists (select 1 from user_tenants ut2 join tenants t2 on t2.id = ut2.tenant_id where ut2.user_id = users.id and t2.uuid in (???))';
|
||||
$params[] = array_values($tenantUuids);
|
||||
} elseif ($tenant !== '') {
|
||||
$where[] = 'exists (select 1 from user_tenants ut2 join tenants t2 on t2.id = ut2.tenant_id where ut2.user_id = users.id and t2.uuid = ?)';
|
||||
$params[] = $tenant;
|
||||
}
|
||||
if ($roleIds) {
|
||||
$where[] = 'exists (select 1 from user_roles ur2 where ur2.user_id = users.id and ur2.role_id in (???))';
|
||||
$params[] = array_map('strval', $roleIds);
|
||||
}
|
||||
if ($departmentIds) {
|
||||
$where[] = 'exists (select 1 from user_departments ud2 where ud2.user_id = users.id and ud2.department_id in (???))';
|
||||
$params[] = array_map('strval', $departmentIds);
|
||||
}
|
||||
if ($emailVerified !== null && $emailVerified !== '' && $emailVerified !== 'all') {
|
||||
$emailVerifiedValue = strtolower((string) $emailVerified);
|
||||
if (in_array($emailVerifiedValue, ['1', 'true', 'verified', 'yes'], true)) {
|
||||
$where[] = 'users.email_verified_at is not null';
|
||||
} elseif (in_array($emailVerifiedValue, ['0', 'false', 'unverified', 'no'], true)) {
|
||||
$where[] = 'users.email_verified_at is null';
|
||||
}
|
||||
}
|
||||
if (!empty($options['tenantUserId'])) {
|
||||
$tenantUserId = (int) $options['tenantUserId'];
|
||||
if ($tenantUserId > 0) {
|
||||
if (defined('TENANT_SCOPE_STRICT') && TENANT_SCOPE_STRICT) {
|
||||
$where[] = 'exists (select 1 from user_tenants ut join user_tenants ut2 on ut2.tenant_id = ut.tenant_id ' .
|
||||
"join tenants t on t.id = ut.tenant_id and t.status = 'active' " .
|
||||
'where ut2.user_id = ? and ut.user_id = users.id)';
|
||||
$params[] = (string) $tenantUserId;
|
||||
} else {
|
||||
$where[] = '(not exists (select 1 from user_tenants ut where ut.user_id = users.id) ' .
|
||||
'or exists (select 1 from user_tenants ut join user_tenants ut2 on ut2.tenant_id = ut.tenant_id ' .
|
||||
"join tenants t on t.id = ut.tenant_id and t.status = 'active' " .
|
||||
'where ut2.user_id = ? and ut.user_id = users.id))';
|
||||
$params[] = (string) $tenantUserId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$whereSql = $where ? (' where ' . implode(' and ', $where)) : '';
|
||||
$count = DB::selectValue('select count(*) from users' . $whereSql, ...$params);
|
||||
$total = $count ? (int) $count : 0;
|
||||
|
||||
$query = 'select users.id, users.uuid, users.first_name, users.last_name, users.email, users.profile_description, users.job_title, users.phone, users.mobile, users.short_dial, users.address, users.postal_code, users.city, users.country, users.region, users.hire_date, users.theme, users.primary_tenant_id, users.created_by, users.modified_by, users.created, users.modified, users.active, ' .
|
||||
'pt.description as primary_tenant_label ' .
|
||||
"from users left join tenants pt on pt.id = users.primary_tenant_id and pt.status = 'active'" .
|
||||
$whereSql .
|
||||
sprintf(' order by `users`.`%s` %s limit ? offset ?', $order, $dir);
|
||||
|
||||
$queryParams = array_merge($params, [(string) $limit, (string) $offset]);
|
||||
$rows = call_user_func_array(['MintyPHP\\DB', 'select'], array_merge([$query], $queryParams));
|
||||
|
||||
$list = self::unwrapList($rows);
|
||||
$labelRows = [];
|
||||
$tenantMapByUser = [];
|
||||
$roleLabelRows = [];
|
||||
$departmentLabelRows = [];
|
||||
|
||||
$ids = [];
|
||||
foreach ($list as $user) {
|
||||
if (isset($user['id'])) {
|
||||
$ids[] = (int) $user['id'];
|
||||
}
|
||||
}
|
||||
if ($ids) {
|
||||
$scopeToActiveTenants = !empty($options['tenantUserId']);
|
||||
$tenantLabelJoin = $scopeToActiveTenants
|
||||
? "join tenants t on t.id = ut.tenant_id and t.status = 'active' "
|
||||
: 'join tenants t on t.id = ut.tenant_id ';
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||
$labelRows = DB::select(
|
||||
'select ut.user_id as user_id, t.id as tenant_id, t.description as description from user_tenants ut ' . $tenantLabelJoin .
|
||||
'where ut.user_id in (' . $placeholders . ') order by t.description asc',
|
||||
...array_map('strval', $ids)
|
||||
);
|
||||
$roleLabelRows = DB::select(
|
||||
'select ur.user_id as user_id, r.description as description from user_roles ur join roles r on r.id = ur.role_id ' .
|
||||
'where ur.user_id in (' . $placeholders . ') order by r.description asc',
|
||||
...array_map('strval', $ids)
|
||||
);
|
||||
$departmentLabelRows = DB::select(
|
||||
'select ud.user_id as user_id, d.description as description from user_departments ud join departments d on d.id = ud.department_id ' .
|
||||
'where ud.user_id in (' . $placeholders . ') order by d.description asc',
|
||||
...array_map('strval', $ids)
|
||||
);
|
||||
|
||||
$collectLabels = static function (array $rows, string $userKey, string $labelKey): array {
|
||||
$labelsByUser = [];
|
||||
foreach ($rows as $row) {
|
||||
$userId = 0;
|
||||
$label = '';
|
||||
if (isset($row[$userKey]) && is_array($row[$userKey])) {
|
||||
$userId = (int) ($row[$userKey]['user_id'] ?? 0);
|
||||
}
|
||||
if (isset($row[$labelKey]) && is_array($row[$labelKey])) {
|
||||
$label = (string) ($row[$labelKey]['description'] ?? '');
|
||||
}
|
||||
if ($userId === 0 || $label === '') {
|
||||
foreach ($row as $value) {
|
||||
if (!is_array($value)) {
|
||||
continue;
|
||||
}
|
||||
if ($userId === 0 && isset($value['user_id'])) {
|
||||
$userId = (int) $value['user_id'];
|
||||
}
|
||||
if ($label === '' && isset($value['description'])) {
|
||||
$label = (string) $value['description'];
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($userId === 0 && isset($row['user_id'])) {
|
||||
$userId = (int) $row['user_id'];
|
||||
}
|
||||
if ($label === '' && isset($row['description'])) {
|
||||
$label = (string) $row['description'];
|
||||
}
|
||||
if ($userId > 0 && $label !== '') {
|
||||
$labelsByUser[$userId] ??= [];
|
||||
$labelsByUser[$userId][] = $label;
|
||||
}
|
||||
}
|
||||
return $labelsByUser;
|
||||
};
|
||||
|
||||
foreach ($labelRows as $row) {
|
||||
$userId = 0;
|
||||
$tenantId = 0;
|
||||
$label = '';
|
||||
if (isset($row['ut']) && is_array($row['ut'])) {
|
||||
$userId = (int) ($row['ut']['user_id'] ?? 0);
|
||||
}
|
||||
if (isset($row['t']) && is_array($row['t'])) {
|
||||
$tenantId = (int) ($row['t']['tenant_id'] ?? 0);
|
||||
$label = (string) ($row['t']['description'] ?? '');
|
||||
}
|
||||
if ($userId === 0 || $tenantId === 0 || $label === '') {
|
||||
foreach ($row as $value) {
|
||||
if (!is_array($value)) {
|
||||
continue;
|
||||
}
|
||||
if ($userId === 0 && isset($value['user_id'])) {
|
||||
$userId = (int) $value['user_id'];
|
||||
}
|
||||
if ($tenantId === 0 && isset($value['tenant_id'])) {
|
||||
$tenantId = (int) $value['tenant_id'];
|
||||
}
|
||||
if ($label === '' && isset($value['description'])) {
|
||||
$label = (string) $value['description'];
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($userId === 0 && isset($row['user_id'])) {
|
||||
$userId = (int) $row['user_id'];
|
||||
}
|
||||
if ($tenantId === 0 && isset($row['tenant_id'])) {
|
||||
$tenantId = (int) $row['tenant_id'];
|
||||
}
|
||||
if ($label === '' && isset($row['description'])) {
|
||||
$label = (string) $row['description'];
|
||||
}
|
||||
if ($userId > 0 && $tenantId > 0 && $label !== '') {
|
||||
$tenantMapByUser[$userId] ??= [];
|
||||
$tenantMapByUser[$userId][$tenantId] = $label;
|
||||
}
|
||||
}
|
||||
$tenantLabelsByUser = [];
|
||||
foreach ($tenantMapByUser as $userId => $map) {
|
||||
$tenantLabelsByUser[$userId] = array_values($map);
|
||||
}
|
||||
$roleLabelsByUser = $collectLabels($roleLabelRows, 'ur', 'r');
|
||||
$departmentLabelsByUser = $collectLabels($departmentLabelRows, 'ud', 'd');
|
||||
|
||||
foreach ($list as &$user) {
|
||||
$userId = (int) ($user['id'] ?? 0);
|
||||
$primaryId = (int) ($user['primary_tenant_id'] ?? 0);
|
||||
$user['tenant_labels'] = $tenantLabelsByUser[$userId] ?? [];
|
||||
if ($primaryId > 0 && isset($tenantMapByUser[$userId][$primaryId])) {
|
||||
$user['primary_tenant_label'] = $tenantMapByUser[$userId][$primaryId];
|
||||
}
|
||||
$user['role_labels'] = $roleLabelsByUser[$userId] ?? [];
|
||||
$user['department_labels'] = $departmentLabelsByUser[$userId] ?? [];
|
||||
}
|
||||
unset($user);
|
||||
}
|
||||
|
||||
$result = [
|
||||
'total' => $total,
|
||||
'rows' => $list,
|
||||
];
|
||||
return $result;
|
||||
}
|
||||
|
||||
public static function find(int $id): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, first_name, last_name, email, profile_description, job_title, phone, mobile, short_dial, address, postal_code, city, country, region, hire_date, email_verified_at, password, locale, totp_secret, theme, primary_tenant_id, created_by, modified_by, created, modified, active from users where id = ? limit 1',
|
||||
(string) $id
|
||||
);
|
||||
return self::unwrap($row);
|
||||
}
|
||||
|
||||
public static function findByUuid(string $uuid): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, first_name, last_name, email, profile_description, job_title, phone, mobile, short_dial, address, postal_code, city, country, region, hire_date, email_verified_at, password, locale, totp_secret, theme, primary_tenant_id, created_by, modified_by, created, modified, active, active_changed_at, active_changed_by from users where uuid = ? limit 1',
|
||||
$uuid
|
||||
);
|
||||
return self::unwrap($row);
|
||||
}
|
||||
|
||||
public static function findByEmail(string $email): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, first_name, last_name, email, profile_description, job_title, phone, mobile, short_dial, address, postal_code, city, country, region, hire_date, email_verified_at, email_verified_at, password, locale, totp_secret, theme, primary_tenant_id, created_by, modified_by, created, modified, active, active_changed_at, active_changed_by from users where email = ? limit 1',
|
||||
$email
|
||||
);
|
||||
return self::unwrap($row);
|
||||
}
|
||||
|
||||
private static function uuidV4(): string
|
||||
{
|
||||
$data = random_bytes(16);
|
||||
$data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
|
||||
$data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
|
||||
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
|
||||
}
|
||||
|
||||
public static function create(array $data)
|
||||
{
|
||||
$hash = password_hash($data['password'], PASSWORD_DEFAULT);
|
||||
return DB::insert(
|
||||
'insert into users (uuid, first_name, last_name, email, profile_description, job_title, phone, mobile, short_dial, address, postal_code, city, country, region, hire_date, password, locale, totp_secret, theme, primary_tenant_id, current_tenant_id, created_by, created, active, active_changed_at, active_changed_by) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW(),?,?,?)',
|
||||
$data['uuid'] ?? self::uuidV4(),
|
||||
$data['first_name'],
|
||||
$data['last_name'],
|
||||
$data['email'],
|
||||
$data['profile_description'] ?? null,
|
||||
$data['job_title'] ?? null,
|
||||
$data['phone'] ?? null,
|
||||
$data['mobile'] ?? null,
|
||||
$data['short_dial'] ?? null,
|
||||
$data['address'] ?? null,
|
||||
$data['postal_code'] ?? null,
|
||||
$data['city'] ?? null,
|
||||
$data['country'] ?? null,
|
||||
$data['region'] ?? null,
|
||||
$data['hire_date'] ?? null,
|
||||
$hash,
|
||||
$data['locale'] ?? null,
|
||||
$data['totp_secret'],
|
||||
$data['theme'] ?? 'light',
|
||||
$data['primary_tenant_id'] ?? null,
|
||||
$data['current_tenant_id'] ?? $data['primary_tenant_id'] ?? null,
|
||||
$data['created_by'] ?? null,
|
||||
$data['active'],
|
||||
$data['active_changed_at'] ?? null,
|
||||
$data['active_changed_by'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
public static function update(int $id, array $data): bool
|
||||
{
|
||||
$fields = [
|
||||
'first_name' => $data['first_name'],
|
||||
'last_name' => $data['last_name'],
|
||||
'email' => $data['email'],
|
||||
'profile_description' => $data['profile_description'] ?? null,
|
||||
'job_title' => $data['job_title'] ?? null,
|
||||
'phone' => $data['phone'] ?? null,
|
||||
'mobile' => $data['mobile'] ?? null,
|
||||
'short_dial' => $data['short_dial'] ?? null,
|
||||
'address' => $data['address'] ?? null,
|
||||
'postal_code' => $data['postal_code'] ?? null,
|
||||
'city' => $data['city'] ?? null,
|
||||
'country' => $data['country'] ?? null,
|
||||
'region' => $data['region'] ?? null,
|
||||
'hire_date' => $data['hire_date'] ?? null,
|
||||
'totp_secret' => $data['totp_secret'],
|
||||
'active' => $data['active'],
|
||||
];
|
||||
if (array_key_exists('locale', $data)) {
|
||||
$fields['locale'] = $data['locale'];
|
||||
}
|
||||
if (array_key_exists('theme', $data)) {
|
||||
$fields['theme'] = $data['theme'];
|
||||
}
|
||||
if (array_key_exists('primary_tenant_id', $data)) {
|
||||
$fields['primary_tenant_id'] = $data['primary_tenant_id'];
|
||||
}
|
||||
if (array_key_exists('current_tenant_id', $data)) {
|
||||
$fields['current_tenant_id'] = $data['current_tenant_id'];
|
||||
}
|
||||
if (array_key_exists('modified_by', $data)) {
|
||||
$fields['modified_by'] = $data['modified_by'];
|
||||
}
|
||||
if (array_key_exists('active_changed_at', $data)) {
|
||||
$fields['active_changed_at'] = $data['active_changed_at'];
|
||||
}
|
||||
if (array_key_exists('active_changed_by', $data)) {
|
||||
$fields['active_changed_by'] = $data['active_changed_by'];
|
||||
}
|
||||
|
||||
if (!empty($data['password'])) {
|
||||
$fields['password'] = password_hash($data['password'], PASSWORD_DEFAULT);
|
||||
}
|
||||
|
||||
$setParts = [];
|
||||
$params = [];
|
||||
foreach ($fields as $field => $value) {
|
||||
$setParts[] = sprintf('`%s` = ?', $field);
|
||||
$params[] = $value;
|
||||
}
|
||||
$params[] = (string) $id;
|
||||
|
||||
$query = 'update users set ' . implode(', ', $setParts) . ' where id = ?';
|
||||
$result = DB::update($query, ...$params);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function setActive(int $id, bool $active, ?int $changedBy = null): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update users set active = ?, active_changed_at = NOW(), active_changed_by = ? where id = ?',
|
||||
$active ? 1 : 0,
|
||||
$changedBy,
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function setCurrentTenant(int $id, int $tenantId): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update users set current_tenant_id = ? where id = ?',
|
||||
(string) $tenantId,
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function setActiveByUuids(array $uuids, bool $active, ?int $modifiedBy = null): bool
|
||||
{
|
||||
$uuids = array_values(array_filter(array_map('trim', $uuids)));
|
||||
if (!$uuids) {
|
||||
return false;
|
||||
}
|
||||
$placeholders = implode(',', array_fill(0, count($uuids), '?'));
|
||||
$query = "update users set active = ?, modified_by = ?, active_changed_at = NOW(), active_changed_by = ? where uuid in ($placeholders)";
|
||||
$params = array_merge([$active ? 1 : 0, $modifiedBy, $modifiedBy], $uuids);
|
||||
$result = DB::update($query, ...$params);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function setLocale(int $id, string $locale): bool
|
||||
{
|
||||
$result = DB::update('update users set locale = ? where id = ?', $locale, (string) $id);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function setTheme(int $id, string $theme): bool
|
||||
{
|
||||
$result = DB::update('update users set theme = ? where id = ?', $theme, (string) $id);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function setPassword(int $id, string $password): bool
|
||||
{
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
$result = DB::update('update users set password = ?, modified_by = NULL where id = ?', $hash, (string) $id);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function setEmailVerified(int $id): bool
|
||||
{
|
||||
$result = DB::update('update users set email_verified_at = NOW() where id = ?', (string) $id);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function delete(int $id): bool
|
||||
{
|
||||
$result = DB::delete('delete from users where id = ?', (string) $id);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function deleteByUuids(array $uuids): bool
|
||||
{
|
||||
$uuids = array_values(array_filter(array_map('trim', $uuids)));
|
||||
if (!$uuids) {
|
||||
return false;
|
||||
}
|
||||
$placeholders = implode(',', array_fill(0, count($uuids), '?'));
|
||||
$query = "delete from users where uuid in ($placeholders)";
|
||||
$result = DB::delete($query, ...$uuids);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
private static function normalizeIdList($value): array
|
||||
{
|
||||
if (is_string($value)) {
|
||||
$value = array_filter(array_map('trim', explode(',', $value)));
|
||||
} elseif (!is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($value as $item) {
|
||||
if ($item === '' || $item === null) {
|
||||
continue;
|
||||
}
|
||||
$ids[] = (int) $item;
|
||||
}
|
||||
$ids = array_values(array_filter(array_unique($ids), static function ($id) {
|
||||
return $id > 0;
|
||||
}));
|
||||
return $ids;
|
||||
}
|
||||
}
|
||||
42
lib/Repository/UserRoleRepository.php
Normal file
42
lib/Repository/UserRoleRepository.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class UserRoleRepository
|
||||
{
|
||||
public static function listRoleIdsByUserId(int $userId): array
|
||||
{
|
||||
$rows = DB::select('select role_id from user_roles where user_id = ?', (string) $userId);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['user_roles'] ?? $row;
|
||||
if (is_array($data) && isset($data['role_id'])) {
|
||||
$ids[] = (int) $data['role_id'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public static function replaceForUser(int $userId, array $roleIds): bool
|
||||
{
|
||||
DB::delete('delete from user_roles where user_id = ?', (string) $userId);
|
||||
if (!$roleIds) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($roleIds as $roleId) {
|
||||
DB::insert(
|
||||
'insert into user_roles (user_id, role_id, created) values (?,?,NOW())',
|
||||
(string) $userId,
|
||||
(string) $roleId
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
42
lib/Repository/UserTenantRepository.php
Normal file
42
lib/Repository/UserTenantRepository.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class UserTenantRepository
|
||||
{
|
||||
public static function listTenantIdsByUserId(int $userId): array
|
||||
{
|
||||
$rows = DB::select('select tenant_id from user_tenants where user_id = ?', (string) $userId);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['user_tenants'] ?? $row;
|
||||
if (is_array($data) && isset($data['tenant_id'])) {
|
||||
$ids[] = (int) $data['tenant_id'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public static function replaceForUser(int $userId, array $tenantIds): bool
|
||||
{
|
||||
DB::delete('delete from user_tenants where user_id = ?', (string) $userId);
|
||||
if (!$tenantIds) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($tenantIds as $tenantId) {
|
||||
DB::insert(
|
||||
'insert into user_tenants (user_id, tenant_id, created) values (?,?,NOW())',
|
||||
(string) $userId,
|
||||
(string) $tenantId
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
163
lib/Service/AuthService.php
Normal file
163
lib/Service/AuthService.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service;
|
||||
|
||||
use MintyPHP\Auth;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Support\Flash;
|
||||
use MintyPHP\Service\UserService;
|
||||
use MintyPHP\Service\PermissionService;
|
||||
use MintyPHP\Service\RememberMeService;
|
||||
use MintyPHP\Service\EmailVerificationService;
|
||||
use MintyPHP\Repository\UserRepository;
|
||||
|
||||
class AuthService
|
||||
{
|
||||
public static function login(string $email, string $password): array
|
||||
{
|
||||
$email = trim($email);
|
||||
|
||||
// Check if email is verified before attempting login
|
||||
$existingUser = UserRepository::findByEmail($email);
|
||||
if ($existingUser && empty($existingUser['email_verified_at'])) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Please verify your email first',
|
||||
'flash_key' => 'login_not_verified',
|
||||
'needs_verification' => true,
|
||||
'email' => $email,
|
||||
];
|
||||
}
|
||||
|
||||
$user = Auth::login($email, $password);
|
||||
|
||||
if (!$user) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Email/password not valid',
|
||||
'flash_key' => 'login_invalid',
|
||||
];
|
||||
}
|
||||
|
||||
if (!($_SESSION['user']['active'] ?? 1)) {
|
||||
Auth::logout();
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Account is inactive',
|
||||
'flash_key' => 'login_inactive',
|
||||
];
|
||||
}
|
||||
|
||||
$userId = (int) ($_SESSION['user']['id'] ?? 0);
|
||||
if ($userId > 0) {
|
||||
PermissionService::getUserPermissions($userId, true);
|
||||
self::loadTenantDataIntoSession($userId);
|
||||
if (!empty($_SESSION['no_active_tenant'])) {
|
||||
Auth::logout();
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'No active tenant assigned',
|
||||
'flash_key' => 'login_no_active_tenant',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
public static function loginAndRedirect(
|
||||
string $email,
|
||||
string $password,
|
||||
string $successTarget = 'admin',
|
||||
string $failTarget = 'login',
|
||||
bool $remember = false
|
||||
): void {
|
||||
$result = self::login($email, $password);
|
||||
|
||||
if (!($result['ok'] ?? false)) {
|
||||
// Check if user needs to verify email
|
||||
if (!empty($result['needs_verification'])) {
|
||||
$_SESSION['email_verification_email'] = $result['email'] ?? $email;
|
||||
Flash::error(t('Please verify your email first'), 'verify-email', 'login_not_verified');
|
||||
Router::redirect('verify-email');
|
||||
}
|
||||
|
||||
$message = $result['message'] ?? 'Email/password not valid';
|
||||
$key = $result['flash_key'] ?? 'login_invalid';
|
||||
Flash::error($message, $failTarget, $key);
|
||||
Router::redirect($failTarget);
|
||||
}
|
||||
|
||||
if ($remember) {
|
||||
$userId = (int) ($_SESSION['user']['id'] ?? 0);
|
||||
if ($userId > 0) {
|
||||
RememberMeService::rememberUser($userId);
|
||||
}
|
||||
}
|
||||
Router::redirect($successTarget);
|
||||
}
|
||||
|
||||
public static function register(array $data): array
|
||||
{
|
||||
$email = trim((string) ($data['email'] ?? ''));
|
||||
$result = UserService::register($data);
|
||||
if (!($result['ok'] ?? false)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Get the created user to send verification email
|
||||
$createdUser = UserRepository::findByEmail($email);
|
||||
if ($createdUser && isset($createdUser['id'])) {
|
||||
$userId = (int) $createdUser['id'];
|
||||
EmailVerificationService::sendVerification($userId);
|
||||
}
|
||||
|
||||
// Don't login - user needs to verify email first
|
||||
return ['ok' => true, 'email' => $email, 'needs_verification' => true];
|
||||
}
|
||||
|
||||
public static function logout(): void
|
||||
{
|
||||
RememberMeService::forgetCurrentUser();
|
||||
Auth::logout();
|
||||
}
|
||||
|
||||
public static function logoutAndRedirect(string $target = 'login'): void
|
||||
{
|
||||
RememberMeService::forgetCurrentUser();
|
||||
Auth::logout();
|
||||
Router::redirect($target);
|
||||
}
|
||||
|
||||
public static function loadTenantDataIntoSession(int $userId): void
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load available tenants first
|
||||
$availableTenants = UserService::getAvailableTenants($userId);
|
||||
$_SESSION['available_tenants'] = $availableTenants;
|
||||
$_SESSION['available_departments_by_tenant'] = UserService::getAvailableDepartmentsByTenant($userId);
|
||||
|
||||
$hasTenantAdminAccess = PermissionService::userHas($userId, PermissionService::TENANTS_UPDATE)
|
||||
|| PermissionService::userHas($userId, PermissionService::SETTINGS_UPDATE);
|
||||
|
||||
if (!$availableTenants && !$hasTenantAdminAccess) {
|
||||
$_SESSION['no_active_tenant'] = true;
|
||||
unset($_SESSION['current_tenant']);
|
||||
return;
|
||||
}
|
||||
$_SESSION['no_active_tenant'] = false;
|
||||
|
||||
// Load current tenant (with fallback to first available)
|
||||
$currentTenant = UserService::getCurrentTenant($userId);
|
||||
if (!$currentTenant && !empty($availableTenants)) {
|
||||
// Fallback: use first available tenant
|
||||
$currentTenant = $availableTenants[0];
|
||||
}
|
||||
if ($currentTenant) {
|
||||
$_SESSION['current_tenant'] = $currentTenant;
|
||||
}
|
||||
}
|
||||
}
|
||||
238
lib/Service/BrandingFaviconService.php
Normal file
238
lib/Service/BrandingFaviconService.php
Normal file
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service;
|
||||
|
||||
class BrandingFaviconService
|
||||
{
|
||||
private const MAX_SIZE = 5242880; // 5 MB
|
||||
private const SIZES = [
|
||||
16 => 'favicon-16x16.png',
|
||||
32 => 'favicon-32x32.png',
|
||||
96 => 'favicon-96x96.png',
|
||||
180 => 'apple-touch-icon.png',
|
||||
192 => 'web-app-manifest-192x192.png',
|
||||
512 => 'web-app-manifest-512x512.png',
|
||||
];
|
||||
|
||||
public static function storageBase(): string
|
||||
{
|
||||
if (defined('APP_STORAGE_PATH') && APP_STORAGE_PATH) {
|
||||
return rtrim(APP_STORAGE_PATH, '/');
|
||||
}
|
||||
return rtrim(dirname(__DIR__, 2) . '/storage', '/');
|
||||
}
|
||||
|
||||
public static function storageDir(): string
|
||||
{
|
||||
return self::storageBase() . '/branding/favicon';
|
||||
}
|
||||
|
||||
public static function publicDir(): string
|
||||
{
|
||||
return rtrim(dirname(__DIR__, 2) . '/web/favicon', '/');
|
||||
}
|
||||
|
||||
public static function hasFavicon(): bool
|
||||
{
|
||||
$path = self::publicDir() . '/favicon-32x32.png';
|
||||
return is_file($path);
|
||||
}
|
||||
|
||||
public static function delete(): void
|
||||
{
|
||||
$dir = self::storageDir();
|
||||
if (is_dir($dir)) {
|
||||
$matches = glob($dir . '/*') ?: [];
|
||||
foreach ($matches as $file) {
|
||||
if (is_file($file)) {
|
||||
@unlink($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
$public = self::publicDir();
|
||||
foreach (self::SIZES as $file) {
|
||||
$target = $public . '/' . $file;
|
||||
if (is_file($target)) {
|
||||
@unlink($target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function saveUpload(array $file): array
|
||||
{
|
||||
if (empty($file) || !isset($file['tmp_name'])) {
|
||||
return ['ok' => false, 'error' => t('No file uploaded')];
|
||||
}
|
||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
return ['ok' => false, 'error' => t('Upload failed')];
|
||||
}
|
||||
if (($file['size'] ?? 0) > self::MAX_SIZE) {
|
||||
return ['ok' => false, 'error' => t('File is too large')];
|
||||
}
|
||||
|
||||
$tmpPath = $file['tmp_name'];
|
||||
$mime = self::detectMime($tmpPath);
|
||||
if ($mime !== 'image/png') {
|
||||
return ['ok' => false, 'error' => t('Invalid image file')];
|
||||
}
|
||||
if (!self::canResize()) {
|
||||
return ['ok' => false, 'error' => t('Upload failed')];
|
||||
}
|
||||
|
||||
$dir = self::storageDir();
|
||||
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
|
||||
return ['ok' => false, 'error' => t('Upload failed')];
|
||||
}
|
||||
|
||||
self::delete();
|
||||
$originalPath = $dir . '/original.png';
|
||||
if (!move_uploaded_file($tmpPath, $originalPath)) {
|
||||
return ['ok' => false, 'error' => t('Upload failed')];
|
||||
}
|
||||
@chmod($originalPath, 0644);
|
||||
|
||||
$publicDir = self::publicDir();
|
||||
if (!is_dir($publicDir) && !mkdir($publicDir, 0755, true) && !is_dir($publicDir)) {
|
||||
return ['ok' => false, 'error' => t('Upload failed')];
|
||||
}
|
||||
|
||||
foreach (self::SIZES as $size => $fileName) {
|
||||
$target = $publicDir . '/' . $fileName;
|
||||
if (!self::resizeSquare($originalPath, $target, $size)) {
|
||||
return ['ok' => false, 'error' => t('Upload failed')];
|
||||
}
|
||||
@chmod($target, 0644);
|
||||
}
|
||||
|
||||
self::updateManifest();
|
||||
return ['ok' => true, 'path' => $originalPath, 'mime' => $mime];
|
||||
}
|
||||
|
||||
public static function detectMime(string $path): string
|
||||
{
|
||||
if (function_exists('finfo_open')) {
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
if ($finfo) {
|
||||
$mime = finfo_file($finfo, $path);
|
||||
if (is_string($mime) && $mime !== '') {
|
||||
return $mime;
|
||||
}
|
||||
}
|
||||
}
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
private static function canResize(): bool
|
||||
{
|
||||
return function_exists('imagecreatetruecolor') && function_exists('imagecopyresampled');
|
||||
}
|
||||
|
||||
private static function loadImage(string $path)
|
||||
{
|
||||
if (function_exists('imagecreatefrompng')) {
|
||||
return imagecreatefrompng($path);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function resizeSquare(string $sourcePath, string $targetPath, int $size): bool
|
||||
{
|
||||
$src = self::loadImage($sourcePath);
|
||||
if (!$src) {
|
||||
return false;
|
||||
}
|
||||
$srcWidth = imagesx($src);
|
||||
$srcHeight = imagesy($src);
|
||||
if ($srcWidth === 0 || $srcHeight === 0) {
|
||||
imagedestroy($src);
|
||||
return false;
|
||||
}
|
||||
|
||||
$cropSize = min($srcWidth, $srcHeight);
|
||||
$srcX = (int) round(($srcWidth - $cropSize) / 2);
|
||||
$srcY = (int) round(($srcHeight - $cropSize) / 2);
|
||||
|
||||
$dst = imagecreatetruecolor($size, $size);
|
||||
imagealphablending($dst, false);
|
||||
imagesavealpha($dst, true);
|
||||
$transparent = imagecolorallocatealpha($dst, 0, 0, 0, 127);
|
||||
imagefilledrectangle($dst, 0, 0, $size, $size, $transparent);
|
||||
|
||||
imagecopyresampled(
|
||||
$dst,
|
||||
$src,
|
||||
0,
|
||||
0,
|
||||
$srcX,
|
||||
$srcY,
|
||||
$size,
|
||||
$size,
|
||||
$cropSize,
|
||||
$cropSize
|
||||
);
|
||||
|
||||
$saved = false;
|
||||
if (function_exists('imagepng')) {
|
||||
$saved = imagepng($dst, $targetPath, 6);
|
||||
}
|
||||
|
||||
imagedestroy($src);
|
||||
imagedestroy($dst);
|
||||
|
||||
return $saved;
|
||||
}
|
||||
|
||||
private static function updateManifest(): void
|
||||
{
|
||||
$manifestPath = self::publicDir() . '/site.webmanifest';
|
||||
$data = [];
|
||||
if (is_file($manifestPath)) {
|
||||
$json = file_get_contents($manifestPath);
|
||||
$decoded = json_decode((string) $json, true);
|
||||
if (is_array($decoded)) {
|
||||
$data = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
$title = null;
|
||||
if (class_exists('MintyPHP\\Service\\SettingService')) {
|
||||
$title = \MintyPHP\Service\SettingService::getAppTitle();
|
||||
}
|
||||
if ($title === null || $title === '') {
|
||||
$title = defined('APP_NAME') ? APP_NAME : 'IMVS';
|
||||
}
|
||||
|
||||
$data['name'] = $title;
|
||||
$data['short_name'] = $title;
|
||||
if (!isset($data['theme_color'])) {
|
||||
$data['theme_color'] = '#ffffff';
|
||||
}
|
||||
if (!isset($data['background_color'])) {
|
||||
$data['background_color'] = '#ffffff';
|
||||
}
|
||||
if (!isset($data['display'])) {
|
||||
$data['display'] = 'standalone';
|
||||
}
|
||||
if (!isset($data['icons']) || !is_array($data['icons'])) {
|
||||
$data['icons'] = [
|
||||
[
|
||||
'src' => '/favicon/web-app-manifest-192x192.png',
|
||||
'sizes' => '192x192',
|
||||
'type' => 'image/png',
|
||||
'purpose' => 'maskable',
|
||||
],
|
||||
[
|
||||
'src' => '/favicon/web-app-manifest-512x512.png',
|
||||
'sizes' => '512x512',
|
||||
'type' => 'image/png',
|
||||
'purpose' => 'maskable',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
if (is_string($encoded)) {
|
||||
file_put_contents($manifestPath, $encoded . "\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
293
lib/Service/BrandingLogoService.php
Normal file
293
lib/Service/BrandingLogoService.php
Normal file
@@ -0,0 +1,293 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service;
|
||||
|
||||
class BrandingLogoService
|
||||
{
|
||||
private const MAX_SIZE = 5242880; // 5 MB
|
||||
private const SIZES = [64, 128, 256];
|
||||
private const DEFAULT_SIZE = 128;
|
||||
|
||||
public static function storageBase(): string
|
||||
{
|
||||
if (defined('APP_STORAGE_PATH') && APP_STORAGE_PATH) {
|
||||
return rtrim(APP_STORAGE_PATH, '/');
|
||||
}
|
||||
return rtrim(dirname(__DIR__, 2) . '/storage', '/');
|
||||
}
|
||||
|
||||
public static function brandingDir(): string
|
||||
{
|
||||
return self::storageBase() . '/branding/logo';
|
||||
}
|
||||
|
||||
public static function findLogoPath(?int $size = null): ?string
|
||||
{
|
||||
$dir = self::brandingDir();
|
||||
if (!is_dir($dir)) {
|
||||
return null;
|
||||
}
|
||||
if ($size) {
|
||||
$size = self::normalizeSize($size);
|
||||
$variant = self::findVariantPath($dir, $size);
|
||||
if ($variant) {
|
||||
return $variant;
|
||||
}
|
||||
}
|
||||
$defaultVariant = self::findVariantPath($dir, self::DEFAULT_SIZE);
|
||||
if ($defaultVariant) {
|
||||
return $defaultVariant;
|
||||
}
|
||||
$original = self::findOriginalPath($dir);
|
||||
return $original ?: null;
|
||||
}
|
||||
|
||||
public static function hasLogo(): bool
|
||||
{
|
||||
$path = self::findLogoPath();
|
||||
return $path ? is_file($path) : false;
|
||||
}
|
||||
|
||||
public static function delete(): bool
|
||||
{
|
||||
$dir = self::brandingDir();
|
||||
if (!is_dir($dir)) {
|
||||
return true;
|
||||
}
|
||||
$matches = array_merge(
|
||||
glob($dir . '/logo-*.*') ?: [],
|
||||
glob($dir . '/logo.*') ?: [],
|
||||
glob($dir . '/original.*') ?: []
|
||||
);
|
||||
foreach ($matches as $file) {
|
||||
if (is_file($file)) {
|
||||
@unlink($file);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function saveUpload(array $file): array
|
||||
{
|
||||
if (empty($file) || !isset($file['tmp_name'])) {
|
||||
return ['ok' => false, 'error' => t('No file uploaded')];
|
||||
}
|
||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
return ['ok' => false, 'error' => t('Upload failed')];
|
||||
}
|
||||
if (($file['size'] ?? 0) > self::MAX_SIZE) {
|
||||
return ['ok' => false, 'error' => t('File is too large')];
|
||||
}
|
||||
|
||||
$tmpPath = $file['tmp_name'];
|
||||
$mime = self::detectMime($tmpPath);
|
||||
$isSvg = self::isSvgUpload($mime, $tmpPath);
|
||||
$ext = self::extensionForMime($mime, $isSvg);
|
||||
if (!$ext) {
|
||||
return ['ok' => false, 'error' => t('Invalid image file')];
|
||||
}
|
||||
if ($isSvg && !self::isSafeSvg($tmpPath)) {
|
||||
return ['ok' => false, 'error' => t('Invalid image file')];
|
||||
}
|
||||
|
||||
$dir = self::brandingDir();
|
||||
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
|
||||
return ['ok' => false, 'error' => t('Upload failed')];
|
||||
}
|
||||
|
||||
self::delete();
|
||||
$originalPath = $dir . '/original.' . $ext;
|
||||
if (!move_uploaded_file($tmpPath, $originalPath)) {
|
||||
return ['ok' => false, 'error' => t('Upload failed')];
|
||||
}
|
||||
@chmod($originalPath, 0644);
|
||||
|
||||
$variantExt = function_exists('imagewebp') ? 'webp' : 'jpg';
|
||||
if (!$isSvg && self::canResize()) {
|
||||
foreach (self::SIZES as $size) {
|
||||
$target = $dir . '/logo-' . $size . '.' . $variantExt;
|
||||
self::resizeAndFit($originalPath, $target, $size, $size, $variantExt);
|
||||
}
|
||||
}
|
||||
|
||||
return ['ok' => true, 'path' => $originalPath, 'mime' => $mime];
|
||||
}
|
||||
|
||||
public static function detectMime(string $path): string
|
||||
{
|
||||
if (function_exists('finfo_open')) {
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
if ($finfo) {
|
||||
$mime = finfo_file($finfo, $path);
|
||||
if (is_string($mime) && $mime !== '') {
|
||||
if (self::isSvgUpload($mime, $path)) {
|
||||
return 'image/svg+xml';
|
||||
}
|
||||
return $mime;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (self::isSvgUpload('application/octet-stream', $path)) {
|
||||
return 'image/svg+xml';
|
||||
}
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
private static function extensionForMime(string $mime, bool $isSvg = false): ?string
|
||||
{
|
||||
if ($isSvg) {
|
||||
return 'svg';
|
||||
}
|
||||
$map = [
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/webp' => 'webp',
|
||||
];
|
||||
return $map[$mime] ?? null;
|
||||
}
|
||||
|
||||
private static function isSvgUpload(string $mime, string $path): bool
|
||||
{
|
||||
if ($mime === 'image/svg+xml') {
|
||||
return true;
|
||||
}
|
||||
$head = @file_get_contents($path, false, null, 0, 1024);
|
||||
if (!is_string($head) || $head === '') {
|
||||
return false;
|
||||
}
|
||||
return stripos($head, '<svg') !== false;
|
||||
}
|
||||
|
||||
private static function isSafeSvg(string $path): bool
|
||||
{
|
||||
$contents = @file_get_contents($path);
|
||||
if (!is_string($contents) || $contents === '') {
|
||||
return false;
|
||||
}
|
||||
$lower = strtolower($contents);
|
||||
if (strpos($lower, '<script') !== false) {
|
||||
return false;
|
||||
}
|
||||
if (strpos($lower, 'onload=') !== false) {
|
||||
return false;
|
||||
}
|
||||
if (strpos($lower, 'javascript:') !== false) {
|
||||
return false;
|
||||
}
|
||||
if (strpos($lower, '<foreignobject') !== false) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function normalizeSize(int $size): int
|
||||
{
|
||||
if (in_array($size, self::SIZES, true)) {
|
||||
return $size;
|
||||
}
|
||||
return self::DEFAULT_SIZE;
|
||||
}
|
||||
|
||||
private static function findVariantPath(string $dir, int $size): ?string
|
||||
{
|
||||
$matches = glob($dir . '/logo-' . $size . '.*');
|
||||
if (!$matches) {
|
||||
return null;
|
||||
}
|
||||
usort($matches, static function ($a, $b) {
|
||||
return filemtime($b) <=> filemtime($a);
|
||||
});
|
||||
return $matches[0] ?? null;
|
||||
}
|
||||
|
||||
private static function findOriginalPath(string $dir): ?string
|
||||
{
|
||||
$matches = glob($dir . '/original.*');
|
||||
if (!$matches) {
|
||||
return null;
|
||||
}
|
||||
usort($matches, static function ($a, $b) {
|
||||
return filemtime($b) <=> filemtime($a);
|
||||
});
|
||||
return $matches[0] ?? null;
|
||||
}
|
||||
|
||||
private static function canResize(): bool
|
||||
{
|
||||
return function_exists('imagecreatetruecolor') && function_exists('imagecopyresampled');
|
||||
}
|
||||
|
||||
private static function createImageResource(string $path, string $mime)
|
||||
{
|
||||
if ($mime === 'image/jpeg' && function_exists('imagecreatefromjpeg')) {
|
||||
return imagecreatefromjpeg($path);
|
||||
}
|
||||
if ($mime === 'image/png' && function_exists('imagecreatefrompng')) {
|
||||
return imagecreatefrompng($path);
|
||||
}
|
||||
if ($mime === 'image/webp' && function_exists('imagecreatefromwebp')) {
|
||||
return imagecreatefromwebp($path);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function resizeAndFit(string $sourcePath, string $targetPath, int $width, int $height, string $ext): bool
|
||||
{
|
||||
$mime = self::detectMime($sourcePath);
|
||||
$src = self::createImageResource($sourcePath, $mime);
|
||||
if (!$src) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$srcWidth = imagesx($src);
|
||||
$srcHeight = imagesy($src);
|
||||
if ($srcWidth === 0 || $srcHeight === 0) {
|
||||
imagedestroy($src);
|
||||
return false;
|
||||
}
|
||||
|
||||
$scale = min($width / $srcWidth, $height / $srcHeight);
|
||||
$dstWidth = (int) round($srcWidth * $scale);
|
||||
$dstHeight = (int) round($srcHeight * $scale);
|
||||
if ($dstWidth < 1) $dstWidth = 1;
|
||||
if ($dstHeight < 1) $dstHeight = 1;
|
||||
|
||||
$dst = imagecreatetruecolor($dstWidth, $dstHeight);
|
||||
if ($ext === 'png' || $ext === 'webp') {
|
||||
imagealphablending($dst, false);
|
||||
imagesavealpha($dst, true);
|
||||
$transparent = imagecolorallocatealpha($dst, 0, 0, 0, 127);
|
||||
imagefilledrectangle($dst, 0, 0, $dstWidth, $dstHeight, $transparent);
|
||||
}
|
||||
|
||||
imagecopyresampled(
|
||||
$dst,
|
||||
$src,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
$dstWidth,
|
||||
$dstHeight,
|
||||
$srcWidth,
|
||||
$srcHeight
|
||||
);
|
||||
|
||||
$saved = false;
|
||||
if ($ext === 'webp' && function_exists('imagewebp')) {
|
||||
$saved = imagewebp($dst, $targetPath, 82);
|
||||
} elseif ($ext === 'png' && function_exists('imagepng')) {
|
||||
$saved = imagepng($dst, $targetPath, 6);
|
||||
} else {
|
||||
$saved = imagejpeg($dst, $targetPath, 85);
|
||||
}
|
||||
|
||||
imagedestroy($src);
|
||||
imagedestroy($dst);
|
||||
|
||||
if ($saved) {
|
||||
@chmod($targetPath, 0644);
|
||||
}
|
||||
return $saved;
|
||||
}
|
||||
}
|
||||
161
lib/Service/DepartmentService.php
Normal file
161
lib/Service/DepartmentService.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service;
|
||||
|
||||
use MintyPHP\Repository\DepartmentRepository;
|
||||
use MintyPHP\Repository\TenantDepartmentRepository;
|
||||
use MintyPHP\Repository\UserDepartmentRepository;
|
||||
use MintyPHP\Service\SettingService;
|
||||
use MintyPHP\Service\TenantScopeService;
|
||||
|
||||
class DepartmentService
|
||||
{
|
||||
public static function list(): array
|
||||
{
|
||||
return DepartmentRepository::list();
|
||||
}
|
||||
|
||||
public static function listPaged(array $options): array
|
||||
{
|
||||
if (!empty($options['tenantUserId'])) {
|
||||
$tenantUserId = (int) $options['tenantUserId'];
|
||||
if ($tenantUserId > 0 && TenantScopeService::hasGlobalAccess($tenantUserId)) {
|
||||
unset($options['tenantUserId'], $options['tenantIds']);
|
||||
}
|
||||
}
|
||||
return DepartmentRepository::listPaged($options);
|
||||
}
|
||||
|
||||
public static function listByTenantIds(array $tenantIds): array
|
||||
{
|
||||
return DepartmentRepository::listByTenantIds($tenantIds);
|
||||
}
|
||||
|
||||
public static function listByIds(array $departmentIds): array
|
||||
{
|
||||
return DepartmentRepository::listByIds($departmentIds);
|
||||
}
|
||||
|
||||
public static function listForUserAssignments(array $tenantIds, array $selectedDepartmentIds): array
|
||||
{
|
||||
$departments = $tenantIds ? self::listByTenantIds($tenantIds) : self::list();
|
||||
$extraDepartments = $selectedDepartmentIds ? self::listByIds($selectedDepartmentIds) : [];
|
||||
if (!$extraDepartments) {
|
||||
return $departments;
|
||||
}
|
||||
$departmentMap = [];
|
||||
foreach ($departments as $department) {
|
||||
if (isset($department['id'])) {
|
||||
$departmentMap[(int) $department['id']] = $department;
|
||||
}
|
||||
}
|
||||
foreach ($extraDepartments as $department) {
|
||||
if (isset($department['id'])) {
|
||||
$departmentMap[(int) $department['id']] = $department;
|
||||
}
|
||||
}
|
||||
return array_values($departmentMap);
|
||||
}
|
||||
|
||||
public static function findByUuid(string $uuid): ?array
|
||||
{
|
||||
return DepartmentRepository::findByUuid($uuid);
|
||||
}
|
||||
|
||||
public static function findById(int $id): ?array
|
||||
{
|
||||
return DepartmentRepository::find($id);
|
||||
}
|
||||
|
||||
public static function createFromAdmin(array $input, int $currentUserId = 0): array
|
||||
{
|
||||
$form = self::sanitizeBase($input);
|
||||
$errors = self::validateBase($form);
|
||||
|
||||
if ($errors) {
|
||||
return ['ok' => false, 'errors' => $errors, 'form' => $form];
|
||||
}
|
||||
|
||||
$createdId = DepartmentRepository::create([
|
||||
'description' => $form['description'],
|
||||
'created_by' => $currentUserId > 0 ? $currentUserId : null,
|
||||
]);
|
||||
|
||||
if (!$createdId) {
|
||||
return ['ok' => false, 'errors' => [t('Department can not be created')], 'form' => $form];
|
||||
}
|
||||
|
||||
$createdDepartment = DepartmentRepository::find((int) $createdId);
|
||||
$uuid = $createdDepartment['uuid'] ?? null;
|
||||
if (!empty($input['is_default']) && $createdId) {
|
||||
SettingService::setDefaultDepartmentId((int) $createdId);
|
||||
}
|
||||
return ['ok' => true, 'form' => $form, 'uuid' => $uuid, 'id' => (int) $createdId];
|
||||
}
|
||||
|
||||
public static function updateFromAdmin(int $departmentId, array $input, int $currentUserId = 0): array
|
||||
{
|
||||
$form = self::sanitizeBase($input);
|
||||
$errors = self::validateBase($form);
|
||||
|
||||
if ($errors) {
|
||||
return ['ok' => false, 'errors' => $errors, 'form' => $form];
|
||||
}
|
||||
|
||||
$updated = DepartmentRepository::update($departmentId, [
|
||||
'description' => $form['description'],
|
||||
'modified_by' => $currentUserId > 0 ? $currentUserId : null,
|
||||
]);
|
||||
|
||||
if (!$updated) {
|
||||
return ['ok' => false, 'errors' => [t('Department can not be updated')], 'form' => $form];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'form' => $form];
|
||||
}
|
||||
|
||||
public static function syncTenants(int $departmentId, array $tenantIds): int
|
||||
{
|
||||
$updated = TenantDepartmentRepository::replaceForDepartment($departmentId, $tenantIds);
|
||||
if (!$updated) {
|
||||
return -1;
|
||||
}
|
||||
return UserDepartmentRepository::removeInvalidForDepartment($departmentId);
|
||||
}
|
||||
|
||||
public static function deleteByUuid(string $uuid): array
|
||||
{
|
||||
$uuid = trim($uuid);
|
||||
if ($uuid === '') {
|
||||
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
|
||||
}
|
||||
|
||||
$department = DepartmentRepository::findByUuid($uuid);
|
||||
if (!$department || !isset($department['id'])) {
|
||||
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
|
||||
}
|
||||
|
||||
$deleted = DepartmentRepository::delete((int) $department['id']);
|
||||
if (!$deleted) {
|
||||
return ['ok' => false, 'status' => 500, 'error' => 'delete_failed'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'department' => $department];
|
||||
}
|
||||
|
||||
private static function sanitizeBase(array $input): array
|
||||
{
|
||||
return [
|
||||
'description' => trim((string) ($input['description'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
private static function validateBase(array $form): array
|
||||
{
|
||||
$errors = [];
|
||||
if ($form['description'] === '') {
|
||||
$errors[] = t('Description cannot be empty');
|
||||
}
|
||||
return $errors;
|
||||
}
|
||||
}
|
||||
146
lib/Service/EmailVerificationService.php
Normal file
146
lib/Service/EmailVerificationService.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service;
|
||||
|
||||
use MintyPHP\Repository\EmailVerificationRepository;
|
||||
use MintyPHP\Repository\UserRepository;
|
||||
use MintyPHP\I18n;
|
||||
use MintyPHP\Http\Request;
|
||||
|
||||
class EmailVerificationService
|
||||
{
|
||||
private const CODE_LENGTH = 6;
|
||||
private const EXPIRY_MINUTES = 30;
|
||||
private const MAX_ATTEMPTS = 5;
|
||||
|
||||
public static function sendVerification(int $userId, ?string $locale = null): array
|
||||
{
|
||||
$user = UserRepository::find($userId);
|
||||
if (!$user || !isset($user['id'])) {
|
||||
return ['ok' => false, 'error' => 'user_not_found'];
|
||||
}
|
||||
|
||||
$email = (string) ($user['email'] ?? '');
|
||||
if ($email === '') {
|
||||
return ['ok' => false, 'error' => 'email_required'];
|
||||
}
|
||||
|
||||
EmailVerificationRepository::invalidateForUser($userId);
|
||||
|
||||
$code = self::generateCode();
|
||||
$codeHash = password_hash($code, PASSWORD_DEFAULT);
|
||||
$expiresAt = gmdate('Y-m-d H:i:s', time() + (self::EXPIRY_MINUTES * 60));
|
||||
|
||||
$verificationId = EmailVerificationRepository::create($userId, $codeHash, $expiresAt);
|
||||
if (!$verificationId) {
|
||||
return ['ok' => false, 'error' => 'create_failed'];
|
||||
}
|
||||
|
||||
$locale = $locale ?: ($user['locale'] ?? null) ?: (I18n::$locale ?? I18n::$defaultLocale);
|
||||
$name = trim(($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? ''));
|
||||
$isGerman = strpos((string) $locale, 'de') === 0;
|
||||
$greeting = $isGerman ? 'Hallo' : 'Hello';
|
||||
if ($name !== '') {
|
||||
$greeting .= ' ' . $name;
|
||||
}
|
||||
$greeting .= ',';
|
||||
$verifyPath = Request::withLocale('verify-email', $locale);
|
||||
$verifyUrl = appUrl($verifyPath);
|
||||
$previousLocale = I18n::$locale ?? null;
|
||||
I18n::$locale = $locale;
|
||||
$subject = t('Email verification code');
|
||||
I18n::$locale = $previousLocale;
|
||||
|
||||
$vars = [
|
||||
'app_name' => appTitle(),
|
||||
'app_logo_url' => appLogoUrlAbsolute(128),
|
||||
'imprint_url' => appUrl(Request::withLocale('imprint', $locale)),
|
||||
'privacy_url' => appUrl(Request::withLocale('privacy', $locale)),
|
||||
'code' => $code,
|
||||
'expires_minutes' => self::EXPIRY_MINUTES,
|
||||
'verify_url' => $verifyUrl,
|
||||
'greeting' => $greeting,
|
||||
];
|
||||
MailService::sendTemplate('email_verification', $vars, $email, $subject, $locale);
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
public static function verifyCode(string $email, string $code): array
|
||||
{
|
||||
$email = trim($email);
|
||||
$code = trim($code);
|
||||
if ($email === '' || $code === '') {
|
||||
return ['ok' => false, 'error' => 'invalid'];
|
||||
}
|
||||
|
||||
$user = UserRepository::findByEmail($email);
|
||||
if (!$user || !isset($user['id'])) {
|
||||
return ['ok' => false, 'error' => 'invalid'];
|
||||
}
|
||||
|
||||
$userId = (int) $user['id'];
|
||||
|
||||
// Check if already verified
|
||||
if (!empty($user['email_verified_at'])) {
|
||||
return ['ok' => false, 'error' => 'already_verified'];
|
||||
}
|
||||
|
||||
$verification = EmailVerificationRepository::findActiveByUserId($userId);
|
||||
if (!$verification || !isset($verification['id'])) {
|
||||
return ['ok' => false, 'error' => 'invalid'];
|
||||
}
|
||||
|
||||
$attempts = (int) ($verification['attempts'] ?? 0);
|
||||
if ($attempts >= self::MAX_ATTEMPTS) {
|
||||
return ['ok' => false, 'error' => 'too_many_attempts'];
|
||||
}
|
||||
|
||||
$hash = (string) ($verification['code_hash'] ?? '');
|
||||
if ($hash === '' || !password_verify($code, $hash)) {
|
||||
EmailVerificationRepository::incrementAttempts((int) $verification['id']);
|
||||
return ['ok' => false, 'error' => 'invalid'];
|
||||
}
|
||||
|
||||
// Mark verification as used
|
||||
EmailVerificationRepository::markUsed((int) $verification['id']);
|
||||
|
||||
// Mark user email as verified
|
||||
UserRepository::setEmailVerified($userId);
|
||||
|
||||
return ['ok' => true, 'user_id' => $userId];
|
||||
}
|
||||
|
||||
public static function resendVerification(string $email, ?string $locale = null): array
|
||||
{
|
||||
$email = trim($email);
|
||||
if ($email === '') {
|
||||
return ['ok' => false, 'error' => 'email_required'];
|
||||
}
|
||||
|
||||
$user = UserRepository::findByEmail($email);
|
||||
if (!$user || !isset($user['id'])) {
|
||||
// Don't reveal if user exists
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
// Check if already verified
|
||||
if (!empty($user['email_verified_at'])) {
|
||||
return ['ok' => false, 'error' => 'already_verified'];
|
||||
}
|
||||
|
||||
return self::sendVerification((int) $user['id'], $locale);
|
||||
}
|
||||
|
||||
public static function getExpiryMinutes(): int
|
||||
{
|
||||
return self::EXPIRY_MINUTES;
|
||||
}
|
||||
|
||||
private static function generateCode(): string
|
||||
{
|
||||
$max = (10 ** self::CODE_LENGTH) - 1;
|
||||
$code = (string) random_int(0, $max);
|
||||
return str_pad($code, self::CODE_LENGTH, '0', STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
18
lib/Service/MailLogService.php
Normal file
18
lib/Service/MailLogService.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service;
|
||||
|
||||
use MintyPHP\Repository\MailLogRepository;
|
||||
|
||||
class MailLogService
|
||||
{
|
||||
public static function listPaged(array $options): array
|
||||
{
|
||||
return MailLogRepository::listPaged($options);
|
||||
}
|
||||
|
||||
public static function find(int $id): ?array
|
||||
{
|
||||
return MailLogRepository::find($id);
|
||||
}
|
||||
}
|
||||
153
lib/Service/MailService.php
Normal file
153
lib/Service/MailService.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service;
|
||||
|
||||
use MintyPHP\Repository\MailLogRepository;
|
||||
use MintyPHP\I18n;
|
||||
use MintyPHP\Service\SettingService;
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
use PHPMailer\PHPMailer\Exception as MailerException;
|
||||
|
||||
class MailService
|
||||
{
|
||||
public static function sendTemplate(
|
||||
string $template,
|
||||
array $vars,
|
||||
string $to,
|
||||
string $subject,
|
||||
?string $locale = null
|
||||
): array {
|
||||
$locale = $locale ?: (I18n::$locale ?? I18n::$defaultLocale);
|
||||
[$html, $text] = self::renderTemplate($template, $vars, $locale);
|
||||
|
||||
return self::send($to, $subject, $html, $text, [
|
||||
'template' => $template,
|
||||
]);
|
||||
}
|
||||
|
||||
public static function send(
|
||||
string $to,
|
||||
string $subject,
|
||||
string $html,
|
||||
string $text,
|
||||
array $meta = []
|
||||
): array {
|
||||
$logId = MailLogRepository::create([
|
||||
'to_email' => $to,
|
||||
'subject' => $subject,
|
||||
'template' => $meta['template'] ?? null,
|
||||
'status' => 'queued',
|
||||
]);
|
||||
|
||||
if (!class_exists(PHPMailer::class)) {
|
||||
if ($logId) {
|
||||
MailLogRepository::markFailed($logId, 'PHPMailer is not installed');
|
||||
}
|
||||
return ['ok' => false, 'error' => 'mailer_missing'];
|
||||
}
|
||||
|
||||
try {
|
||||
$mailer = self::createMailer();
|
||||
$mailer->addAddress($to);
|
||||
$mailer->Subject = $subject;
|
||||
$mailer->Body = $html;
|
||||
$mailer->AltBody = $text;
|
||||
$mailer->isHTML(true);
|
||||
$mailer->send();
|
||||
|
||||
if ($logId) {
|
||||
MailLogRepository::markSent($logId, $mailer->getLastMessageID() ?: null);
|
||||
}
|
||||
return ['ok' => true];
|
||||
} catch (MailerException $e) {
|
||||
if ($logId) {
|
||||
MailLogRepository::markFailed($logId, $e->getMessage());
|
||||
}
|
||||
return ['ok' => false, 'error' => 'send_failed'];
|
||||
}
|
||||
}
|
||||
|
||||
private static function renderTemplate(string $template, array $vars, string $locale): array
|
||||
{
|
||||
$base = dirname(__DIR__, 2) . '/templates/emails';
|
||||
$htmlPath = $base . '/' . $locale . '/' . $template . '.html';
|
||||
$textPath = $base . '/' . $locale . '/' . $template . '.txt';
|
||||
$htmlHeaderPath = $base . '/' . $locale . '/partials/header.html';
|
||||
$htmlFooterPath = $base . '/' . $locale . '/partials/footer.html';
|
||||
$textHeaderPath = $base . '/' . $locale . '/partials/header.txt';
|
||||
$textFooterPath = $base . '/' . $locale . '/partials/footer.txt';
|
||||
|
||||
if (!is_file($htmlPath)) {
|
||||
$htmlPath = $base . '/' . $template . '.html';
|
||||
}
|
||||
if (!is_file($textPath)) {
|
||||
$textPath = $base . '/' . $template . '.txt';
|
||||
}
|
||||
if (!is_file($htmlHeaderPath)) {
|
||||
$htmlHeaderPath = $base . '/partials/header.html';
|
||||
}
|
||||
if (!is_file($htmlFooterPath)) {
|
||||
$htmlFooterPath = $base . '/partials/footer.html';
|
||||
}
|
||||
if (!is_file($textHeaderPath)) {
|
||||
$textHeaderPath = $base . '/partials/header.txt';
|
||||
}
|
||||
if (!is_file($textFooterPath)) {
|
||||
$textFooterPath = $base . '/partials/footer.txt';
|
||||
}
|
||||
|
||||
$html = is_file($htmlPath) ? file_get_contents($htmlPath) : '';
|
||||
$text = is_file($textPath) ? file_get_contents($textPath) : '';
|
||||
if (!isset($vars['email_header'])) {
|
||||
$vars['email_header'] = is_file($htmlHeaderPath) ? file_get_contents($htmlHeaderPath) : '';
|
||||
}
|
||||
if (!isset($vars['email_footer'])) {
|
||||
$vars['email_footer'] = is_file($htmlFooterPath) ? file_get_contents($htmlFooterPath) : '';
|
||||
}
|
||||
if (!isset($vars['email_header_text'])) {
|
||||
$vars['email_header_text'] = is_file($textHeaderPath) ? file_get_contents($textHeaderPath) : '';
|
||||
}
|
||||
if (!isset($vars['email_footer_text'])) {
|
||||
$vars['email_footer_text'] = is_file($textFooterPath) ? file_get_contents($textFooterPath) : '';
|
||||
}
|
||||
|
||||
foreach ($vars as $key => $value) {
|
||||
$placeholder = '{{' . $key . '}}';
|
||||
$html = str_replace($placeholder, (string) $value, $html);
|
||||
$text = str_replace($placeholder, (string) $value, $text);
|
||||
}
|
||||
// Second pass to resolve placeholders inside injected header/footer content.
|
||||
foreach ($vars as $key => $value) {
|
||||
$placeholder = '{{' . $key . '}}';
|
||||
$html = str_replace($placeholder, (string) $value, $html);
|
||||
$text = str_replace($placeholder, (string) $value, $text);
|
||||
}
|
||||
|
||||
return [$html, $text];
|
||||
}
|
||||
|
||||
private static function createMailer(): PHPMailer
|
||||
{
|
||||
$mailer = new PHPMailer(true);
|
||||
$mailer->isSMTP();
|
||||
$host = SettingService::getSmtpHost() ?? (getenv('SMTP_HOST') ?: '');
|
||||
$port = SettingService::getSmtpPort() ?? (int) (getenv('SMTP_PORT') ?: 587);
|
||||
$user = SettingService::getSmtpUser() ?? (getenv('SMTP_USER') ?: '');
|
||||
$pass = SettingService::getSmtpPassword() ?? (getenv('SMTP_PASS') ?: '');
|
||||
$secure = SettingService::getSmtpSecure() ?? strtolower((string) (getenv('SMTP_SECURE') ?: 'tls'));
|
||||
$from = SettingService::getSmtpFrom() ?? (getenv('SMTP_FROM') ?: ($user ?: 'no-reply@localhost'));
|
||||
$fromName = SettingService::getSmtpFromName() ?? (getenv('SMTP_FROM_NAME') ?: appTitle());
|
||||
|
||||
$mailer->Host = $host;
|
||||
$mailer->Port = $port;
|
||||
$mailer->SMTPAuth = $user !== '' || $pass !== '';
|
||||
$mailer->Username = $user;
|
||||
$mailer->Password = $pass;
|
||||
if (in_array($secure, ['tls', 'ssl'], true)) {
|
||||
$mailer->SMTPSecure = $secure;
|
||||
}
|
||||
$mailer->setFrom($from, $fromName);
|
||||
$mailer->CharSet = 'UTF-8';
|
||||
return $mailer;
|
||||
}
|
||||
}
|
||||
161
lib/Service/PageService.php
Normal file
161
lib/Service/PageService.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service;
|
||||
|
||||
use MintyPHP\Repository\PageRepository;
|
||||
use MintyPHP\Repository\PageContentRepository;
|
||||
use MintyPHP\I18n;
|
||||
|
||||
class PageService
|
||||
{
|
||||
public static function findBySlug(string $slug): ?array
|
||||
{
|
||||
$slug = trim($slug);
|
||||
if ($slug === '') {
|
||||
return null;
|
||||
}
|
||||
return PageRepository::findBySlug($slug);
|
||||
}
|
||||
|
||||
public static function findBySlugWithLocale(string $slug, ?string $locale = null): ?array
|
||||
{
|
||||
$page = self::findBySlug($slug);
|
||||
if (!$page || !isset($page['id'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$locale = $locale ?: (I18n::$locale ?? I18n::$defaultLocale);
|
||||
$content = PageContentRepository::findByPageIdAndLocale((int) $page['id'], $locale);
|
||||
$usedLocale = $locale;
|
||||
|
||||
$fallbackLocale = I18n::$defaultLocale;
|
||||
if (defined('APP_LOCALES') && is_array(APP_LOCALES) && count(APP_LOCALES) > 0) {
|
||||
$fallbackLocale = APP_LOCALES[0];
|
||||
}
|
||||
|
||||
if (!$content && $fallbackLocale !== $locale) {
|
||||
$content = PageContentRepository::findByPageIdAndLocale((int) $page['id'], $fallbackLocale);
|
||||
if ($content) {
|
||||
$usedLocale = $fallbackLocale;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$content) {
|
||||
$content = [
|
||||
'locale' => $usedLocale,
|
||||
'content' => null,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'page' => $page,
|
||||
'content' => $content,
|
||||
'locale' => $usedLocale,
|
||||
];
|
||||
}
|
||||
|
||||
public static function updateContentBySlug(
|
||||
string $slug,
|
||||
string $content,
|
||||
int $currentUserId = 0,
|
||||
?string $locale = null
|
||||
): array
|
||||
{
|
||||
$slug = trim($slug);
|
||||
if ($slug === '') {
|
||||
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
|
||||
}
|
||||
|
||||
$page = PageRepository::findBySlug($slug);
|
||||
if (!$page || !isset($page['id'])) {
|
||||
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
|
||||
}
|
||||
|
||||
$locale = $locale ?: (I18n::$locale ?? I18n::$defaultLocale);
|
||||
|
||||
$content = trim($content);
|
||||
$normalized = null;
|
||||
if ($content !== '') {
|
||||
$decoded = json_decode($content, true);
|
||||
if (!is_array($decoded)) {
|
||||
return ['ok' => false, 'errors' => [t('Content is invalid')]];
|
||||
}
|
||||
if (!isset($decoded['blocks']) || !is_array($decoded['blocks'])) {
|
||||
$decoded['blocks'] = [];
|
||||
}
|
||||
$normalized = json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
$existing = PageContentRepository::findByPageIdAndLocale((int) $page['id'], $locale);
|
||||
$updated = false;
|
||||
if ($existing && isset($existing['id'])) {
|
||||
$updated = PageContentRepository::update((int) $existing['id'], [
|
||||
'content' => $normalized,
|
||||
'modified_by' => $currentUserId > 0 ? $currentUserId : null,
|
||||
]);
|
||||
} else {
|
||||
$createdId = PageContentRepository::create([
|
||||
'page_id' => (int) $page['id'],
|
||||
'locale' => $locale,
|
||||
'content' => $normalized,
|
||||
'created_by' => $currentUserId > 0 ? $currentUserId : null,
|
||||
]);
|
||||
$updated = (bool) $createdId;
|
||||
}
|
||||
|
||||
if (!$updated) {
|
||||
return ['ok' => false, 'errors' => [t('Page can not be updated')]];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'page' => $page];
|
||||
}
|
||||
|
||||
public static function copyContentToLocale(
|
||||
string $slug,
|
||||
string $fromLocale,
|
||||
string $toLocale,
|
||||
int $currentUserId = 0
|
||||
): array {
|
||||
$slug = trim($slug);
|
||||
$fromLocale = trim($fromLocale);
|
||||
$toLocale = trim($toLocale);
|
||||
if ($slug === '' || $fromLocale === '' || $toLocale === '') {
|
||||
return ['ok' => false, 'errors' => [t('Target language required')]];
|
||||
}
|
||||
|
||||
$page = PageRepository::findBySlug($slug);
|
||||
if (!$page || !isset($page['id'])) {
|
||||
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
|
||||
}
|
||||
|
||||
$source = PageContentRepository::findByPageIdAndLocale((int) $page['id'], $fromLocale);
|
||||
if (!$source) {
|
||||
return ['ok' => false, 'errors' => [t('Source content not found')]];
|
||||
}
|
||||
|
||||
$target = PageContentRepository::findByPageIdAndLocale((int) $page['id'], $toLocale);
|
||||
|
||||
$content = $source['content'] ?? null;
|
||||
$updated = false;
|
||||
if ($target && isset($target['id'])) {
|
||||
$updated = PageContentRepository::update((int) $target['id'], [
|
||||
'content' => $content,
|
||||
'modified_by' => $currentUserId > 0 ? $currentUserId : null,
|
||||
]);
|
||||
} else {
|
||||
$createdId = PageContentRepository::create([
|
||||
'page_id' => (int) $page['id'],
|
||||
'locale' => $toLocale,
|
||||
'content' => $content,
|
||||
'created_by' => $currentUserId > 0 ? $currentUserId : null,
|
||||
]);
|
||||
$updated = (bool) $createdId;
|
||||
}
|
||||
|
||||
if (!$updated) {
|
||||
return ['ok' => false, 'errors' => [t('Page can not be updated')]];
|
||||
}
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
}
|
||||
147
lib/Service/PasswordResetService.php
Normal file
147
lib/Service/PasswordResetService.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service;
|
||||
|
||||
use MintyPHP\Repository\PasswordResetRepository;
|
||||
use MintyPHP\Repository\UserRepository;
|
||||
use MintyPHP\I18n;
|
||||
use MintyPHP\Http\Request;
|
||||
use MintyPHP\Service\RememberMeService;
|
||||
|
||||
class PasswordResetService
|
||||
{
|
||||
private const CODE_LENGTH = 6;
|
||||
private const EXPIRY_MINUTES = 15;
|
||||
private const MAX_ATTEMPTS = 5;
|
||||
|
||||
public static function requestReset(string $email, ?string $locale = null): array
|
||||
{
|
||||
$email = trim($email);
|
||||
if ($email === '') {
|
||||
return ['ok' => false, 'error' => 'email_required'];
|
||||
}
|
||||
|
||||
$user = UserRepository::findByEmail($email);
|
||||
if (!$user || !isset($user['id'])) {
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
$userId = (int) $user['id'];
|
||||
PasswordResetRepository::invalidateForUser($userId);
|
||||
|
||||
$code = self::generateCode();
|
||||
$codeHash = password_hash($code, PASSWORD_DEFAULT);
|
||||
$expiresAt = gmdate('Y-m-d H:i:s', time() + (self::EXPIRY_MINUTES * 60));
|
||||
|
||||
$resetId = PasswordResetRepository::create($userId, $codeHash, $expiresAt);
|
||||
if (!$resetId) {
|
||||
return ['ok' => false, 'error' => 'create_failed'];
|
||||
}
|
||||
|
||||
$locale = $locale ?: ($user['locale'] ?? null) ?: (I18n::$locale ?? I18n::$defaultLocale);
|
||||
$name = trim(($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? ''));
|
||||
$isGerman = strpos((string) $locale, 'de') === 0;
|
||||
$greeting = $isGerman ? 'Hallo' : 'Hello';
|
||||
if ($name !== '') {
|
||||
$greeting .= ' ' . $name;
|
||||
}
|
||||
$greeting .= ',';
|
||||
$verifyPath = Request::withLocale('password/verify', $locale);
|
||||
$verifyUrl = appUrl($verifyPath);
|
||||
$previousLocale = I18n::$locale ?? null;
|
||||
I18n::$locale = $locale;
|
||||
$subject = t('Password reset code');
|
||||
I18n::$locale = $previousLocale;
|
||||
|
||||
$vars = [
|
||||
'app_name' => appTitle(),
|
||||
'app_logo_url' => appLogoUrlAbsolute(128),
|
||||
'imprint_url' => appUrl(Request::withLocale('imprint', $locale)),
|
||||
'privacy_url' => appUrl(Request::withLocale('privacy', $locale)),
|
||||
'code' => $code,
|
||||
'expires_minutes' => self::EXPIRY_MINUTES,
|
||||
'verify_url' => $verifyUrl,
|
||||
'greeting' => $greeting,
|
||||
];
|
||||
MailService::sendTemplate('reset_code', $vars, $email, $subject, $locale);
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
public static function verifyCode(string $email, string $code): array
|
||||
{
|
||||
$email = trim($email);
|
||||
$code = trim($code);
|
||||
if ($email === '' || $code === '') {
|
||||
return ['ok' => false, 'error' => 'invalid'];
|
||||
}
|
||||
|
||||
$user = UserRepository::findByEmail($email);
|
||||
if (!$user || !isset($user['id'])) {
|
||||
return ['ok' => false, 'error' => 'invalid'];
|
||||
}
|
||||
|
||||
$reset = PasswordResetRepository::findActiveByUserId((int) $user['id']);
|
||||
if (!$reset || !isset($reset['id'])) {
|
||||
return ['ok' => false, 'error' => 'invalid'];
|
||||
}
|
||||
|
||||
$attempts = (int) ($reset['attempts'] ?? 0);
|
||||
if ($attempts >= self::MAX_ATTEMPTS) {
|
||||
return ['ok' => false, 'error' => 'too_many_attempts'];
|
||||
}
|
||||
|
||||
$hash = (string) ($reset['code_hash'] ?? '');
|
||||
if ($hash === '' || !password_verify($code, $hash)) {
|
||||
PasswordResetRepository::incrementAttempts((int) $reset['id']);
|
||||
return ['ok' => false, 'error' => 'invalid'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'reset_id' => (int) $reset['id'], 'user_id' => (int) $user['id']];
|
||||
}
|
||||
|
||||
public static function resetPassword(int $resetId, string $password, string $password2): array
|
||||
{
|
||||
$reset = PasswordResetRepository::findById($resetId);
|
||||
if (!$reset || !isset($reset['id'])) {
|
||||
return ['ok' => false, 'errors' => [t('Reset request not found')]];
|
||||
}
|
||||
|
||||
if (!empty($reset['used_at'])) {
|
||||
return ['ok' => false, 'errors' => [t('Reset request already used')]];
|
||||
}
|
||||
|
||||
$expiresAt = (string) ($reset['expires_at'] ?? '');
|
||||
if ($expiresAt !== '') {
|
||||
try {
|
||||
$expiry = new \DateTimeImmutable($expiresAt, new \DateTimeZone('UTC'));
|
||||
if ($expiry->getTimestamp() <= time()) {
|
||||
return ['ok' => false, 'errors' => [t('Reset request expired')]];
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return ['ok' => false, 'errors' => [t('Reset request expired')]];
|
||||
}
|
||||
}
|
||||
|
||||
$userId = (int) ($reset['user_id'] ?? 0);
|
||||
if ($userId <= 0) {
|
||||
return ['ok' => false, 'errors' => [t('Reset request not found')]];
|
||||
}
|
||||
|
||||
$result = UserService::resetPassword($userId, $password, $password2);
|
||||
if (!($result['ok'] ?? false)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
PasswordResetRepository::markUsed($resetId);
|
||||
RememberMeService::forgetAllForUser($userId);
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
private static function generateCode(): string
|
||||
{
|
||||
$max = (10 ** self::CODE_LENGTH) - 1;
|
||||
$code = (string) random_int(0, $max);
|
||||
return str_pad($code, self::CODE_LENGTH, '0', STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
180
lib/Service/PermissionService.php
Normal file
180
lib/Service/PermissionService.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service;
|
||||
|
||||
use MintyPHP\Repository\RolePermissionRepository;
|
||||
use MintyPHP\Repository\UserRoleRepository;
|
||||
use MintyPHP\Repository\PermissionRepository;
|
||||
|
||||
class PermissionService
|
||||
{
|
||||
public const USERS_CREATE = 'users.create';
|
||||
public const USERS_DELETE = 'users.delete';
|
||||
public const USERS_VIEW = 'users.view';
|
||||
public const USERS_UPDATE = 'users.update';
|
||||
public const USERS_SELF_UPDATE = 'users.self_update';
|
||||
public const USERS_UPDATE_ASSIGNMENTS = 'users.update_assignments';
|
||||
public const ADDRESS_BOOK_VIEW = 'address_book.view';
|
||||
public const TENANTS_VIEW = 'tenants.view';
|
||||
public const TENANTS_CREATE = 'tenants.create';
|
||||
public const TENANTS_UPDATE = 'tenants.update';
|
||||
public const TENANTS_DELETE = 'tenants.delete';
|
||||
public const DEPARTMENTS_VIEW = 'departments.view';
|
||||
public const DEPARTMENTS_CREATE = 'departments.create';
|
||||
public const DEPARTMENTS_UPDATE = 'departments.update';
|
||||
public const DEPARTMENTS_DELETE = 'departments.delete';
|
||||
public const ROLES_VIEW = 'roles.view';
|
||||
public const ROLES_CREATE = 'roles.create';
|
||||
public const ROLES_UPDATE = 'roles.update';
|
||||
public const ROLES_DELETE = 'roles.delete';
|
||||
public const PERMISSIONS_VIEW = 'permissions.view';
|
||||
public const PERMISSIONS_CREATE = 'permissions.create';
|
||||
public const PERMISSIONS_UPDATE = 'permissions.update';
|
||||
public const PERMISSIONS_DELETE = 'permissions.delete';
|
||||
public const SETTINGS_VIEW = 'settings.view';
|
||||
public const SETTINGS_UPDATE = 'settings.update';
|
||||
public const MAIL_LOG_VIEW = 'mail_log.view';
|
||||
public const STATS_VIEW = 'stats.view';
|
||||
|
||||
public static function userHas(int $userId, string $permissionKey): bool
|
||||
{
|
||||
$permissionKey = trim((string) $permissionKey);
|
||||
if ($userId <= 0 || $permissionKey === '') {
|
||||
return false;
|
||||
}
|
||||
$keys = self::getUserPermissions($userId);
|
||||
return in_array($permissionKey, $keys, true);
|
||||
}
|
||||
|
||||
public static function getUserPermissions(int $userId, bool $refresh = false): array
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return [];
|
||||
}
|
||||
$cache = $_SESSION['permissions'] ?? null;
|
||||
if ($refresh) {
|
||||
$cache = null;
|
||||
}
|
||||
if (is_array($cache) && (int) ($cache['user_id'] ?? 0) === $userId) {
|
||||
$keys = $cache['keys'] ?? [];
|
||||
if (is_array($keys)) {
|
||||
return $keys;
|
||||
}
|
||||
}
|
||||
$roleIds = UserRoleRepository::listRoleIdsByUserId($userId);
|
||||
$keys = RolePermissionRepository::listPermissionKeysByRoleIds($roleIds);
|
||||
$_SESSION['permissions'] = [
|
||||
'user_id' => $userId,
|
||||
'keys' => $keys,
|
||||
];
|
||||
return $keys;
|
||||
}
|
||||
|
||||
public static function getCachedPermissions(int $userId): array
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return [];
|
||||
}
|
||||
$cache = $_SESSION['permissions'] ?? null;
|
||||
if (is_array($cache) && (int) ($cache['user_id'] ?? 0) === $userId) {
|
||||
$keys = $cache['keys'] ?? [];
|
||||
return is_array($keys) ? $keys : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
public static function clearUserCache(int $userId): void
|
||||
{
|
||||
$cache = $_SESSION['permissions'] ?? null;
|
||||
if (is_array($cache) && (int) ($cache['user_id'] ?? 0) === $userId) {
|
||||
unset($_SESSION['permissions']);
|
||||
}
|
||||
}
|
||||
|
||||
public static function list(): array
|
||||
{
|
||||
return PermissionRepository::list();
|
||||
}
|
||||
|
||||
public static function listPaged(array $options): array
|
||||
{
|
||||
return PermissionRepository::listPaged($options);
|
||||
}
|
||||
|
||||
public static function find(int $id): ?array
|
||||
{
|
||||
return PermissionRepository::find($id);
|
||||
}
|
||||
|
||||
public static function findByKey(string $key): ?array
|
||||
{
|
||||
return PermissionRepository::findByKey($key);
|
||||
}
|
||||
|
||||
public static function createFromAdmin(array $input): array
|
||||
{
|
||||
$form = self::sanitizeBase($input);
|
||||
$errors = self::validateBase($form);
|
||||
if ($errors) {
|
||||
return ['ok' => false, 'errors' => $errors, 'form' => $form];
|
||||
}
|
||||
if (PermissionRepository::findByKey($form['key'])) {
|
||||
return ['ok' => false, 'errors' => [t('Permission key already exists')], 'form' => $form];
|
||||
}
|
||||
$createdId = PermissionRepository::create($form);
|
||||
if (!$createdId) {
|
||||
return ['ok' => false, 'errors' => [t('Permission can not be created')], 'form' => $form];
|
||||
}
|
||||
return ['ok' => true, 'form' => $form, 'id' => (int) $createdId];
|
||||
}
|
||||
|
||||
public static function updateFromAdmin(int $id, array $input): array
|
||||
{
|
||||
$form = self::sanitizeBase($input);
|
||||
$errors = self::validateBase($form);
|
||||
if ($errors) {
|
||||
return ['ok' => false, 'errors' => $errors, 'form' => $form];
|
||||
}
|
||||
$existing = PermissionRepository::findByKey($form['key']);
|
||||
if ($existing && (int) ($existing['id'] ?? 0) !== $id) {
|
||||
return ['ok' => false, 'errors' => [t('Permission key already exists')], 'form' => $form];
|
||||
}
|
||||
$updated = PermissionRepository::update($id, $form);
|
||||
if (!$updated) {
|
||||
return ['ok' => false, 'errors' => [t('Permission can not be updated')], 'form' => $form];
|
||||
}
|
||||
return ['ok' => true, 'form' => $form];
|
||||
}
|
||||
|
||||
public static function deleteById(int $id): array
|
||||
{
|
||||
$permission = PermissionRepository::find($id);
|
||||
if (!$permission) {
|
||||
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
|
||||
}
|
||||
$deleted = PermissionRepository::delete($id);
|
||||
if (!$deleted) {
|
||||
return ['ok' => false, 'status' => 500, 'error' => 'delete_failed'];
|
||||
}
|
||||
return ['ok' => true, 'permission' => $permission];
|
||||
}
|
||||
|
||||
private static function sanitizeBase(array $input): array
|
||||
{
|
||||
return [
|
||||
'key' => trim((string) ($input['key'] ?? '')),
|
||||
'description' => trim((string) ($input['description'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
private static function validateBase(array $form): array
|
||||
{
|
||||
$errors = [];
|
||||
if ($form['key'] === '') {
|
||||
$errors[] = t('Permission key cannot be empty');
|
||||
} elseif (!preg_match('/^[a-z0-9._-]+$/i', $form['key'])) {
|
||||
$errors[] = t('Permission key is invalid');
|
||||
}
|
||||
return $errors;
|
||||
}
|
||||
}
|
||||
157
lib/Service/RememberMeService.php
Normal file
157
lib/Service/RememberMeService.php
Normal file
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service;
|
||||
|
||||
use MintyPHP\Session;
|
||||
use MintyPHP\Repository\RememberTokenRepository;
|
||||
use MintyPHP\Repository\UserRepository;
|
||||
use MintyPHP\I18n;
|
||||
use MintyPHP\Service\PermissionService;
|
||||
use MintyPHP\Service\AuthService;
|
||||
|
||||
class RememberMeService
|
||||
{
|
||||
private const COOKIE_NAME = 'remember';
|
||||
private const LIFETIME_DAYS = 30;
|
||||
|
||||
public static function rememberUser(int $userId): void
|
||||
{
|
||||
$selector = bin2hex(random_bytes(12));
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$tokenHash = hash('sha256', $token);
|
||||
$expiresAt = gmdate('Y-m-d H:i:s', time() + self::lifetimeSeconds());
|
||||
RememberTokenRepository::create($userId, $selector, $tokenHash, $expiresAt);
|
||||
self::setCookie($selector, $token);
|
||||
}
|
||||
|
||||
public static function autoLoginFromCookie(): bool
|
||||
{
|
||||
if (!empty($_SESSION['user']['id'])) {
|
||||
return false;
|
||||
}
|
||||
$value = $_COOKIE[self::cookieName()] ?? '';
|
||||
if ($value === '' || strpos($value, ':') === false) {
|
||||
return false;
|
||||
}
|
||||
[$selector, $token] = explode(':', $value, 2);
|
||||
$selector = trim($selector);
|
||||
$token = trim($token);
|
||||
if ($selector === '' || $token === '') {
|
||||
self::clearCookie();
|
||||
return false;
|
||||
}
|
||||
|
||||
$record = RememberTokenRepository::findBySelector($selector);
|
||||
if (!$record) {
|
||||
self::clearCookie();
|
||||
return false;
|
||||
}
|
||||
|
||||
$expiresAt = (string) ($record['expires_at'] ?? '');
|
||||
if ($expiresAt !== '' && strtotime($expiresAt . ' UTC') <= time()) {
|
||||
RememberTokenRepository::deleteById((int) $record['id']);
|
||||
self::clearCookie();
|
||||
return false;
|
||||
}
|
||||
|
||||
$hash = (string) ($record['token_hash'] ?? '');
|
||||
if ($hash === '' || !hash_equals($hash, hash('sha256', $token))) {
|
||||
RememberTokenRepository::deleteById((int) $record['id']);
|
||||
self::clearCookie();
|
||||
return false;
|
||||
}
|
||||
|
||||
$userId = (int) ($record['user_id'] ?? 0);
|
||||
if ($userId <= 0) {
|
||||
self::clearCookie();
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = UserRepository::find($userId);
|
||||
if (!$user || empty($user['id']) || !($user['active'] ?? 1)) {
|
||||
RememberTokenRepository::deleteById((int) $record['id']);
|
||||
self::clearCookie();
|
||||
return false;
|
||||
}
|
||||
|
||||
Session::regenerate();
|
||||
$_SESSION['user'] = $user;
|
||||
if (!empty($user['locale'])) {
|
||||
I18n::$locale = (string) $user['locale'];
|
||||
}
|
||||
$userId = (int) ($user['id'] ?? 0);
|
||||
if ($userId > 0) {
|
||||
PermissionService::getUserPermissions($userId, true);
|
||||
AuthService::loadTenantDataIntoSession($userId);
|
||||
}
|
||||
|
||||
$newToken = bin2hex(random_bytes(32));
|
||||
$newHash = hash('sha256', $newToken);
|
||||
$newExpires = gmdate('Y-m-d H:i:s', time() + self::lifetimeSeconds());
|
||||
RememberTokenRepository::updateToken((int) $record['id'], $newHash, $newExpires);
|
||||
self::setCookie($selector, $newToken);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function forgetCurrentUser(): void
|
||||
{
|
||||
$value = $_COOKIE[self::cookieName()] ?? '';
|
||||
if ($value !== '' && strpos($value, ':') !== false) {
|
||||
[$selector] = explode(':', $value, 2);
|
||||
$selector = trim($selector);
|
||||
if ($selector !== '') {
|
||||
$record = RememberTokenRepository::findBySelector($selector);
|
||||
if ($record && isset($record['id'])) {
|
||||
RememberTokenRepository::deleteById((int) $record['id']);
|
||||
}
|
||||
}
|
||||
}
|
||||
self::clearCookie();
|
||||
}
|
||||
|
||||
public static function forgetAllForUser(int $userId): void
|
||||
{
|
||||
RememberTokenRepository::deleteByUserId($userId);
|
||||
}
|
||||
|
||||
private static function setCookie(string $selector, string $token): void
|
||||
{
|
||||
$value = $selector . ':' . $token;
|
||||
$expires = time() + self::lifetimeSeconds();
|
||||
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
|
||||
|
||||
setcookie(self::cookieName(), $value, [
|
||||
'expires' => $expires,
|
||||
'path' => '/',
|
||||
'secure' => $secure,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
}
|
||||
|
||||
private static function clearCookie(): void
|
||||
{
|
||||
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
|
||||
setcookie(self::cookieName(), '', [
|
||||
'expires' => time() - 3600,
|
||||
'path' => '/',
|
||||
'secure' => $secure,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
}
|
||||
|
||||
private static function lifetimeSeconds(): int
|
||||
{
|
||||
return self::LIFETIME_DAYS * 24 * 60 * 60;
|
||||
}
|
||||
|
||||
private static function cookieName(): string
|
||||
{
|
||||
$name = getenv('REMEMBER_COOKIE_NAME') ?: self::COOKIE_NAME;
|
||||
return trim($name) !== '' ? $name : self::COOKIE_NAME;
|
||||
}
|
||||
}
|
||||
116
lib/Service/RoleService.php
Normal file
116
lib/Service/RoleService.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service;
|
||||
|
||||
use MintyPHP\Repository\RoleRepository;
|
||||
use MintyPHP\Service\SettingService;
|
||||
|
||||
class RoleService
|
||||
{
|
||||
public static function list(): array
|
||||
{
|
||||
return RoleRepository::list();
|
||||
}
|
||||
|
||||
public static function listPaged(array $options): array
|
||||
{
|
||||
return RoleRepository::listPaged($options);
|
||||
}
|
||||
|
||||
public static function findByUuid(string $uuid): ?array
|
||||
{
|
||||
return RoleRepository::findByUuid($uuid);
|
||||
}
|
||||
|
||||
public static function findById(int $id): ?array
|
||||
{
|
||||
return RoleRepository::find($id);
|
||||
}
|
||||
|
||||
public static function createFromAdmin(array $input, int $currentUserId = 0): array
|
||||
{
|
||||
$form = self::sanitizeBase($input);
|
||||
$errors = self::validateBase($form);
|
||||
|
||||
if ($errors) {
|
||||
return ['ok' => false, 'errors' => $errors, 'form' => $form];
|
||||
}
|
||||
|
||||
$createdId = RoleRepository::create([
|
||||
'description' => $form['description'],
|
||||
'created_by' => $currentUserId > 0 ? $currentUserId : null,
|
||||
]);
|
||||
|
||||
if (!$createdId) {
|
||||
return ['ok' => false, 'errors' => [t('Role can not be created')], 'form' => $form];
|
||||
}
|
||||
|
||||
$createdRole = RoleRepository::find((int) $createdId);
|
||||
$uuid = $createdRole['uuid'] ?? null;
|
||||
if (!empty($input['is_default']) && $createdId) {
|
||||
SettingService::setDefaultRoleId((int) $createdId);
|
||||
}
|
||||
return ['ok' => true, 'form' => $form, 'uuid' => $uuid, 'id' => (int) $createdId];
|
||||
}
|
||||
|
||||
public static function updateFromAdmin(int $roleId, array $input, int $currentUserId = 0): array
|
||||
{
|
||||
$form = self::sanitizeBase($input);
|
||||
$errors = self::validateBase($form);
|
||||
|
||||
if ($errors) {
|
||||
return ['ok' => false, 'errors' => $errors, 'form' => $form];
|
||||
}
|
||||
|
||||
$updated = RoleRepository::update($roleId, [
|
||||
'description' => $form['description'],
|
||||
'modified_by' => $currentUserId > 0 ? $currentUserId : null,
|
||||
]);
|
||||
|
||||
if (!$updated) {
|
||||
return ['ok' => false, 'errors' => [t('Role can not be updated')], 'form' => $form];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'form' => $form];
|
||||
}
|
||||
|
||||
public static function deleteByUuid(string $uuid): array
|
||||
{
|
||||
$uuid = trim($uuid);
|
||||
if ($uuid === '') {
|
||||
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
|
||||
}
|
||||
|
||||
$role = RoleRepository::findByUuid($uuid);
|
||||
if (!$role || !isset($role['id'])) {
|
||||
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
|
||||
}
|
||||
|
||||
if (($role['description'] ?? '') === 'Admin') {
|
||||
return ['ok' => false, 'status' => 403, 'error' => 'admin_role_protected'];
|
||||
}
|
||||
|
||||
$deleted = RoleRepository::delete((int) $role['id']);
|
||||
if (!$deleted) {
|
||||
return ['ok' => false, 'status' => 500, 'error' => 'delete_failed'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'role' => $role];
|
||||
}
|
||||
|
||||
private static function sanitizeBase(array $input): array
|
||||
{
|
||||
return [
|
||||
'description' => trim((string) ($input['description'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
private static function validateBase(array $form): array
|
||||
{
|
||||
$errors = [];
|
||||
if ($form['description'] === '') {
|
||||
$errors[] = t('Description cannot be empty');
|
||||
}
|
||||
return $errors;
|
||||
}
|
||||
}
|
||||
340
lib/Service/SettingService.php
Normal file
340
lib/Service/SettingService.php
Normal file
@@ -0,0 +1,340 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service;
|
||||
|
||||
use MintyPHP\Repository\SettingRepository;
|
||||
use MintyPHP\Repository\TenantRepository;
|
||||
use MintyPHP\Repository\RoleRepository;
|
||||
use MintyPHP\Repository\DepartmentRepository;
|
||||
|
||||
class SettingService
|
||||
{
|
||||
public const DEFAULT_TENANT_KEY = 'default_tenant_id';
|
||||
public const DEFAULT_ROLE_KEY = 'default_role_id';
|
||||
public const DEFAULT_DEPARTMENT_KEY = 'default_department_id';
|
||||
public const APP_TITLE_KEY = 'app_title';
|
||||
public const APP_LOCALE_KEY = 'app_locale';
|
||||
public const APP_THEME_KEY = 'app_theme';
|
||||
public const APP_THEME_USER_KEY = 'app_theme_user';
|
||||
public const APP_PRIMARY_COLOR_KEY = 'app_primary_color';
|
||||
public const SMTP_HOST_KEY = 'smtp_host';
|
||||
public const SMTP_PORT_KEY = 'smtp_port';
|
||||
public const SMTP_USER_KEY = 'smtp_user';
|
||||
public const SMTP_PASSWORD_KEY = 'smtp_password';
|
||||
public const SMTP_SECURE_KEY = 'smtp_secure';
|
||||
public const SMTP_FROM_KEY = 'smtp_from';
|
||||
public const SMTP_FROM_NAME_KEY = 'smtp_from_name';
|
||||
|
||||
public static function get(string $key): ?array
|
||||
{
|
||||
return SettingRepository::find($key);
|
||||
}
|
||||
|
||||
public static function getValue(string $key): ?string
|
||||
{
|
||||
return SettingRepository::getValue($key);
|
||||
}
|
||||
|
||||
public static function getInt(string $key): ?int
|
||||
{
|
||||
$value = SettingRepository::getValue($key);
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
public static function set(string $key, ?string $value, ?string $description = null): bool
|
||||
{
|
||||
return SettingRepository::set($key, $value, $description);
|
||||
}
|
||||
|
||||
public static function getDefaultTenantId(): ?int
|
||||
{
|
||||
$id = self::getInt(self::DEFAULT_TENANT_KEY);
|
||||
return $id && $id > 0 ? $id : null;
|
||||
}
|
||||
|
||||
public static function setDefaultTenantId(?int $tenantId, ?string $description = null): bool
|
||||
{
|
||||
$value = $tenantId && $tenantId > 0 ? (string) $tenantId : null;
|
||||
if ($value !== null) {
|
||||
$exists = TenantRepository::find($tenantId ?? 0);
|
||||
if (!$exists) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
$desc = $description ?? 'setting.default_tenant';
|
||||
return SettingRepository::set(self::DEFAULT_TENANT_KEY, $value, $desc);
|
||||
}
|
||||
|
||||
public static function getDefaultRoleId(): ?int
|
||||
{
|
||||
$id = self::getInt(self::DEFAULT_ROLE_KEY);
|
||||
return $id && $id > 0 ? $id : null;
|
||||
}
|
||||
|
||||
public static function setDefaultRoleId(?int $roleId, ?string $description = null): bool
|
||||
{
|
||||
$value = $roleId && $roleId > 0 ? (string) $roleId : null;
|
||||
if ($value !== null) {
|
||||
$exists = RoleRepository::find($roleId ?? 0);
|
||||
if (!$exists) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
$desc = $description ?? 'setting.default_role';
|
||||
return SettingRepository::set(self::DEFAULT_ROLE_KEY, $value, $desc);
|
||||
}
|
||||
|
||||
public static function getDefaultDepartmentId(): ?int
|
||||
{
|
||||
$id = self::getInt(self::DEFAULT_DEPARTMENT_KEY);
|
||||
return $id && $id > 0 ? $id : null;
|
||||
}
|
||||
|
||||
public static function setDefaultDepartmentId(?int $departmentId, ?string $description = null): bool
|
||||
{
|
||||
$value = $departmentId && $departmentId > 0 ? (string) $departmentId : null;
|
||||
if ($value !== null) {
|
||||
$exists = DepartmentRepository::find($departmentId ?? 0);
|
||||
if (!$exists) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
$desc = $description ?? 'setting.default_department';
|
||||
return SettingRepository::set(self::DEFAULT_DEPARTMENT_KEY, $value, $desc);
|
||||
}
|
||||
|
||||
public static function getAppTitle(): ?string
|
||||
{
|
||||
$value = SettingRepository::getValue(self::APP_TITLE_KEY);
|
||||
$value = $value !== null ? trim($value) : null;
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
public static function setAppTitle(?string $title, ?string $description = null): bool
|
||||
{
|
||||
$value = $title !== null ? trim($title) : null;
|
||||
if ($value === '') {
|
||||
$value = null;
|
||||
}
|
||||
$desc = $description ?? 'setting.app_title';
|
||||
return SettingRepository::set(self::APP_TITLE_KEY, $value, $desc);
|
||||
}
|
||||
|
||||
public static function getAppLocale(): ?string
|
||||
{
|
||||
$value = SettingRepository::getValue(self::APP_LOCALE_KEY);
|
||||
$value = $value !== null ? trim((string) $value) : '';
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
public static function setAppLocale(?string $locale, ?string $description = null): bool
|
||||
{
|
||||
$value = $locale !== null ? trim((string) $locale) : '';
|
||||
if ($value === '') {
|
||||
$value = null;
|
||||
} else {
|
||||
$allowed = defined('APP_LOCALES') ? APP_LOCALES : [];
|
||||
if ($allowed && !in_array($value, $allowed, true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
$desc = $description ?? 'setting.app_locale';
|
||||
return SettingRepository::set(self::APP_LOCALE_KEY, $value, $desc);
|
||||
}
|
||||
|
||||
public static function getAppTheme(): ?string
|
||||
{
|
||||
$value = SettingRepository::getValue(self::APP_THEME_KEY);
|
||||
$value = $value !== null ? strtolower(trim((string) $value)) : '';
|
||||
if (!in_array($value, ['light', 'dark'], true)) {
|
||||
return null;
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
public static function setAppTheme(?string $theme, ?string $description = null): bool
|
||||
{
|
||||
$value = $theme !== null ? strtolower(trim((string) $theme)) : '';
|
||||
if ($value === '' || !in_array($value, ['light', 'dark'], true)) {
|
||||
$value = null;
|
||||
}
|
||||
$desc = $description ?? 'setting.app_theme';
|
||||
return SettingRepository::set(self::APP_THEME_KEY, $value, $desc);
|
||||
}
|
||||
|
||||
public static function isUserThemeAllowed(): bool
|
||||
{
|
||||
$value = SettingRepository::getValue(self::APP_THEME_USER_KEY);
|
||||
if ($value === null || $value === '') {
|
||||
return true;
|
||||
}
|
||||
return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
public static function setUserThemeAllowed(bool $allowed, ?string $description = null): bool
|
||||
{
|
||||
$value = $allowed ? '1' : '0';
|
||||
$desc = $description ?? 'setting.app_theme_user';
|
||||
return SettingRepository::set(self::APP_THEME_USER_KEY, $value, $desc);
|
||||
}
|
||||
|
||||
public static function getAppPrimaryColor(): ?string
|
||||
{
|
||||
$value = SettingRepository::getValue(self::APP_PRIMARY_COLOR_KEY);
|
||||
$value = $value !== null ? strtolower(trim((string) $value)) : '';
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
if (!preg_match('/^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i', $value)) {
|
||||
return null;
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
public static function setAppPrimaryColor(?string $color, ?string $description = null): bool
|
||||
{
|
||||
$value = $color !== null ? strtolower(trim((string) $color)) : '';
|
||||
if ($value !== '' && $value[0] !== '#') {
|
||||
if (preg_match('/^[0-9a-f]{3}$/i', $value)
|
||||
|| preg_match('/^[0-9a-f]{4}$/i', $value)
|
||||
|| preg_match('/^[0-9a-f]{6}$/i', $value)
|
||||
|| preg_match('/^[0-9a-f]{8}$/i', $value)) {
|
||||
$value = '#' . $value;
|
||||
}
|
||||
}
|
||||
if ($value === '') {
|
||||
$value = null;
|
||||
} elseif (!preg_match('/^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i', $value)) {
|
||||
return false;
|
||||
}
|
||||
$desc = $description ?? 'setting.app_primary_color';
|
||||
return SettingRepository::set(self::APP_PRIMARY_COLOR_KEY, $value, $desc);
|
||||
}
|
||||
|
||||
public static function getSmtpHost(): ?string
|
||||
{
|
||||
$value = SettingRepository::getValue(self::SMTP_HOST_KEY);
|
||||
$value = $value !== null ? trim((string) $value) : '';
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
public static function setSmtpHost(?string $host, ?string $description = null): bool
|
||||
{
|
||||
$value = $host !== null ? trim((string) $host) : '';
|
||||
if ($value === '') {
|
||||
$value = null;
|
||||
}
|
||||
$desc = $description ?? 'setting.smtp_host';
|
||||
return SettingRepository::set(self::SMTP_HOST_KEY, $value, $desc);
|
||||
}
|
||||
|
||||
public static function getSmtpPort(): ?int
|
||||
{
|
||||
$value = SettingRepository::getValue(self::SMTP_PORT_KEY);
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
$port = (int) $value;
|
||||
return $port > 0 ? $port : null;
|
||||
}
|
||||
|
||||
public static function setSmtpPort(?int $port, ?string $description = null): bool
|
||||
{
|
||||
$value = $port && $port > 0 ? (string) $port : null;
|
||||
$desc = $description ?? 'setting.smtp_port';
|
||||
return SettingRepository::set(self::SMTP_PORT_KEY, $value, $desc);
|
||||
}
|
||||
|
||||
public static function getSmtpUser(): ?string
|
||||
{
|
||||
$value = SettingRepository::getValue(self::SMTP_USER_KEY);
|
||||
$value = $value !== null ? trim((string) $value) : '';
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
public static function setSmtpUser(?string $user, ?string $description = null): bool
|
||||
{
|
||||
$value = $user !== null ? trim((string) $user) : '';
|
||||
if ($value === '') {
|
||||
$value = null;
|
||||
}
|
||||
$desc = $description ?? 'setting.smtp_user';
|
||||
return SettingRepository::set(self::SMTP_USER_KEY, $value, $desc);
|
||||
}
|
||||
|
||||
public static function getSmtpPassword(): ?string
|
||||
{
|
||||
$value = SettingRepository::getValue(self::SMTP_PASSWORD_KEY);
|
||||
$value = $value !== null ? (string) $value : '';
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
public static function setSmtpPassword(?string $password, ?string $description = null): bool
|
||||
{
|
||||
$value = $password !== null ? (string) $password : '';
|
||||
if ($value === '') {
|
||||
$value = null;
|
||||
}
|
||||
$desc = $description ?? 'setting.smtp_password';
|
||||
return SettingRepository::set(self::SMTP_PASSWORD_KEY, $value, $desc);
|
||||
}
|
||||
|
||||
public static function getSmtpSecure(): ?string
|
||||
{
|
||||
$value = SettingRepository::getValue(self::SMTP_SECURE_KEY);
|
||||
$value = $value !== null ? strtolower(trim((string) $value)) : '';
|
||||
if (!in_array($value, ['tls', 'ssl', 'none'], true)) {
|
||||
return null;
|
||||
}
|
||||
return $value === 'none' ? null : $value;
|
||||
}
|
||||
|
||||
public static function setSmtpSecure(?string $secure, ?string $description = null): bool
|
||||
{
|
||||
$value = $secure !== null ? strtolower(trim((string) $secure)) : '';
|
||||
if ($value === '' || $value === 'none') {
|
||||
$value = null;
|
||||
} elseif (!in_array($value, ['tls', 'ssl'], true)) {
|
||||
return false;
|
||||
}
|
||||
$desc = $description ?? 'setting.smtp_secure';
|
||||
return SettingRepository::set(self::SMTP_SECURE_KEY, $value, $desc);
|
||||
}
|
||||
|
||||
public static function getSmtpFrom(): ?string
|
||||
{
|
||||
$value = SettingRepository::getValue(self::SMTP_FROM_KEY);
|
||||
$value = $value !== null ? trim((string) $value) : '';
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
public static function setSmtpFrom(?string $from, ?string $description = null): bool
|
||||
{
|
||||
$value = $from !== null ? trim((string) $from) : '';
|
||||
if ($value === '') {
|
||||
$value = null;
|
||||
}
|
||||
$desc = $description ?? 'setting.smtp_from';
|
||||
return SettingRepository::set(self::SMTP_FROM_KEY, $value, $desc);
|
||||
}
|
||||
|
||||
public static function getSmtpFromName(): ?string
|
||||
{
|
||||
$value = SettingRepository::getValue(self::SMTP_FROM_NAME_KEY);
|
||||
$value = $value !== null ? trim((string) $value) : '';
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
public static function setSmtpFromName(?string $fromName, ?string $description = null): bool
|
||||
{
|
||||
$value = $fromName !== null ? trim((string) $fromName) : '';
|
||||
if ($value === '') {
|
||||
$value = null;
|
||||
}
|
||||
$desc = $description ?? 'setting.smtp_from_name';
|
||||
return SettingRepository::set(self::SMTP_FROM_NAME_KEY, $value, $desc);
|
||||
}
|
||||
}
|
||||
328
lib/Service/TenantAvatarService.php
Normal file
328
lib/Service/TenantAvatarService.php
Normal file
@@ -0,0 +1,328 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service;
|
||||
|
||||
class TenantAvatarService
|
||||
{
|
||||
private const MAX_SIZE = 5242880; // 5 MB
|
||||
private const SIZES = [64, 128, 256];
|
||||
private const DEFAULT_SIZE = 128;
|
||||
|
||||
public static function isValidUuid(string $uuid): bool
|
||||
{
|
||||
return (bool) preg_match('/^[a-f0-9-]{36}$/i', $uuid);
|
||||
}
|
||||
|
||||
public static function storageBase(): string
|
||||
{
|
||||
if (defined('APP_STORAGE_PATH') && APP_STORAGE_PATH) {
|
||||
return rtrim(APP_STORAGE_PATH, '/');
|
||||
}
|
||||
return rtrim(dirname(__DIR__, 2) . '/storage', '/');
|
||||
}
|
||||
|
||||
public static function tenantDir(string $uuid): string
|
||||
{
|
||||
return self::storageBase() . '/tenants/' . $uuid . '/avatar';
|
||||
}
|
||||
|
||||
public static function findAvatarPath(string $uuid, ?int $size = null): ?string
|
||||
{
|
||||
if (!self::isValidUuid($uuid)) {
|
||||
return null;
|
||||
}
|
||||
$dirs = self::avatarDirs($uuid);
|
||||
foreach ($dirs as $dir) {
|
||||
if (!is_dir($dir)) {
|
||||
continue;
|
||||
}
|
||||
if ($size) {
|
||||
$size = self::normalizeSize($size);
|
||||
$variant = self::findVariantPath($dir, $size);
|
||||
if ($variant) {
|
||||
return $variant;
|
||||
}
|
||||
}
|
||||
$defaultVariant = self::findVariantPath($dir, self::DEFAULT_SIZE);
|
||||
if ($defaultVariant) {
|
||||
return $defaultVariant;
|
||||
}
|
||||
$original = self::findOriginalPath($dir);
|
||||
if ($original) {
|
||||
return $original;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function hasAvatar(string $uuid): bool
|
||||
{
|
||||
$path = self::findAvatarPath($uuid);
|
||||
return $path ? is_file($path) : false;
|
||||
}
|
||||
|
||||
public static function delete(string $uuid): bool
|
||||
{
|
||||
if (!self::isValidUuid($uuid)) {
|
||||
return false;
|
||||
}
|
||||
foreach (self::avatarDirs($uuid) as $dir) {
|
||||
if (!is_dir($dir)) {
|
||||
continue;
|
||||
}
|
||||
$matches = array_merge(
|
||||
glob($dir . '/avatar-*.*') ?: [],
|
||||
glob($dir . '/avatar.*') ?: [],
|
||||
glob($dir . '/original.*') ?: []
|
||||
);
|
||||
foreach ($matches as $file) {
|
||||
if (is_file($file)) {
|
||||
@unlink($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function saveUpload(string $uuid, array $file): array
|
||||
{
|
||||
if (!self::isValidUuid($uuid)) {
|
||||
return ['ok' => false, 'error' => t('Tenant not found')];
|
||||
}
|
||||
if (empty($file) || !isset($file['tmp_name'])) {
|
||||
return ['ok' => false, 'error' => t('No file uploaded')];
|
||||
}
|
||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
return ['ok' => false, 'error' => t('Upload failed')];
|
||||
}
|
||||
if (($file['size'] ?? 0) > self::MAX_SIZE) {
|
||||
return ['ok' => false, 'error' => t('File is too large')];
|
||||
}
|
||||
|
||||
$tmpPath = $file['tmp_name'];
|
||||
$mime = self::detectMime($tmpPath);
|
||||
$isSvg = self::isSvgUpload($mime, $tmpPath);
|
||||
$ext = self::extensionForMime($mime, $isSvg);
|
||||
if (!$ext) {
|
||||
return ['ok' => false, 'error' => t('Invalid image file')];
|
||||
}
|
||||
if ($isSvg && !self::isSafeSvg($tmpPath)) {
|
||||
return ['ok' => false, 'error' => t('Invalid image file')];
|
||||
}
|
||||
|
||||
$dir = self::tenantDir($uuid);
|
||||
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
|
||||
return ['ok' => false, 'error' => t('Upload failed')];
|
||||
}
|
||||
|
||||
self::delete($uuid);
|
||||
$originalPath = $dir . '/original.' . $ext;
|
||||
if (!move_uploaded_file($tmpPath, $originalPath)) {
|
||||
return ['ok' => false, 'error' => t('Upload failed')];
|
||||
}
|
||||
@chmod($originalPath, 0644);
|
||||
|
||||
$variantExt = function_exists('imagewebp') ? 'webp' : 'jpg';
|
||||
if (!$isSvg && self::canResize()) {
|
||||
foreach (self::SIZES as $size) {
|
||||
$target = $dir . '/avatar-' . $size . '.' . $variantExt;
|
||||
self::resizeAndFit($originalPath, $target, $size, $size, $variantExt);
|
||||
}
|
||||
}
|
||||
|
||||
return ['ok' => true, 'path' => $originalPath, 'mime' => $mime];
|
||||
}
|
||||
|
||||
public static function detectMime(string $path): string
|
||||
{
|
||||
if (function_exists('finfo_open')) {
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
if ($finfo) {
|
||||
$mime = finfo_file($finfo, $path);
|
||||
if (is_string($mime) && $mime !== '') {
|
||||
if (self::isSvgUpload($mime, $path)) {
|
||||
return 'image/svg+xml';
|
||||
}
|
||||
return $mime;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (self::isSvgUpload('application/octet-stream', $path)) {
|
||||
return 'image/svg+xml';
|
||||
}
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
private static function extensionForMime(string $mime, bool $isSvg = false): ?string
|
||||
{
|
||||
if ($isSvg) {
|
||||
return 'svg';
|
||||
}
|
||||
$map = [
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/webp' => 'webp',
|
||||
];
|
||||
return $map[$mime] ?? null;
|
||||
}
|
||||
|
||||
private static function isSvgUpload(string $mime, string $path): bool
|
||||
{
|
||||
if ($mime === 'image/svg+xml') {
|
||||
return true;
|
||||
}
|
||||
$head = @file_get_contents($path, false, null, 0, 1024);
|
||||
if (!is_string($head) || $head === '') {
|
||||
return false;
|
||||
}
|
||||
return stripos($head, '<svg') !== false;
|
||||
}
|
||||
|
||||
private static function isSafeSvg(string $path): bool
|
||||
{
|
||||
$contents = @file_get_contents($path);
|
||||
if (!is_string($contents) || $contents === '') {
|
||||
return false;
|
||||
}
|
||||
$lower = strtolower($contents);
|
||||
if (strpos($lower, '<script') !== false) {
|
||||
return false;
|
||||
}
|
||||
if (strpos($lower, 'onload=') !== false) {
|
||||
return false;
|
||||
}
|
||||
if (strpos($lower, 'javascript:') !== false) {
|
||||
return false;
|
||||
}
|
||||
if (strpos($lower, '<foreignobject') !== false) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function normalizeSize(int $size): int
|
||||
{
|
||||
if (in_array($size, self::SIZES, true)) {
|
||||
return $size;
|
||||
}
|
||||
return self::DEFAULT_SIZE;
|
||||
}
|
||||
|
||||
private static function findVariantPath(string $dir, int $size): ?string
|
||||
{
|
||||
$matches = glob($dir . '/avatar-' . $size . '.*');
|
||||
if (!$matches) {
|
||||
return null;
|
||||
}
|
||||
usort($matches, static function ($a, $b) {
|
||||
return filemtime($b) <=> filemtime($a);
|
||||
});
|
||||
return $matches[0] ?? null;
|
||||
}
|
||||
|
||||
private static function findOriginalPath(string $dir): ?string
|
||||
{
|
||||
$matches = glob($dir . '/original.*');
|
||||
if (!$matches) {
|
||||
return null;
|
||||
}
|
||||
usort($matches, static function ($a, $b) {
|
||||
return filemtime($b) <=> filemtime($a);
|
||||
});
|
||||
return $matches[0] ?? null;
|
||||
}
|
||||
|
||||
private static function canResize(): bool
|
||||
{
|
||||
return function_exists('imagecreatetruecolor') && function_exists('imagecopyresampled');
|
||||
}
|
||||
|
||||
private static function createImageResource(string $path, string $mime)
|
||||
{
|
||||
if ($mime === 'image/jpeg' && function_exists('imagecreatefromjpeg')) {
|
||||
return imagecreatefromjpeg($path);
|
||||
}
|
||||
if ($mime === 'image/png' && function_exists('imagecreatefrompng')) {
|
||||
return imagecreatefrompng($path);
|
||||
}
|
||||
if ($mime === 'image/webp' && function_exists('imagecreatefromwebp')) {
|
||||
return imagecreatefromwebp($path);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function resizeAndFit(string $sourcePath, string $targetPath, int $width, int $height, string $ext): bool
|
||||
{
|
||||
$mime = self::detectMime($sourcePath);
|
||||
$src = self::createImageResource($sourcePath, $mime);
|
||||
if (!$src) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$srcWidth = imagesx($src);
|
||||
$srcHeight = imagesy($src);
|
||||
if ($srcWidth === 0 || $srcHeight === 0) {
|
||||
imagedestroy($src);
|
||||
return false;
|
||||
}
|
||||
|
||||
$scale = min($width / $srcWidth, $height / $srcHeight);
|
||||
$dstWidth = (int) round($srcWidth * $scale);
|
||||
$dstHeight = (int) round($srcHeight * $scale);
|
||||
if ($dstWidth < 1) $dstWidth = 1;
|
||||
if ($dstHeight < 1) $dstHeight = 1;
|
||||
|
||||
$dst = imagecreatetruecolor($dstWidth, $dstHeight);
|
||||
if ($ext === 'png' || $ext === 'webp') {
|
||||
imagealphablending($dst, false);
|
||||
imagesavealpha($dst, true);
|
||||
$transparent = imagecolorallocatealpha($dst, 0, 0, 0, 127);
|
||||
imagefilledrectangle($dst, 0, 0, $dstWidth, $dstHeight, $transparent);
|
||||
}
|
||||
|
||||
imagecopyresampled(
|
||||
$dst,
|
||||
$src,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
$dstWidth,
|
||||
$dstHeight,
|
||||
$srcWidth,
|
||||
$srcHeight
|
||||
);
|
||||
|
||||
$saved = false;
|
||||
if ($ext === 'webp' && function_exists('imagewebp')) {
|
||||
$saved = imagewebp($dst, $targetPath, 82);
|
||||
} elseif ($ext === 'png' && function_exists('imagepng')) {
|
||||
$saved = imagepng($dst, $targetPath, 6);
|
||||
} else {
|
||||
$saved = imagejpeg($dst, $targetPath, 85);
|
||||
}
|
||||
|
||||
imagedestroy($src);
|
||||
imagedestroy($dst);
|
||||
|
||||
if ($saved) {
|
||||
@chmod($targetPath, 0644);
|
||||
}
|
||||
return $saved;
|
||||
}
|
||||
|
||||
private static function avatarDirs(string $uuid): array
|
||||
{
|
||||
$dirs = [self::tenantDir($uuid)];
|
||||
$legacy = self::legacyTenantDir($uuid);
|
||||
if ($legacy !== $dirs[0]) {
|
||||
$dirs[] = $legacy;
|
||||
}
|
||||
return $dirs;
|
||||
}
|
||||
|
||||
private static function legacyTenantDir(string $uuid): string
|
||||
{
|
||||
return self::storageBase() . '/tenants/' . $uuid;
|
||||
}
|
||||
}
|
||||
214
lib/Service/TenantFaviconService.php
Normal file
214
lib/Service/TenantFaviconService.php
Normal file
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service;
|
||||
|
||||
class TenantFaviconService
|
||||
{
|
||||
private const MAX_SIZE = 5242880; // 5 MB
|
||||
private const SIZES = [
|
||||
16 => 'favicon-16x16.png',
|
||||
32 => 'favicon-32x32.png',
|
||||
96 => 'favicon-96x96.png',
|
||||
180 => 'apple-touch-icon.png',
|
||||
192 => 'web-app-manifest-192x192.png',
|
||||
512 => 'web-app-manifest-512x512.png',
|
||||
];
|
||||
|
||||
public static function isValidUuid(string $uuid): bool
|
||||
{
|
||||
return (bool) preg_match('/^[a-f0-9-]{36}$/i', $uuid);
|
||||
}
|
||||
|
||||
public static function storageBase(): string
|
||||
{
|
||||
if (defined('APP_STORAGE_PATH') && APP_STORAGE_PATH) {
|
||||
return rtrim(APP_STORAGE_PATH, '/');
|
||||
}
|
||||
return rtrim(dirname(__DIR__, 2) . '/storage', '/');
|
||||
}
|
||||
|
||||
public static function storageDir(string $uuid): string
|
||||
{
|
||||
return self::storageBase() . '/tenants/' . $uuid . '/favicon';
|
||||
}
|
||||
|
||||
public static function publicDir(string $uuid): string
|
||||
{
|
||||
return rtrim(dirname(__DIR__, 2) . '/web/favicon/tenants/' . $uuid, '/');
|
||||
}
|
||||
|
||||
public static function hasFavicon(string $uuid): bool
|
||||
{
|
||||
if (!self::isValidUuid($uuid)) {
|
||||
return false;
|
||||
}
|
||||
$path = self::publicDir($uuid) . '/favicon-32x32.png';
|
||||
return is_file($path);
|
||||
}
|
||||
|
||||
public static function delete(string $uuid): void
|
||||
{
|
||||
if (!self::isValidUuid($uuid)) {
|
||||
return;
|
||||
}
|
||||
foreach (self::storageDirs($uuid) as $dir) {
|
||||
if (!is_dir($dir)) {
|
||||
continue;
|
||||
}
|
||||
$matches = glob($dir . '/*') ?: [];
|
||||
foreach ($matches as $file) {
|
||||
if (is_file($file)) {
|
||||
@unlink($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
$public = self::publicDir($uuid);
|
||||
foreach (self::SIZES as $file) {
|
||||
$target = $public . '/' . $file;
|
||||
if (is_file($target)) {
|
||||
@unlink($target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function saveUpload(string $uuid, array $file): array
|
||||
{
|
||||
if (!self::isValidUuid($uuid)) {
|
||||
return ['ok' => false, 'error' => t('Tenant not found')];
|
||||
}
|
||||
if (empty($file) || !isset($file['tmp_name'])) {
|
||||
return ['ok' => false, 'error' => t('No file uploaded')];
|
||||
}
|
||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
return ['ok' => false, 'error' => t('Upload failed')];
|
||||
}
|
||||
if (($file['size'] ?? 0) > self::MAX_SIZE) {
|
||||
return ['ok' => false, 'error' => t('File is too large')];
|
||||
}
|
||||
|
||||
$tmpPath = $file['tmp_name'];
|
||||
$mime = self::detectMime($tmpPath);
|
||||
if ($mime !== 'image/png') {
|
||||
return ['ok' => false, 'error' => t('Invalid image file')];
|
||||
}
|
||||
if (!self::canResize()) {
|
||||
return ['ok' => false, 'error' => t('Upload failed')];
|
||||
}
|
||||
|
||||
$dir = self::storageDir($uuid);
|
||||
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
|
||||
return ['ok' => false, 'error' => t('Upload failed')];
|
||||
}
|
||||
|
||||
self::delete($uuid);
|
||||
$originalPath = $dir . '/original.png';
|
||||
if (!move_uploaded_file($tmpPath, $originalPath)) {
|
||||
return ['ok' => false, 'error' => t('Upload failed')];
|
||||
}
|
||||
@chmod($originalPath, 0644);
|
||||
|
||||
$publicDir = self::publicDir($uuid);
|
||||
if (!is_dir($publicDir) && !mkdir($publicDir, 0755, true) && !is_dir($publicDir)) {
|
||||
return ['ok' => false, 'error' => t('Upload failed')];
|
||||
}
|
||||
|
||||
foreach (self::SIZES as $size => $fileName) {
|
||||
$target = $publicDir . '/' . $fileName;
|
||||
if (!self::resizeSquare($originalPath, $target, $size)) {
|
||||
return ['ok' => false, 'error' => t('Upload failed')];
|
||||
}
|
||||
@chmod($target, 0644);
|
||||
}
|
||||
|
||||
return ['ok' => true, 'path' => $originalPath, 'mime' => $mime];
|
||||
}
|
||||
|
||||
public static function detectMime(string $path): string
|
||||
{
|
||||
if (function_exists('finfo_open')) {
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
if ($finfo) {
|
||||
$mime = finfo_file($finfo, $path);
|
||||
if (is_string($mime) && $mime !== '') {
|
||||
return $mime;
|
||||
}
|
||||
}
|
||||
}
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
private static function canResize(): bool
|
||||
{
|
||||
return function_exists('imagecreatetruecolor') && function_exists('imagecopyresampled');
|
||||
}
|
||||
|
||||
private static function loadImage(string $path)
|
||||
{
|
||||
if (function_exists('imagecreatefrompng')) {
|
||||
return imagecreatefrompng($path);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function resizeSquare(string $sourcePath, string $targetPath, int $size): bool
|
||||
{
|
||||
$src = self::loadImage($sourcePath);
|
||||
if (!$src) {
|
||||
return false;
|
||||
}
|
||||
$srcWidth = imagesx($src);
|
||||
$srcHeight = imagesy($src);
|
||||
if ($srcWidth === 0 || $srcHeight === 0) {
|
||||
imagedestroy($src);
|
||||
return false;
|
||||
}
|
||||
|
||||
$cropSize = min($srcWidth, $srcHeight);
|
||||
$srcX = (int) round(($srcWidth - $cropSize) / 2);
|
||||
$srcY = (int) round(($srcHeight - $cropSize) / 2);
|
||||
|
||||
$dst = imagecreatetruecolor($size, $size);
|
||||
imagealphablending($dst, false);
|
||||
imagesavealpha($dst, true);
|
||||
$transparent = imagecolorallocatealpha($dst, 0, 0, 0, 127);
|
||||
imagefilledrectangle($dst, 0, 0, $size, $size, $transparent);
|
||||
|
||||
imagecopyresampled(
|
||||
$dst,
|
||||
$src,
|
||||
0,
|
||||
0,
|
||||
$srcX,
|
||||
$srcY,
|
||||
$size,
|
||||
$size,
|
||||
$cropSize,
|
||||
$cropSize
|
||||
);
|
||||
|
||||
$saved = false;
|
||||
if (function_exists('imagepng')) {
|
||||
$saved = imagepng($dst, $targetPath, 6);
|
||||
}
|
||||
|
||||
imagedestroy($src);
|
||||
imagedestroy($dst);
|
||||
|
||||
return $saved;
|
||||
}
|
||||
|
||||
private static function storageDirs(string $uuid): array
|
||||
{
|
||||
$dirs = [self::storageDir($uuid)];
|
||||
$legacy = self::legacyStorageDir($uuid);
|
||||
if ($legacy !== $dirs[0]) {
|
||||
$dirs[] = $legacy;
|
||||
}
|
||||
return $dirs;
|
||||
}
|
||||
|
||||
private static function legacyStorageDir(string $uuid): string
|
||||
{
|
||||
return self::storageBase() . '/branding/tenants/' . $uuid . '/favicon';
|
||||
}
|
||||
}
|
||||
118
lib/Service/TenantScopeService.php
Normal file
118
lib/Service/TenantScopeService.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service;
|
||||
|
||||
use MintyPHP\Repository\TenantDepartmentRepository;
|
||||
use MintyPHP\Repository\UserTenantRepository;
|
||||
use MintyPHP\Repository\TenantRepository;
|
||||
use MintyPHP\Service\PermissionService;
|
||||
|
||||
class TenantScopeService
|
||||
{
|
||||
public static function isStrict(): bool
|
||||
{
|
||||
return defined('TENANT_SCOPE_STRICT') ? (bool) TENANT_SCOPE_STRICT : true;
|
||||
}
|
||||
|
||||
public static function getUserTenantIds(int $userId, bool $onlyActive = true): array
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return [];
|
||||
}
|
||||
if (self::canBypass($userId)) {
|
||||
$ids = TenantRepository::listIds();
|
||||
} else {
|
||||
$ids = array_values(array_unique(array_map('intval', UserTenantRepository::listTenantIdsByUserId($userId))));
|
||||
}
|
||||
if ($onlyActive) {
|
||||
return self::filterActiveTenantIds($ids);
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
|
||||
public static function canAccess(string $resource, int $resourceId, int $userId): bool
|
||||
{
|
||||
if ($resourceId <= 0 || $userId <= 0) {
|
||||
return false;
|
||||
}
|
||||
if (self::canBypass($userId)) {
|
||||
return true;
|
||||
}
|
||||
$resourceTenantIdsRaw = self::getResourceTenantIds($resource, $resourceId);
|
||||
$userTenantIdsRaw = self::getUserTenantIds($userId, false);
|
||||
$resourceTenantIds = self::filterActiveTenantIds($resourceTenantIdsRaw);
|
||||
$userTenantIds = self::filterActiveTenantIds($userTenantIdsRaw);
|
||||
|
||||
if ($resourceTenantIdsRaw && !$resourceTenantIds) {
|
||||
return false;
|
||||
}
|
||||
if (!$resourceTenantIds) {
|
||||
return self::isStrict() ? false : true;
|
||||
}
|
||||
if (!$userTenantIds) {
|
||||
return false;
|
||||
}
|
||||
return (bool) array_intersect($resourceTenantIds, $userTenantIds);
|
||||
}
|
||||
|
||||
public static function filterTenantIdsForUser(array $tenantIds, int $userId): array
|
||||
{
|
||||
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
|
||||
$userTenantIds = self::getUserTenantIds($userId);
|
||||
if (!$userTenantIds) {
|
||||
return [];
|
||||
}
|
||||
return array_values(array_intersect($tenantIds, $userTenantIds));
|
||||
}
|
||||
|
||||
public static function mergeTenantIdsPreservingOutOfScope(array $requestedTenantIds, array $existingTenantIds, array $allowedTenantIds): array
|
||||
{
|
||||
$requestedTenantIds = array_values(array_unique(array_map('intval', $requestedTenantIds)));
|
||||
$existingTenantIds = array_values(array_unique(array_map('intval', $existingTenantIds)));
|
||||
$allowedTenantIds = array_values(array_unique(array_map('intval', $allowedTenantIds)));
|
||||
|
||||
if (!$allowedTenantIds) {
|
||||
return $existingTenantIds;
|
||||
}
|
||||
$outOfScope = array_values(array_diff($existingTenantIds, $allowedTenantIds));
|
||||
return array_values(array_unique(array_merge($requestedTenantIds, $outOfScope)));
|
||||
}
|
||||
|
||||
public static function hasGlobalAccess(int $userId): bool
|
||||
{
|
||||
return self::canBypass($userId);
|
||||
}
|
||||
|
||||
private static function getResourceTenantIds(string $resource, int $resourceId): array
|
||||
{
|
||||
$resource = strtolower(trim($resource));
|
||||
if ($resource === 'tenants') {
|
||||
return $resourceId > 0 ? [$resourceId] : [];
|
||||
}
|
||||
if ($resource === 'users') {
|
||||
return array_values(array_unique(array_map('intval', UserTenantRepository::listTenantIdsByUserId($resourceId))));
|
||||
}
|
||||
if ($resource === 'departments') {
|
||||
return array_values(array_unique(array_map('intval', TenantDepartmentRepository::listTenantIdsByDepartmentId($resourceId))));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private static function filterActiveTenantIds(array $tenantIds): array
|
||||
{
|
||||
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
|
||||
if (!$tenantIds) {
|
||||
return [];
|
||||
}
|
||||
return TenantRepository::listActiveIdsByIds($tenantIds);
|
||||
}
|
||||
|
||||
private static function canBypass(int $userId): bool
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return false;
|
||||
}
|
||||
return PermissionService::userHas($userId, PermissionService::TENANTS_UPDATE)
|
||||
|| PermissionService::userHas($userId, PermissionService::SETTINGS_UPDATE);
|
||||
}
|
||||
}
|
||||
207
lib/Service/TenantService.php
Normal file
207
lib/Service/TenantService.php
Normal file
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service;
|
||||
|
||||
use MintyPHP\Repository\TenantRepository;
|
||||
use MintyPHP\Service\SettingService;
|
||||
|
||||
class TenantService
|
||||
{
|
||||
public static function list(): array
|
||||
{
|
||||
return TenantRepository::list();
|
||||
}
|
||||
|
||||
public static function listPaged(array $options): array
|
||||
{
|
||||
return TenantRepository::listPaged($options);
|
||||
}
|
||||
|
||||
public static function findByUuid(string $uuid): ?array
|
||||
{
|
||||
return TenantRepository::findByUuid($uuid);
|
||||
}
|
||||
|
||||
public static function findById(int $id): ?array
|
||||
{
|
||||
return TenantRepository::find($id);
|
||||
}
|
||||
|
||||
public static function createFromAdmin(array $input, int $currentUserId = 0): array
|
||||
{
|
||||
$form = self::sanitizeBase($input);
|
||||
$form['status'] = self::normalizeStatus($form['status'] ?? '');
|
||||
$errors = self::validateBase($form);
|
||||
|
||||
if ($errors) {
|
||||
return ['ok' => false, 'errors' => $errors, 'form' => $form];
|
||||
}
|
||||
|
||||
$statusChangedAt = gmdate('Y-m-d H:i:s');
|
||||
$createdId = TenantRepository::create([
|
||||
'description' => $form['description'],
|
||||
'address' => $form['address'],
|
||||
'postal_code' => $form['postal_code'],
|
||||
'city' => $form['city'],
|
||||
'country' => $form['country'],
|
||||
'region' => $form['region'],
|
||||
'vat_id' => $form['vat_id'],
|
||||
'tax_number' => $form['tax_number'],
|
||||
'phone' => $form['phone'],
|
||||
'fax' => $form['fax'],
|
||||
'email' => $form['email'],
|
||||
'support_email' => $form['support_email'],
|
||||
'support_phone' => $form['support_phone'],
|
||||
'billing_email' => $form['billing_email'],
|
||||
'website' => $form['website'],
|
||||
'privacy_url' => $form['privacy_url'],
|
||||
'imprint_url' => $form['imprint_url'],
|
||||
'primary_color' => $form['primary_color_use_default']
|
||||
? null
|
||||
: ($form['primary_color'] !== '' ? $form['primary_color'] : null),
|
||||
'status' => $form['status'],
|
||||
'status_changed_at' => $statusChangedAt,
|
||||
'status_changed_by' => $currentUserId > 0 ? $currentUserId : null,
|
||||
'created_by' => $currentUserId > 0 ? $currentUserId : null,
|
||||
]);
|
||||
|
||||
if (!$createdId) {
|
||||
return ['ok' => false, 'errors' => [t('Tenant can not be created')], 'form' => $form];
|
||||
}
|
||||
|
||||
$createdTenant = TenantRepository::find((int) $createdId);
|
||||
$uuid = $createdTenant['uuid'] ?? null;
|
||||
if (!empty($input['is_default']) && $createdId) {
|
||||
SettingService::setDefaultTenantId((int) $createdId);
|
||||
}
|
||||
return ['ok' => true, 'form' => $form, 'uuid' => $uuid, 'id' => (int) $createdId];
|
||||
}
|
||||
|
||||
public static function updateFromAdmin(int $tenantId, array $input, int $currentUserId = 0): array
|
||||
{
|
||||
$form = self::sanitizeBase($input);
|
||||
$form['status'] = self::normalizeStatus($form['status'] ?? '');
|
||||
$errors = self::validateBase($form);
|
||||
|
||||
if ($errors) {
|
||||
return ['ok' => false, 'errors' => $errors, 'form' => $form];
|
||||
}
|
||||
|
||||
$existing = TenantRepository::find($tenantId);
|
||||
$statusChanged = false;
|
||||
if ($existing && isset($existing['status'])) {
|
||||
$statusChanged = (string) $existing['status'] !== $form['status'];
|
||||
}
|
||||
|
||||
$updateData = [
|
||||
'description' => $form['description'],
|
||||
'address' => $form['address'],
|
||||
'postal_code' => $form['postal_code'],
|
||||
'city' => $form['city'],
|
||||
'country' => $form['country'],
|
||||
'region' => $form['region'],
|
||||
'vat_id' => $form['vat_id'],
|
||||
'tax_number' => $form['tax_number'],
|
||||
'phone' => $form['phone'],
|
||||
'fax' => $form['fax'],
|
||||
'email' => $form['email'],
|
||||
'support_email' => $form['support_email'],
|
||||
'support_phone' => $form['support_phone'],
|
||||
'billing_email' => $form['billing_email'],
|
||||
'website' => $form['website'],
|
||||
'privacy_url' => $form['privacy_url'],
|
||||
'imprint_url' => $form['imprint_url'],
|
||||
'primary_color' => $form['primary_color_use_default']
|
||||
? null
|
||||
: ($form['primary_color'] !== '' ? $form['primary_color'] : null),
|
||||
'status' => $form['status'],
|
||||
'modified_by' => $currentUserId > 0 ? $currentUserId : null,
|
||||
];
|
||||
if ($statusChanged) {
|
||||
$updateData['status_changed_at'] = gmdate('Y-m-d H:i:s');
|
||||
$updateData['status_changed_by'] = $currentUserId > 0 ? $currentUserId : null;
|
||||
}
|
||||
|
||||
$updated = TenantRepository::update($tenantId, $updateData);
|
||||
|
||||
if (!$updated) {
|
||||
return ['ok' => false, 'errors' => [t('Tenant can not be updated')], 'form' => $form];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'form' => $form];
|
||||
}
|
||||
|
||||
public static function deleteByUuid(string $uuid): array
|
||||
{
|
||||
$uuid = trim($uuid);
|
||||
if ($uuid === '') {
|
||||
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
|
||||
}
|
||||
|
||||
$tenant = TenantRepository::findByUuid($uuid);
|
||||
if (!$tenant || !isset($tenant['id'])) {
|
||||
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
|
||||
}
|
||||
|
||||
$deleted = TenantRepository::delete((int) $tenant['id']);
|
||||
if (!$deleted) {
|
||||
return ['ok' => false, 'status' => 500, 'error' => 'delete_failed'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'tenant' => $tenant];
|
||||
}
|
||||
|
||||
private static function sanitizeBase(array $input): array
|
||||
{
|
||||
return [
|
||||
'description' => trim((string) ($input['description'] ?? '')),
|
||||
'address' => trim((string) ($input['address'] ?? '')),
|
||||
'postal_code' => trim((string) ($input['postal_code'] ?? '')),
|
||||
'city' => trim((string) ($input['city'] ?? '')),
|
||||
'country' => trim((string) ($input['country'] ?? '')),
|
||||
'region' => trim((string) ($input['region'] ?? '')),
|
||||
'vat_id' => trim((string) ($input['vat_id'] ?? '')),
|
||||
'tax_number' => trim((string) ($input['tax_number'] ?? '')),
|
||||
'phone' => trim((string) ($input['phone'] ?? '')),
|
||||
'fax' => trim((string) ($input['fax'] ?? '')),
|
||||
'email' => trim((string) ($input['email'] ?? '')),
|
||||
'support_email' => trim((string) ($input['support_email'] ?? '')),
|
||||
'support_phone' => trim((string) ($input['support_phone'] ?? '')),
|
||||
'billing_email' => trim((string) ($input['billing_email'] ?? '')),
|
||||
'website' => trim((string) ($input['website'] ?? '')),
|
||||
'privacy_url' => trim((string) ($input['privacy_url'] ?? '')),
|
||||
'imprint_url' => trim((string) ($input['imprint_url'] ?? '')),
|
||||
'primary_color' => trim((string) ($input['primary_color'] ?? '')),
|
||||
'primary_color_use_default' => !empty($input['primary_color_use_default']),
|
||||
'status' => trim((string) ($input['status'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
private static function validateBase(array $form): array
|
||||
{
|
||||
$errors = [];
|
||||
if ($form['description'] === '') {
|
||||
$errors[] = t('Description cannot be empty');
|
||||
}
|
||||
if (!in_array($form['status'], ['active', 'inactive'], true)) {
|
||||
$errors[] = t('Status is invalid');
|
||||
}
|
||||
if (
|
||||
!$form['primary_color_use_default']
|
||||
&& $form['primary_color'] !== ''
|
||||
&& !preg_match('/^#([0-9a-f]{3}|[0-9a-f]{6})$/i', $form['primary_color'])
|
||||
) {
|
||||
$errors[] = t('Primary color is invalid');
|
||||
}
|
||||
return $errors;
|
||||
}
|
||||
|
||||
private static function normalizeStatus(string $status): string
|
||||
{
|
||||
$status = strtolower(trim($status));
|
||||
if (!in_array($status, ['active', 'inactive'], true)) {
|
||||
return 'active';
|
||||
}
|
||||
return $status;
|
||||
}
|
||||
}
|
||||
307
lib/Service/UserAvatarService.php
Normal file
307
lib/Service/UserAvatarService.php
Normal file
@@ -0,0 +1,307 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service;
|
||||
|
||||
class UserAvatarService
|
||||
{
|
||||
private const MAX_SIZE = 5242880; // 5 MB
|
||||
private const SIZES = [64, 128, 256];
|
||||
private const DEFAULT_SIZE = 128;
|
||||
|
||||
public static function isValidUuid(string $uuid): bool
|
||||
{
|
||||
return (bool) preg_match('/^[a-f0-9-]{36}$/i', $uuid);
|
||||
}
|
||||
|
||||
public static function storageBase(): string
|
||||
{
|
||||
if (defined('APP_STORAGE_PATH') && APP_STORAGE_PATH) {
|
||||
return rtrim(APP_STORAGE_PATH, '/');
|
||||
}
|
||||
return rtrim(dirname(__DIR__, 2) . '/storage', '/');
|
||||
}
|
||||
|
||||
public static function userDir(string $uuid): string
|
||||
{
|
||||
return self::storageBase() . '/users/' . $uuid;
|
||||
}
|
||||
|
||||
public static function findAvatarPath(string $uuid, ?int $size = null): ?string
|
||||
{
|
||||
if (!self::isValidUuid($uuid)) {
|
||||
return null;
|
||||
}
|
||||
$dir = self::userDir($uuid);
|
||||
if (!is_dir($dir)) {
|
||||
return null;
|
||||
}
|
||||
if ($size) {
|
||||
$size = self::normalizeSize($size);
|
||||
$variant = self::findVariantPath($dir, $size);
|
||||
if ($variant) {
|
||||
return $variant;
|
||||
}
|
||||
}
|
||||
$defaultVariant = self::findVariantPath($dir, self::DEFAULT_SIZE);
|
||||
if ($defaultVariant) {
|
||||
return $defaultVariant;
|
||||
}
|
||||
$original = self::findOriginalPath($dir);
|
||||
return $original ?: null;
|
||||
}
|
||||
|
||||
public static function hasAvatar(string $uuid): bool
|
||||
{
|
||||
$path = self::findAvatarPath($uuid);
|
||||
return $path ? is_file($path) : false;
|
||||
}
|
||||
|
||||
public static function delete(string $uuid): bool
|
||||
{
|
||||
if (!self::isValidUuid($uuid)) {
|
||||
return false;
|
||||
}
|
||||
$dir = self::userDir($uuid);
|
||||
if (!is_dir($dir)) {
|
||||
return true;
|
||||
}
|
||||
$matches = array_merge(
|
||||
glob($dir . '/avatar-*.*') ?: [],
|
||||
glob($dir . '/avatar.*') ?: [],
|
||||
glob($dir . '/original.*') ?: []
|
||||
);
|
||||
foreach ($matches as $file) {
|
||||
if (is_file($file)) {
|
||||
@unlink($file);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function saveUpload(string $uuid, array $file): array
|
||||
{
|
||||
if (!self::isValidUuid($uuid)) {
|
||||
return ['ok' => false, 'error' => t('User not found')];
|
||||
}
|
||||
if (empty($file) || !isset($file['tmp_name'])) {
|
||||
return ['ok' => false, 'error' => t('No file uploaded')];
|
||||
}
|
||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
return ['ok' => false, 'error' => t('Upload failed')];
|
||||
}
|
||||
if (($file['size'] ?? 0) > self::MAX_SIZE) {
|
||||
return ['ok' => false, 'error' => t('File is too large')];
|
||||
}
|
||||
|
||||
$tmpPath = $file['tmp_name'];
|
||||
$mime = self::detectMime($tmpPath);
|
||||
$isSvg = self::isSvgUpload($mime, $tmpPath);
|
||||
$ext = self::extensionForMime($mime, $isSvg);
|
||||
if (!$ext) {
|
||||
return ['ok' => false, 'error' => t('Invalid image file')];
|
||||
}
|
||||
if ($isSvg && !self::isSafeSvg($tmpPath)) {
|
||||
return ['ok' => false, 'error' => t('Invalid image file')];
|
||||
}
|
||||
|
||||
$dir = self::userDir($uuid);
|
||||
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
|
||||
return ['ok' => false, 'error' => t('Upload failed')];
|
||||
}
|
||||
|
||||
self::delete($uuid);
|
||||
$originalPath = $dir . '/original.' . $ext;
|
||||
if (!move_uploaded_file($tmpPath, $originalPath)) {
|
||||
return ['ok' => false, 'error' => t('Upload failed')];
|
||||
}
|
||||
@chmod($originalPath, 0644);
|
||||
|
||||
$variantExt = function_exists('imagewebp') ? 'webp' : 'jpg';
|
||||
if (!$isSvg && self::canResize()) {
|
||||
foreach (self::SIZES as $size) {
|
||||
$target = $dir . '/avatar-' . $size . '.' . $variantExt;
|
||||
self::resizeAndFit($originalPath, $target, $size, $size, $variantExt);
|
||||
}
|
||||
}
|
||||
|
||||
return ['ok' => true, 'path' => $originalPath, 'mime' => $mime];
|
||||
}
|
||||
|
||||
public static function detectMime(string $path): string
|
||||
{
|
||||
if (function_exists('finfo_open')) {
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
if ($finfo) {
|
||||
$mime = finfo_file($finfo, $path);
|
||||
if (is_string($mime) && $mime !== '') {
|
||||
if (self::isSvgUpload($mime, $path)) {
|
||||
return 'image/svg+xml';
|
||||
}
|
||||
return $mime;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (self::isSvgUpload('application/octet-stream', $path)) {
|
||||
return 'image/svg+xml';
|
||||
}
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
private static function extensionForMime(string $mime, bool $isSvg = false): ?string
|
||||
{
|
||||
if ($isSvg) {
|
||||
return 'svg';
|
||||
}
|
||||
$map = [
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/webp' => 'webp',
|
||||
];
|
||||
return $map[$mime] ?? null;
|
||||
}
|
||||
|
||||
private static function isSvgUpload(string $mime, string $path): bool
|
||||
{
|
||||
if ($mime === 'image/svg+xml') {
|
||||
return true;
|
||||
}
|
||||
$head = @file_get_contents($path, false, null, 0, 1024);
|
||||
if (!is_string($head) || $head === '') {
|
||||
return false;
|
||||
}
|
||||
return stripos($head, '<svg') !== false;
|
||||
}
|
||||
|
||||
private static function isSafeSvg(string $path): bool
|
||||
{
|
||||
$contents = @file_get_contents($path);
|
||||
if (!is_string($contents) || $contents === '') {
|
||||
return false;
|
||||
}
|
||||
$lower = strtolower($contents);
|
||||
if (strpos($lower, '<script') !== false) {
|
||||
return false;
|
||||
}
|
||||
if (strpos($lower, 'onload=') !== false) {
|
||||
return false;
|
||||
}
|
||||
if (strpos($lower, 'javascript:') !== false) {
|
||||
return false;
|
||||
}
|
||||
if (strpos($lower, '<foreignobject') !== false) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function normalizeSize(int $size): int
|
||||
{
|
||||
if (in_array($size, self::SIZES, true)) {
|
||||
return $size;
|
||||
}
|
||||
return self::DEFAULT_SIZE;
|
||||
}
|
||||
|
||||
private static function findVariantPath(string $dir, int $size): ?string
|
||||
{
|
||||
$matches = glob($dir . '/avatar-' . $size . '.*');
|
||||
if (!$matches) {
|
||||
return null;
|
||||
}
|
||||
usort($matches, static function ($a, $b) {
|
||||
return filemtime($b) <=> filemtime($a);
|
||||
});
|
||||
return $matches[0] ?? null;
|
||||
}
|
||||
|
||||
private static function findOriginalPath(string $dir): ?string
|
||||
{
|
||||
$matches = glob($dir . '/original.*');
|
||||
if (!$matches) {
|
||||
return null;
|
||||
}
|
||||
usort($matches, static function ($a, $b) {
|
||||
return filemtime($b) <=> filemtime($a);
|
||||
});
|
||||
return $matches[0] ?? null;
|
||||
}
|
||||
|
||||
private static function canResize(): bool
|
||||
{
|
||||
return function_exists('imagecreatetruecolor') && function_exists('imagecopyresampled');
|
||||
}
|
||||
|
||||
private static function createImageResource(string $path, string $mime)
|
||||
{
|
||||
if ($mime === 'image/jpeg' && function_exists('imagecreatefromjpeg')) {
|
||||
return imagecreatefromjpeg($path);
|
||||
}
|
||||
if ($mime === 'image/png' && function_exists('imagecreatefrompng')) {
|
||||
return imagecreatefrompng($path);
|
||||
}
|
||||
if ($mime === 'image/webp' && function_exists('imagecreatefromwebp')) {
|
||||
return imagecreatefromwebp($path);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function resizeAndFit(string $sourcePath, string $targetPath, int $width, int $height, string $ext): bool
|
||||
{
|
||||
$mime = self::detectMime($sourcePath);
|
||||
$src = self::createImageResource($sourcePath, $mime);
|
||||
if (!$src) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$srcWidth = imagesx($src);
|
||||
$srcHeight = imagesy($src);
|
||||
if ($srcWidth === 0 || $srcHeight === 0) {
|
||||
imagedestroy($src);
|
||||
return false;
|
||||
}
|
||||
|
||||
$scale = min($width / $srcWidth, $height / $srcHeight);
|
||||
$dstWidth = (int) round($srcWidth * $scale);
|
||||
$dstHeight = (int) round($srcHeight * $scale);
|
||||
if ($dstWidth < 1) $dstWidth = 1;
|
||||
if ($dstHeight < 1) $dstHeight = 1;
|
||||
|
||||
$dst = imagecreatetruecolor($dstWidth, $dstHeight);
|
||||
if ($ext === 'png' || $ext === 'webp') {
|
||||
imagealphablending($dst, false);
|
||||
imagesavealpha($dst, true);
|
||||
$transparent = imagecolorallocatealpha($dst, 0, 0, 0, 127);
|
||||
imagefilledrectangle($dst, 0, 0, $dstWidth, $dstHeight, $transparent);
|
||||
}
|
||||
|
||||
imagecopyresampled(
|
||||
$dst,
|
||||
$src,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
$dstWidth,
|
||||
$dstHeight,
|
||||
$srcWidth,
|
||||
$srcHeight
|
||||
);
|
||||
|
||||
$saved = false;
|
||||
if ($ext === 'webp' && function_exists('imagewebp')) {
|
||||
$saved = imagewebp($dst, $targetPath, 82);
|
||||
} elseif ($ext === 'png' && function_exists('imagepng')) {
|
||||
$saved = imagepng($dst, $targetPath, 6);
|
||||
} else {
|
||||
$saved = imagejpeg($dst, $targetPath, 85);
|
||||
}
|
||||
|
||||
imagedestroy($src);
|
||||
imagedestroy($dst);
|
||||
|
||||
if ($saved) {
|
||||
@chmod($targetPath, 0644);
|
||||
}
|
||||
return $saved;
|
||||
}
|
||||
}
|
||||
804
lib/Service/UserService.php
Normal file
804
lib/Service/UserService.php
Normal file
@@ -0,0 +1,804 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service;
|
||||
|
||||
use MintyPHP\I18n;
|
||||
use MintyPHP\Repository\DepartmentRepository;
|
||||
use MintyPHP\Repository\RoleRepository;
|
||||
use MintyPHP\Repository\TenantRepository;
|
||||
use MintyPHP\Repository\UserRepository;
|
||||
use MintyPHP\Repository\UserDepartmentRepository;
|
||||
use MintyPHP\Repository\UserRoleRepository;
|
||||
use MintyPHP\Repository\UserTenantRepository;
|
||||
use MintyPHP\Repository\TenantDepartmentRepository;
|
||||
use MintyPHP\Service\PermissionService;
|
||||
use MintyPHP\Service\TenantScopeService;
|
||||
|
||||
class UserService
|
||||
{
|
||||
private const PASSWORD_MIN_LENGTH = 12;
|
||||
public static function list(): array
|
||||
{
|
||||
return UserRepository::list();
|
||||
}
|
||||
|
||||
public static function listPaged(array $options): array
|
||||
{
|
||||
if (!empty($options['tenantUserId'])) {
|
||||
$tenantUserId = (int) $options['tenantUserId'];
|
||||
if ($tenantUserId > 0 && TenantScopeService::hasGlobalAccess($tenantUserId)) {
|
||||
unset($options['tenantUserId']);
|
||||
}
|
||||
}
|
||||
return UserRepository::listPaged($options);
|
||||
}
|
||||
|
||||
public static function findByUuid(string $uuid): ?array
|
||||
{
|
||||
return UserRepository::findByUuid($uuid);
|
||||
}
|
||||
|
||||
public static function findById(int $id): ?array
|
||||
{
|
||||
return UserRepository::find($id);
|
||||
}
|
||||
|
||||
public static function findByEmail(string $email): ?array
|
||||
{
|
||||
return UserRepository::findByEmail($email);
|
||||
}
|
||||
|
||||
public static function setLocale(int $userId, string $locale): bool
|
||||
{
|
||||
return UserRepository::setLocale($userId, $locale);
|
||||
}
|
||||
|
||||
public static function setTheme(int $userId, string $theme): bool
|
||||
{
|
||||
$theme = self::normalizeTheme($theme);
|
||||
return UserRepository::setTheme($userId, $theme);
|
||||
}
|
||||
|
||||
public static function deleteByUuid(string $uuid, int $currentUserId = 0): array
|
||||
{
|
||||
$uuid = trim($uuid);
|
||||
if ($uuid === '') {
|
||||
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
|
||||
}
|
||||
|
||||
$user = UserRepository::findByUuid($uuid);
|
||||
if (!$user || !isset($user['id'])) {
|
||||
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
|
||||
}
|
||||
|
||||
$userId = (int) $user['id'];
|
||||
if ($currentUserId && $currentUserId === $userId) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'status' => 400,
|
||||
'error' => 'self_delete',
|
||||
'message' => t('You cannot delete your own account'),
|
||||
];
|
||||
}
|
||||
|
||||
$deleted = UserRepository::delete($userId);
|
||||
if (!$deleted) {
|
||||
return ['ok' => false, 'status' => 500, 'error' => 'delete_failed'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'user' => $user];
|
||||
}
|
||||
|
||||
public static function deleteByUuids(array $uuids, int $currentUserId = 0): array
|
||||
{
|
||||
$uuids = array_values(array_filter(array_map('trim', $uuids)));
|
||||
if (!$uuids) {
|
||||
return ['ok' => false, 'error' => 'no_selection'];
|
||||
}
|
||||
|
||||
if ($currentUserId > 0) {
|
||||
$uuids = self::filterUuidsByTenantScope($uuids, $currentUserId);
|
||||
if (!$uuids) {
|
||||
return ['ok' => false, 'error' => 'permission_denied'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($currentUserId > 0) {
|
||||
$currentUser = UserRepository::find($currentUserId);
|
||||
$currentUuid = $currentUser['uuid'] ?? '';
|
||||
if ($currentUuid !== '') {
|
||||
$uuids = array_values(array_filter($uuids, static fn ($uuid) => $uuid !== $currentUuid));
|
||||
}
|
||||
if (!$uuids) {
|
||||
return ['ok' => false, 'error' => 'self_delete'];
|
||||
}
|
||||
}
|
||||
|
||||
$deleted = UserRepository::deleteByUuids($uuids);
|
||||
if (!$deleted) {
|
||||
return ['ok' => false, 'error' => 'delete_failed'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'count' => count($uuids)];
|
||||
}
|
||||
|
||||
public static function createFromAdmin(array $input, int $currentUserId = 0): array
|
||||
{
|
||||
$form = self::sanitizeBase($input);
|
||||
$form['totp_secret'] = trim((string) ($input['totp_secret'] ?? ''));
|
||||
$form['theme'] = self::normalizeTheme($input['theme'] ?? null);
|
||||
$form['locale'] = self::normalizeLocale($input['locale'] ?? null);
|
||||
$primaryTenantId = (int) ($input['primary_tenant_id'] ?? 0);
|
||||
if (array_key_exists('active', $input)) {
|
||||
$form['active'] = isset($input['active']) ? 1 : 0;
|
||||
} else {
|
||||
$form['active'] = 1;
|
||||
}
|
||||
$password = (string) ($input['password'] ?? '');
|
||||
$password2 = (string) ($input['password2'] ?? '');
|
||||
|
||||
$errors = self::validateBase($form);
|
||||
$errors = array_merge($errors, self::validatePassword($password, $password2, true, $form['email']));
|
||||
|
||||
$tenantIds = $input['tenant_ids'] ?? [];
|
||||
if (!is_array($tenantIds)) {
|
||||
$tenantIds = [$tenantIds];
|
||||
}
|
||||
$tenantIds = self::normalizeTenantIds($tenantIds);
|
||||
if (!$tenantIds) {
|
||||
$defaultTenantId = SettingService::getDefaultTenantId();
|
||||
if ($defaultTenantId) {
|
||||
$tenantIds = [$defaultTenantId];
|
||||
}
|
||||
}
|
||||
[$primaryTenantId, $primaryErrors] = self::normalizePrimaryTenant($primaryTenantId, $tenantIds);
|
||||
$errors = array_merge($errors, $primaryErrors);
|
||||
|
||||
if ($errors) {
|
||||
$form['primary_tenant_id'] = $primaryTenantId;
|
||||
return ['ok' => false, 'errors' => $errors, 'form' => $form];
|
||||
}
|
||||
|
||||
$activeChangedAt = gmdate('Y-m-d H:i:s');
|
||||
$created = UserRepository::create([
|
||||
'first_name' => $form['first_name'],
|
||||
'last_name' => $form['last_name'],
|
||||
'email' => $form['email'],
|
||||
'profile_description' => $form['profile_description'] !== '' ? $form['profile_description'] : null,
|
||||
'job_title' => $form['job_title'] !== '' ? $form['job_title'] : null,
|
||||
'phone' => $form['phone'] !== '' ? $form['phone'] : null,
|
||||
'mobile' => $form['mobile'] !== '' ? $form['mobile'] : null,
|
||||
'short_dial' => $form['short_dial'] !== '' ? $form['short_dial'] : null,
|
||||
'address' => $form['address'] !== '' ? $form['address'] : null,
|
||||
'postal_code' => $form['postal_code'] !== '' ? $form['postal_code'] : null,
|
||||
'city' => $form['city'] !== '' ? $form['city'] : null,
|
||||
'country' => $form['country'] !== '' ? $form['country'] : null,
|
||||
'region' => $form['region'] !== '' ? $form['region'] : null,
|
||||
'hire_date' => $form['hire_date'] !== '' ? $form['hire_date'] : null,
|
||||
'password' => $password,
|
||||
'locale' => $form['locale'],
|
||||
'totp_secret' => $form['totp_secret'],
|
||||
'theme' => $form['theme'],
|
||||
'primary_tenant_id' => $primaryTenantId > 0 ? $primaryTenantId : null,
|
||||
'active' => $form['active'],
|
||||
'created_by' => $currentUserId > 0 ? $currentUserId : null,
|
||||
'active_changed_at' => $activeChangedAt,
|
||||
'active_changed_by' => $currentUserId > 0 ? $currentUserId : null,
|
||||
]);
|
||||
|
||||
if (!$created) {
|
||||
return ['ok' => false, 'errors' => [t('User can not be registered')], 'form' => $form];
|
||||
}
|
||||
|
||||
$createdUser = UserRepository::findByEmail($form['email']);
|
||||
$uuid = $createdUser['uuid'] ?? null;
|
||||
$userId = (int) ($createdUser['id'] ?? 0);
|
||||
|
||||
if ($userId > 0 && $tenantIds) {
|
||||
self::syncTenants($userId, $tenantIds);
|
||||
}
|
||||
|
||||
$roleIds = self::normalizeIdInput($input['role_ids'] ?? []);
|
||||
if (!$roleIds) {
|
||||
$defaultRoleId = SettingService::getDefaultRoleId();
|
||||
if ($defaultRoleId) {
|
||||
$roleIds = [$defaultRoleId];
|
||||
}
|
||||
}
|
||||
if ($userId > 0 && $roleIds) {
|
||||
self::syncRoles($userId, $roleIds);
|
||||
}
|
||||
|
||||
$departmentIds = self::normalizeIdInput($input['department_ids'] ?? []);
|
||||
if (!$departmentIds) {
|
||||
$defaultDepartmentId = SettingService::getDefaultDepartmentId();
|
||||
if ($defaultDepartmentId) {
|
||||
$departmentIds = [$defaultDepartmentId];
|
||||
}
|
||||
}
|
||||
if ($userId > 0 && $departmentIds) {
|
||||
self::syncDepartments($userId, $departmentIds);
|
||||
}
|
||||
|
||||
return ['ok' => true, 'form' => $form, 'uuid' => $uuid];
|
||||
}
|
||||
|
||||
public static function updateFromAdmin(int $userId, array $input, int $currentUserId = 0): array
|
||||
{
|
||||
$form = self::sanitizeBase($input);
|
||||
$form['totp_secret'] = trim((string) ($input['totp_secret'] ?? ''));
|
||||
$form['theme'] = self::normalizeTheme($input['theme'] ?? null);
|
||||
$form['locale'] = self::normalizeLocale($input['locale'] ?? null);
|
||||
$primaryTenantId = (int) ($input['primary_tenant_id'] ?? 0);
|
||||
$tenantIdsProvided = array_key_exists('tenant_ids', $input);
|
||||
$primaryProvided = array_key_exists('primary_tenant_id', $input);
|
||||
if ($tenantIdsProvided) {
|
||||
$tenantIds = $input['tenant_ids'] ?? [];
|
||||
if (!is_array($tenantIds)) {
|
||||
$tenantIds = [$tenantIds];
|
||||
}
|
||||
$tenantIds = self::normalizeTenantIds($tenantIds);
|
||||
} else {
|
||||
$tenantIds = UserTenantRepository::listTenantIdsByUserId($userId);
|
||||
}
|
||||
if (array_key_exists('active', $input)) {
|
||||
$form['active'] = isset($input['active']) ? 1 : 0;
|
||||
} else {
|
||||
$existing = UserRepository::find($userId);
|
||||
$form['active'] = (int) ($existing['active'] ?? 1);
|
||||
}
|
||||
$password = (string) ($input['password'] ?? '');
|
||||
$password2 = (string) ($input['password2'] ?? '');
|
||||
|
||||
$errors = self::validateBase($form, $userId);
|
||||
if ($userId === $currentUserId && !$form['active']) {
|
||||
$errors[] = t('You cannot deactivate your own account');
|
||||
}
|
||||
$errors = array_merge($errors, self::validatePassword($password, $password2, false, $form['email']));
|
||||
if ($tenantIds && ($tenantIdsProvided || $primaryProvided)) {
|
||||
[$primaryTenantId, $primaryErrors] = self::normalizePrimaryTenant($primaryTenantId, $tenantIds);
|
||||
$errors = array_merge($errors, $primaryErrors);
|
||||
}
|
||||
|
||||
if ($errors) {
|
||||
if ($tenantIdsProvided || $primaryProvided) {
|
||||
$form['primary_tenant_id'] = $primaryTenantId;
|
||||
}
|
||||
return ['ok' => false, 'errors' => $errors, 'form' => $form];
|
||||
}
|
||||
|
||||
if ($tenantIdsProvided || $primaryProvided) {
|
||||
$form['primary_tenant_id'] = $primaryTenantId > 0 ? $primaryTenantId : null;
|
||||
}
|
||||
|
||||
$updateData = [
|
||||
'first_name' => $form['first_name'],
|
||||
'last_name' => $form['last_name'],
|
||||
'email' => $form['email'],
|
||||
'profile_description' => $form['profile_description'] !== '' ? $form['profile_description'] : null,
|
||||
'job_title' => $form['job_title'] !== '' ? $form['job_title'] : null,
|
||||
'phone' => $form['phone'] !== '' ? $form['phone'] : null,
|
||||
'mobile' => $form['mobile'] !== '' ? $form['mobile'] : null,
|
||||
'short_dial' => $form['short_dial'] !== '' ? $form['short_dial'] : null,
|
||||
'address' => $form['address'] !== '' ? $form['address'] : null,
|
||||
'postal_code' => $form['postal_code'] !== '' ? $form['postal_code'] : null,
|
||||
'city' => $form['city'] !== '' ? $form['city'] : null,
|
||||
'country' => $form['country'] !== '' ? $form['country'] : null,
|
||||
'region' => $form['region'] !== '' ? $form['region'] : null,
|
||||
'hire_date' => $form['hire_date'] !== '' ? $form['hire_date'] : null,
|
||||
'totp_secret' => $form['totp_secret'],
|
||||
'theme' => $form['theme'],
|
||||
'locale' => $form['locale'],
|
||||
'active' => $form['active'],
|
||||
'password' => $password,
|
||||
'modified_by' => $currentUserId > 0 ? $currentUserId : null,
|
||||
];
|
||||
if ($tenantIdsProvided || $primaryProvided) {
|
||||
$updateData['primary_tenant_id'] = $form['primary_tenant_id'] ?? null;
|
||||
}
|
||||
|
||||
if ((int) ($existing['active'] ?? 1) !== (int) $form['active']) {
|
||||
$updateData['active_changed_at'] = gmdate('Y-m-d H:i:s');
|
||||
$updateData['active_changed_by'] = $currentUserId > 0 ? $currentUserId : null;
|
||||
}
|
||||
|
||||
$updated = UserRepository::update($userId, $updateData);
|
||||
|
||||
if (!$updated) {
|
||||
return ['ok' => false, 'errors' => [t('User can not be updated')], 'form' => $form];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'form' => $form];
|
||||
}
|
||||
|
||||
public static function setActiveByUuid(string $uuid, bool $active, int $currentUserId = 0): array
|
||||
{
|
||||
$uuid = trim($uuid);
|
||||
if ($uuid === '') {
|
||||
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
|
||||
}
|
||||
|
||||
$user = UserRepository::findByUuid($uuid);
|
||||
if (!$user || !isset($user['id'])) {
|
||||
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
|
||||
}
|
||||
|
||||
$userId = (int) $user['id'];
|
||||
if (!$active && $currentUserId && $currentUserId === $userId) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'status' => 400,
|
||||
'error' => 'self_deactivate',
|
||||
'message' => t('You cannot deactivate your own account'),
|
||||
];
|
||||
}
|
||||
|
||||
$updated = UserRepository::setActive($userId, $active, $currentUserId > 0 ? $currentUserId : null);
|
||||
if (!$updated) {
|
||||
return ['ok' => false, 'status' => 500, 'error' => 'update_failed'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'user' => $user];
|
||||
}
|
||||
|
||||
public static function setActiveByUuids(array $uuids, bool $active, int $currentUserId = 0): array
|
||||
{
|
||||
$uuids = array_values(array_filter(array_map('trim', $uuids)));
|
||||
if (!$uuids) {
|
||||
return ['ok' => false, 'error' => 'no_selection'];
|
||||
}
|
||||
|
||||
if ($currentUserId > 0) {
|
||||
$uuids = self::filterUuidsByTenantScope($uuids, $currentUserId);
|
||||
if (!$uuids) {
|
||||
return ['ok' => false, 'error' => 'permission_denied'];
|
||||
}
|
||||
}
|
||||
|
||||
$updated = UserRepository::setActiveByUuids($uuids, $active, $currentUserId > 0 ? $currentUserId : null);
|
||||
if (!$updated) {
|
||||
return ['ok' => false, 'error' => 'update_failed'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'count' => count($uuids)];
|
||||
}
|
||||
|
||||
private static function filterUuidsByTenantScope(array $uuids, int $currentUserId): array
|
||||
{
|
||||
$allowed = [];
|
||||
foreach ($uuids as $uuid) {
|
||||
$user = UserRepository::findByUuid((string) $uuid);
|
||||
if (!$user || !isset($user['id'])) {
|
||||
continue;
|
||||
}
|
||||
$userId = (int) $user['id'];
|
||||
if ($userId === $currentUserId) {
|
||||
$allowed[] = (string) $uuid;
|
||||
continue;
|
||||
}
|
||||
if (TenantScopeService::canAccess('users', $userId, $currentUserId)) {
|
||||
$allowed[] = (string) $uuid;
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($allowed));
|
||||
}
|
||||
|
||||
public static function register(array $input): array
|
||||
{
|
||||
$form = self::sanitizeBase($input);
|
||||
$password = (string) ($input['password'] ?? '');
|
||||
$password2 = (string) ($input['password2'] ?? '');
|
||||
|
||||
$errors = self::validateBase($form);
|
||||
$errors = array_merge($errors, self::validatePassword($password, $password2, true, $form['email']));
|
||||
|
||||
if ($errors) {
|
||||
return ['ok' => false, 'error' => $errors[0]];
|
||||
}
|
||||
|
||||
$defaultTheme = SettingService::getAppTheme();
|
||||
$defaultTenantId = SettingService::getDefaultTenantId();
|
||||
$activeChangedAt = gmdate('Y-m-d H:i:s');
|
||||
$created = UserRepository::create([
|
||||
'first_name' => $form['first_name'],
|
||||
'last_name' => $form['last_name'],
|
||||
'email' => $form['email'],
|
||||
'password' => $password,
|
||||
'locale' => I18n::$locale,
|
||||
'totp_secret' => '',
|
||||
'theme' => self::normalizeTheme($defaultTheme ?? 'light'),
|
||||
'primary_tenant_id' => $defaultTenantId ?: null,
|
||||
'active' => 1,
|
||||
'created_by' => null,
|
||||
'active_changed_at' => $activeChangedAt,
|
||||
'active_changed_by' => null,
|
||||
]);
|
||||
|
||||
if (!$created) {
|
||||
return ['ok' => false, 'error' => t('User can not be registered')];
|
||||
}
|
||||
|
||||
$createdUser = UserRepository::findByEmail($form['email']);
|
||||
$userId = (int) ($createdUser['id'] ?? 0);
|
||||
if ($userId > 0) {
|
||||
if ($defaultTenantId) {
|
||||
self::syncTenants($userId, [$defaultTenantId]);
|
||||
}
|
||||
$defaultRoleId = SettingService::getDefaultRoleId();
|
||||
if ($defaultRoleId) {
|
||||
self::syncRoles($userId, [$defaultRoleId]);
|
||||
}
|
||||
$defaultDepartmentId = SettingService::getDefaultDepartmentId();
|
||||
if ($defaultDepartmentId) {
|
||||
self::syncDepartments($userId, [$defaultDepartmentId]);
|
||||
}
|
||||
}
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
public static function syncTenants(int $userId, array $tenantIds): bool
|
||||
{
|
||||
$ids = self::normalizeTenantIds($tenantIds);
|
||||
return UserTenantRepository::replaceForUser($userId, $ids);
|
||||
}
|
||||
|
||||
public static function syncRoles(int $userId, array $roleIds): bool
|
||||
{
|
||||
$ids = array_values(array_unique(array_map('intval', $roleIds)));
|
||||
$ids = array_filter($ids, static fn ($id) => $id > 0);
|
||||
$validIds = RoleRepository::listIds();
|
||||
if ($validIds) {
|
||||
$validMap = array_fill_keys($validIds, true);
|
||||
$ids = array_values(array_filter($ids, static fn ($id) => isset($validMap[$id])));
|
||||
}
|
||||
$result = UserRoleRepository::replaceForUser($userId, $ids);
|
||||
PermissionService::clearUserCache($userId);
|
||||
return $result;
|
||||
}
|
||||
|
||||
public static function syncDepartments(int $userId, array $departmentIds): bool
|
||||
{
|
||||
$ids = array_values(array_unique(array_map('intval', $departmentIds)));
|
||||
$ids = array_filter($ids, static fn ($id) => $id > 0);
|
||||
$userTenantIds = TenantScopeService::getUserTenantIds($userId);
|
||||
if (!$userTenantIds) {
|
||||
$ids = [];
|
||||
} else {
|
||||
$allowedIds = TenantDepartmentRepository::listDepartmentIdsByTenantIds($userTenantIds);
|
||||
if ($allowedIds) {
|
||||
$allowedMap = array_fill_keys($allowedIds, true);
|
||||
$ids = array_values(array_filter($ids, static fn ($id) => isset($allowedMap[$id])));
|
||||
} else {
|
||||
$ids = [];
|
||||
}
|
||||
}
|
||||
return UserDepartmentRepository::replaceForUser($userId, $ids);
|
||||
}
|
||||
|
||||
private static function sanitizeBase(array $input): array
|
||||
{
|
||||
return [
|
||||
'first_name' => trim((string) ($input['first_name'] ?? '')),
|
||||
'last_name' => trim((string) ($input['last_name'] ?? '')),
|
||||
'email' => trim((string) ($input['email'] ?? '')),
|
||||
'profile_description' => trim((string) ($input['profile_description'] ?? '')),
|
||||
'job_title' => trim((string) ($input['job_title'] ?? '')),
|
||||
'phone' => trim((string) ($input['phone'] ?? '')),
|
||||
'mobile' => trim((string) ($input['mobile'] ?? '')),
|
||||
'short_dial' => trim((string) ($input['short_dial'] ?? '')),
|
||||
'address' => trim((string) ($input['address'] ?? '')),
|
||||
'postal_code' => trim((string) ($input['postal_code'] ?? '')),
|
||||
'city' => trim((string) ($input['city'] ?? '')),
|
||||
'country' => trim((string) ($input['country'] ?? '')),
|
||||
'region' => trim((string) ($input['region'] ?? '')),
|
||||
'hire_date' => trim((string) ($input['hire_date'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
public static function normalizeIdInput($value): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
$value = [$value];
|
||||
}
|
||||
$ids = array_values(array_unique(array_map('intval', $value)));
|
||||
return array_values(array_filter($ids, static fn ($id) => $id > 0));
|
||||
}
|
||||
|
||||
private static function normalizeTenantIds(array $tenantIds): array
|
||||
{
|
||||
$ids = array_values(array_unique(array_map('intval', $tenantIds)));
|
||||
$ids = array_filter($ids, static fn ($id) => $id > 0);
|
||||
$validIds = TenantRepository::listIds();
|
||||
if ($validIds) {
|
||||
$validMap = array_fill_keys($validIds, true);
|
||||
$ids = array_values(array_filter($ids, static fn ($id) => isset($validMap[$id])));
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
|
||||
private static function normalizeTheme($value): string
|
||||
{
|
||||
$theme = strtolower(trim((string) $value));
|
||||
if (!in_array($theme, ['dark', 'light'], true)) {
|
||||
return 'light';
|
||||
}
|
||||
return $theme;
|
||||
}
|
||||
|
||||
private static function normalizeLocale($value): string
|
||||
{
|
||||
$locale = strtolower(trim((string) $value));
|
||||
$available = defined('APP_LOCALES') ? APP_LOCALES : [I18n::$defaultLocale];
|
||||
if ($locale === '' || !in_array($locale, $available, true)) {
|
||||
return I18n::$locale ?? I18n::$defaultLocale;
|
||||
}
|
||||
return $locale;
|
||||
}
|
||||
|
||||
private static function validateBase(array $form, ?int $excludeId = null): array
|
||||
{
|
||||
$errors = [];
|
||||
if ($form['first_name'] === '') {
|
||||
$errors[] = t('First name cannot be empty');
|
||||
}
|
||||
if ($form['last_name'] === '') {
|
||||
$errors[] = t('Last name cannot be empty');
|
||||
}
|
||||
if ($form['email'] === '') {
|
||||
$errors[] = t('Email cannot be empty');
|
||||
} elseif (!filter_var($form['email'], FILTER_VALIDATE_EMAIL)) {
|
||||
$errors[] = t('Email is not valid');
|
||||
} else {
|
||||
$existing = UserRepository::findByEmail($form['email']);
|
||||
if ($existing && (!isset($existing['id']) || (int) $existing['id'] !== (int) $excludeId)) {
|
||||
$errors[] = t('Email is already taken');
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
private static function normalizePrimaryTenant(int $primaryTenantId, array $tenantIds): array
|
||||
{
|
||||
if (!$tenantIds) {
|
||||
return [0, []];
|
||||
}
|
||||
if ($primaryTenantId > 0 && !in_array($primaryTenantId, $tenantIds, true)) {
|
||||
return [$primaryTenantId, [t('Primary tenant must be one of the assigned tenants')]];
|
||||
}
|
||||
if ($primaryTenantId === 0 && count($tenantIds) > 1) {
|
||||
return [0, [t('Please select a primary tenant')]];
|
||||
}
|
||||
if ($primaryTenantId === 0 && count($tenantIds) === 1) {
|
||||
return [(int) $tenantIds[0], []];
|
||||
}
|
||||
return [$primaryTenantId, []];
|
||||
}
|
||||
|
||||
private static function validatePassword(
|
||||
string $password,
|
||||
string $password2,
|
||||
bool $required,
|
||||
?string $email
|
||||
): array {
|
||||
return self::validatePasswordStrength($password, $password2, $required, $email);
|
||||
}
|
||||
|
||||
public static function passwordHints(): array
|
||||
{
|
||||
return [
|
||||
['rule' => 'min', 'text' => t('At least %d characters', self::PASSWORD_MIN_LENGTH)],
|
||||
['rule' => 'upper', 'text' => t('At least one uppercase letter')],
|
||||
['rule' => 'lower', 'text' => t('At least one lowercase letter')],
|
||||
['rule' => 'number', 'text' => t('At least one number')],
|
||||
['rule' => 'symbol', 'text' => t('At least one symbol')],
|
||||
['rule' => 'email', 'text' => t('Must not contain your email')],
|
||||
];
|
||||
}
|
||||
|
||||
public static function passwordMinLength(): int
|
||||
{
|
||||
return self::PASSWORD_MIN_LENGTH;
|
||||
}
|
||||
|
||||
public static function resetPassword(int $userId, string $password, string $password2): array
|
||||
{
|
||||
$errors = self::validatePasswordStrength($password, $password2, true, '');
|
||||
if ($errors) {
|
||||
return ['ok' => false, 'errors' => $errors];
|
||||
}
|
||||
|
||||
$updated = UserRepository::setPassword($userId, $password);
|
||||
if (!$updated) {
|
||||
return ['ok' => false, 'errors' => [t('Password can not be updated')]];
|
||||
}
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
private static function validatePasswordStrength(
|
||||
string $password,
|
||||
string $password2,
|
||||
bool $required,
|
||||
?string $email
|
||||
): array {
|
||||
$errors = [];
|
||||
if ($required && $password === '') {
|
||||
$errors[] = t('Password cannot be empty');
|
||||
return $errors;
|
||||
}
|
||||
if ($password !== '' && $password !== $password2) {
|
||||
$errors[] = t('Passwords must match');
|
||||
}
|
||||
if ($password === '') {
|
||||
return $errors;
|
||||
}
|
||||
if (strlen($password) < self::PASSWORD_MIN_LENGTH) {
|
||||
$errors[] = t('Password must be at least %d characters', self::PASSWORD_MIN_LENGTH);
|
||||
}
|
||||
if (!preg_match('/[A-Z]/', $password)) {
|
||||
$errors[] = t('Password must include an uppercase letter');
|
||||
}
|
||||
if (!preg_match('/[a-z]/', $password)) {
|
||||
$errors[] = t('Password must include a lowercase letter');
|
||||
}
|
||||
if (!preg_match('/\\d/', $password)) {
|
||||
$errors[] = t('Password must include a number');
|
||||
}
|
||||
if (!preg_match('/[^a-zA-Z0-9]/', $password)) {
|
||||
$errors[] = t('Password must include a symbol');
|
||||
}
|
||||
if ($email) {
|
||||
$email = trim((string) $email);
|
||||
if ($email !== '' && stripos($password, $email) !== false) {
|
||||
$errors[] = t('Password must not contain your email');
|
||||
}
|
||||
}
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current tenant ID for a user (with fallback to primary tenant)
|
||||
*/
|
||||
public static function getCurrentTenantId(int $userId): ?int
|
||||
{
|
||||
$user = UserRepository::find($userId);
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$activeTenantIds = TenantScopeService::getUserTenantIds($userId);
|
||||
if (!$activeTenantIds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$currentTenantId = (int) ($user['current_tenant_id'] ?? 0);
|
||||
if ($currentTenantId > 0 && in_array($currentTenantId, $activeTenantIds, true)) {
|
||||
return $currentTenantId;
|
||||
}
|
||||
|
||||
$primaryTenantId = (int) ($user['primary_tenant_id'] ?? 0);
|
||||
if ($primaryTenantId > 0 && in_array($primaryTenantId, $activeTenantIds, true)) {
|
||||
return $primaryTenantId;
|
||||
}
|
||||
|
||||
return $activeTenantIds[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current tenant data for a user
|
||||
*/
|
||||
public static function getCurrentTenant(int $userId): ?array
|
||||
{
|
||||
$currentTenantId = self::getCurrentTenantId($userId);
|
||||
if (!$currentTenantId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return TenantRepository::find($currentTenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tenants the user has access to
|
||||
*/
|
||||
public static function getAvailableTenants(int $userId): array
|
||||
{
|
||||
$tenantIds = TenantScopeService::getUserTenantIds($userId);
|
||||
if (!$tenantIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$tenants = [];
|
||||
foreach ($tenantIds as $tenantId) {
|
||||
$tenant = TenantRepository::find($tenantId);
|
||||
if ($tenant && (($tenant['status'] ?? 'active') === 'active')) {
|
||||
$tenants[] = $tenant;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by description
|
||||
usort($tenants, static function ($a, $b) {
|
||||
return strcmp((string) ($a['description'] ?? ''), (string) ($b['description'] ?? ''));
|
||||
});
|
||||
|
||||
return $tenants;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available departments grouped by tenant for a user.
|
||||
* Returns an array of groups: ['tenant' => [...], 'departments' => [...]]
|
||||
*/
|
||||
public static function getAvailableDepartmentsByTenant(int $userId): array
|
||||
{
|
||||
$tenants = self::getAvailableTenants($userId);
|
||||
if (!$tenants) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$groups = [];
|
||||
foreach ($tenants as $tenant) {
|
||||
$tenantId = (int) ($tenant['id'] ?? 0);
|
||||
if ($tenantId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$departmentIds = TenantDepartmentRepository::listDepartmentIdsByTenantIds([$tenantId]);
|
||||
$departments = $departmentIds ? DepartmentRepository::listByIds($departmentIds) : [];
|
||||
usort($departments, static function ($a, $b) {
|
||||
return strcmp((string) ($a['description'] ?? ''), (string) ($b['description'] ?? ''));
|
||||
});
|
||||
$departments = array_map(static function (array $department): array {
|
||||
return [
|
||||
'id' => (int) ($department['id'] ?? 0),
|
||||
'uuid' => $department['uuid'] ?? '',
|
||||
'description' => $department['description'] ?? '',
|
||||
];
|
||||
}, $departments);
|
||||
|
||||
$groups[] = [
|
||||
'tenant' => [
|
||||
'id' => (int) ($tenant['id'] ?? 0),
|
||||
'uuid' => $tenant['uuid'] ?? '',
|
||||
'description' => $tenant['description'] ?? '',
|
||||
],
|
||||
'departments' => $departments,
|
||||
];
|
||||
}
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current tenant for a user (with validation)
|
||||
*/
|
||||
public static function setCurrentTenant(int $userId, int $tenantId): array
|
||||
{
|
||||
if ($tenantId <= 0) {
|
||||
return ['ok' => false, 'error' => 'invalid_tenant'];
|
||||
}
|
||||
|
||||
// Verify user has access to this tenant
|
||||
$userTenantIds = UserTenantRepository::listTenantIdsByUserId($userId);
|
||||
if (!in_array($tenantId, $userTenantIds, true)) {
|
||||
return ['ok' => false, 'error' => 'tenant_not_assigned'];
|
||||
}
|
||||
|
||||
// Verify tenant exists
|
||||
$tenant = TenantRepository::find($tenantId);
|
||||
if (!$tenant) {
|
||||
return ['ok' => false, 'error' => 'tenant_not_found'];
|
||||
}
|
||||
if (($tenant['status'] ?? 'active') !== 'active') {
|
||||
return ['ok' => false, 'error' => 'tenant_inactive'];
|
||||
}
|
||||
|
||||
// Update current_tenant_id
|
||||
$updated = UserRepository::setCurrentTenant($userId, $tenantId);
|
||||
|
||||
if (!$updated) {
|
||||
return ['ok' => false, 'error' => 'update_failed'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'tenant' => $tenant];
|
||||
}
|
||||
}
|
||||
103
lib/Support/Flash.php
Normal file
103
lib/Support/Flash.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Support;
|
||||
|
||||
use MintyPHP\Session;
|
||||
|
||||
class Flash
|
||||
{
|
||||
private const SESSION_KEY = 'flash_messages';
|
||||
private const KEEP_KEY = 'flash_keep';
|
||||
|
||||
private static function ensureSession()
|
||||
{
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
Session::start();
|
||||
}
|
||||
if (!isset($_SESSION[self::SESSION_KEY])) {
|
||||
$_SESSION[self::SESSION_KEY] = [];
|
||||
}
|
||||
}
|
||||
|
||||
public static function add(string $type, string $message, ?string $scope = null, ?string $key = null): string
|
||||
{
|
||||
self::ensureSession();
|
||||
if ($key !== null) {
|
||||
$_SESSION[self::SESSION_KEY] = array_values(array_filter(
|
||||
$_SESSION[self::SESSION_KEY],
|
||||
function ($existing) use ($key, $scope) {
|
||||
$sameKey = ($existing['key'] ?? null) === $key;
|
||||
$sameScope = ($existing['scope'] ?? null) === $scope;
|
||||
return !($sameKey && $sameScope);
|
||||
}
|
||||
));
|
||||
}
|
||||
$id = bin2hex(random_bytes(8));
|
||||
$_SESSION[self::SESSION_KEY][] = [
|
||||
'id' => $id,
|
||||
'type' => $type,
|
||||
'message' => $message,
|
||||
'scope' => $scope,
|
||||
'key' => $key,
|
||||
];
|
||||
return $id;
|
||||
}
|
||||
|
||||
public static function success(string $message, ?string $scope = null, ?string $key = null): string
|
||||
{
|
||||
return self::add('success', $message, $scope, $key);
|
||||
}
|
||||
|
||||
public static function error(string $message, ?string $scope = null, ?string $key = null): string
|
||||
{
|
||||
return self::add('error', $message, $scope, $key);
|
||||
}
|
||||
|
||||
public static function info(string $message, ?string $scope = null, ?string $key = null): string
|
||||
{
|
||||
return self::add('info', $message, $scope, $key);
|
||||
}
|
||||
|
||||
public static function peek(?string $scope = null): array
|
||||
{
|
||||
self::ensureSession();
|
||||
$messages = $_SESSION[self::SESSION_KEY] ?? [];
|
||||
if ($scope === null) {
|
||||
return $messages;
|
||||
}
|
||||
|
||||
return array_values(array_filter($messages, function ($message) use ($scope) {
|
||||
$messageScope = $message['scope'] ?? null;
|
||||
return $messageScope === null || $messageScope === $scope;
|
||||
}));
|
||||
}
|
||||
|
||||
public static function has(): bool
|
||||
{
|
||||
self::ensureSession();
|
||||
return !empty($_SESSION[self::SESSION_KEY]);
|
||||
}
|
||||
|
||||
public static function dismiss(string $id): void
|
||||
{
|
||||
self::ensureSession();
|
||||
$messages = $_SESSION[self::SESSION_KEY] ?? [];
|
||||
$messages = array_values(array_filter($messages, function ($message) use ($id) {
|
||||
return ($message['id'] ?? '') !== $id;
|
||||
}));
|
||||
|
||||
if ($messages) {
|
||||
$_SESSION[self::SESSION_KEY] = $messages;
|
||||
} else {
|
||||
unset($_SESSION[self::SESSION_KEY]);
|
||||
}
|
||||
}
|
||||
|
||||
public static function keep(int $times = 1)
|
||||
{
|
||||
self::ensureSession();
|
||||
$times = max(1, $times);
|
||||
$current = (int) ($_SESSION[self::KEEP_KEY] ?? 0);
|
||||
$_SESSION[self::KEEP_KEY] = max($current, $times);
|
||||
}
|
||||
}
|
||||
92
lib/Support/Guard.php
Normal file
92
lib/Support/Guard.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Support;
|
||||
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Service\PermissionService;
|
||||
use MintyPHP\Service\TenantService;
|
||||
use MintyPHP\Service\AuthService;
|
||||
use MintyPHP\Http\Request;
|
||||
|
||||
class Guard
|
||||
{
|
||||
private const TENANT_REFRESH_INTERVAL = 60;
|
||||
|
||||
public static function requireLogin(string $redirect = 'login'): void
|
||||
{
|
||||
if (!isset($_SESSION['user'])) {
|
||||
Flash::error('Login required', 'login', 'login_required');
|
||||
Router::redirect($redirect);
|
||||
}
|
||||
self::refreshTenantContext();
|
||||
if (!empty($_SESSION['no_active_tenant'])) {
|
||||
AuthService::logout();
|
||||
Flash::error('No active tenant assigned', 'login', 'no_active_tenant');
|
||||
Router::redirect($redirect);
|
||||
}
|
||||
}
|
||||
|
||||
private static function validateCurrentTenant(): void
|
||||
{
|
||||
$currentTenant = $_SESSION['current_tenant'] ?? null;
|
||||
if (!$currentTenant || empty($currentTenant['id'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tenantId = (int) $currentTenant['id'];
|
||||
$tenant = TenantService::findById($tenantId);
|
||||
if ($tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Current tenant was deleted, refresh session data
|
||||
$userId = (int) ($_SESSION['user']['id'] ?? 0);
|
||||
if ($userId > 0) {
|
||||
AuthService::loadTenantDataIntoSession($userId);
|
||||
} else {
|
||||
unset($_SESSION['current_tenant']);
|
||||
}
|
||||
}
|
||||
|
||||
private static function refreshTenantContext(): void
|
||||
{
|
||||
$userId = (int) ($_SESSION['user']['id'] ?? 0);
|
||||
if ($userId <= 0) {
|
||||
return;
|
||||
}
|
||||
$last = (int) ($_SESSION['tenant_context_refreshed_at'] ?? 0);
|
||||
$now = time();
|
||||
if ($last > 0 && ($now - $last) < self::TENANT_REFRESH_INTERVAL) {
|
||||
return;
|
||||
}
|
||||
AuthService::loadTenantDataIntoSession($userId);
|
||||
$_SESSION['tenant_context_refreshed_at'] = $now;
|
||||
}
|
||||
|
||||
public static function requirePermission(string $permissionKey, string $redirect = 'admin'): void
|
||||
{
|
||||
self::requireLogin();
|
||||
self::validateCurrentTenant();
|
||||
$userId = (int) ($_SESSION['user']['id'] ?? 0);
|
||||
if (!$userId || !PermissionService::userHas($userId, $permissionKey)) {
|
||||
Flash::error('Permission denied', $redirect, 'permission_denied');
|
||||
Router::redirect($redirect);
|
||||
}
|
||||
}
|
||||
|
||||
public static function requirePermissionOrForbidden(string $permissionKey): void
|
||||
{
|
||||
self::requireLogin();
|
||||
self::validateCurrentTenant();
|
||||
$userId = (int) ($_SESSION['user']['id'] ?? 0);
|
||||
if (!$userId || !PermissionService::userHas($userId, $permissionKey)) {
|
||||
if (Request::wantsJson()) {
|
||||
http_response_code(403);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['error' => 'forbidden']);
|
||||
exit;
|
||||
}
|
||||
Router::redirect('error/forbidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
13
lib/Support/Hello.php
Normal file
13
lib/Support/Hello.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Support;
|
||||
|
||||
class Hello
|
||||
{
|
||||
public static $name = 'MintyPHP';
|
||||
|
||||
public static function getGreeting(): string
|
||||
{
|
||||
return 'Hello ' . static::$name . '!';
|
||||
}
|
||||
}
|
||||
230
lib/Support/SearchConfig.php
Normal file
230
lib/Support/SearchConfig.php
Normal file
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Support;
|
||||
|
||||
use MintyPHP\Service\PermissionService;
|
||||
use MintyPHP\Service\UserAvatarService;
|
||||
|
||||
class SearchConfig
|
||||
{
|
||||
public static function tenantScopeFilters(): array
|
||||
{
|
||||
return [
|
||||
'users' => 'and exists (select 1 from user_tenants ut where ut.user_id = users.id and ut.tenant_id in (???))',
|
||||
'address-book' => 'and exists (select 1 from user_tenants ut where ut.user_id = users.id and ut.tenant_id in (???))',
|
||||
'tenants' => 'and id in (???)',
|
||||
'departments' => 'and exists (select 1 from tenant_departments td where td.department_id = departments.id and td.tenant_id in (???))',
|
||||
];
|
||||
}
|
||||
|
||||
public static function resources(string $query, string $locale): array
|
||||
{
|
||||
$like = '%' . $query . '%';
|
||||
|
||||
return [
|
||||
[
|
||||
'key' => 'address-book',
|
||||
'label' => t('Address book'),
|
||||
'permission' => PermissionService::ADDRESS_BOOK_VIEW,
|
||||
'countSql' => 'select count(*) from users where active = 1 and (first_name like ? or last_name like ? or email like ?) {{tenantFilter}}',
|
||||
'countParams' => [$like, $like, $like],
|
||||
'previewSql' => 'select uuid, first_name, last_name, email from users where active = 1 and (first_name like ? or last_name like ? or email like ?) {{tenantFilter}} order by last_name, first_name limit ?',
|
||||
'previewParams' => [$like, $like, $like],
|
||||
'resultSql' => 'select uuid, first_name, last_name, email from users where active = 1 and (first_name like ? or last_name like ? or email like ?) {{tenantFilter}} order by last_name, first_name',
|
||||
'resultParams' => [$like, $like, $like],
|
||||
],
|
||||
[
|
||||
'key' => 'users',
|
||||
'label' => t('Users'),
|
||||
'permission' => PermissionService::USERS_VIEW,
|
||||
'countSql' => 'select count(*) from users where (first_name like ? or last_name like ? or email like ?) {{tenantFilter}}',
|
||||
'countParams' => [$like, $like, $like],
|
||||
'previewSql' => 'select uuid, first_name, last_name, email from users where (first_name like ? or last_name like ? or email like ?) {{tenantFilter}} order by last_name, first_name limit ?',
|
||||
'previewParams' => [$like, $like, $like],
|
||||
'resultSql' => 'select uuid, first_name, last_name, email from users where (first_name like ? or last_name like ? or email like ?) {{tenantFilter}} order by last_name, first_name',
|
||||
'resultParams' => [$like, $like, $like],
|
||||
],
|
||||
[
|
||||
'key' => 'tenants',
|
||||
'label' => t('Tenants'),
|
||||
'permission' => PermissionService::TENANTS_VIEW,
|
||||
'countSql' => 'select count(*) from tenants where description like ? {{tenantFilter}}',
|
||||
'countParams' => [$like],
|
||||
'previewSql' => 'select uuid, description from tenants where description like ? {{tenantFilter}} order by description limit ?',
|
||||
'previewParams' => [$like],
|
||||
'resultSql' => 'select uuid, description from tenants where description like ? {{tenantFilter}} order by description',
|
||||
'resultParams' => [$like],
|
||||
],
|
||||
[
|
||||
'key' => 'departments',
|
||||
'label' => t('Departments'),
|
||||
'permission' => PermissionService::DEPARTMENTS_VIEW,
|
||||
'countSql' => 'select count(*) from departments where description like ? {{tenantFilter}}',
|
||||
'countParams' => [$like],
|
||||
'previewSql' => 'select uuid, description from departments where description like ? {{tenantFilter}} order by description limit ?',
|
||||
'previewParams' => [$like],
|
||||
'resultSql' => 'select uuid, description from departments where description like ? {{tenantFilter}} order by description',
|
||||
'resultParams' => [$like],
|
||||
],
|
||||
[
|
||||
'key' => 'roles',
|
||||
'label' => t('Roles'),
|
||||
'permission' => PermissionService::ROLES_VIEW,
|
||||
'countSql' => 'select count(*) from roles where description like ?',
|
||||
'countParams' => [$like],
|
||||
'previewSql' => 'select uuid, description from roles where description like ? order by description limit ?',
|
||||
'previewParams' => [$like],
|
||||
'resultSql' => 'select uuid, description from roles where description like ? order by description',
|
||||
'resultParams' => [$like],
|
||||
],
|
||||
[
|
||||
'key' => 'permissions',
|
||||
'label' => t('Permissions'),
|
||||
'permission' => PermissionService::PERMISSIONS_VIEW,
|
||||
'countSql' => 'select count(*) from permissions where `key` like ? or description like ?',
|
||||
'countParams' => [$like, $like],
|
||||
'previewSql' => 'select id, description, `key` from permissions where `key` like ? or description like ? order by `key` limit ?',
|
||||
'previewParams' => [$like, $like],
|
||||
'resultSql' => 'select id, description, `key` from permissions where `key` like ? or description like ? order by `key`',
|
||||
'resultParams' => [$like, $like],
|
||||
],
|
||||
[
|
||||
'key' => 'pages',
|
||||
'label' => t('Pages'),
|
||||
'permission' => '',
|
||||
'countSql' => 'select count(*) from pages p join page_contents pc on pc.page_id = p.id and pc.locale = ? where p.slug like ? or pc.content like ?',
|
||||
'countParams' => [$locale, $like, $like],
|
||||
'previewSql' => 'select p.slug from pages p join page_contents pc on pc.page_id = p.id and pc.locale = ? where p.slug like ? or pc.content like ? order by p.slug limit ?',
|
||||
'previewParams' => [$locale, $like, $like],
|
||||
'resultSql' => 'select p.slug from pages p join page_contents pc on pc.page_id = p.id and pc.locale = ? where p.slug like ? or pc.content like ? order by p.slug',
|
||||
'resultParams' => [$locale, $like, $like],
|
||||
],
|
||||
[
|
||||
'key' => 'settings',
|
||||
'label' => t('Settings'),
|
||||
'permission' => PermissionService::SETTINGS_VIEW,
|
||||
'countSql' => 'select count(*) from settings where `key` like ? or value like ?',
|
||||
'countParams' => [$like, $like],
|
||||
'previewSql' => null,
|
||||
'previewParams' => [],
|
||||
'resultSql' => 'select `key`, value from settings where `key` like ? or value like ? order by `key`',
|
||||
'resultParams' => [$like, $like],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public static function iconForKey(string $key): string
|
||||
{
|
||||
return match ($key) {
|
||||
'address-book' => 'bi-people',
|
||||
'users' => 'bi-person-badge',
|
||||
'tenants' => 'bi-buildings',
|
||||
'departments' => 'bi-diagram-3',
|
||||
'roles' => 'bi-people',
|
||||
'permissions' => 'bi-shield-lock',
|
||||
'pages' => 'bi-file-text',
|
||||
'settings' => 'bi-gear',
|
||||
default => 'bi-search',
|
||||
};
|
||||
}
|
||||
|
||||
public static function listUrl(string $key, string $query): string
|
||||
{
|
||||
$encoded = urlencode($query);
|
||||
|
||||
return match ($key) {
|
||||
'address-book' => lurl('address-book') . '?search=' . $encoded,
|
||||
'users' => lurl('admin/users') . '?search=' . $encoded,
|
||||
'tenants' => lurl('admin/tenants') . '?search=' . $encoded,
|
||||
'departments' => lurl('admin/departments') . '?search=' . $encoded,
|
||||
'roles' => lurl('admin/roles') . '?search=' . $encoded,
|
||||
'permissions' => lurl('admin/permissions') . '?search=' . $encoded,
|
||||
'pages' => lurl(''),
|
||||
'settings' => lurl('admin/settings'),
|
||||
default => lurl(''),
|
||||
};
|
||||
}
|
||||
|
||||
public static function mapPreviewItem(string $key, array $data, string $query): ?array
|
||||
{
|
||||
if ($key === 'users') {
|
||||
$label = trim(($data['first_name'] ?? '') . ' ' . ($data['last_name'] ?? ''));
|
||||
$email = trim((string) ($data['email'] ?? ''));
|
||||
if ($email !== '') {
|
||||
$label = $label === '' ? $email : ($label . ' — ' . $email);
|
||||
}
|
||||
$url = lurl('admin/users/edit/' . ($data['uuid'] ?? '')) . '?search=' . urlencode($query);
|
||||
} elseif ($key === 'address-book') {
|
||||
$label = trim(($data['first_name'] ?? '') . ' ' . ($data['last_name'] ?? ''));
|
||||
$email = trim((string) ($data['email'] ?? ''));
|
||||
if ($email !== '') {
|
||||
$label = $label === '' ? $email : ($label . ' — ' . $email);
|
||||
}
|
||||
$url = lurl('address-book/view/' . ($data['uuid'] ?? '')) . '?search=' . urlencode($query);
|
||||
} elseif ($key === 'permissions') {
|
||||
$label = (string) ($data['description'] ?? '');
|
||||
$url = lurl('admin/permissions/edit/' . ($data['id'] ?? '')) . '?search=' . urlencode($query);
|
||||
} elseif ($key === 'pages') {
|
||||
$label = (string) ($data['slug'] ?? '');
|
||||
$url = lurl('page/' . $label) . '?search=' . urlencode($query);
|
||||
} else {
|
||||
$label = (string) ($data['description'] ?? '');
|
||||
$url = lurl('admin/' . $key . '/edit/' . ($data['uuid'] ?? '')) . '?search=' . urlencode($query);
|
||||
}
|
||||
|
||||
if ($label === '' || $url === '') {
|
||||
return null;
|
||||
}
|
||||
return ['label' => $label, 'url' => $url];
|
||||
}
|
||||
|
||||
public static function mapResultItem(string $key, string $label, array $data): ?array
|
||||
{
|
||||
$title = '';
|
||||
$description = '';
|
||||
$url = '';
|
||||
$image = '';
|
||||
if ($key === 'users') {
|
||||
$title = trim(($data['first_name'] ?? '') . ' ' . ($data['last_name'] ?? ''));
|
||||
$description = (string) ($data['email'] ?? '');
|
||||
$url = lurl('admin/users/edit/' . ($data['uuid'] ?? ''));
|
||||
} elseif ($key === 'address-book') {
|
||||
$title = trim(($data['first_name'] ?? '') . ' ' . ($data['last_name'] ?? ''));
|
||||
$description = (string) ($data['email'] ?? '');
|
||||
$uuid = (string) ($data['uuid'] ?? '');
|
||||
$url = lurl('address-book/view/' . $uuid);
|
||||
if ($uuid !== '' && UserAvatarService::hasAvatar($uuid)) {
|
||||
$image = lurl('admin/users/avatar-file') . '?uuid=' . urlencode($uuid) . '&size=64';
|
||||
}
|
||||
} elseif ($key === 'permissions') {
|
||||
$title = (string) ($data['key'] ?? '');
|
||||
$description = (string) ($data['description'] ?? '');
|
||||
$url = lurl('admin/permissions/edit/' . ($data['id'] ?? ''));
|
||||
} elseif ($key === 'pages') {
|
||||
$title = (string) ($data['slug'] ?? '');
|
||||
$description = t('Page');
|
||||
$url = lurl('page/' . $title);
|
||||
} elseif ($key === 'settings') {
|
||||
$title = (string) ($data['key'] ?? '');
|
||||
$description = (string) ($data['value'] ?? '');
|
||||
$url = lurl('admin/settings');
|
||||
} else {
|
||||
$title = (string) ($data['description'] ?? '');
|
||||
$description = $label;
|
||||
$url = lurl('admin/' . $key . '/edit/' . ($data['uuid'] ?? ''));
|
||||
}
|
||||
|
||||
if ($title === '' || $url === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => $label,
|
||||
'title' => $title,
|
||||
'description' => $description,
|
||||
'url' => $url,
|
||||
'image' => $image,
|
||||
'icon' => self::iconForKey($key),
|
||||
];
|
||||
}
|
||||
}
|
||||
28
lib/Support/Tile.php
Normal file
28
lib/Support/Tile.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Support;
|
||||
|
||||
class Tile
|
||||
{
|
||||
public static function render(array $options = []): void
|
||||
{
|
||||
$defaults = [
|
||||
'href' => '#',
|
||||
'label' => '',
|
||||
'count' => null,
|
||||
'icon' => 'bi bi-grid-1x2',
|
||||
'iconBg' => null,
|
||||
'iconColor' => null,
|
||||
'class' => '',
|
||||
];
|
||||
|
||||
$data = array_merge($defaults, $options);
|
||||
$template = dirname(__DIR__, 2) . '/templates/partials/app-tile.phtml';
|
||||
if (!is_file($template)) {
|
||||
return;
|
||||
}
|
||||
|
||||
extract($data, EXTR_SKIP);
|
||||
require $template;
|
||||
}
|
||||
}
|
||||
6
lib/Support/helpers.php
Normal file
6
lib/Support/helpers.php
Normal file
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
require __DIR__ . '/helpers/app.php';
|
||||
require __DIR__ . '/helpers/auth.php';
|
||||
require __DIR__ . '/helpers/i18n.php';
|
||||
require __DIR__ . '/helpers/ui.php';
|
||||
208
lib/Support/helpers/app.php
Normal file
208
lib/Support/helpers/app.php
Normal file
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
function e($string): void
|
||||
{
|
||||
echo htmlspecialchars((string) $string, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
function asset(string $path): string
|
||||
{
|
||||
return \MintyPHP\Router::getBaseUrl() . ltrim($path, '/');
|
||||
}
|
||||
|
||||
function localeBase(): string
|
||||
{
|
||||
$locale = \MintyPHP\I18n::$locale ?? \MintyPHP\I18n::$defaultLocale;
|
||||
$prefix = $locale !== '' ? $locale . '/' : '';
|
||||
return \MintyPHP\Router::getBaseUrl() . $prefix;
|
||||
}
|
||||
|
||||
function lurl(string $path = ''): string
|
||||
{
|
||||
return localeBase() . ltrim($path, '/');
|
||||
}
|
||||
|
||||
function appTitle(): string
|
||||
{
|
||||
$default = defined('APP_NAME') ? APP_NAME : 'IMVS';
|
||||
$title = appSetting('app_title');
|
||||
if ($title !== null) {
|
||||
return $title;
|
||||
}
|
||||
return $default;
|
||||
}
|
||||
|
||||
function appUrl(string $path = ''): string
|
||||
{
|
||||
if ($path !== '' && preg_match('#^https?://#i', $path)) {
|
||||
return $path;
|
||||
}
|
||||
$base = getenv('APP_URL') ?: '';
|
||||
$base = rtrim((string) $base, '/');
|
||||
if ($base === '') {
|
||||
$scheme = 'http';
|
||||
$https = $_SERVER['HTTPS'] ?? '';
|
||||
$forwarded = $_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '';
|
||||
if ($https === 'on' || $https === '1' || $forwarded === 'https') {
|
||||
$scheme = 'https';
|
||||
}
|
||||
$host = $_SERVER['HTTP_HOST'] ?? '';
|
||||
if ($host !== '') {
|
||||
$base = $scheme . '://' . $host;
|
||||
}
|
||||
}
|
||||
if ($base === '') {
|
||||
return \MintyPHP\Router::getBaseUrl() . ltrim((string) $path, '/');
|
||||
}
|
||||
$path = ltrim((string) $path, '/');
|
||||
return $base . '/' . $path;
|
||||
}
|
||||
|
||||
function appLogoUrlAbsolute(int $size = 128): string
|
||||
{
|
||||
$url = appLogoUrl($size);
|
||||
return appUrl($url);
|
||||
}
|
||||
|
||||
function appSetting(string $key): ?string
|
||||
{
|
||||
$cacheFile = dirname(__DIR__, 3) . '/config/settings.php';
|
||||
if (!is_file($cacheFile)) {
|
||||
return null;
|
||||
}
|
||||
$settings = include $cacheFile;
|
||||
if (!is_array($settings)) {
|
||||
return null;
|
||||
}
|
||||
$value = $settings[$key] ?? null;
|
||||
$value = $value !== null ? trim((string) $value) : '';
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
function appDefaultLocale(): ?string
|
||||
{
|
||||
$locale = appSetting('app_locale');
|
||||
if ($locale === null) {
|
||||
return null;
|
||||
}
|
||||
$available = defined('APP_LOCALES') ? APP_LOCALES : [];
|
||||
if ($available && !in_array($locale, $available, true)) {
|
||||
return null;
|
||||
}
|
||||
return $locale;
|
||||
}
|
||||
|
||||
function appDefaultTheme(): string
|
||||
{
|
||||
$setting = appSetting('app_theme');
|
||||
if (in_array($setting, ['light', 'dark'], true)) {
|
||||
return $setting;
|
||||
}
|
||||
$envTheme = getenv('APP_THEME') ?: 'light';
|
||||
$envTheme = strtolower(trim((string) $envTheme));
|
||||
return in_array($envTheme, ['light', 'dark'], true) ? $envTheme : 'light';
|
||||
}
|
||||
|
||||
function allowUserTheme(): bool
|
||||
{
|
||||
$value = appSetting('app_theme_user');
|
||||
if ($value === null || $value === '') {
|
||||
return true;
|
||||
}
|
||||
return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
function appPrimaryColor(): ?string
|
||||
{
|
||||
$tenantColor = $_SESSION['current_tenant']['primary_color'] ?? null;
|
||||
if ($tenantColor !== null) {
|
||||
$tenantColor = strtolower(trim((string) $tenantColor));
|
||||
if (preg_match('/^#([0-9a-f]{3}|[0-9a-f]{6})$/i', $tenantColor)) {
|
||||
return $tenantColor;
|
||||
}
|
||||
}
|
||||
|
||||
$value = appSetting('app_primary_color');
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
$value = strtolower(trim($value));
|
||||
if (!preg_match('/^#([0-9a-f]{3}|[0-9a-f]{6})$/i', $value)) {
|
||||
return null;
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
function appPrimaryCssVars(): string
|
||||
{
|
||||
$hex = appPrimaryColor();
|
||||
if ($hex === null) {
|
||||
return '';
|
||||
}
|
||||
$hex = ltrim($hex, '#');
|
||||
if (strlen($hex) === 3) {
|
||||
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
|
||||
}
|
||||
$r = hexdec(substr($hex, 0, 2)) / 255;
|
||||
$g = hexdec(substr($hex, 2, 2)) / 255;
|
||||
$b = hexdec(substr($hex, 4, 2)) / 255;
|
||||
|
||||
$max = max($r, $g, $b);
|
||||
$min = min($r, $g, $b);
|
||||
$delta = $max - $min;
|
||||
|
||||
$h = 0.0;
|
||||
if ($delta !== 0.0) {
|
||||
if ($max === $r) {
|
||||
$h = 60 * fmod((($g - $b) / $delta), 6);
|
||||
} elseif ($max === $g) {
|
||||
$h = 60 * ((($b - $r) / $delta) + 2);
|
||||
} else {
|
||||
$h = 60 * ((($r - $g) / $delta) + 4);
|
||||
}
|
||||
}
|
||||
if ($h < 0) {
|
||||
$h += 360;
|
||||
}
|
||||
|
||||
$l = ($max + $min) / 2;
|
||||
$s = $delta === 0.0 ? 0.0 : $delta / (1 - abs(2 * $l - 1));
|
||||
|
||||
$h = round($h, 2);
|
||||
$s = round($s * 100, 2) . '%';
|
||||
$l = round($l * 100, 2) . '%';
|
||||
|
||||
return "--app-primary-h-base: {$h}; --app-primary-s-base: {$s}; --app-primary-l-base: {$l}; --app-primary-h-light: {$h}; --app-primary-s-light: {$s}; --app-primary-l-light: {$l};";
|
||||
}
|
||||
|
||||
function appLogoUrl(?int $size = null): string
|
||||
{
|
||||
if (class_exists('MintyPHP\\Service\\BrandingLogoService') && \MintyPHP\Service\BrandingLogoService::hasLogo()) {
|
||||
$query = $size ? '?size=' . (int) $size : '';
|
||||
return lurl('branding/logo' . $query);
|
||||
}
|
||||
return asset('brand/logo.svg');
|
||||
}
|
||||
|
||||
function appFaviconUrl(string $file): string
|
||||
{
|
||||
$tenantUuid = $_SESSION['current_tenant']['uuid'] ?? '';
|
||||
if ($tenantUuid !== '' && class_exists('MintyPHP\\Service\\TenantFaviconService')) {
|
||||
if (\MintyPHP\Service\TenantFaviconService::hasFavicon($tenantUuid)) {
|
||||
return asset('favicon/tenants/' . $tenantUuid . '/' . ltrim($file, '/'));
|
||||
}
|
||||
}
|
||||
return asset('favicon/' . ltrim($file, '/'));
|
||||
}
|
||||
|
||||
function d()
|
||||
{
|
||||
return call_user_func_array('MintyPHP\\Debugger::debug', func_get_args());
|
||||
}
|
||||
|
||||
function sortByDescription(array &$items): void
|
||||
{
|
||||
usort($items, static fn($a, $b) =>
|
||||
strcasecmp((string) ($a['description'] ?? ''), (string) ($b['description'] ?? ''))
|
||||
);
|
||||
}
|
||||
27
lib/Support/helpers/auth.php
Normal file
27
lib/Support/helpers/auth.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
function accountUrl(): string
|
||||
{
|
||||
$user = $_SESSION['user'] ?? [];
|
||||
$uuid = (string) ($user['uuid'] ?? '');
|
||||
return $uuid !== '' ? lurl('profile') : lurl('login');
|
||||
}
|
||||
|
||||
function can(string $permissionKey): bool
|
||||
{
|
||||
$userId = (int) ($_SESSION['user']['id'] ?? 0);
|
||||
if ($userId <= 0) {
|
||||
return false;
|
||||
}
|
||||
if (!class_exists('MintyPHP\\Service\\PermissionService')) {
|
||||
return false;
|
||||
}
|
||||
$keys = \MintyPHP\Service\PermissionService::getCachedPermissions($userId);
|
||||
if (!$keys) {
|
||||
$keys = \MintyPHP\Service\PermissionService::getUserPermissions($userId);
|
||||
if (!$keys) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return in_array($permissionKey, $keys, true);
|
||||
}
|
||||
42
lib/Support/helpers/i18n.php
Normal file
42
lib/Support/helpers/i18n.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
function t()
|
||||
{
|
||||
$arguments = func_get_args();
|
||||
$arguments[0] = \MintyPHP\I18n::translate($arguments[0]);
|
||||
if (count($arguments) === 1) {
|
||||
return $arguments[0];
|
||||
}
|
||||
return call_user_func_array('sprintf', $arguments);
|
||||
}
|
||||
|
||||
function dt($value, ?string $format = null)
|
||||
{
|
||||
$displayTz = null;
|
||||
try {
|
||||
$displayTz = new \DateTimeZone(defined('APP_TIMEZONE') ? APP_TIMEZONE : date_default_timezone_get());
|
||||
} catch (\Exception $e) {
|
||||
$displayTz = new \DateTimeZone(date_default_timezone_get());
|
||||
}
|
||||
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
$date = (new \DateTimeImmutable('@' . $value->getTimestamp()))->setTimezone($displayTz);
|
||||
} elseif (is_string($value) && $value !== '') {
|
||||
try {
|
||||
// Treat DB timestamps as UTC and convert for display
|
||||
$date = new \DateTimeImmutable($value, new \DateTimeZone('UTC'));
|
||||
$date = $date->setTimezone($displayTz);
|
||||
} catch (\Exception $e) {
|
||||
return $value;
|
||||
}
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($format === null) {
|
||||
$locale = \MintyPHP\I18n::$locale ?? '';
|
||||
$format = (strpos((string) $locale, 'de') === 0) ? 'd.m.Y H:i' : 'Y-m-d H:i';
|
||||
}
|
||||
|
||||
return $date->format($format);
|
||||
}
|
||||
178
lib/Support/helpers/ui.php
Normal file
178
lib/Support/helpers/ui.php
Normal file
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
function gridLang(): array
|
||||
{
|
||||
return [
|
||||
'search' => [
|
||||
'placeholder' => t('Search...'),
|
||||
],
|
||||
'sort' => [
|
||||
'sortAsc' => t('Sort column ascending'),
|
||||
'sortDesc' => t('Sort column descending'),
|
||||
],
|
||||
'pagination' => [
|
||||
'previous' => t('Previous'),
|
||||
'next' => t('Next'),
|
||||
'navigate' => t('Page %d of %d'),
|
||||
'page' => t('Page %d'),
|
||||
'showing' => t('Showing'),
|
||||
'of' => t('of'),
|
||||
'to' => t('to'),
|
||||
'results' => t('results'),
|
||||
],
|
||||
'loading' => t('Loading...'),
|
||||
'noRecordsFound' => t('No records found'),
|
||||
'error' => t('An error happened while fetching the data'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a multi-select filter field with hidden input sync.
|
||||
*
|
||||
* @param string $id Base ID for the hidden input (select gets "-ui" suffix)
|
||||
* @param string $label Field label (will be translated)
|
||||
* @param string $placeholder Placeholder text (will be translated)
|
||||
* @param array $items Array of items with 'id' and 'description' keys
|
||||
* @param array $selected Array of selected item IDs
|
||||
*/
|
||||
function multiSelectFilter(
|
||||
string $id,
|
||||
string $label,
|
||||
string $placeholder,
|
||||
array $items,
|
||||
array $selected
|
||||
): void {
|
||||
?>
|
||||
<label class="app-field">
|
||||
<span><?php e(t($label)); ?></span>
|
||||
<input type="hidden" id="<?php e($id); ?>" value="<?php e(implode(',', $selected)); ?>">
|
||||
<select
|
||||
id="<?php e($id); ?>-ui"
|
||||
multiple
|
||||
data-multi-select
|
||||
data-sync-target="#<?php e($id); ?>"
|
||||
data-select-all="true"
|
||||
data-list-all="false"
|
||||
data-search="true"
|
||||
data-placeholder="<?php e(t($placeholder)); ?>"
|
||||
data-search-placeholder="<?php e(t('Search...')); ?>"
|
||||
data-select-all-label="<?php e(t('Select all')); ?>"
|
||||
data-selected-label="<?php e(t('%d selected')); ?>"
|
||||
>
|
||||
<?php foreach ($items as $item): ?>
|
||||
<?php $itemId = (string) ($item['id'] ?? ''); ?>
|
||||
<?php if ($itemId !== ''): ?>
|
||||
<option value="<?php e($itemId); ?>"<?php if (in_array($itemId, $selected, true)) { ?> selected<?php } ?>>
|
||||
<?php e($item['description'] ?? ''); ?>
|
||||
</option>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a multi-select form field for data entry.
|
||||
*
|
||||
* @param string $id Element ID
|
||||
* @param string $name Form field name
|
||||
* @param string $label Field label (will be translated)
|
||||
* @param string $placeholder Placeholder text (will be translated)
|
||||
* @param array $items Array of items with 'id' and 'description' keys
|
||||
* @param array $selected Array of selected item IDs
|
||||
* @param bool $disabled Whether the field is disabled/readonly
|
||||
* @param string|array $labelKeys Key(s) to use for option label (tries in order)
|
||||
* @param string|null $formId Form ID for selects outside form element
|
||||
*/
|
||||
function multiSelectForm(
|
||||
string $id,
|
||||
string $name,
|
||||
string $label,
|
||||
string $placeholder,
|
||||
array $items,
|
||||
array $selected,
|
||||
bool $disabled = false,
|
||||
string|array $labelKeys = 'description',
|
||||
?string $formId = null
|
||||
): void {
|
||||
$labelKeyList = is_array($labelKeys) ? $labelKeys : [$labelKeys];
|
||||
?>
|
||||
<label>
|
||||
<span><?php e(t($label)); ?></span>
|
||||
</label>
|
||||
<select
|
||||
id="<?php e($id); ?>"
|
||||
name="<?php e($name); ?>"
|
||||
<?php if ($formId !== null): ?>form="<?php e($formId); ?>"<?php endif; ?>
|
||||
multiple
|
||||
data-multi-select="true"
|
||||
data-select-all="true"
|
||||
data-search="true"
|
||||
data-placeholder="<?php e(t($placeholder)); ?>"
|
||||
data-search-placeholder="<?php e(t('Search...')); ?>"
|
||||
data-select-all-label="<?php e(t('Select all')); ?>"
|
||||
<?php if ($disabled): ?>data-disabled="true" disabled<?php endif; ?>
|
||||
>
|
||||
<?php foreach ($items as $item): ?>
|
||||
<?php $itemId = (int) ($item['id'] ?? 0); ?>
|
||||
<?php if ($itemId > 0): ?>
|
||||
<?php
|
||||
$isSelected = in_array($itemId, $selected, true);
|
||||
$itemLabel = '';
|
||||
foreach ($labelKeyList as $key) {
|
||||
if (isset($item[$key]) && (string) $item[$key] !== '') {
|
||||
$itemLabel = (string) $item[$key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
?>
|
||||
<option value="<?php e((string) $itemId); ?>"<?php if ($isSelected) { ?> selected<?php } ?>>
|
||||
<?php e($itemLabel); ?>
|
||||
</option>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php
|
||||
}
|
||||
|
||||
function navActive($path, bool $prefix = false): array
|
||||
{
|
||||
$current = \MintyPHP\Http\Request::path();
|
||||
$paths = is_array($path) ? $path : [$path];
|
||||
$isActive = false;
|
||||
foreach ($paths as $p) {
|
||||
$p = (string) $p;
|
||||
$match = $prefix ? strpos($current, $p) === 0 : $current === $p;
|
||||
if (!$match && ($current === '' || $current === '/')) {
|
||||
$match = $p === '' || $p === '/';
|
||||
}
|
||||
if ($match) {
|
||||
$isActive = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'class' => $isActive ? 'active' : '',
|
||||
'aria' => $isActive ? 'aria-current="page"' : '',
|
||||
'isActive' => $isActive,
|
||||
];
|
||||
}
|
||||
|
||||
function navActivePublicPages(array $extraSlugs = []): array
|
||||
{
|
||||
$current = \MintyPHP\Http\Request::path();
|
||||
if ($current === '') {
|
||||
return ['class' => '', 'aria' => '', 'isActive' => false];
|
||||
}
|
||||
|
||||
$publicSlugs = array_merge(['imprint', 'privacy'], $extraSlugs);
|
||||
$isActive = strpos($current, 'page/') === 0 || in_array($current, $publicSlugs, true);
|
||||
|
||||
return [
|
||||
'class' => $isActive ? 'active' : '',
|
||||
'aria' => $isActive ? 'aria-current="page"' : '',
|
||||
'isActive' => $isActive,
|
||||
];
|
||||
}
|
||||
BIN
pages/.DS_Store
vendored
Normal file
BIN
pages/.DS_Store
vendored
Normal file
Binary file not shown.
18
pages/account/profile().php
Normal file
18
pages/account/profile().php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Support\Guard;
|
||||
use MintyPHP\Router;
|
||||
|
||||
Guard::requireLogin();
|
||||
|
||||
$user = $_SESSION['user'] ?? [];
|
||||
$uuid = trim((string) ($user['uuid'] ?? ''));
|
||||
if ($uuid === '') {
|
||||
Router::redirect('login');
|
||||
}
|
||||
|
||||
if (!can('users.view') && !can('users.self_update') && !can('users.update')) {
|
||||
Router::redirect('error/forbidden');
|
||||
}
|
||||
|
||||
Router::redirect(lurl("admin/users/edit/{$uuid}"));
|
||||
92
pages/address-book/data().php
Normal file
92
pages/address-book/data().php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Support\Guard;
|
||||
use MintyPHP\Service\UserService;
|
||||
use MintyPHP\Service\UserAvatarService;
|
||||
use MintyPHP\Service\PermissionService;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requirePermissionOrForbidden(PermissionService::ADDRESS_BOOK_VIEW);
|
||||
|
||||
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
|
||||
|
||||
$limit = (int) ($_GET['limit'] ?? 10);
|
||||
$offset = (int) ($_GET['offset'] ?? 0);
|
||||
$search = trim((string) ($_GET['search'] ?? ''));
|
||||
$order = (string) ($_GET['order'] ?? 'last_name');
|
||||
$dir = (string) ($_GET['dir'] ?? 'asc');
|
||||
$tenant = trim((string) ($_GET['tenant'] ?? ''));
|
||||
$tenants = $_GET['tenants'] ?? '';
|
||||
$departments = $_GET['departments'] ?? '';
|
||||
$roles = $_GET['roles'] ?? '';
|
||||
|
||||
$result = UserService::listPaged([
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
'search' => $search,
|
||||
'order' => $order,
|
||||
'dir' => $dir,
|
||||
'tenant' => $tenant,
|
||||
'tenants' => $tenants,
|
||||
'departments' => $departments,
|
||||
'roles' => $roles,
|
||||
'tenantUserId' => $currentUserId,
|
||||
'active' => 'active',
|
||||
]);
|
||||
|
||||
$rows = [];
|
||||
foreach ($result['rows'] as $row) {
|
||||
$tenantLabels = $row['tenant_labels'] ?? [];
|
||||
if (is_string($tenantLabels)) {
|
||||
$tenantList = $tenantLabels !== ''
|
||||
? array_values(array_filter(array_map('trim', explode('||', $tenantLabels))))
|
||||
: [];
|
||||
} elseif (is_array($tenantLabels)) {
|
||||
$tenantList = array_values(array_filter(array_map('trim', $tenantLabels)));
|
||||
} else {
|
||||
$tenantList = [];
|
||||
}
|
||||
|
||||
$departmentLabels = $row['department_labels'] ?? [];
|
||||
if (is_string($departmentLabels)) {
|
||||
$departmentList = $departmentLabels !== ''
|
||||
? array_values(array_filter(array_map('trim', explode('||', $departmentLabels))))
|
||||
: [];
|
||||
} elseif (is_array($departmentLabels)) {
|
||||
$departmentList = array_values(array_filter(array_map('trim', $departmentLabels)));
|
||||
} else {
|
||||
$departmentList = [];
|
||||
}
|
||||
|
||||
$roleLabels = $row['role_labels'] ?? [];
|
||||
if (is_string($roleLabels)) {
|
||||
$roleList = $roleLabels !== ''
|
||||
? array_values(array_filter(array_map('trim', explode('||', $roleLabels))))
|
||||
: [];
|
||||
} elseif (is_array($roleLabels)) {
|
||||
$roleList = array_values(array_filter(array_map('trim', $roleLabels)));
|
||||
} else {
|
||||
$roleList = [];
|
||||
}
|
||||
|
||||
$uuid = (string) ($row['uuid'] ?? '');
|
||||
$rows[] = [
|
||||
'uuid' => $uuid,
|
||||
'first_name' => $row['first_name'] ?? '',
|
||||
'last_name' => $row['last_name'] ?? '',
|
||||
'email' => $row['email'] ?? '',
|
||||
'phone' => $row['phone'] ?? '',
|
||||
'mobile' => $row['mobile'] ?? '',
|
||||
'short_dial' => $row['short_dial'] ?? '',
|
||||
'tenants' => $tenantList,
|
||||
'departments' => $departmentList,
|
||||
'roles' => $roleList,
|
||||
'has_avatar' => $uuid !== '' && UserAvatarService::hasAvatar($uuid),
|
||||
];
|
||||
}
|
||||
|
||||
Router::json([
|
||||
'data' => $rows,
|
||||
'total' => $result['total'] ?? 0,
|
||||
]);
|
||||
52
pages/address-book/index().php
Normal file
52
pages/address-book/index().php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Buffer;
|
||||
use MintyPHP\Support\Guard;
|
||||
use MintyPHP\Session;
|
||||
use MintyPHP\Service\PermissionService;
|
||||
use MintyPHP\Service\TenantScopeService;
|
||||
use MintyPHP\Service\TenantService;
|
||||
use MintyPHP\Service\DepartmentService;
|
||||
use MintyPHP\Service\RoleService;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requirePermissionOrForbidden(PermissionService::ADDRESS_BOOK_VIEW);
|
||||
|
||||
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
|
||||
$activeTenants = array_filter(array_map('trim', explode(',', (string) ($_GET['tenants'] ?? ''))));
|
||||
$activeRoles = array_filter(array_map('trim', explode(',', (string) ($_GET['roles'] ?? ''))));
|
||||
$activeDepartments = array_filter(array_map('trim', explode(',', (string) ($_GET['departments'] ?? ''))));
|
||||
|
||||
$tenantIds = TenantScopeService::getUserTenantIds($currentUserId);
|
||||
$tenants = TenantService::list();
|
||||
if ($tenantIds) {
|
||||
$tenants = array_values(array_filter($tenants, static function (array $tenant) use ($tenantIds): bool {
|
||||
$id = (int) ($tenant['id'] ?? 0);
|
||||
return $id > 0 && in_array($id, $tenantIds, true);
|
||||
}));
|
||||
} elseif (TenantScopeService::isStrict()) {
|
||||
$tenants = [];
|
||||
}
|
||||
$tenantItems = array_map(
|
||||
static fn (array $tenant): array => [
|
||||
'id' => (string) ($tenant['uuid'] ?? ''),
|
||||
'description' => $tenant['description'] ?? '',
|
||||
],
|
||||
$tenants
|
||||
);
|
||||
|
||||
$departments = $tenantIds
|
||||
? DepartmentService::listByTenantIds($tenantIds)
|
||||
: (TenantScopeService::isStrict() ? [] : DepartmentService::list());
|
||||
|
||||
$roles = RoleService::list();
|
||||
|
||||
$csrfKey = Session::$csrfSessionKey;
|
||||
$csrfToken = $_SESSION[$csrfKey] ?? '';
|
||||
|
||||
Buffer::set('title', t('Address book'));
|
||||
Buffer::set('grid_lang', json_encode(gridLang(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
Buffer::set(
|
||||
'grid_csrf',
|
||||
json_encode(['key' => $csrfKey, 'token' => $csrfToken], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
);
|
||||
156
pages/address-book/index(default).phtml
Normal file
156
pages/address-book/index(default).phtml
Normal file
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
use MintyPHP\Buffer;
|
||||
?>
|
||||
<div class="app-breadcrumb">
|
||||
<a href="/admin">Startseite</a><i class="bi bi-chevron-right"></i><?php e(t('Address book')); ?>
|
||||
</div>
|
||||
<div class="app-list-titlebar">
|
||||
<h1><?php e(t('Address book')); ?></h1>
|
||||
<div class="app-list-titlebar-actions">
|
||||
<button
|
||||
class="outline secondary"
|
||||
type="button"
|
||||
data-toolbar-toggle
|
||||
data-toolbar-target="#address-book-toolbar"
|
||||
data-toolbar-text-show="<?php e(t('Show filters')); ?>"
|
||||
data-toolbar-text-hide="<?php e(t('Hide filters')); ?>"
|
||||
>
|
||||
<i class="bi bi-funnel-fill"></i> <span data-toolbar-label><?php e(t('Hide filters')); ?></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-list-toolbar" id="address-book-toolbar">
|
||||
<label class="app-field">
|
||||
<span><?php e(t('Search')); ?></span>
|
||||
<input type="search" id="address-book-search" placeholder="<?php e(t('Search...')); ?>">
|
||||
</label>
|
||||
<?php multiSelectFilter('address-book-tenant-filter', 'Tenants', 'Select tenants', $tenantItems ?? [], $activeTenants ?? []); ?>
|
||||
<?php multiSelectFilter('address-book-department-filter', 'Departments', 'Select departments', $departments ?? [], $activeDepartments ?? []); ?>
|
||||
<?php multiSelectFilter('address-book-role-filter', 'Roles', 'Select roles', $roles ?? [], $activeRoles ?? []); ?>
|
||||
</div>
|
||||
<div class="app-list-table">
|
||||
<div id="address-book-grid"></div>
|
||||
</div>
|
||||
|
||||
<?php $gridFactoryVersion = @filemtime('web/js/core/app-grid-factory.js') ?: time(); ?>
|
||||
<script src="<?php e(asset('vendor/gridjs/gridjs.umd.js')); ?>"></script>
|
||||
<script type="module">
|
||||
import { initToolbarToggles } from "<?php e(asset('js/components/app-toggle-toolbar.js')); ?>";
|
||||
import { createServerGrid } from "<?php e(asset('js/core/app-grid-factory.js?v=' . $gridFactoryVersion)); ?>";
|
||||
initToolbarToggles();
|
||||
|
||||
const appBase = "<?php e(localeBase()); ?>";
|
||||
|
||||
const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (char) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[char]));
|
||||
const buildBadgeList = (items) => {
|
||||
if (!Array.isArray(items) || items.length === 0) return '';
|
||||
const badges = items.map((label) => `<span class="badge" data-variant="neutral">${escapeHtml(label)}</span>`).join(' ');
|
||||
return `<div class="badge-list">${badges}</div>`;
|
||||
};
|
||||
const uuidIndex = 1;
|
||||
const normalizeCommaSeparated = (value) => {
|
||||
if (Array.isArray(value)) return value.filter(Boolean);
|
||||
return String(value || '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
const initialsForRow = (row) => {
|
||||
const first = row?.cells?.[2]?.data ?? '';
|
||||
const last = row?.cells?.[3]?.data ?? '';
|
||||
const parts = [first, last]
|
||||
.map((value) => String(value || '').trim())
|
||||
.filter(Boolean);
|
||||
const chars = parts.map((value) => value[0] || '').join('').toUpperCase();
|
||||
return chars || '?';
|
||||
};
|
||||
|
||||
createServerGrid({
|
||||
gridjs: window.gridjs,
|
||||
container: '#address-book-grid',
|
||||
dataUrl: 'address-book/data',
|
||||
appBase,
|
||||
columns: [
|
||||
{
|
||||
name: "<?php e(t('Avatar')); ?>",
|
||||
sort: false,
|
||||
formatter: (cell, row) => {
|
||||
if (!cell) {
|
||||
const initials = escapeHtml(initialsForRow(row));
|
||||
return gridjs.html(`<span class="grid-avatar grid-avatar-placeholder">${initials}</span>`);
|
||||
}
|
||||
const src = new URL(`admin/users/avatar-file?uuid=${cell}&size=64`, appBase).toString();
|
||||
const full = new URL(`admin/users/avatar-file?uuid=${cell}&size=256`, appBase).toString();
|
||||
return gridjs.html(`<a data-fslightbox="address-book-avatars" href="${full}"><img class="grid-avatar" src="${src}" alt="" loading="lazy"></a>`);
|
||||
}
|
||||
},
|
||||
{ name: 'UUID', hidden: true },
|
||||
{ name: "<?php e(t('First name')); ?>", sort: true },
|
||||
{ name: "<?php e(t('Last name')); ?>", sort: true },
|
||||
{ name: "<?php e(t('Email')); ?>", sort: true },
|
||||
{ name: "<?php e(t('Phone')); ?>", sort: false },
|
||||
{ name: "<?php e(t('Mobile')); ?>", sort: false },
|
||||
{ name: "<?php e(t('Short dial')); ?>", sort: false },
|
||||
{ name: "<?php e(t('Departments')); ?>", sort: false, formatter: (cell) => gridjs.html(buildBadgeList(cell)) },
|
||||
{ name: "<?php e(t('Roles')); ?>", sort: false, formatter: (cell) => gridjs.html(buildBadgeList(cell)) },
|
||||
{ name: "<?php e(t('Tenants')); ?>", sort: false, formatter: (cell) => gridjs.html(buildBadgeList(cell)) }
|
||||
],
|
||||
sortColumns: [null, null, 'first_name', 'last_name', 'email', null, null, null, null, null, null],
|
||||
paginationLimit: 10,
|
||||
language: <?php MintyPHP\Buffer::get('grid_lang'); ?>,
|
||||
mapData: (data) => data.data.map((row) => [
|
||||
row.has_avatar ? row.uuid : '',
|
||||
row.uuid,
|
||||
row.first_name,
|
||||
row.last_name,
|
||||
row.email,
|
||||
row.phone,
|
||||
row.mobile,
|
||||
row.short_dial,
|
||||
row.departments,
|
||||
row.roles,
|
||||
row.tenants
|
||||
]),
|
||||
search: {
|
||||
input: '#address-book-search',
|
||||
param: 'search',
|
||||
debounce: 250
|
||||
},
|
||||
filters: [
|
||||
{
|
||||
input: '#address-book-tenant-filter',
|
||||
param: 'tenants',
|
||||
default: [],
|
||||
normalize: normalizeCommaSeparated
|
||||
},
|
||||
{
|
||||
input: '#address-book-department-filter',
|
||||
param: 'departments',
|
||||
default: [],
|
||||
normalize: normalizeCommaSeparated
|
||||
},
|
||||
{
|
||||
input: '#address-book-role-filter',
|
||||
param: 'roles',
|
||||
default: [],
|
||||
normalize: normalizeCommaSeparated
|
||||
}
|
||||
],
|
||||
urlSync: true,
|
||||
rowDataset: (row) => ({
|
||||
uuid: row?.cells?.[uuidIndex]?.data
|
||||
}),
|
||||
rowDblClick: {
|
||||
getUrl: (rowData) => {
|
||||
const uuid = rowData?.cells?.[uuidIndex]?.data;
|
||||
return uuid ? new URL(`address-book/view/${uuid}`, appBase).toString() : '';
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
111
pages/address-book/view($id).php
Normal file
111
pages/address-book/view($id).php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Buffer;
|
||||
use MintyPHP\DB;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Support\Flash;
|
||||
use MintyPHP\Support\Guard;
|
||||
use MintyPHP\Service\PermissionService;
|
||||
use MintyPHP\Service\TenantScopeService;
|
||||
use MintyPHP\Service\UserService;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requirePermissionOrForbidden(PermissionService::ADDRESS_BOOK_VIEW);
|
||||
|
||||
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
|
||||
$uuid = trim((string) ($id ?? ''));
|
||||
if ($uuid === '') {
|
||||
Router::redirect('address-book');
|
||||
}
|
||||
|
||||
$user = UserService::findByUuid($uuid);
|
||||
if (!$user || !isset($user['id'])) {
|
||||
Flash::error('User not found', 'address-book', 'user_not_found');
|
||||
Router::redirect('address-book');
|
||||
}
|
||||
|
||||
$userId = (int) ($user['id'] ?? 0);
|
||||
if ($currentUserId !== $userId && !TenantScopeService::canAccess('users', $userId, $currentUserId)) {
|
||||
Router::redirect('error/forbidden');
|
||||
}
|
||||
|
||||
$getRowValue = static function (array $row, string $key): ?string {
|
||||
foreach ($row as $value) {
|
||||
if (is_array($value) && array_key_exists($key, $value)) {
|
||||
return (string) $value[$key];
|
||||
}
|
||||
}
|
||||
if (array_key_exists($key, $row) && !is_array($row[$key])) {
|
||||
return (string) $row[$key];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
$extractLabels = static function (array $rows): array {
|
||||
$labels = [];
|
||||
foreach ($rows as $row) {
|
||||
$label = '';
|
||||
foreach ($row as $value) {
|
||||
if (is_array($value) && isset($value['description'])) {
|
||||
$label = (string) $value['description'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($label === '' && isset($row['description'])) {
|
||||
$label = (string) $row['description'];
|
||||
}
|
||||
if ($label !== '') {
|
||||
$labels[] = $label;
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($labels));
|
||||
};
|
||||
|
||||
$tenantRows = DB::select(
|
||||
'select t.id as id, t.description as description from user_tenants ut join tenants t on t.id = ut.tenant_id where ut.user_id = ? order by t.description asc',
|
||||
(string) $userId
|
||||
);
|
||||
$departmentRows = DB::select(
|
||||
'select td.tenant_id as tenant_id, d.description as description from user_departments ud join tenant_departments td on td.department_id = ud.department_id join departments d on d.id = ud.department_id where ud.user_id = ? order by d.description asc',
|
||||
(string) $userId
|
||||
);
|
||||
$roleRows = DB::select(
|
||||
'select r.description as description from user_roles ur join roles r on r.id = ur.role_id where ur.user_id = ? order by r.description asc',
|
||||
(string) $userId
|
||||
);
|
||||
|
||||
$departmentMap = [];
|
||||
foreach (is_array($departmentRows) ? $departmentRows : [] as $row) {
|
||||
$tenantId = (int) ($getRowValue($row, 'tenant_id') ?? 0);
|
||||
$label = (string) ($getRowValue($row, 'description') ?? '');
|
||||
if ($tenantId > 0 && $label !== '') {
|
||||
$departmentMap[$tenantId][] = $label;
|
||||
}
|
||||
}
|
||||
foreach ($departmentMap as $tenantId => $labels) {
|
||||
$departmentMap[$tenantId] = array_values(array_unique($labels));
|
||||
}
|
||||
|
||||
$primaryTenantId = (int) ($user['primary_tenant_id'] ?? 0);
|
||||
$tenantGroups = [];
|
||||
foreach (is_array($tenantRows) ? $tenantRows : [] as $row) {
|
||||
$tenantId = (int) ($getRowValue($row, 'id') ?? 0);
|
||||
$tenantLabel = (string) ($getRowValue($row, 'description') ?? '');
|
||||
if ($tenantId <= 0 || $tenantLabel === '') {
|
||||
continue;
|
||||
}
|
||||
$tenantGroups[] = [
|
||||
'label' => $tenantLabel,
|
||||
'is_primary' => $primaryTenantId > 0 && $tenantId === $primaryTenantId,
|
||||
'departments' => $departmentMap[$tenantId] ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
$roleLabels = $extractLabels(is_array($roleRows) ? $roleRows : []);
|
||||
|
||||
$user['tenant_groups'] = $tenantGroups;
|
||||
$user['role_labels'] = $roleLabels;
|
||||
|
||||
$name = trim(($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? ''));
|
||||
$title = $name !== '' ? $name : ($user['email'] ?? t('Address book'));
|
||||
Buffer::set('title', $title);
|
||||
277
pages/address-book/view(default).phtml
Normal file
277
pages/address-book/view(default).phtml
Normal file
@@ -0,0 +1,277 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Service\UserAvatarService;
|
||||
use MintyPHP\I18n;
|
||||
|
||||
$values = $user ?? [];
|
||||
$name = trim(($values['first_name'] ?? '') . ' ' . ($values['last_name'] ?? ''));
|
||||
$displayName = $name !== '' ? $name : ($values['email'] ?? t('Address book'));
|
||||
$email = trim((string) ($values['email'] ?? ''));
|
||||
$phone = trim((string) ($values['phone'] ?? ''));
|
||||
$mobile = trim((string) ($values['mobile'] ?? ''));
|
||||
$shortDial = trim((string) ($values['short_dial'] ?? ''));
|
||||
$address = trim((string) ($values['address'] ?? ''));
|
||||
$postalCode = trim((string) ($values['postal_code'] ?? ''));
|
||||
$city = trim((string) ($values['city'] ?? ''));
|
||||
$region = trim((string) ($values['region'] ?? ''));
|
||||
$country = trim((string) ($values['country'] ?? ''));
|
||||
$profileDescription = trim((string) ($values['profile_description'] ?? ''));
|
||||
$jobTitle = trim((string) ($values['job_title'] ?? ''));
|
||||
$hireDate = trim((string) ($values['hire_date'] ?? ''));
|
||||
$hireDateLabel = '';
|
||||
if ($hireDate !== '') {
|
||||
$format = (strpos((string) (I18n::$locale ?? ''), 'de') === 0) ? 'd.m.Y' : 'Y-m-d';
|
||||
try {
|
||||
$hireDateLabel = (new \DateTimeImmutable($hireDate))->format($format);
|
||||
} catch (\Exception $e) {
|
||||
$hireDateLabel = $hireDate;
|
||||
}
|
||||
}
|
||||
$avatarUuid = (string) ($values['uuid'] ?? '');
|
||||
$hasAvatar = $avatarUuid !== '' && UserAvatarService::hasAvatar($avatarUuid);
|
||||
|
||||
$initials = '';
|
||||
if ($displayName !== '') {
|
||||
$parts = array_filter(array_map('trim', preg_split('/\s+/', $displayName) ?: []));
|
||||
$chars = '';
|
||||
foreach ($parts as $part) {
|
||||
if (function_exists('mb_substr')) {
|
||||
$chars .= mb_substr($part, 0, 1);
|
||||
} else {
|
||||
$chars .= substr($part, 0, 1);
|
||||
}
|
||||
}
|
||||
$initials = strtoupper($chars !== '' ? $chars : '?');
|
||||
}
|
||||
$tenantGroups = $values['tenant_groups'] ?? [];
|
||||
$roleLabels = $values['role_labels'] ?? [];
|
||||
$hasContact = ($email !== '' || $phone !== '' || $mobile !== '' || $shortDial !== '');
|
||||
$hasAddress = ($address !== '' || $postalCode !== '' || $city !== '' || $region !== '' || $country !== '');
|
||||
$hasOrganization = (!empty($tenantGroups) || !empty($roleLabels));
|
||||
$aboutSummary = '';
|
||||
if ($profileDescription !== '') {
|
||||
foreach (preg_split('/\\r\\n|\\r|\\n/', $profileDescription) as $line) {
|
||||
$line = trim((string) $line);
|
||||
if ($line !== '') {
|
||||
$aboutSummary = $line;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
<div class="app-details-container">
|
||||
<section>
|
||||
<div class="app-breadcrumb">
|
||||
<a href="address-book"><?php e(t('Address book')); ?></a><i
|
||||
class="bi bi-chevron-right"></i><?php e($displayName); ?>
|
||||
</div>
|
||||
<!-- <div class="app-details-titlebar">
|
||||
<h1>
|
||||
<a href="address-book" title="<?php e(t('Back')); ?>"><i class="bi bi-arrow-left"></i></a>
|
||||
<?php e(t('Address book')); ?>
|
||||
</h1>
|
||||
</div> -->
|
||||
|
||||
<div class="app-profile-card-container">
|
||||
<div class="app-profile-card">
|
||||
<div class="app-profile-banner"></div>
|
||||
<div class="app-profile-header">
|
||||
<div class="user-avatar-block avatar-round app-profile-avatar">
|
||||
<?php if ($hasAvatar): ?>
|
||||
<a data-fslightbox="address-book-avatar"
|
||||
href="admin/users/avatar-file?uuid=<?php e($avatarUuid); ?>&size=256">
|
||||
<img class="user-avatar-image" src="admin/users/avatar-file?uuid=<?php e($avatarUuid); ?>&size=160"
|
||||
alt="">
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<span class="user-avatar-placeholder"><?php e($initials ?: '?'); ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="app-profile-meta">
|
||||
<hgroup>
|
||||
<h2><?php e($displayName); ?></h2>
|
||||
<?php if ($jobTitle !== ''): ?>
|
||||
<p><small><?php e($jobTitle); ?></small></p>
|
||||
<?php endif; ?>
|
||||
<?php if ($email !== ''): ?>
|
||||
<p>
|
||||
<a href="mailto:<?php e($email); ?>">
|
||||
<small><?php e($email); ?></small>
|
||||
</a>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</hgroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-profile-body-container">
|
||||
<div class="app-profile-body">
|
||||
<div class="app-tabs" data-tabs data-tabs-param="tab" data-tabs-storage-key="address-book-profile">
|
||||
<div class="app-tabs-nav">
|
||||
<?php if ($hasContact): ?>
|
||||
<button type="button" data-tab="contact" data-tab-default><small><?php e(t('Contact')); ?></small></button>
|
||||
<?php endif; ?>
|
||||
<?php if ($hasAddress): ?>
|
||||
<button type="button" data-tab="address"><small><?php e(t('Address')); ?></small></button>
|
||||
<?php endif; ?>
|
||||
<?php if ($hasOrganization): ?>
|
||||
<button type="button" data-tab="organization"><small><?php e(t('Organization')); ?></small></button>
|
||||
<?php endif; ?>
|
||||
<button type="button" data-tab="about"><small><?php e(t('About')); ?></small></button>
|
||||
</div>
|
||||
<?php if ($hasContact): ?>
|
||||
<div data-tab-panel="contact">
|
||||
<div class="grid">
|
||||
<?php if ($email !== ''): ?>
|
||||
<div>
|
||||
<small><?php e(t('Email')); ?></small>
|
||||
<p><a href="mailto:<?php e($email); ?>"><?php e($email); ?></a></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($phone !== ''): ?>
|
||||
<div>
|
||||
<small><?php e(t('Phone')); ?></small>
|
||||
<p><a href="tel:<?php e($phone); ?>"><?php e($phone); ?></a></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<?php if ($mobile !== ''): ?>
|
||||
<div>
|
||||
<small><?php e(t('Mobile')); ?></small>
|
||||
<p><a href="tel:<?php e($mobile); ?>"><?php e($mobile); ?></a></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($shortDial !== ''): ?>
|
||||
<div>
|
||||
<small><?php e(t('Short dial')); ?></small>
|
||||
<p><?php e($shortDial); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($hasAddress): ?>
|
||||
<div data-tab-panel="address">
|
||||
<div>
|
||||
<?php if ($address !== ''): ?>
|
||||
<p><?php e($address); ?></p>
|
||||
<?php endif; ?>
|
||||
<?php $line2 = trim($postalCode . ' ' . $city); ?>
|
||||
<?php if ($line2 !== ''): ?>
|
||||
<p><?php e($line2); ?></p>
|
||||
<?php endif; ?>
|
||||
<?php if ($region !== ''): ?>
|
||||
<p><?php e($region); ?></p>
|
||||
<?php endif; ?>
|
||||
<?php if ($country !== ''): ?>
|
||||
<p><?php e($country); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($hasOrganization): ?>
|
||||
<div data-tab-panel="organization">
|
||||
<?php if (!empty($tenantGroups)): ?>
|
||||
<table class="app-profile-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php e(t('Tenant')); ?></th>
|
||||
<th><?php e(t('Departments')); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($tenantGroups as $group): ?>
|
||||
<?php
|
||||
$departments = $group['departments'] ?? [];
|
||||
$departments = is_array($departments) ? $departments : [];
|
||||
?>
|
||||
<tr>
|
||||
<td>
|
||||
<?php e($group['label'] ?? ''); ?>
|
||||
<?php if (!empty($group['is_primary'])): ?>
|
||||
<small>(<?php e(t('Primary tenant')); ?>)</small>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if (!empty($departments)): ?>
|
||||
<?php e(implode(', ', $departments)); ?>
|
||||
<?php else: ?>
|
||||
-
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr>
|
||||
<?php elseif (!empty($roleLabels)): ?>
|
||||
<div>
|
||||
<small><?php e(t('Roles')); ?></small>
|
||||
<p><?php e(implode(', ', $roleLabels)); ?></p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<p>-</p>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($roleLabels)): ?>
|
||||
<table class="app-profile-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php e(t('Roles')); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><?php e(implode(', ', $roleLabels)); ?></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div data-tab-panel="about">
|
||||
<?php if ($hireDateLabel !== ''): ?>
|
||||
<div>
|
||||
<small><?php e(t('Hire date')); ?></small>
|
||||
<p><?php e($hireDateLabel); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($profileDescription !== ''): ?>
|
||||
<?php foreach (preg_split('/\\r\\n|\\r|\\n/', $profileDescription) as $line): ?>
|
||||
<?php if (trim($line) !== ''): ?>
|
||||
<p><?php e($line); ?></p>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
<?php elseif ($hireDateLabel === ''): ?>
|
||||
<p>-</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<aside id="app-details-aside-section">
|
||||
<div class="app-details-aside-section">
|
||||
<hgroup>
|
||||
<h2><?php e(t('Contact')); ?></h2>
|
||||
<p><?php e(t('Quick actions')); ?></p>
|
||||
</hgroup>
|
||||
<hr>
|
||||
<?php if ($email !== ''): ?>
|
||||
<p><a href="mailto:<?php e($email); ?>"><?php e(t('Send email')); ?></a></p>
|
||||
<?php endif; ?>
|
||||
<?php if ($phone !== ''): ?>
|
||||
<p><a href="tel:<?php e($phone); ?>"><?php e(t('Call phone')); ?></a></p>
|
||||
<?php endif; ?>
|
||||
<?php if ($mobile !== ''): ?>
|
||||
<p><a href="tel:<?php e($mobile); ?>"><?php e(t('Call mobile')); ?></a></p>
|
||||
<?php endif; ?>
|
||||
<?php if (!$hasContact): ?>
|
||||
<p>-</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
55
pages/admin/departments/_form.phtml
Normal file
55
pages/admin/departments/_form.phtml
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @var array $values
|
||||
* @var string|null $formId
|
||||
* @var bool $detailsOpenAll
|
||||
* @var bool $isReadOnly
|
||||
* @var array $tenants
|
||||
* @var array $selectedTenantIds
|
||||
*/
|
||||
|
||||
use MintyPHP\Session;
|
||||
|
||||
$values = $values ?? [];
|
||||
$formId = $formId ?? 'department-form';
|
||||
$detailsOpenAll = $detailsOpenAll ?? false;
|
||||
$isReadOnly = $isReadOnly ?? false;
|
||||
$tenants = $tenants ?? [];
|
||||
$selectedTenantIds = $selectedTenantIds ?? [];
|
||||
$detailsOpenAttr = $detailsOpenAll ? 'open' : '';
|
||||
$readonlyAttr = $isReadOnly ? 'readonly' : '';
|
||||
$disabledAttr = $isReadOnly ? 'disabled' : '';
|
||||
$dataDisabledAttr = $isReadOnly ? 'data-disabled="true"' : '';
|
||||
|
||||
?>
|
||||
<form id="<?php e($formId); ?>" method="post">
|
||||
<details name="basic-data" open>
|
||||
<summary>Stammdaten</summary>
|
||||
<hr>
|
||||
<label for="description">
|
||||
<span><?php e(t('Description')); ?></span>
|
||||
<input required type="text" name="description" id="description" value="<?php e($values['description'] ?? ''); ?>" <?php e($readonlyAttr); ?> />
|
||||
</label>
|
||||
</details>
|
||||
<?php if ($tenants): ?>
|
||||
<label>
|
||||
<span><?php e(t('Assigned tenants')); ?></span>
|
||||
</label>
|
||||
<select id="department-tenants" name="tenant_ids" multiple data-multi-select="true" <?php e($dataDisabledAttr); ?> <?php e($disabledAttr); ?>
|
||||
data-placeholder="<?php e(t('Select tenants')); ?>" data-search="true"
|
||||
data-search-placeholder="<?php e(t('Search...')); ?>" data-select-all="true"
|
||||
data-select-all-label="<?php e(t('Select all')); ?>">
|
||||
<?php foreach ($tenants as $tenant): ?>
|
||||
<?php
|
||||
$tenantId = (int) ($tenant['id'] ?? 0);
|
||||
$selected = in_array($tenantId, $selectedTenantIds ?? [], true) ? 'selected' : '';
|
||||
?>
|
||||
<option value="<?php e((string) $tenantId); ?>" <?php e($selected); ?>>
|
||||
<?php e($tenant['description'] ?? ''); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php endif; ?>
|
||||
<?php Session::getCsrfInput(); ?>
|
||||
</form>
|
||||
75
pages/admin/departments/create().php
Normal file
75
pages/admin/departments/create().php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Buffer;
|
||||
use MintyPHP\Support\Flash;
|
||||
use MintyPHP\Support\Guard;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Service\DepartmentService;
|
||||
use MintyPHP\Service\PermissionService;
|
||||
use MintyPHP\Service\TenantService;
|
||||
use MintyPHP\Service\TenantScopeService;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requirePermissionOrForbidden(PermissionService::DEPARTMENTS_CREATE);
|
||||
|
||||
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
|
||||
$allowedTenantIds = TenantScopeService::getUserTenantIds($currentUserId);
|
||||
if (TenantScopeService::isStrict() && !$allowedTenantIds) {
|
||||
Router::redirect('error/forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
$form = [
|
||||
'description' => '',
|
||||
];
|
||||
$tenants = TenantService::list();
|
||||
if ($allowedTenantIds) {
|
||||
$tenants = array_values(array_filter($tenants, static function (array $tenant) use ($allowedTenantIds): bool {
|
||||
$tenantId = (int) ($tenant['id'] ?? 0);
|
||||
return $tenantId > 0 && in_array($tenantId, $allowedTenantIds, true);
|
||||
}));
|
||||
} elseif (TenantScopeService::isStrict()) {
|
||||
$tenants = [];
|
||||
}
|
||||
$selectedTenantIds = [];
|
||||
|
||||
if (isset($_POST['description'])) {
|
||||
$result = DepartmentService::createFromAdmin($_POST, $currentUserId);
|
||||
$form = $result['form'] ?? $form;
|
||||
$errors = $result['errors'] ?? [];
|
||||
$tenantIds = $_POST['tenant_ids'] ?? [];
|
||||
if (!is_array($tenantIds)) {
|
||||
$tenantIds = [$tenantIds];
|
||||
}
|
||||
$selectedTenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
|
||||
$selectedTenantIds = TenantScopeService::filterTenantIdsForUser($selectedTenantIds, $currentUserId);
|
||||
|
||||
if (TenantScopeService::isStrict() && !$selectedTenantIds) {
|
||||
$errors[] = t('Please select at least one tenant');
|
||||
}
|
||||
|
||||
if (($result['ok'] ?? false) && !$errors) {
|
||||
$departmentId = (int) ($result['id'] ?? 0);
|
||||
if ($departmentId) {
|
||||
DepartmentService::syncTenants($departmentId, $selectedTenantIds);
|
||||
}
|
||||
$action = (string) ($_POST['action'] ?? 'create');
|
||||
if ($action === 'create_close') {
|
||||
Flash::success('Department created', 'admin/departments', 'department_created');
|
||||
Router::redirect('admin/departments');
|
||||
} else {
|
||||
$uuid = (string) ($result['uuid'] ?? '');
|
||||
if ($uuid !== '') {
|
||||
$target = "admin/departments/edit/{$uuid}";
|
||||
Flash::success('Department created', $target, 'department_created');
|
||||
Router::redirect($target);
|
||||
} else {
|
||||
Flash::success('Department created', 'admin/departments', 'department_created');
|
||||
Router::redirect('admin/departments');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Buffer::set('title', t('Create department'));
|
||||
44
pages/admin/departments/create(default).phtml
Normal file
44
pages/admin/departments/create(default).phtml
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @var array<int, string> $errors
|
||||
* @var array $form
|
||||
*/
|
||||
|
||||
?>
|
||||
<div class="app-details-container">
|
||||
<section>
|
||||
<div class="app-breadcrumb">
|
||||
<a href="/admin">Startseite</a><i class="bi bi-chevron-right"></i><a
|
||||
href="/admin/departments"><?php e(t('Departments')); ?></a><i class="bi bi-chevron-right"></i><?php e(t('Create department')); ?>
|
||||
</div>
|
||||
<div class="app-details-titlebar">
|
||||
<h1>
|
||||
<a href="admin/departments" title="<?php e(t('Cancel')); ?>"><i class="bi bi-arrow-left"></i></a>
|
||||
<?php e(t('Create department')); ?>
|
||||
</h1>
|
||||
<div class="app-details-titlebar-actions">
|
||||
<button type="submit" form="department-form" name="action" value="create" class="secondary outline">
|
||||
<?php e(t('Create')); ?>
|
||||
</button>
|
||||
<button type="submit" form="department-form" name="action" value="create_close" class="primary">
|
||||
<?php e(t('Create & close')); ?>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<?php if (!empty($errors)): ?>
|
||||
<div class="notice" data-variant="error">
|
||||
<ul>
|
||||
<?php foreach ($errors as $error): ?>
|
||||
<li><?php e($error); ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
$values = $form ?? [];
|
||||
$detailsOpenAll = true;
|
||||
require __DIR__ . '/_form.phtml';
|
||||
?>
|
||||
</section>
|
||||
</div>
|
||||
52
pages/admin/departments/data().php
Normal file
52
pages/admin/departments/data().php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Support\Guard;
|
||||
use MintyPHP\Service\DepartmentService;
|
||||
use MintyPHP\Service\SettingService;
|
||||
use MintyPHP\Service\PermissionService;
|
||||
use MintyPHP\Service\TenantScopeService;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requirePermissionOrForbidden(PermissionService::DEPARTMENTS_VIEW);
|
||||
|
||||
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
|
||||
$tenantIds = TenantScopeService::getUserTenantIds($currentUserId);
|
||||
|
||||
$limit = (int) ($_GET['limit'] ?? 10);
|
||||
$offset = (int) ($_GET['offset'] ?? 0);
|
||||
$search = trim((string) ($_GET['search'] ?? ''));
|
||||
$order = (string) ($_GET['order'] ?? 'description');
|
||||
$dir = (string) ($_GET['dir'] ?? 'asc');
|
||||
$tenant = trim((string) ($_GET['tenant'] ?? ''));
|
||||
|
||||
$result = DepartmentService::listPaged([
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
'search' => $search,
|
||||
'order' => $order,
|
||||
'dir' => $dir,
|
||||
'tenant' => $tenant,
|
||||
'tenantIds' => $tenantIds,
|
||||
'tenantUserId' => $currentUserId,
|
||||
]);
|
||||
|
||||
$defaultDepartmentId = SettingService::getDefaultDepartmentId();
|
||||
$rows = [];
|
||||
foreach ($result['rows'] as $row) {
|
||||
$departmentId = (int) ($row['id'] ?? 0);
|
||||
$rows[] = [
|
||||
'id' => $row['id'] ?? null,
|
||||
'uuid' => $row['uuid'] ?? '',
|
||||
'is_default' => $departmentId > 0 && $defaultDepartmentId === $departmentId,
|
||||
'description' => $row['description'] ?? '',
|
||||
'tenants' => $row['tenant_labels'] ?? [],
|
||||
'created' => dt($row['created'] ?? ''),
|
||||
'modified' => dt($row['modified'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
Router::json([
|
||||
'data' => $rows,
|
||||
'total' => $result['total'] ?? 0,
|
||||
]);
|
||||
37
pages/admin/departments/delete($id).php
Normal file
37
pages/admin/departments/delete($id).php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Support\Flash;
|
||||
use MintyPHP\Support\Guard;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Service\AuthService;
|
||||
use MintyPHP\Service\DepartmentService;
|
||||
use MintyPHP\Service\PermissionService;
|
||||
use MintyPHP\Service\TenantScopeService;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requirePermissionOrForbidden(PermissionService::DEPARTMENTS_DELETE);
|
||||
|
||||
$uuid = trim((string) ($id ?? ''));
|
||||
if ($uuid === '') {
|
||||
Router::redirect('admin/departments');
|
||||
}
|
||||
|
||||
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
|
||||
$department = $uuid !== '' ? DepartmentService::findByUuid($uuid) : null;
|
||||
if ($department && !TenantScopeService::canAccess('departments', (int) ($department['id'] ?? 0), $currentUserId)) {
|
||||
Router::redirect('error/forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
$result = DepartmentService::deleteByUuid($uuid);
|
||||
if (!($result['ok'] ?? false)) {
|
||||
Flash::error('Department not found', 'admin/departments', 'department_not_found');
|
||||
Router::redirect('admin/departments');
|
||||
}
|
||||
|
||||
if ($currentUserId > 0) {
|
||||
AuthService::loadTenantDataIntoSession($currentUserId);
|
||||
}
|
||||
|
||||
Flash::success('Department deleted', 'admin/departments', 'department_deleted');
|
||||
Router::redirect('admin/departments');
|
||||
111
pages/admin/departments/edit($id).php
Normal file
111
pages/admin/departments/edit($id).php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Buffer;
|
||||
use MintyPHP\Support\Flash;
|
||||
use MintyPHP\Support\Guard;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Service\DepartmentService;
|
||||
use MintyPHP\Repository\TenantDepartmentRepository;
|
||||
use MintyPHP\Service\TenantService;
|
||||
use MintyPHP\Service\UserService;
|
||||
use MintyPHP\Service\SettingService;
|
||||
use MintyPHP\Service\PermissionService;
|
||||
use MintyPHP\Service\TenantScopeService;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requirePermissionOrForbidden(PermissionService::DEPARTMENTS_VIEW);
|
||||
|
||||
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
|
||||
if ($currentUserId > 0) {
|
||||
PermissionService::getUserPermissions($currentUserId);
|
||||
}
|
||||
$allowedTenantIds = TenantScopeService::getUserTenantIds($currentUserId);
|
||||
|
||||
$uuid = trim((string) ($id ?? ''));
|
||||
$department = $uuid !== '' ? DepartmentService::findByUuid($uuid) : null;
|
||||
if (!$department) {
|
||||
Flash::error('Department not found', 'admin/departments', 'department_not_found');
|
||||
Router::redirect('admin/departments');
|
||||
}
|
||||
|
||||
$departmentId = (int) ($department['id'] ?? 0);
|
||||
if (!TenantScopeService::canAccess('departments', $departmentId, $currentUserId)) {
|
||||
Router::redirect('error/forbidden');
|
||||
return;
|
||||
}
|
||||
$creatorId = (int) ($department['created_by'] ?? 0);
|
||||
if ($creatorId > 0) {
|
||||
$creator = UserService::findById($creatorId);
|
||||
if ($creator) {
|
||||
$creatorName = trim(($creator['first_name'] ?? '') . ' ' . ($creator['last_name'] ?? ''));
|
||||
$department['created_by_label'] = $creatorName !== '' ? $creatorName : ($creator['email'] ?? '');
|
||||
$department['created_by_uuid'] = $creator['uuid'] ?? null;
|
||||
}
|
||||
}
|
||||
$modifierId = (int) ($department['modified_by'] ?? 0);
|
||||
if ($modifierId > 0) {
|
||||
$modifier = UserService::findById($modifierId);
|
||||
if ($modifier) {
|
||||
$modifierName = trim(($modifier['first_name'] ?? '') . ' ' . ($modifier['last_name'] ?? ''));
|
||||
$department['modified_by_label'] = $modifierName !== '' ? $modifierName : ($modifier['email'] ?? '');
|
||||
$department['modified_by_uuid'] = $modifier['uuid'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
$form = $department;
|
||||
$tenants = TenantService::list();
|
||||
$selectedTenantIds = TenantDepartmentRepository::listTenantIdsByDepartmentId($departmentId);
|
||||
if ($allowedTenantIds) {
|
||||
$tenants = array_values(array_filter($tenants, static function (array $tenant) use ($allowedTenantIds): bool {
|
||||
$tenantId = (int) ($tenant['id'] ?? 0);
|
||||
return $tenantId > 0 && in_array($tenantId, $allowedTenantIds, true);
|
||||
}));
|
||||
$selectedTenantIds = array_values(array_intersect($selectedTenantIds, $allowedTenantIds));
|
||||
} elseif (TenantScopeService::isStrict()) {
|
||||
$tenants = [];
|
||||
$selectedTenantIds = [];
|
||||
}
|
||||
|
||||
if (isset($_POST['description'])) {
|
||||
if (!PermissionService::userHas($currentUserId, PermissionService::DEPARTMENTS_UPDATE)) {
|
||||
Router::redirect('error/forbidden');
|
||||
return;
|
||||
}
|
||||
$result = DepartmentService::updateFromAdmin($departmentId, $_POST, $currentUserId);
|
||||
$form = $result['form'] ?? $form;
|
||||
$errors = $result['errors'] ?? [];
|
||||
$tenantIds = $_POST['tenant_ids'] ?? [];
|
||||
if (!is_array($tenantIds)) {
|
||||
$tenantIds = [$tenantIds];
|
||||
}
|
||||
$selectedTenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
|
||||
$selectedTenantIds = TenantScopeService::filterTenantIdsForUser($selectedTenantIds, $currentUserId);
|
||||
|
||||
if (TenantScopeService::isStrict() && !$selectedTenantIds) {
|
||||
$errors[] = t('Please select at least one tenant');
|
||||
}
|
||||
|
||||
if (($result['ok'] ?? false) && !$errors) {
|
||||
$existingTenantIds = TenantDepartmentRepository::listTenantIdsByDepartmentId($departmentId);
|
||||
$finalTenantIds = TenantScopeService::mergeTenantIdsPreservingOutOfScope(
|
||||
$selectedTenantIds,
|
||||
$existingTenantIds,
|
||||
$allowedTenantIds
|
||||
);
|
||||
$cleaned = DepartmentService::syncTenants($departmentId, $finalTenantIds);
|
||||
$action = (string) ($_POST['action'] ?? 'save');
|
||||
if ($cleaned > 0) {
|
||||
Flash::info(t('Department assignments cleaned: %d', $cleaned), "admin/departments/edit/{$uuid}", 'department_assignments_cleaned');
|
||||
}
|
||||
if ($action === 'save_close') {
|
||||
Flash::success('Department updated', 'admin/departments', 'department_updated');
|
||||
Router::redirect('admin/departments');
|
||||
} else {
|
||||
Flash::success('Department updated', "admin/departments/edit/{$uuid}", 'department_updated');
|
||||
Router::redirect("admin/departments/edit/{$uuid}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Buffer::set('title', t('Edit department'));
|
||||
167
pages/admin/departments/edit(default).phtml
Normal file
167
pages/admin/departments/edit(default).phtml
Normal file
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @var array<int, string> $errors
|
||||
* @var array $form
|
||||
* @var array $department
|
||||
*/
|
||||
|
||||
use MintyPHP\Session;
|
||||
|
||||
$values = $form ?? $department ?? [];
|
||||
$isReadOnly = !can('departments.update');
|
||||
|
||||
?>
|
||||
|
||||
<div class="app-details-container">
|
||||
<section>
|
||||
<div class="app-breadcrumb">
|
||||
<a href="/admin">Startseite</a><i class="bi bi-chevron-right"></i><a
|
||||
href="/admin/departments"><?php e(t('Departments')); ?></a><i class="bi bi-chevron-right"></i><?php e(t('Edit department')); ?>
|
||||
</div>
|
||||
<div class="app-details-titlebar">
|
||||
<h1>
|
||||
<a href="admin/departments" title="<?php e(t('Cancel')); ?>"><i class="bi bi-arrow-left"></i></a>
|
||||
<?php e(t('Edit department')); ?>
|
||||
</h1>
|
||||
<div class="app-details-titlebar-actions">
|
||||
<?php if (can('departments.delete')): ?>
|
||||
<details class="dropdown">
|
||||
<summary role="button" class="outline secondary"><i class="bi bi-three-dots"></i></summary>
|
||||
<ul dir="rtl">
|
||||
<li>
|
||||
<form method="post" action="admin/departments/delete/<?php e($values['uuid'] ?? ''); ?>"
|
||||
onsubmit="return confirm('<?php e(t('Delete this department?')); ?>');">
|
||||
<?php Session::getCsrfInput(); ?>
|
||||
<button type="submit" class="transparent">
|
||||
<?php e(t('Delete')); ?>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (can('departments.update')): ?>
|
||||
<button type="submit" form="department-form" name="action" value="save" class="secondary outline">
|
||||
<?php e(t('Save')); ?>
|
||||
</button>
|
||||
<button type="submit" form="department-form" name="action" value="save_close" class="primary">
|
||||
<?php e(t('Save & close')); ?>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($errors)): ?>
|
||||
<div class="app-details-errors">
|
||||
<div class="notice" data-variant="error">
|
||||
<ul>
|
||||
<?php foreach ($errors as $error): ?>
|
||||
<li><?php e($error); ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
$detailsOpenAll = false;
|
||||
$isReadOnly = $isReadOnly ?? false;
|
||||
require __DIR__ . '/_form.phtml';
|
||||
?>
|
||||
</section>
|
||||
<aside id="app-details-aside-section">
|
||||
<div class="app-details-aside-section">
|
||||
<hgroup>
|
||||
<h2><?php e($values['description'] ?? ''); ?></h2>
|
||||
<p><?php e(t('Department')); ?></p>
|
||||
</hgroup>
|
||||
<hr>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<small><?php e(t('ID')); ?></small>
|
||||
<p>
|
||||
<span class="badge" data-variant="neutral" data-copy="true"
|
||||
data-copy-value="<?php e($values['id'] ?? ''); ?>">
|
||||
<?php e($values['id'] ?? '-'); ?>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
$createdByLabel = $values['created_by_label'] ?? '';
|
||||
$createdById = $values['created_by'] ?? null;
|
||||
$createdByUuid = $values['created_by_uuid'] ?? null;
|
||||
$modifiedByLabel = $values['modified_by_label'] ?? '';
|
||||
$modifiedById = $values['modified_by'] ?? null;
|
||||
$modifiedByUuid = $values['modified_by_uuid'] ?? null;
|
||||
?>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<small><?php e(t('Created')); ?></small>
|
||||
<p><?php e(dt($values['created'] ?? '') ?: '-'); ?></p>
|
||||
</div>
|
||||
<div>
|
||||
<small>
|
||||
<?php e(t('Created by')); ?>
|
||||
</small>
|
||||
<p>
|
||||
<?php if ($createdByLabel !== ''): ?>
|
||||
<?php if ($createdByUuid): ?>
|
||||
<a href="admin/users/edit/<?php e($createdByUuid); ?>"><?php e($createdByLabel); ?></a>
|
||||
<?php else: ?>
|
||||
<?php e($createdByLabel); ?>
|
||||
<?php endif; ?>
|
||||
<?php elseif ($createdById): ?>
|
||||
<?php e('#' . $createdById); ?>
|
||||
<?php else: ?>
|
||||
-
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<small><?php e(t('Modified')); ?></small>
|
||||
<p><?php e(dt($values['modified'] ?? '') ?: '-'); ?></p>
|
||||
</div>
|
||||
<div>
|
||||
<small>
|
||||
<?php e(t('Modified by')); ?>
|
||||
</small>
|
||||
<p>
|
||||
<?php if ($modifiedByLabel !== ''): ?>
|
||||
<?php if ($modifiedByUuid): ?>
|
||||
<a href="admin/users/edit/<?php e($modifiedByUuid); ?>"><?php e($modifiedByLabel); ?></a>
|
||||
<?php else: ?>
|
||||
<?php e($modifiedByLabel); ?>
|
||||
<?php endif; ?>
|
||||
<?php elseif ($modifiedById): ?>
|
||||
<?php e('#' . $modifiedById); ?>
|
||||
<?php else: ?>
|
||||
-
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<small>
|
||||
<?php e(t('UUID')); ?>
|
||||
</small>
|
||||
<p>
|
||||
<span class="badge" data-variant="neutral" data-copy="true"
|
||||
data-copy-value="<?php e($values['uuid'] ?? ''); ?>">
|
||||
<?php
|
||||
$uuidValue = (string) ($values['uuid'] ?? '');
|
||||
$uuidDisplay = $uuidValue !== '' ? substr($uuidValue, 0, 10) : '-';
|
||||
e($uuidDisplay);
|
||||
?>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
39
pages/admin/departments/index().php
Normal file
39
pages/admin/departments/index().php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Buffer;
|
||||
use MintyPHP\Support\Guard;
|
||||
use MintyPHP\Session;
|
||||
use MintyPHP\Service\PermissionService;
|
||||
use MintyPHP\Service\TenantService;
|
||||
use MintyPHP\Service\TenantScopeService;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requirePermissionOrForbidden(PermissionService::DEPARTMENTS_VIEW);
|
||||
|
||||
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
|
||||
if ($currentUserId > 0) {
|
||||
PermissionService::getUserPermissions($currentUserId);
|
||||
}
|
||||
|
||||
$tenants = TenantService::list();
|
||||
$allowedTenantIds = TenantScopeService::getUserTenantIds($currentUserId);
|
||||
if ($allowedTenantIds) {
|
||||
$tenants = array_values(array_filter($tenants, static function (array $tenant) use ($allowedTenantIds): bool {
|
||||
$tenantId = (int) ($tenant['id'] ?? 0);
|
||||
return $tenantId > 0 && in_array($tenantId, $allowedTenantIds, true);
|
||||
}));
|
||||
} elseif (TenantScopeService::isStrict()) {
|
||||
$tenants = [];
|
||||
}
|
||||
usort($tenants, static function ($a, $b) {
|
||||
return strcmp((string) ($a['description'] ?? ''), (string) ($b['description'] ?? ''));
|
||||
});
|
||||
|
||||
Buffer::set('title', t('Departments'));
|
||||
Buffer::set('grid_lang', json_encode(gridLang(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
$csrfKey = Session::$csrfSessionKey;
|
||||
$csrfToken = $_SESSION[$csrfKey] ?? '';
|
||||
Buffer::set(
|
||||
'grid_csrf',
|
||||
json_encode(['key' => $csrfKey, 'token' => $csrfToken], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
);
|
||||
175
pages/admin/departments/index(default).phtml
Normal file
175
pages/admin/departments/index(default).phtml
Normal file
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
use MintyPHP\Router;
|
||||
|
||||
$canCreateDepartments = can('departments.create');
|
||||
$canDeleteDepartments = can('departments.delete');
|
||||
$activeTenant = trim((string) ($_GET['tenant'] ?? ''));
|
||||
$showTenantTabs = isset($tenants) && is_array($tenants) && count($tenants) > 1;
|
||||
?>
|
||||
<div class="app-breadcrumb">
|
||||
<a href="/admin">Startseite</a><i class="bi bi-chevron-right"></i><?php e(t('Departments')); ?>
|
||||
</div>
|
||||
<div class="app-list-titlebar">
|
||||
<h1><?php e(t('Departments')); ?></h1>
|
||||
<div class="app-list-titlebar-actions">
|
||||
<button
|
||||
class="outline secondary"
|
||||
type="button"
|
||||
data-toolbar-toggle
|
||||
data-toolbar-target="#departments-toolbar"
|
||||
data-toolbar-text-show="<?php e(t('Show filters')); ?>"
|
||||
data-toolbar-text-hide="<?php e(t('Hide filters')); ?>"
|
||||
>
|
||||
<i class="bi bi-funnel-fill"></i> <span data-toolbar-label><?php e(t('Hide filters')); ?></span>
|
||||
</button>
|
||||
<?php if ($canCreateDepartments): ?>
|
||||
<a role="button" class="primary" href="admin/departments/create">
|
||||
<i class="bi bi-plus"></i> <?php e(t('Create department')); ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php if ($showTenantTabs): ?>
|
||||
<div class="app-list-tabs" id="departments-tabs">
|
||||
<a href="admin/departments" class="<?php e($activeTenant === '' ? 'tab-link is-active' : 'tab-link'); ?>">
|
||||
<?php e(t('All')); ?>
|
||||
</a>
|
||||
<?php foreach ($tenants ?? [] as $tenant): ?>
|
||||
<?php if (!empty($tenant['uuid'])): ?>
|
||||
<?php $isActive = $activeTenant === $tenant['uuid']; ?>
|
||||
<a href="admin/departments?tenant=<?php e($tenant['uuid']); ?>" class="<?php e($isActive ? 'tab-link is-active' : 'tab-link'); ?>">
|
||||
<?php e($tenant['description'] ?? ''); ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="app-list-toolbar" id="departments-toolbar">
|
||||
<input type="hidden" id="department-tenant-filter" value="<?php e($activeTenant); ?>">
|
||||
<input type="search" id="department-search" placeholder="<?php e(t('Search...')); ?>">
|
||||
</div>
|
||||
<div class="app-list-table">
|
||||
<div id="departments-grid"></div>
|
||||
</div>
|
||||
|
||||
<?php $gridFactoryVersion = @filemtime('web/js/core/app-grid-factory.js') ?: time(); ?>
|
||||
<script src="<?php e(asset('vendor/gridjs/gridjs.umd.js')); ?>"></script>
|
||||
<script type="module">
|
||||
import { initToolbarToggles } from "<?php e(asset('js/components/app-toggle-toolbar.js')); ?>";
|
||||
import { createServerGrid } from "<?php e(asset('js/core/app-grid-factory.js?v=' . $gridFactoryVersion)); ?>";
|
||||
import { buildUrl, postAction, badgeHtml } from "<?php e(asset('js/pages/app-list-utils.js')); ?>";
|
||||
|
||||
initToolbarToggles();
|
||||
|
||||
const appBase = "<?php e(localeBase()); ?>";
|
||||
const csrf = <?php MintyPHP\Buffer::get('grid_csrf'); ?>;
|
||||
const deleteConfirm = "<?php e(t('Delete this department?')); ?>";
|
||||
const buildActionUrl = (action, id) => buildUrl(appBase, `admin/departments/${action}/${id}`);
|
||||
|
||||
const initDepartmentsGrid = () => {
|
||||
const formatBadge = (value) => badgeHtml(gridjs, value);
|
||||
const buildBadgeList = (items) => {
|
||||
if (!Array.isArray(items) || items.length === 0) return '';
|
||||
const badges = items.map((label) => `<span class="badge" data-variant="neutral">${label}</span>`).join(' ');
|
||||
return `<div class="badge-list">${badges}</div>`;
|
||||
};
|
||||
const gridConfig = createServerGrid({
|
||||
gridjs: window.gridjs,
|
||||
container: '#departments-grid',
|
||||
dataUrl: 'admin/departments/data',
|
||||
appBase,
|
||||
columns: [
|
||||
{ name: "<?php e(t('Description')); ?>", sort: true },
|
||||
{
|
||||
name: "<?php e(t('Created')); ?>",
|
||||
sort: true,
|
||||
formatter: (cell) => formatBadge(cell)
|
||||
},
|
||||
{
|
||||
name: "<?php e(t('Modified')); ?>",
|
||||
sort: true,
|
||||
formatter: (cell) => formatBadge(cell)
|
||||
},
|
||||
{
|
||||
name: "UUID",
|
||||
hidden: true
|
||||
},
|
||||
{
|
||||
name: "ID",
|
||||
hidden: true
|
||||
}
|
||||
],
|
||||
sortColumns: ['description', 'created', 'modified', null, null],
|
||||
paginationLimit: 10,
|
||||
language: <?php MintyPHP\Buffer::get('grid_lang'); ?>,
|
||||
mapData: (data) => data.data.map((row) => [
|
||||
row.description,
|
||||
row.created,
|
||||
row.modified,
|
||||
row.uuid,
|
||||
row.id
|
||||
]),
|
||||
actions: {
|
||||
enabled: true,
|
||||
label: "<?php e(t('Actions')); ?>",
|
||||
getRowId: (row) => row?.cells?.[3]?.data,
|
||||
items: [
|
||||
{
|
||||
key: 'edit',
|
||||
type: 'link',
|
||||
label: "<i class=\"bi bi-pencil-fill\"></i>",
|
||||
href: ({ id }) => buildActionUrl('edit', id)
|
||||
},
|
||||
<?php if ($canDeleteDepartments): ?>
|
||||
{
|
||||
key: 'delete',
|
||||
label: "<i class=\"bi bi-trash3-fill\"></i>",
|
||||
className: 'danger outline',
|
||||
confirm: deleteConfirm
|
||||
}
|
||||
<?php endif; ?>
|
||||
],
|
||||
onAction: async ({ action, id, grid }) => {
|
||||
if (action !== 'delete') return;
|
||||
const url = buildActionUrl('delete', id);
|
||||
const response = await postAction(url, csrf);
|
||||
if (response.ok) {
|
||||
grid.forceRender();
|
||||
} else {
|
||||
window.location.href = new URL('admin/departments', appBase).toString();
|
||||
}
|
||||
}
|
||||
},
|
||||
search: {
|
||||
input: '#department-search',
|
||||
param: 'search',
|
||||
debounce: 250
|
||||
},
|
||||
filters: [
|
||||
{
|
||||
input: '#department-tenant-filter',
|
||||
param: 'tenant'
|
||||
}
|
||||
],
|
||||
urlSync: true,
|
||||
rowDataset: (row) => ({
|
||||
uuid: row?.cells?.[4]?.data
|
||||
}),
|
||||
rowDblClick: {
|
||||
getUrl: (rowData) => {
|
||||
const uuid = rowData?.cells?.[3]?.data;
|
||||
return uuid ? new URL(`admin/departments/edit/${uuid}`, appBase).toString() : '';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!gridConfig || !gridConfig.grid) {
|
||||
console.warn('Departments grid init failed');
|
||||
return null;
|
||||
}
|
||||
|
||||
return gridConfig;
|
||||
};
|
||||
|
||||
initDepartmentsGrid();
|
||||
</script>
|
||||
16
pages/admin/index($slug).php
Normal file
16
pages/admin/index($slug).php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Support\Guard;
|
||||
use MintyPHP\Buffer;
|
||||
|
||||
Guard::requireLogin();
|
||||
|
||||
$slug = trim((string) ($slug ?? ''));
|
||||
if ($slug === '') {
|
||||
require __DIR__ . '/index().php';
|
||||
return;
|
||||
}
|
||||
|
||||
http_response_code(404);
|
||||
Buffer::set('title', t('Page not found'));
|
||||
$notFound = true;
|
||||
10
pages/admin/index().php
Normal file
10
pages/admin/index().php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
use MintyPHP\Buffer;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
|
||||
$user = $_SESSION['user'] ?? null;
|
||||
$first_name = $user['first_name'] ?? '';
|
||||
|
||||
Buffer::set('title', t('Admin'));
|
||||
25
pages/admin/index(default).phtml
Normal file
25
pages/admin/index(default).phtml
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @var array|null $user
|
||||
* @var string $first_name
|
||||
*/
|
||||
|
||||
if (!empty($notFound)) {
|
||||
require __DIR__ . '/../error/not_found(error).phtml';
|
||||
return;
|
||||
}
|
||||
|
||||
?>
|
||||
<div class="app-breadcrumb">
|
||||
<a href="/logout">Login</a><i class="bi bi-chevron-right"></i><?php e(t('Home')); ?>
|
||||
</div>
|
||||
<div class="app-dashboard-titlebar">
|
||||
<h1><?php e(t('Welcome back')); ?> <?php e($first_name )?></h1>
|
||||
<div class="app-dashboard-titlebar-actions">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-dashboard">
|
||||
|
||||
</div>
|
||||
57
pages/admin/mail-log/data().php
Normal file
57
pages/admin/mail-log/data().php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Support\Guard;
|
||||
use MintyPHP\Service\MailLogService;
|
||||
use MintyPHP\Service\PermissionService;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requirePermissionOrForbidden(PermissionService::MAIL_LOG_VIEW);
|
||||
|
||||
$limit = (int) ($_GET['limit'] ?? 10);
|
||||
$offset = (int) ($_GET['offset'] ?? 0);
|
||||
$search = trim((string) ($_GET['search'] ?? ''));
|
||||
$order = (string) ($_GET['order'] ?? 'created_at');
|
||||
$dir = (string) ($_GET['dir'] ?? 'desc');
|
||||
$status = trim((string) ($_GET['status'] ?? ''));
|
||||
$createdFrom = trim((string) ($_GET['created_from'] ?? ''));
|
||||
$createdTo = trim((string) ($_GET['created_to'] ?? ''));
|
||||
|
||||
$result = MailLogService::listPaged([
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
'search' => $search,
|
||||
'order' => $order,
|
||||
'dir' => $dir,
|
||||
'status' => $status,
|
||||
'created_from' => $createdFrom,
|
||||
'created_to' => $createdTo,
|
||||
]);
|
||||
|
||||
$rows = [];
|
||||
foreach ($result['rows'] as $row) {
|
||||
$status = (string) ($row['status'] ?? '');
|
||||
$statusBadge = 'neutral';
|
||||
if ($status === 'sent') {
|
||||
$statusBadge = 'success';
|
||||
} elseif ($status === 'failed') {
|
||||
$statusBadge = 'danger';
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'id' => $row['id'] ?? null,
|
||||
'to_email' => $row['to_email'] ?? '',
|
||||
'subject' => $row['subject'] ?? '',
|
||||
'template' => $row['template'] ?? '',
|
||||
'status' => $status,
|
||||
'status_badge' => $statusBadge,
|
||||
'status_label' => t(ucfirst($status)),
|
||||
'created_at' => dt($row['created_at'] ?? ''),
|
||||
'sent_at' => dt($row['sent_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
Router::json([
|
||||
'data' => $rows,
|
||||
'total' => $result['total'] ?? 0,
|
||||
]);
|
||||
23
pages/admin/mail-log/index().php
Normal file
23
pages/admin/mail-log/index().php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Buffer;
|
||||
use MintyPHP\Support\Guard;
|
||||
use MintyPHP\Session;
|
||||
use MintyPHP\Service\PermissionService;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requirePermission(PermissionService::MAIL_LOG_VIEW);
|
||||
|
||||
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
|
||||
if ($currentUserId > 0) {
|
||||
PermissionService::getUserPermissions($currentUserId);
|
||||
}
|
||||
|
||||
Buffer::set('title', t('Mail logs'));
|
||||
Buffer::set('grid_lang', json_encode(gridLang(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
$csrfKey = Session::$csrfSessionKey;
|
||||
$csrfToken = $_SESSION[$csrfKey] ?? '';
|
||||
Buffer::set(
|
||||
'grid_csrf',
|
||||
json_encode(['key' => $csrfKey, 'token' => $csrfToken], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
);
|
||||
174
pages/admin/mail-log/index(default).phtml
Normal file
174
pages/admin/mail-log/index(default).phtml
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
use MintyPHP\Router;
|
||||
?>
|
||||
<div class="app-breadcrumb">
|
||||
<a href="/admin">Startseite</a><i class="bi bi-chevron-right"></i><?php e(t('Mail logs')); ?>
|
||||
</div>
|
||||
<div class="app-list-titlebar">
|
||||
<h1><?php e(t('Mail logs')); ?></h1>
|
||||
<div class="app-list-titlebar-actions">
|
||||
<button
|
||||
class="outline secondary"
|
||||
type="button"
|
||||
data-toolbar-toggle
|
||||
data-toolbar-target="#mail-log-toolbar"
|
||||
data-toolbar-text-show="<?php e(t('Show filters')); ?>"
|
||||
data-toolbar-text-hide="<?php e(t('Hide filters')); ?>"
|
||||
>
|
||||
<i class="bi bi-funnel-fill"></i> <span data-toolbar-label><?php e(t('Hide filters')); ?></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-list-toolbar" id="mail-log-toolbar">
|
||||
<label class="app-field">
|
||||
<span><?php e(t('Search')); ?></span>
|
||||
<input type="search" id="mail-log-search" placeholder="<?php e(t('Search...')); ?>">
|
||||
</label>
|
||||
<label class="app-field">
|
||||
<span><?php e(t('Status')); ?></span>
|
||||
<select id="mail-log-status-filter">
|
||||
<option value=""><?php e(t('All statuses')); ?></option>
|
||||
<option value="sent"><?php e(t('Sent')); ?></option>
|
||||
<option value="queued"><?php e(t('Queued')); ?></option>
|
||||
<option value="failed"><?php e(t('Failed')); ?></option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="app-field">
|
||||
<span><?php e(t('Created from')); ?></span>
|
||||
<input type="date" id="mail-log-created-from">
|
||||
</label>
|
||||
<label class="app-field">
|
||||
<span><?php e(t('Created to')); ?></span>
|
||||
<input type="date" id="mail-log-created-to">
|
||||
</label>
|
||||
</div>
|
||||
<div class="app-list-table">
|
||||
<div id="mail-log-grid"></div>
|
||||
</div>
|
||||
|
||||
<?php $gridFactoryVersion = @filemtime('web/js/core/app-grid-factory.js') ?: time(); ?>
|
||||
<script src="<?php e(asset('vendor/gridjs/gridjs.umd.js')); ?>"></script>
|
||||
<script type="module">
|
||||
import { initToolbarToggles } from "<?php e(asset('js/components/app-toggle-toolbar.js')); ?>";
|
||||
import { createServerGrid } from "<?php e(asset('js/core/app-grid-factory.js?v=' . $gridFactoryVersion)); ?>";
|
||||
import { buildUrl, badgeHtml } from "<?php e(asset('js/pages/app-list-utils.js')); ?>";
|
||||
|
||||
initToolbarToggles();
|
||||
|
||||
const appBase = "<?php e(localeBase()); ?>";
|
||||
|
||||
const initMailLogGrid = () => {
|
||||
const formatBadge = (value) => badgeHtml(gridjs, value);
|
||||
const gridConfig = createServerGrid({
|
||||
gridjs: window.gridjs,
|
||||
container: '#mail-log-grid',
|
||||
dataUrl: 'admin/mail-log/data',
|
||||
appBase,
|
||||
columns: [
|
||||
{
|
||||
name: "<?php e(t('Created')); ?>",
|
||||
sort: true,
|
||||
formatter: (cell) => formatBadge(cell)
|
||||
},
|
||||
{
|
||||
name: "<?php e(t('Recipient')); ?>",
|
||||
sort: true,
|
||||
formatter: (cell) => {
|
||||
if (!cell || typeof cell !== 'object') {
|
||||
return gridjs.html(String(cell ?? ''));
|
||||
}
|
||||
return gridjs.html(String(cell ?? ''));
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "<?php e(t('Subject')); ?>",
|
||||
sort: true
|
||||
},
|
||||
{
|
||||
name: "<?php e(t('Template')); ?>",
|
||||
sort: false
|
||||
},
|
||||
{
|
||||
name: "<?php e(t('Status')); ?>",
|
||||
sort: true,
|
||||
formatter: (cell) => {
|
||||
if (!cell || typeof cell !== 'object') {
|
||||
return gridjs.html('');
|
||||
}
|
||||
const variant = cell.variant || 'neutral';
|
||||
const label = cell.label || '';
|
||||
return gridjs.html(`<span class="badge" data-variant="${variant}">${label}</span>`);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "ID",
|
||||
hidden: true
|
||||
}
|
||||
],
|
||||
sortColumns: ['created_at', 'to_email', 'subject', null, 'status', null],
|
||||
paginationLimit: 10,
|
||||
language: <?php MintyPHP\Buffer::get('grid_lang'); ?>,
|
||||
mapData: (data) => data.data.map((row) => [
|
||||
row.created_at,
|
||||
row.to_email,
|
||||
row.subject,
|
||||
row.template || '-',
|
||||
{
|
||||
variant: row.status_badge,
|
||||
label: row.status_label
|
||||
},
|
||||
row.id
|
||||
]),
|
||||
actions: {
|
||||
enabled: true,
|
||||
label: "<?php e(t('Actions')); ?>",
|
||||
getRowId: (row) => row?.cells?.[5]?.data,
|
||||
items: [
|
||||
{
|
||||
key: 'view',
|
||||
type: 'link',
|
||||
label: "<i class=\"bi bi-eye-fill\"></i>",
|
||||
href: ({ id }) => buildUrl(appBase, `admin/mail-log/view/${id}`)
|
||||
}
|
||||
]
|
||||
},
|
||||
search: {
|
||||
input: '#mail-log-search',
|
||||
param: 'search',
|
||||
debounce: 250
|
||||
},
|
||||
filters: [
|
||||
{
|
||||
input: '#mail-log-status-filter',
|
||||
param: 'status'
|
||||
},
|
||||
{
|
||||
input: '#mail-log-created-from',
|
||||
param: 'created_from',
|
||||
normalize: (value) => (value ? value : '')
|
||||
},
|
||||
{
|
||||
input: '#mail-log-created-to',
|
||||
param: 'created_to',
|
||||
normalize: (value) => (value ? value : '')
|
||||
}
|
||||
],
|
||||
urlSync: true,
|
||||
rowDblClick: {
|
||||
getUrl: (rowData) => {
|
||||
const id = rowData?.cells?.[5]?.data;
|
||||
return id ? new URL(`admin/mail-log/view/${id}`, appBase).toString() : '';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!gridConfig || !gridConfig.grid) {
|
||||
console.warn('Mail log grid init failed');
|
||||
return null;
|
||||
}
|
||||
|
||||
return gridConfig;
|
||||
};
|
||||
|
||||
initMailLogGrid();
|
||||
</script>
|
||||
25
pages/admin/mail-log/view($id).php
Normal file
25
pages/admin/mail-log/view($id).php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Buffer;
|
||||
use MintyPHP\Support\Flash;
|
||||
use MintyPHP\Support\Guard;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Service\MailLogService;
|
||||
use MintyPHP\Service\PermissionService;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requirePermission(PermissionService::MAIL_LOG_VIEW);
|
||||
|
||||
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
|
||||
if ($currentUserId > 0) {
|
||||
PermissionService::getUserPermissions($currentUserId);
|
||||
}
|
||||
|
||||
$mailLogId = (int) ($id ?? 0);
|
||||
$mailLog = $mailLogId > 0 ? MailLogService::find($mailLogId) : null;
|
||||
if (!$mailLog) {
|
||||
Flash::error('Mail log not found', 'admin/mail-log', 'mail_log_not_found');
|
||||
Router::redirect('admin/mail-log');
|
||||
}
|
||||
|
||||
Buffer::set('title', t('View mail log'));
|
||||
137
pages/admin/mail-log/view(default).phtml
Normal file
137
pages/admin/mail-log/view(default).phtml
Normal file
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @var array $mailLog
|
||||
*/
|
||||
|
||||
$mailLog = $mailLog ?? [];
|
||||
$status = (string) ($mailLog['status'] ?? '');
|
||||
$statusBadge = 'neutral';
|
||||
if ($status === 'sent') {
|
||||
$statusBadge = 'success';
|
||||
} elseif ($status === 'failed') {
|
||||
$statusBadge = 'danger';
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
<div class="app-details-container">
|
||||
<section>
|
||||
<div class="app-breadcrumb">
|
||||
<a href="/admin">Startseite</a><i class="bi bi-chevron-right"></i><a
|
||||
href="/admin/mail-log"><?php e(t('Mail logs')); ?></a><i class="bi bi-chevron-right"></i><?php e(t('View')); ?>
|
||||
</div>
|
||||
<div class="app-details-titlebar">
|
||||
<h1>
|
||||
<a href="admin/mail-log" title="<?php e(t('Back')); ?>"><i class="bi bi-arrow-left"></i></a>
|
||||
<?php e(t('View mail log')); ?>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="app-details-content">
|
||||
<details open>
|
||||
<summary><?php e(t('Email details')); ?></summary>
|
||||
<hr>
|
||||
<div class="app-details-section">
|
||||
<div class="grid">
|
||||
<div class="app-form-group">
|
||||
<label><?php e(t('Recipient')); ?></label>
|
||||
<input type="text" value="<?php e($mailLog['to_email'] ?? ''); ?>" readonly>
|
||||
</div>
|
||||
<div class="app-form-group">
|
||||
<label><?php e(t('Subject')); ?></label>
|
||||
<input type="text" value="<?php e($mailLog['subject'] ?? ''); ?>" readonly>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="app-form-group">
|
||||
<label><?php e(t('Template')); ?></label>
|
||||
<input type="text" value="<?php e($mailLog['template'] ?? '-'); ?>" readonly>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<?php if ($status === 'sent'): ?>
|
||||
<hr>
|
||||
<details open>
|
||||
<summary><?php e(t('Delivery information')); ?></summary>
|
||||
<hr>
|
||||
<div class="app-details-section">
|
||||
<div class="app-form-group">
|
||||
<label><?php e(t('Sent at')); ?></label>
|
||||
<input type="text" value="<?php e(dt($mailLog['sent_at'] ?? '')); ?>" readonly>
|
||||
</div>
|
||||
<?php if (!empty($mailLog['provider_message_id'])): ?>
|
||||
<div class="app-form-group">
|
||||
<label><?php e(t('Provider message ID')); ?></label>
|
||||
<input type="text" value="<?php e($mailLog['provider_message_id'] ?? ''); ?>" readonly>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</details>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($status === 'failed' && !empty($mailLog['error_message'])): ?>
|
||||
<hr>
|
||||
<details open>
|
||||
<summary><?php e(t('Error information')); ?></summary>
|
||||
<hr>
|
||||
<div class="app-details-section">
|
||||
<div class="app-form-group">
|
||||
<label><?php e(t('Error message')); ?></label>
|
||||
<textarea readonly rows="6"><?php e($mailLog['error_message'] ?? ''); ?></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
<aside id="app-details-aside-section">
|
||||
<div class="app-details-aside-section">
|
||||
<hgroup>
|
||||
<strong><?php e($mailLog['to_email'] ?? ''); ?></strong>
|
||||
<p><small><?php e(t('Mail log')); ?></small></p>
|
||||
</hgroup>
|
||||
<hr>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<small><?php e(t('ID')); ?></small>
|
||||
<p>
|
||||
<span class="badge" data-variant="neutral">
|
||||
<?php e($mailLog['id'] ?? '-'); ?>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<small>
|
||||
<?php e(t('Status')); ?>
|
||||
</small>
|
||||
<div>
|
||||
<span class="badge" data-variant="<?php e($statusBadge); ?>">
|
||||
<?php e(t(ucfirst($status))); ?>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<small><?php e(t('Created')); ?></small>
|
||||
<p><?php e(dt($mailLog['created_at'] ?? '') ?: '-'); ?></p>
|
||||
</div>
|
||||
<?php if ($status === 'sent' && !empty($mailLog['sent_at'])): ?>
|
||||
<div>
|
||||
<small><?php e(t('Sent')); ?></small>
|
||||
<p><?php e(dt($mailLog['sent_at'] ?? '') ?: '-'); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($mailLog['provider_message_id'])): ?>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<small><?php e(t('Provider message ID')); ?></small>
|
||||
<span class="badge" data-copy="true" data-copy-value="<?php e($mailLog['provider_message_id']); ?>"
|
||||
data-variant="neutral"><?php e(substr($mailLog['provider_message_id'], 0, 20)); ?></span>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
38
pages/admin/permissions/_form.phtml
Normal file
38
pages/admin/permissions/_form.phtml
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @var array $values
|
||||
* @var string|null $formId
|
||||
* @var bool $detailsOpenAll
|
||||
* @var bool $isReadOnly
|
||||
*/
|
||||
|
||||
use MintyPHP\Session;
|
||||
|
||||
$values = $values ?? [];
|
||||
$formId = $formId ?? 'permission-form';
|
||||
$detailsOpenAll = $detailsOpenAll ?? false;
|
||||
$isReadOnly = $isReadOnly ?? false;
|
||||
$detailsOpenAttr = $detailsOpenAll ? 'open' : '';
|
||||
$readonlyAttr = $isReadOnly ? 'readonly' : '';
|
||||
|
||||
?>
|
||||
<form id="<?php e($formId); ?>" method="post">
|
||||
<details name="basic-data" <?php e($detailsOpenAttr); ?>>
|
||||
<summary>Stammdaten</summary>
|
||||
<hr>
|
||||
<div class="grid">
|
||||
<label for="permission-description">
|
||||
<span><?php e(t('Description')); ?></span>
|
||||
<input type="text" name="description" id="permission-description"
|
||||
value="<?php e($values['description'] ?? ''); ?>" <?php e($readonlyAttr); ?> />
|
||||
</label>
|
||||
<label for="permission-key">
|
||||
<span><?php e(t('Permission key')); ?></span>
|
||||
<input required type="text" name="key" id="permission-key" value="<?php e($values['key'] ?? ''); ?>" <?php e($readonlyAttr); ?> />
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
<hr>
|
||||
<?php Session::getCsrfInput(); ?>
|
||||
</form>
|
||||
42
pages/admin/permissions/create().php
Normal file
42
pages/admin/permissions/create().php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Buffer;
|
||||
use MintyPHP\Support\Flash;
|
||||
use MintyPHP\Support\Guard;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Service\PermissionService;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requirePermission(PermissionService::PERMISSIONS_CREATE);
|
||||
|
||||
$errors = [];
|
||||
$form = [
|
||||
'key' => '',
|
||||
'description' => '',
|
||||
];
|
||||
|
||||
if (isset($_POST['key'])) {
|
||||
$result = PermissionService::createFromAdmin($_POST);
|
||||
$form = $result['form'] ?? $form;
|
||||
$errors = $result['errors'] ?? [];
|
||||
|
||||
if ($result['ok'] ?? false) {
|
||||
$action = (string) ($_POST['action'] ?? 'create');
|
||||
if ($action === 'create_close') {
|
||||
Flash::success('Permission created', 'admin/permissions', 'permission_created');
|
||||
Router::redirect('admin/permissions');
|
||||
} else {
|
||||
$id = (int) ($result['id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
$target = "admin/permissions/edit/{$id}";
|
||||
Flash::success('Permission created', $target, 'permission_created');
|
||||
Router::redirect($target);
|
||||
} else {
|
||||
Flash::success('Permission created', 'admin/permissions', 'permission_created');
|
||||
Router::redirect('admin/permissions');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Buffer::set('title', t('Create permission'));
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user