This commit is contained in:
2026-02-04 23:31:53 +01:00
commit cd59ccd99b
2401 changed files with 56808 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

19
.env.example Normal file
View 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
View File

@@ -0,0 +1,9 @@
.vscode/
composer.phar
config/config.php
.env
/vendor/
/web/debugger/
/data/
/storage/*
!/storage/.gitkeep

5
.htaccess Normal file
View File

@@ -0,0 +1,5 @@
<IfModule mod_rewrite.c>
RewriteEngine on
RewriteRule ^$ web/ [L]
RewriteRule (.*) web/$1 [L]
</IfModule>

21
LICENSE.md Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

69
config/config.php.example Normal file
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

29
docker/nginx/default.conf Normal file
View 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
View 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
View 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
View 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
View 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
View 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`.

View 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. loggedin).
- 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
View 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
View 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

Binary file not shown.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,13 @@
<?php
namespace MintyPHP\Support;
class Hello
{
public static $name = 'MintyPHP';
public static function getGreeting(): string
{
return 'Hello ' . static::$name . '!';
}
}

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

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

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

Binary file not shown.

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

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

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

View 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) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[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>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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