From 3eb9cc0ac45a6210ec46dd4d6f0cfd784d234943 Mon Sep 17 00:00:00 2001 From: fs Date: Wed, 11 Feb 2026 19:28:12 +0100 Subject: [PATCH] big restructure --- config/settings.php | 3 +- config/themes.php | 7 + db/init/init.sql | 92 +-- docs/ARCHITECTURE.md | 4 +- docs/todo-2fa.md | 24 + i18n/default_de.json | 81 ++- i18n/default_en.json | 81 ++- .../Access/PermissionRepository.php | 140 +++++ .../{ => Access}/RolePermissionRepository.php | 10 +- .../{ => Access}/RoleRepository.php | 91 ++- .../{ => Access}/UserRoleRepository.php | 2 +- .../EmailVerificationRepository.php | 2 +- .../{ => Auth}/PasswordResetRepository.php | 35 +- .../Auth/RememberTokenRepository.php | 102 +++ .../{ => Content}/PageContentRepository.php | 2 +- .../{ => Content}/PageRepository.php | 2 +- .../{ => Mail}/MailLogRepository.php | 38 +- .../{ => Org}/DepartmentRepository.php | 92 +-- .../{ => Org}/UserDepartmentRepository.php | 2 +- lib/Repository/PermissionRepository.php | 104 ---- lib/Repository/RememberTokenRepository.php | 55 -- .../{ => Settings}/SettingRepository.php | 2 +- lib/Repository/Support/RepoQuery.php | 120 ++++ .../TenantDepartmentRepository.php | 35 +- .../{ => Tenant}/TenantRepository.php | 32 +- .../{ => Tenant}/UserTenantRepository.php | 2 +- lib/Repository/{ => User}/UserRepository.php | 516 ++++++++-------- .../{ => Access}/PermissionService.php | 41 +- lib/Service/{ => Access}/RoleService.php | 36 +- lib/Service/{ => Auth}/AuthService.php | 13 +- .../{ => Auth}/EmailVerificationService.php | 6 +- .../{ => Auth}/PasswordResetService.php | 8 +- lib/Service/{ => Auth}/RememberMeService.php | 18 +- .../{ => Branding}/BrandingFaviconService.php | 10 +- .../{ => Branding}/BrandingLogoService.php | 4 +- lib/Service/{ => Content}/PageService.php | 6 +- lib/Service/{ => Mail}/MailLogService.php | 4 +- lib/Service/{ => Mail}/MailService.php | 8 +- lib/Service/{ => Org}/DepartmentService.php | 50 +- lib/Service/{ => Settings}/SettingService.php | 52 +- .../{ => Tenant}/TenantAvatarService.php | 4 +- .../{ => Tenant}/TenantFaviconService.php | 6 +- .../{ => Tenant}/TenantScopeService.php | 10 +- lib/Service/{ => Tenant}/TenantService.php | 6 +- lib/Service/{ => User}/UserAvatarService.php | 4 +- lib/Service/{ => User}/UserService.php | 29 +- lib/Support/Guard.php | 6 +- lib/Support/SearchConfig.php | 80 ++- lib/Support/Tile.php | 2 + lib/Support/helpers/app.php | 126 +++- lib/Support/helpers/auth.php | 6 +- pages/address-book/data().php | 6 +- pages/address-book/index().php | 28 +- pages/address-book/index(default).phtml | 60 +- pages/address-book/view($id).php | 8 +- pages/address-book/view(default).phtml | 52 +- pages/admin/departments/_form.phtml | 69 ++- pages/admin/departments/create().php | 22 +- pages/admin/departments/create(default).phtml | 21 +- pages/admin/departments/data().php | 13 +- pages/admin/departments/delete($id).php | 8 +- pages/admin/departments/edit($id).php | 22 +- pages/admin/departments/edit(default).phtml | 185 +++--- pages/admin/departments/index().php | 6 +- pages/admin/departments/index(default).phtml | 78 ++- pages/admin/index(default).phtml | 10 +- pages/admin/mail-log/data().php | 4 +- pages/admin/mail-log/index().php | 2 +- pages/admin/mail-log/index(default).phtml | 32 +- pages/admin/mail-log/view($id).php | 4 +- pages/admin/mail-log/view(default).phtml | 14 +- pages/admin/permissions/_form.phtml | 26 +- pages/admin/permissions/create().php | 4 +- pages/admin/permissions/create(default).phtml | 12 +- pages/admin/permissions/data().php | 6 +- pages/admin/permissions/delete($id).php | 9 +- pages/admin/permissions/edit($id).php | 8 +- pages/admin/permissions/edit(default).phtml | 36 +- pages/admin/permissions/index().php | 2 +- pages/admin/permissions/index(default).phtml | 113 ++-- pages/admin/roles/_form.phtml | 19 + pages/admin/roles/create().php | 12 +- pages/admin/roles/create(default).phtml | 12 +- pages/admin/roles/data().php | 10 +- pages/admin/roles/delete($id).php | 4 +- pages/admin/roles/edit($id).php | 14 +- pages/admin/roles/edit(default).phtml | 169 ++--- pages/admin/roles/index().php | 2 +- pages/admin/roles/index(default).phtml | 79 ++- pages/admin/search/data().php | 4 +- .../settings/expire-remember-tokens().php | 18 + pages/admin/settings/favicon().php | 4 +- pages/admin/settings/favicon-delete().php | 4 +- pages/admin/settings/index().php | 19 +- pages/admin/settings/index(default).phtml | 66 +- pages/admin/settings/logo().php | 4 +- pages/admin/settings/logo-delete().php | 4 +- pages/admin/stats/index().php | 207 ++----- pages/admin/stats/index(default).phtml | 579 +++++++++++------- pages/admin/tenants/_form.phtml | 43 +- pages/admin/tenants/avatar($id).php | 4 +- pages/admin/tenants/avatar-delete($id).php | 4 +- pages/admin/tenants/avatar-file(none).phtml | 4 +- pages/admin/tenants/create().php | 4 +- pages/admin/tenants/create(default).phtml | 12 +- pages/admin/tenants/data().php | 12 +- pages/admin/tenants/delete($id).php | 6 +- pages/admin/tenants/edit($id).php | 10 +- pages/admin/tenants/edit(default).phtml | 19 +- pages/admin/tenants/favicon($id).php | 4 +- pages/admin/tenants/favicon-delete($id).php | 4 +- pages/admin/tenants/index().php | 2 +- pages/admin/tenants/index(default).phtml | 68 +- pages/admin/users/_form.phtml | 227 +++++-- pages/admin/users/activate($id).php | 4 +- pages/admin/users/avatar($id).php | 6 +- pages/admin/users/avatar-delete($id).php | 6 +- pages/admin/users/avatar-file().php | 8 +- pages/admin/users/bulk($action).php | 6 +- pages/admin/users/create().php | 16 +- pages/admin/users/create(default).phtml | 14 +- pages/admin/users/data().php | 9 +- pages/admin/users/deactivate($id).php | 4 +- pages/admin/users/delete($id).php | 6 +- pages/admin/users/edit($id).php | 38 +- pages/admin/users/edit(default).phtml | 269 ++++---- pages/admin/users/export().php | 2 +- pages/admin/users/forget-tokens($id).php | 8 +- pages/admin/users/index().php | 12 +- pages/admin/users/index(default).phtml | 147 +++-- pages/admin/users/send-access($id).php | 8 +- pages/admin/users/switch-tenant().php | 2 +- pages/admin/users/theme().php | 7 +- pages/auth/forgot().php | 2 +- pages/auth/login().php | 2 +- pages/auth/login(login).phtml | 6 +- pages/auth/logout().php | 2 +- pages/auth/register().php | 7 +- pages/auth/register(login).phtml | 2 +- pages/auth/reset().php | 4 +- pages/auth/verify().php | 2 +- pages/auth/verify-email().php | 2 +- pages/branding/logo(none).phtml | 4 +- pages/help/hotkeys(default).phtml | 68 ++ pages/lang().php | 2 +- pages/page/copy-language().php | 2 +- pages/page/index($slug).php | 2 +- pages/search/data().php | 7 +- pages/search/index(default).phtml | 24 +- templates/default.phtml | 40 +- templates/login.phtml | 15 +- templates/partials/app-aside-icon-bar.phtml | 8 +- templates/partials/app-aside.phtml | 42 +- templates/partials/app-breadcrumb.phtml | 31 + templates/partials/app-footer.phtml | 10 +- templates/partials/app-tile.phtml | 11 +- templates/partials/app-topbar.phtml | 70 ++- tests/I18n/TranslationKeysTest.php | 102 +++ tests/Repository/RepoQueryTest.php | 83 +++ tests/Service/UserServicePasswordTest.php | 2 +- .../{variables.css => variables.base.css} | 100 ++- web/css/base/variables.contrast.css | 44 ++ web/css/base/variables.theme-dark-green.css | 42 ++ web/css/components/app-badges.css | 52 +- web/css/components/app-blockquote.css | 6 - web/css/components/app-details.css | 29 +- web/css/components/app-flash.css | 21 +- web/css/components/app-list-tabs.css | 6 - web/css/components/app-list-toolbar.css | 10 +- web/css/components/app-search.css | 20 +- web/css/components/app-tabs.css | 8 +- web/css/components/app-tile.css | 109 ++-- web/css/components/app-tooltips.css | 9 - web/css/components/vendor-gridjs.css | 7 +- web/css/components/vendor-multi-select.css | 47 +- web/css/layout/app-aside-icon-bar.css | 94 ++- web/css/layout/app-shell.css | 69 ++- web/css/layout/app-sidebar.css | 194 +++--- web/css/layout/app-topbar.css | 52 +- web/favicon/tenants | 1 + .../apple-touch-icon.png | Bin 6025 -> 0 bytes .../favicon-16x16.png | Bin 521 -> 0 bytes .../favicon-32x32.png | Bin 1005 -> 0 bytes .../favicon-96x96.png | Bin 2881 -> 0 bytes .../web-app-manifest-192x192.png | Bin 5938 -> 0 bytes .../web-app-manifest-512x512.png | Bin 22668 -> 0 bytes web/index.php | 22 +- web/js/app-boot.js | 40 ++ web/js/app-init.js | 10 +- web/js/components/app-async-flash.js | 4 +- web/js/components/app-bulk-selection.js | 12 +- web/js/components/app-color-default-toggle.js | 7 + web/js/components/app-contrast-toggle.js | 41 ++ web/js/components/app-filter-overflow.js | 73 +++ web/js/components/app-fslightbox-refresh.js | 9 + web/js/components/app-global-search.js | 79 ++- web/js/components/app-multiselect-cascade.js | 98 +++ web/js/components/app-multiselect-init.js | 21 +- web/js/components/app-nav-history.js | 23 + web/js/components/app-page-editor.js | 6 +- web/js/components/app-password-hints.js | 11 + web/js/components/app-tenant-switcher.js | 14 +- web/js/components/app-theme-toggle.js | 90 ++- web/js/components/app-toggle-aside-panels.js | 56 +- web/js/components/app-toggle-details-aside.js | 11 +- web/js/components/app-toggle-sidebar.js | 84 ++- web/js/core/app-dom.js | 27 + web/js/core/app-grid-factory.js | 8 + web/js/pages/app-list-utils.js | 46 ++ 209 files changed, 5101 insertions(+), 2459 deletions(-) create mode 100644 config/themes.php create mode 100644 docs/todo-2fa.md create mode 100644 lib/Repository/Access/PermissionRepository.php rename lib/Repository/{ => Access}/RolePermissionRepository.php (92%) rename lib/Repository/{ => Access}/RoleRepository.php (56%) rename lib/Repository/{ => Access}/UserRoleRepository.php (96%) rename lib/Repository/{ => Auth}/EmailVerificationRepository.php (98%) rename lib/Repository/{ => Auth}/PasswordResetRepository.php (68%) create mode 100644 lib/Repository/Auth/RememberTokenRepository.php rename lib/Repository/{ => Content}/PageContentRepository.php (97%) rename lib/Repository/{ => Content}/PageRepository.php (92%) rename lib/Repository/{ => Mail}/MailLogRepository.php (77%) rename lib/Repository/{ => Org}/DepartmentRepository.php (76%) rename lib/Repository/{ => Org}/UserDepartmentRepository.php (98%) delete mode 100644 lib/Repository/PermissionRepository.php delete mode 100644 lib/Repository/RememberTokenRepository.php rename lib/Repository/{ => Settings}/SettingRepository.php (96%) create mode 100644 lib/Repository/Support/RepoQuery.php rename lib/Repository/{ => Tenant}/TenantDepartmentRepository.php (63%) rename lib/Repository/{ => Tenant}/TenantRepository.php (91%) rename lib/Repository/{ => Tenant}/UserTenantRepository.php (96%) rename lib/Repository/{ => User}/UserRepository.php (55%) rename lib/Service/{ => Access}/PermissionService.php (83%) rename lib/Service/{ => Access}/RoleService.php (73%) rename lib/Service/{ => Auth}/AuthService.php (94%) rename lib/Service/{ => Auth}/EmailVerificationService.php (97%) rename lib/Service/{ => Auth}/PasswordResetService.php (96%) rename lib/Service/{ => Auth}/RememberMeService.php (91%) rename lib/Service/{ => Branding}/BrandingFaviconService.php (95%) rename lib/Service/{ => Branding}/BrandingLogoService.php (98%) rename lib/Service/{ => Content}/PageService.php (97%) rename lib/Service/{ => Mail}/MailLogService.php (76%) rename lib/Service/{ => Mail}/MailService.php (96%) rename lib/Service/{ => Org}/DepartmentService.php (71%) rename lib/Service/{ => Settings}/SettingService.php (87%) rename lib/Service/{ => Tenant}/TenantAvatarService.php (99%) rename lib/Service/{ => Tenant}/TenantFaviconService.php (97%) rename lib/Service/{ => Tenant}/TenantScopeService.php (94%) rename lib/Service/{ => Tenant}/TenantService.php (98%) rename lib/Service/{ => User}/UserAvatarService.php (99%) rename lib/Service/{ => User}/UserService.php (97%) create mode 100644 pages/admin/settings/expire-remember-tokens().php create mode 100644 pages/help/hotkeys(default).phtml create mode 100644 templates/partials/app-breadcrumb.phtml create mode 100644 tests/I18n/TranslationKeysTest.php create mode 100644 tests/Repository/RepoQueryTest.php rename web/css/base/{variables.css => variables.base.css} (91%) create mode 100644 web/css/base/variables.contrast.css create mode 100644 web/css/base/variables.theme-dark-green.css create mode 120000 web/favicon/tenants delete mode 100644 web/favicon/tenants/21a17705-d67e-4e20-8cc3-cf994bcfc7bb/apple-touch-icon.png delete mode 100644 web/favicon/tenants/21a17705-d67e-4e20-8cc3-cf994bcfc7bb/favicon-16x16.png delete mode 100644 web/favicon/tenants/21a17705-d67e-4e20-8cc3-cf994bcfc7bb/favicon-32x32.png delete mode 100644 web/favicon/tenants/21a17705-d67e-4e20-8cc3-cf994bcfc7bb/favicon-96x96.png delete mode 100644 web/favicon/tenants/21a17705-d67e-4e20-8cc3-cf994bcfc7bb/web-app-manifest-192x192.png delete mode 100644 web/favicon/tenants/21a17705-d67e-4e20-8cc3-cf994bcfc7bb/web-app-manifest-512x512.png create mode 100644 web/js/app-boot.js create mode 100644 web/js/components/app-contrast-toggle.js create mode 100644 web/js/components/app-filter-overflow.js create mode 100644 web/js/components/app-multiselect-cascade.js create mode 100644 web/js/core/app-dom.js diff --git a/config/settings.php b/config/settings.php index 9895ae2..a906847 100644 --- a/config/settings.php +++ b/config/settings.php @@ -4,5 +4,6 @@ return array ( 'app_locale' => 'de', 'app_theme' => 'dark', 'app_theme_user' => '1', - 'app_primary_color' => '#9b3dc7', + 'app_registration' => '1', + 'app_primary_color' => '#105433', ); diff --git a/config/themes.php b/config/themes.php new file mode 100644 index 0000000..2d461b2 --- /dev/null +++ b/config/themes.php @@ -0,0 +1,7 @@ + 'Light', + 'dark' => 'Dark', + 'dark-green' => 'Dark green', +]; diff --git a/db/init/init.sql b/db/init/init.sql index 872fdb9..e703f26 100644 --- a/db/init/init.sql +++ b/db/init/init.sql @@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS `users` ( `uuid` CHAR(36) NOT NULL, `first_name` VARCHAR(100) NOT NULL, `last_name` VARCHAR(100) NOT NULL, + `display_name` VARCHAR(210) NOT NULL, `email` VARCHAR(255) NOT NULL, `profile_description` TEXT NULL, `job_title` VARCHAR(160) NULL, @@ -19,6 +20,7 @@ CREATE TABLE IF NOT EXISTS `users` ( `region` VARCHAR(100) NULL, `hire_date` DATE NULL, `email_verified_at` DATETIME NULL DEFAULT NULL, + `last_login_at` DATETIME NULL DEFAULT NULL, `password` VARCHAR(255) NOT NULL, `locale` VARCHAR(10) DEFAULT NULL, `totp_secret` VARCHAR(255) DEFAULT NULL, @@ -88,12 +90,16 @@ CREATE TABLE IF NOT EXISTS `roles` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `uuid` CHAR(36) NOT NULL, `description` VARCHAR(255) NOT NULL, + `code` VARCHAR(50) DEFAULT NULL, + `active` TINYINT(1) NOT NULL DEFAULT 1, `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`), + UNIQUE KEY `uniq_roles_code` (`code`), + KEY `idx_roles_active` (`active`), 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, @@ -104,21 +110,29 @@ CREATE TABLE IF NOT EXISTS `permissions` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `key` VARCHAR(191) NOT NULL, `description` VARCHAR(255) DEFAULT NULL, + `active` TINYINT(1) NOT NULL DEFAULT 1, + `is_system` TINYINT(1) NOT NULL DEFAULT 0, `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), - UNIQUE KEY `uniq_permissions_key` (`key`) + UNIQUE KEY `uniq_permissions_key` (`key`), + KEY `idx_permissions_active` (`active`), + KEY `idx_permissions_system` (`is_system`) ) 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, + `code` VARCHAR(50) DEFAULT NULL, + `cost_center` VARCHAR(50) DEFAULT NULL, + `active` TINYINT(1) NOT NULL DEFAULT 1, `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_active` (`active`), 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, @@ -274,6 +288,7 @@ CREATE TABLE IF NOT EXISTS `user_remember_tokens` ( `selector` CHAR(24) NOT NULL, `token_hash` CHAR(64) NOT NULL, `expires_at` DATETIME NOT NULL, + `expired_by_admin_at` DATETIME NULL DEFAULT NULL COMMENT 'Expired by admin action', `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `last_used` DATETIME NULL DEFAULT NULL, PRIMARY KEY (`id`), @@ -287,18 +302,19 @@ 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() +INSERT INTO `departments` (`uuid`, `description`, `code`, `active`, `created`) +SELECT UUID(), 'IT', 'IT', 1, NOW() WHERE NOT EXISTS (SELECT 1 FROM departments WHERE description = 'IT'); -INSERT INTO `roles` (`uuid`, `description`, `created`) -SELECT UUID(), 'Admin', NOW() +INSERT INTO `roles` (`uuid`, `description`, `code`, `active`, `created`) +SELECT UUID(), 'Admin', 'ADMIN', 1, NOW() WHERE NOT EXISTS (SELECT 1 FROM roles WHERE description = 'Admin'); INSERT INTO `users` ( `uuid`, `first_name`, `last_name`, + `display_name`, `email`, `password`, `locale`, @@ -315,6 +331,7 @@ SELECT UUID(), 'Detlef', 'Demo', + 'Detlef Demo', 'demo@user.com', '$2y$12$KVCQvuy4Pl1aySBuzSpc7ehpZhAzYZkndDI9OaMi05E2P/Mhob5HO', 'de', @@ -375,42 +392,47 @@ WHERE NOT EXISTS ( SELECT 1 FROM page_contents pc WHERE pc.page_id = p.id AND pc.locale = 'de' ); -INSERT INTO `permissions` (`key`, `description`) +INSERT INTO `permissions` (`key`, `description`, `active`, `is_system`) 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`); + ('users.view', 'Can view users', 1, 1), + ('users.view_meta', 'Can view user status and meta', 1, 1), + ('users.view_audit', 'Can view user audit log', 1, 1), + ('users.create', 'Can create users', 1, 1), + ('users.update', 'Can update users', 1, 1), + ('users.delete', 'Can delete users', 1, 1), + ('users.self_update', 'Can update own user', 1, 1), + ('users.update_assignments', 'Can update user assignments', 1, 1), + ('address_book.view', 'Can view address book', 1, 1), + ('tenants.view', 'Can view tenants', 1, 1), + ('tenants.create', 'Can create tenants', 1, 1), + ('tenants.update', 'Can update tenants', 1, 1), + ('tenants.delete', 'Can delete tenants', 1, 1), + ('departments.view', 'Can view departments', 1, 1), + ('departments.create', 'Can create departments', 1, 1), + ('departments.update', 'Can update departments', 1, 1), + ('departments.delete', 'Can delete departments', 1, 1), + ('roles.view', 'Can view roles', 1, 1), + ('roles.create', 'Can create roles', 1, 1), + ('roles.update', 'Can update roles', 1, 1), + ('roles.delete', 'Can delete roles', 1, 1), + ('permissions.view', 'Can view permissions', 1, 1), + ('permissions.create', 'Can create permissions', 1, 1), + ('permissions.update', 'Can update permissions', 1, 1), + ('permissions.delete', 'Can delete permissions', 1, 1), + ('settings.view', 'Can view settings', 1, 1), + ('settings.update', 'Can update settings', 1, 1), + ('mail_log.view', 'Can view mail logs', 1, 1), + ('stats.view', 'Can view statistics', 1, 1) +ON DUPLICATE KEY UPDATE + `description` = VALUES(`description`), + `active` = VALUES(`active`), + `is_system` = VALUES(`is_system`); 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.view', 'users.view_meta', 'users.view_audit', 'users.create', 'users.update', 'users.delete', 'users.self_update', 'users.update_assignments', 'address_book.view', 'tenants.view', 'tenants.create', 'tenants.update', 'tenants.delete', diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5726081..e6adff2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -405,7 +405,9 @@ can('users.view'); ``` web/css/ ├── base/ -│ └── variables.css # CSS-Variablen +│ ├── variables.base.css # CSS-Variablen (Basis + Light/Dark) +│ ├── variables.theme-dark-green.css # Theme-Overrides +│ └── variables.contrast.css # High-Contrast Overrides ├── layout/ │ ├── app-shell.css # Haupt-Layout │ ├── app-topbar.css # Header diff --git a/docs/todo-2fa.md b/docs/todo-2fa.md new file mode 100644 index 0000000..980a7f7 --- /dev/null +++ b/docs/todo-2fa.md @@ -0,0 +1,24 @@ +# TODO: 2FA (TOTP) Integration + +Goal: Add optional TOTP-based 2FA for users (admin-managed, later self-managed). + +Proposed library: +- Start with a lightweight TOTP library (e.g. remotemerge/totp-php) or a more established one (e.g. spomky-labs/otphp). + +Data model: +- users: totp_secret (encrypted), totp_enabled (tinyint), totp_verified_at (nullable) +- user_backup_codes: user_id, code_hash, created_at, used_at (nullable) + +Admin user edit flow: +- Enable 2FA: generate secret + show QR (otpauth URI). +- Verify code to activate. +- Generate backup codes (show once). + +Login flow: +- After password: prompt TOTP if totp_enabled. +- Rate-limit attempts. + +Security notes: +- Encrypt secret at rest. +- Accept small time drift (+/- 1 step). +- Default to SHA-1 for compatibility. diff --git a/i18n/default_de.json b/i18n/default_de.json index 5763f45..45e79aa 100644 --- a/i18n/default_de.json +++ b/i18n/default_de.json @@ -148,6 +148,7 @@ "Theme": "Theme", "Light": "Hell", "Dark": "Dunkel", + "Dark green": "Dunkelgrün", "Toggle theme": "Theme umschalten", "Toggle Sidebar": "Sidebar umschalten", "Toggle Detail Sidebar": "Detail-Seitenleiste umschalten", @@ -242,6 +243,9 @@ "Files": "Dateien", "Settings": "Einstellungen", "System": "System", + "System permission": "Systemberechtigung", + "System permission can not be deleted": "Systemberechtigung kann nicht gelöscht werden", + "System permissions are core and should rarely be disabled.": "Systemberechtigungen sind kernrelevant und sollten nur selten deaktiviert werden.", "Statistics": "Statistiken", "Organization breakdown": "Organisations-Aufschlüsselung", "Department assignments": "Abteilungs-Zuweisungen", @@ -306,14 +310,17 @@ "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_registration": "Öffentliche Selbstregistrierung erlauben", "setting.app_primary_color": "Überschreibt die Standard-Primärfarbe (Hex wie #2FA4A4)", "Default theme": "Standard-Theme", "Allow user theme": "Benutzer-Theme erlauben", + "Allow registration": "Registrierung 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.", + "Inactive tenants are excluded from user access and tenant-scoped data.": "Inaktive Mandanten werden vom Benutzerzugriff und mandantenspezifischen Daten ausgeschlossen.", "Status & meta": "Status & Meta", "Audit": "Audit", "Overview": "Übersicht", @@ -323,6 +330,10 @@ "Call mobile": "Mobil anrufen", "Choose color": "Farbe wählen", "Be careful - this resets the tenants color": "Achtung – das setzt die Mandantenfarbe zurück", + "Code": "Code", + "Cost center": "Kostenstelle", + "Department code already exists": "Abteilungscode existiert bereits", + "Role code already exists": "Rollencode existiert bereits", "Internationalization": "Internationalisierung", "User creation rules": "Benutzeranlage-Regeln", "Branding": "Branding", @@ -449,6 +460,7 @@ "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.", + "Registration is currently disabled": "Registrierung ist derzeit deaktiviert.", "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.", @@ -477,5 +489,72 @@ "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" + "setting.smtp_from_name": "Absendername für ausgehende Nachrichten", + "High contrast": "Hoher Kontrast", + "Toggle contrast": "Kontrast umschalten", + "View in address book": "Im Adressbuch ansehen", + "Overview": "Übersicht", + "Organization & Quality": "Organisation & Qualität", + "Security": "Sicherheit", + "Communication": "Kommunikation", + "Email security": "E-Mail Sicherheit", + "Recipient": "Empfänger", + "Error": "Fehler", + "Active tenants": "Aktive Mandanten", + "Inactive tenants": "Inaktive Mandanten", + "Active departments": "Aktive Abteilungen", + "Inactive departments": "Inaktive Abteilungen", + "Active roles": "Aktive Rollen", + "Inactive roles": "Inaktive Rollen", + "Roles without permissions": "Rollen ohne Berechtigungen", + "Permissions without roles": "Berechtigungen ohne Rollen", + "Users without roles": "Benutzer ohne Rollen", + "Users without tenants": "Benutzer ohne Mandant", + "Departments without tenants": "Abteilungen ohne Mandant", + "Mail failures": "E-Mail Fehler", + "Last email sent": "Letzte E-Mail gesendet", + "Never": "Nie", + "Recent logins": "Letzte Anmeldungen", + "Never logged in": "Nie angemeldet", + "Last login": "Letzte Anmeldung", + "User": "Benutzer", + "No entries found": "Keine Einträge gefunden", + "Login status": "Anmeldestatus", + "Logged in": "Angemeldet", + "More filters": "Weitere Filter", + "Email unverified": "E-Mail nicht verifiziert" + , + "Password resets": "Passwort-Resets", + "Requested at": "Angefordert am", + "Expires at": "Gültig bis", + "Used at": "Verwendet am", + "Attempts": "Versuche", + "Used": "Verwendet", + "Expired": "Abgelaufen", + "Expired by admin": "Durch Admin abgelaufen", + "Login tokens": "Login-Tokens", + "Expire all login tokens": "Alle Login-Tokens ablaufen", + "Expire all login tokens?": "Alle Login-Tokens ablaufen?", + "%d active login tokens": "%d aktive Login-Tokens", + "%d login tokens expired": "%d Login-Tokens abgelaufen", + "Danger zone": "Gefahrenzone", + "This will mark all remember-me tokens as expired. Users will need to sign in again.": "Alle Angemeldet-bleiben Tokens werden als abgelaufen markiert. Benutzer müssen sich erneut anmelden.", + "Last used": "Zuletzt verwendet", + "Keyboard shortcuts": "Tastenkürzel", + "Open search": "Suche öffnen", + "Switch sidebar section": "Sidebar-Bereich wechseln", + "Action": "Aktion", + "Mac": "Mac", + "Windows": "Windows", + "Email verification code": "E-Mail Verifizierungscode", + "Failed to switch tenant": "Mandantwechsel fehlgeschlagen", + "IDs": "IDs", + "Pages": "Seiten", + "Permission": "Berechtigung", + "Please verify your email first": "Bitte bestätige zuerst deine E-Mail", + "Toggle search details": "Suchdetails umschalten", + "Toggle sidebar": "Sidebar umschalten", + "Unverified": "Nicht verifiziert", + "Imprint": "Impressum", + "Privacy": "Datenschutz" } diff --git a/i18n/default_en.json b/i18n/default_en.json index 5b8ecdd..020ace5 100644 --- a/i18n/default_en.json +++ b/i18n/default_en.json @@ -148,6 +148,7 @@ "Theme": "Theme", "Light": "Light", "Dark": "Dark", + "Dark green": "Dark green", "Toggle theme": "Toggle theme", "Toggle Sidebar": "Toggle Sidebar", "Toggle Detail Sidebar": "Toggle Detail Sidebar", @@ -242,6 +243,9 @@ "Files": "Files", "Settings": "Settings", "System": "System", + "System permission": "System permission", + "System permission can not be deleted": "System permission can not be deleted", + "System permissions are core and should rarely be disabled.": "System permissions are core and should rarely be disabled.", "Statistics": "Statistics", "Organization breakdown": "Organization breakdown", "Department assignments": "Department assignments", @@ -306,14 +310,17 @@ "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_registration": "Allow public self-registration", "setting.app_primary_color": "Overrides the default primary color (hex like #2FA4A4)", "Default theme": "Default theme", "Allow user theme": "Allow user theme", + "Allow registration": "Allow registration", "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.", + "Inactive tenants are excluded from user access and tenant-scoped data.": "Inactive tenants are excluded from user access and tenant-scoped data.", "Status & meta": "Status & meta", "Audit": "Audit", "Overview": "Overview", @@ -323,6 +330,10 @@ "Call mobile": "Call mobile", "Choose color": "Choose color", "Be careful - this resets the tenants color": "Be careful - this resets the tenant color", + "Code": "Code", + "Cost center": "Cost center", + "Department code already exists": "Department code already exists", + "Role code already exists": "Role code already exists", "Internationalization": "Internationalization", "User creation rules": "User creation rules", "Branding": "Branding", @@ -449,6 +460,7 @@ "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.", + "Registration is currently disabled": "Registration is currently disabled.", "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.", @@ -477,5 +489,72 @@ "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" + "setting.smtp_from_name": "From name for outgoing emails", + "High contrast": "High contrast", + "Toggle contrast": "Toggle contrast", + "View in address book": "View in address book", + "Overview": "Overview", + "Organization & Quality": "Organization & Quality", + "Security": "Security", + "Communication": "Communication", + "Email security": "Email security", + "Recipient": "Recipient", + "Error": "Error", + "Active tenants": "Active tenants", + "Inactive tenants": "Inactive tenants", + "Active departments": "Active departments", + "Inactive departments": "Inactive departments", + "Active roles": "Active roles", + "Inactive roles": "Inactive roles", + "Roles without permissions": "Roles without permissions", + "Permissions without roles": "Permissions without roles", + "Users without roles": "Users without roles", + "Users without tenants": "Users without tenants", + "Departments without tenants": "Departments without tenants", + "Mail failures": "Mail failures", + "Last email sent": "Last email sent", + "Never": "Never", + "Recent logins": "Recent logins", + "Never logged in": "Never logged in", + "Last login": "Last login", + "User": "User", + "No entries found": "No entries found", + "Login status": "Login status", + "Logged in": "Logged in", + "More filters": "More filters", + "Email unverified": "Email unverified" + , + "Password resets": "Password resets", + "Requested at": "Requested at", + "Expires at": "Expires at", + "Used at": "Used at", + "Attempts": "Attempts", + "Used": "Used", + "Expired": "Expired", + "Expired by admin": "Expired by admin", + "Login tokens": "Login tokens", + "Expire all login tokens": "Expire all login tokens", + "Expire all login tokens?": "Expire all login tokens?", + "%d active login tokens": "%d active login tokens", + "%d login tokens expired": "%d login tokens expired", + "Danger zone": "Danger zone", + "This will mark all remember-me tokens as expired. Users will need to sign in again.": "This will mark all remember-me tokens as expired. Users will need to sign in again.", + "Last used": "Last used", + "Keyboard shortcuts": "Keyboard shortcuts", + "Open search": "Open search", + "Switch sidebar section": "Switch sidebar section", + "Action": "Action", + "Mac": "Mac", + "Windows": "Windows", + "Email verification code": "Email verification code", + "Failed to switch tenant": "Failed to switch tenant", + "IDs": "IDs", + "Pages": "Pages", + "Permission": "Permission", + "Please verify your email first": "Please verify your email first", + "Toggle search details": "Toggle search details", + "Toggle sidebar": "Toggle sidebar", + "Unverified": "Unverified", + "Imprint": "Imprint", + "Privacy": "Privacy" } diff --git a/lib/Repository/Access/PermissionRepository.php b/lib/Repository/Access/PermissionRepository.php new file mode 100644 index 0000000..576f8f8 --- /dev/null +++ b/lib/Repository/Access/PermissionRepository.php @@ -0,0 +1,140 @@ + '`key`', + 'description' => 'description', + 'active' => 'active', + 'created' => 'created', + ]; + [$limit, $offset] = RepoQuery::sanitizeLimitOffset($options); + [$order, $dir] = RepoQuery::sanitizeOrder($options, array_keys($allowedOrder), 'key', 'asc'); + $orderBy = $allowedOrder[$order] ?? $allowedOrder['key']; + + $where = []; + $params = []; + RepoQuery::addLikeFilter($where, $params, ['permissions.`key`', 'permissions.description'], $search); + $activeValue = array_key_exists('active', $options) ? $options['active'] : null; + RepoQuery::addEnumFilter($where, $params, $activeValue, [ + ['aliases' => ['1', 'true', 'active'], 'sql' => 'permissions.active = 1'], + ['aliases' => ['0', 'false', 'inactive'], 'sql' => 'permissions.active = 0'], + ]); + $whereSql = $where ? ' where ' . implode(' and ', $where) : ''; + + $total = DB::selectValue('select count(*) from permissions' . $whereSql, ...$params); + $query = 'select id, `key`, description, active, is_system, 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, active, is_system, 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, active, is_system, 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'] ?? '')); + $active = (int) ($data['active'] ?? 1); + $isSystem = (int) ($data['is_system'] ?? 0); + $result = DB::insert( + 'insert into permissions (`key`, description, active, is_system, created) values (?,?,?,?,NOW())', + $key, + $desc !== '' ? $desc : null, + (string) $active, + (string) $isSystem + ); + return $result ? (int) $result : null; + } + + public static function update(int $id, array $data): bool + { + $key = trim((string) ($data['key'] ?? '')); + $desc = trim((string) ($data['description'] ?? '')); + $active = (int) ($data['active'] ?? 1); + $isSystem = (int) ($data['is_system'] ?? 0); + $result = DB::update( + 'update permissions set `key` = ?, description = ?, active = ?, is_system = ? where id = ?', + $key, + $desc !== '' ? $desc : null, + (string) $active, + (string) $isSystem, + (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; + } +} diff --git a/lib/Repository/RolePermissionRepository.php b/lib/Repository/Access/RolePermissionRepository.php similarity index 92% rename from lib/Repository/RolePermissionRepository.php rename to lib/Repository/Access/RolePermissionRepository.php index c1a9dda..8b121eb 100644 --- a/lib/Repository/RolePermissionRepository.php +++ b/lib/Repository/Access/RolePermissionRepository.php @@ -1,6 +1,6 @@ 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'; - } + $allowedOrder = ['id', 'uuid', 'description', 'code', 'active', 'created', 'modified']; + [$limit, $offset] = RepoQuery::sanitizeLimitOffset($options); + [$order, $dir] = RepoQuery::sanitizeOrder($options, $allowedOrder); $where = []; $params = []; - if ($search !== '') { - $like = '%' . $search . '%'; - $where[] = '(description like ? or uuid like ?)'; - $params[] = $like; - $params[] = $like; - } + RepoQuery::addLikeFilter($where, $params, ['description', 'uuid', 'code'], $search); + $activeValue = array_key_exists('active', $options) ? $options['active'] : null; + RepoQuery::addEnumFilter($where, $params, $activeValue, [ + ['aliases' => ['1', 'true', 'active'], 'sql' => 'roles.active = 1'], + ['aliases' => ['0', 'false', 'inactive'], 'sql' => 'roles.active = 0'], + ]); $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' . + $query = 'select id, uuid, description, code, active, created_by, modified_by, created, modified from roles' . $whereSql . sprintf(' order by `%s` %s limit ? offset ?', $order, $dir); @@ -107,7 +114,7 @@ class RoleRepository 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', + 'select id, uuid, description, code, active, created_by, modified_by, created, modified from roles where id = ? limit 1', (string) $id ); return self::unwrap($row); @@ -116,12 +123,26 @@ class RoleRepository 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', + 'select id, uuid, description, code, active, created_by, modified_by, created, modified from roles where uuid = ? limit 1', $uuid ); return self::unwrap($row); } + public static function existsByCode(string $code, int $excludeId = 0): bool + { + $code = trim($code); + if ($code === '') { + return false; + } + if ($excludeId > 0) { + $count = DB::selectValue('select count(*) from roles where code = ? and id != ?', $code, (string) $excludeId); + } else { + $count = DB::selectValue('select count(*) from roles where code = ?', $code); + } + return $count ? ((int) $count > 0) : false; + } + private static function uuidV4(): string { $data = random_bytes(16); @@ -133,9 +154,11 @@ class RoleRepository public static function create(array $data) { return DB::insert( - 'insert into roles (uuid, description, created_by, created) values (?,?,?,NOW())', + 'insert into roles (uuid, description, code, active, created_by, created) values (?,?,?,?,?,NOW())', $data['uuid'] ?? self::uuidV4(), $data['description'], + $data['code'] ?? null, + $data['active'] ?? 1, $data['created_by'] ?? null ); } @@ -144,6 +167,8 @@ class RoleRepository { $fields = [ 'description' => $data['description'], + 'code' => $data['code'] ?? null, + 'active' => $data['active'] ?? 1, ]; if (array_key_exists('modified_by', $data)) { $fields['modified_by'] = $data['modified_by']; diff --git a/lib/Repository/UserRoleRepository.php b/lib/Repository/Access/UserRoleRepository.php similarity index 96% rename from lib/Repository/UserRoleRepository.php rename to lib/Repository/Access/UserRoleRepository.php index fd8c9eb..9e8e035 100644 --- a/lib/Repository/UserRoleRepository.php +++ b/lib/Repository/Access/UserRoleRepository.php @@ -1,6 +1,6 @@ 100) { + $limit = 100; + } + $rows = DB::select( + 'select id, user_id, expires_at, attempts, used_at, created from password_resets where user_id = ? order by id desc limit ?', + (string) $userId, + (string) $limit + ); + return self::unwrapList($rows); + } } diff --git a/lib/Repository/Auth/RememberTokenRepository.php b/lib/Repository/Auth/RememberTokenRepository.php new file mode 100644 index 0000000..ad895e7 --- /dev/null +++ b/lib/Repository/Auth/RememberTokenRepository.php @@ -0,0 +1,102 @@ + NOW()' + ); + return $result !== false ? (int) $result : 0; + } + + 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; + } + + public static function listByUserId(int $userId, int $limit = 20): array + { + if ($userId <= 0) { + return []; + } + if ($limit < 1) { + $limit = 20; + } elseif ($limit > 100) { + $limit = 100; + } + $rows = DB::select( + 'select id, user_id, selector, expires_at, expired_by_admin_at, last_used, created from user_remember_tokens where user_id = ? order by id desc limit ?', + (string) $userId, + (string) $limit + ); + return self::unwrapList($rows); + } + + public static function countActive(): int + { + $count = DB::selectValue('select count(*) from user_remember_tokens where expires_at > NOW()'); + return $count ? (int) $count : 0; + } +} diff --git a/lib/Repository/PageContentRepository.php b/lib/Repository/Content/PageContentRepository.php similarity index 97% rename from lib/Repository/PageContentRepository.php rename to lib/Repository/Content/PageContentRepository.php index a40fc41..9aa03e6 100644 --- a/lib/Repository/PageContentRepository.php +++ b/lib/Repository/Content/PageContentRepository.php @@ -1,6 +1,6 @@ 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'; - } + [$limit, $offset] = RepoQuery::sanitizeLimitOffset($options); + [$order, $dir] = RepoQuery::sanitizeOrder($options, $allowedOrder, 'created_at', '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; - } + RepoQuery::addLikeFilter($where, $params, ['to_email', 'subject', 'template'], $search); + RepoQuery::addEqualsFilter($where, $params, $status, 'status = ?'); if ($createdFrom !== '') { $where[] = 'created_at >= ?'; $params[] = $createdFrom . ' 00:00:00'; diff --git a/lib/Repository/DepartmentRepository.php b/lib/Repository/Org/DepartmentRepository.php similarity index 76% rename from lib/Repository/DepartmentRepository.php rename to lib/Repository/Org/DepartmentRepository.php index eef88bc..8886cfd 100644 --- a/lib/Repository/DepartmentRepository.php +++ b/lib/Repository/Org/DepartmentRepository.php @@ -1,8 +1,9 @@ 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'; - } + $allowedOrder = ['id', 'uuid', 'description', 'code', 'cost_center', 'active', 'created', 'modified']; + [$limit, $offset] = RepoQuery::sanitizeLimitOffset($options); + [$order, $dir] = RepoQuery::sanitizeOrder($options, $allowedOrder); $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; - } + RepoQuery::addLikeFilter( + $where, + $params, + ['description', 'uuid', 'code', 'cost_center'], + $search + ); + $activeValue = array_key_exists('active', $options) ? $options['active'] : null; + RepoQuery::addEnumFilter($where, $params, $activeValue, [ + ['aliases' => ['1', 'true', 'active'], 'sql' => 'departments.active = 1'], + ['aliases' => ['0', 'false', 'inactive'], 'sql' => 'departments.active = 0'], + ]); + RepoQuery::addEqualsFilter( + $where, + $params, + $tenant, + "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 = ?)" + ); if (!empty($options['tenantUserId'])) { $tenantUserId = (int) $options['tenantUserId']; if ($tenantUserId > 0) { @@ -168,7 +158,7 @@ class DepartmentRepository $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' . + $query = 'select id, uuid, description, code, cost_center, active, created_by, modified_by, created, modified from departments' . $whereSql . sprintf(' order by `%s` %s limit ? offset ?', $order, $dir); @@ -244,7 +234,7 @@ class DepartmentRepository 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', + 'select id, uuid, description, code, cost_center, active, created_by, modified_by, created, modified from departments where id = ? limit 1', (string) $id ); return self::unwrap($row); @@ -253,12 +243,26 @@ class DepartmentRepository 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', + 'select id, uuid, description, code, cost_center, active, created_by, modified_by, created, modified from departments where uuid = ? limit 1', $uuid ); return self::unwrap($row); } + public static function existsByCode(string $code, int $excludeId = 0): bool + { + $code = trim($code); + if ($code === '') { + return false; + } + if ($excludeId > 0) { + $count = DB::selectValue('select count(*) from departments where code = ? and id != ?', $code, (string) $excludeId); + } else { + $count = DB::selectValue('select count(*) from departments where code = ?', $code); + } + return (int) $count > 0; + } + private static function uuidV4(): string { $data = random_bytes(16); @@ -270,9 +274,12 @@ class DepartmentRepository public static function create(array $data) { return DB::insert( - 'insert into departments (uuid, description, created_by, created) values (?,?,?,NOW())', + 'insert into departments (uuid, description, code, cost_center, active, created_by, created) values (?,?,?,?,?,?,NOW())', $data['uuid'] ?? self::uuidV4(), $data['description'], + $data['code'] ?? null, + $data['cost_center'] ?? null, + $data['active'] ?? 1, $data['created_by'] ?? null ); } @@ -281,6 +288,9 @@ class DepartmentRepository { $fields = [ 'description' => $data['description'], + 'code' => $data['code'] ?? null, + 'cost_center' => $data['cost_center'] ?? null, + 'active' => $data['active'] ?? 1, ]; if (array_key_exists('modified_by', $data)) { $fields['modified_by'] = $data['modified_by']; diff --git a/lib/Repository/UserDepartmentRepository.php b/lib/Repository/Org/UserDepartmentRepository.php similarity index 98% rename from lib/Repository/UserDepartmentRepository.php rename to lib/Repository/Org/UserDepartmentRepository.php index 0087a5b..893f673 100644 --- a/lib/Repository/UserDepartmentRepository.php +++ b/lib/Repository/Org/UserDepartmentRepository.php @@ -1,6 +1,6 @@ '`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; - } -} diff --git a/lib/Repository/RememberTokenRepository.php b/lib/Repository/RememberTokenRepository.php deleted file mode 100644 index 39a3240..0000000 --- a/lib/Repository/RememberTokenRepository.php +++ /dev/null @@ -1,55 +0,0 @@ - $maxLimit) { + $limit = $maxLimit; + } + + $offset = (int) ($options['offset'] ?? $defaultOffset); + if ($offset < 0) { + $offset = 0; + } + + return [$limit, $offset]; + } + + public static function sanitizeOrder( + array $options, + array $allowedOrder, + string $defaultOrder = 'id', + string $defaultDir = 'desc' + ): array { + $order = (string) ($options['order'] ?? $defaultOrder); + $dir = strtolower((string) ($options['dir'] ?? $defaultDir)); + if (!in_array($order, $allowedOrder, true)) { + $order = $defaultOrder; + } + if (!in_array($dir, ['asc', 'desc'], true)) { + $dir = $defaultDir; + } + return [$order, $dir]; + } + + public static function addLikeFilter(array &$where, array &$params, array $fields, string $value): void + { + $like = self::normalizeLikeValue($value); + if ($like === '') { + return; + } + $clauses = []; + foreach ($fields as $field) { + $clauses[] = $field . " like ? escape '\\\\'"; + $params[] = $like; + } + if ($clauses) { + $where[] = '(' . implode(' or ', $clauses) . ')'; + } + } + + public static function addEnumFilter(array &$where, array &$params, $value, array $map): void + { + if ($value === null) { + return; + } + $normalized = strtolower(trim((string) $value)); + if ($normalized === '' || $normalized === 'all') { + return; + } + foreach ($map as $entry) { + $aliases = $entry['aliases'] ?? []; + $aliases = array_map('strtolower', $aliases); + if (!in_array($normalized, $aliases, true)) { + continue; + } + $sql = (string) ($entry['sql'] ?? ''); + if ($sql === '') { + return; + } + $where[] = $sql; + $paramsToAdd = $entry['params'] ?? []; + foreach ($paramsToAdd as $param) { + $params[] = $param; + } + return; + } + } + + public static function addEqualsFilter(array &$where, array &$params, $value, string $sql, bool $allowEmpty = false): void + { + // Use for simple "= ?" filters; skip when empty to keep WHERE clean. + if ($value === null) { + return; + } + $normalized = trim((string) $value); + if ($normalized === '' && !$allowEmpty) { + return; + } + $where[] = $sql; + $params[] = $normalized; + } +} diff --git a/lib/Repository/TenantDepartmentRepository.php b/lib/Repository/Tenant/TenantDepartmentRepository.php similarity index 63% rename from lib/Repository/TenantDepartmentRepository.php rename to lib/Repository/Tenant/TenantDepartmentRepository.php index 28991d7..f384c0d 100644 --- a/lib/Repository/TenantDepartmentRepository.php +++ b/lib/Repository/Tenant/TenantDepartmentRepository.php @@ -1,6 +1,6 @@ $id > 0); + if (!$tenantIds) { + return []; + } + $placeholders = implode(',', array_fill(0, count($tenantIds), '?')); + $rows = DB::select( + 'select tenant_id, department_id from tenant_departments where tenant_id in (' . $placeholders . ')', + ...array_map('strval', $tenantIds) + ); + if (!is_array($rows)) { + return []; + } + $map = []; + foreach ($rows as $row) { + $data = $row['tenant_departments'] ?? $row; + if (is_array($data) && isset($data['tenant_id'], $data['department_id'])) { + $tenantId = (int) $data['tenant_id']; + $departmentId = (int) $data['department_id']; + if ($tenantId > 0 && $departmentId > 0) { + $map[$tenantId] ??= []; + $map[$tenantId][] = $departmentId; + } + } + } + foreach ($map as $tenantId => $items) { + $map[$tenantId] = array_values(array_unique($items)); + } + return $map; + } + public static function replaceForDepartment(int $departmentId, array $tenantIds): bool { DB::delete('delete from tenant_departments where department_id = ?', (string) $departmentId); diff --git a/lib/Repository/TenantRepository.php b/lib/Repository/Tenant/TenantRepository.php similarity index 91% rename from lib/Repository/TenantRepository.php rename to lib/Repository/Tenant/TenantRepository.php index d0b49d7..00eb595 100644 --- a/lib/Repository/TenantRepository.php +++ b/lib/Repository/Tenant/TenantRepository.php @@ -1,8 +1,9 @@ 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'; - } + [$limit, $offset] = RepoQuery::sanitizeLimitOffset($options); + [$order, $dir] = RepoQuery::sanitizeOrder($options, $allowedOrder); $where = []; $params = []; - if ($search !== '') { - $like = '%' . $search . '%'; - $where[] = '(description like ? or uuid like ?)'; - $params[] = $like; - $params[] = $like; - } + RepoQuery::addLikeFilter($where, $params, ['description', 'uuid'], $search); if (!empty($options['tenantUserId'])) { $tenantUserId = (int) $options['tenantUserId']; if ($tenantUserId > 0) { diff --git a/lib/Repository/UserTenantRepository.php b/lib/Repository/Tenant/UserTenantRepository.php similarity index 96% rename from lib/Repository/UserTenantRepository.php rename to lib/Repository/Tenant/UserTenantRepository.php index 4fc0bba..dc95e18 100644 --- a/lib/Repository/UserTenantRepository.php +++ b/lib/Repository/Tenant/UserTenantRepository.php @@ -1,6 +1,6 @@ ['1', 'true', 'active'], 'sql' => 'users.active = ?', 'params' => ['1']], + ['aliases' => ['0', 'false', 'inactive'], 'sql' => 'users.active = ?', 'params' => ['0']], + ]); + + 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); + } else { + RepoQuery::addEqualsFilter( + $where, + $params, + $tenant, + '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 = ?)' + ); + } + 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); + } + RepoQuery::addEnumFilter($where, $params, $emailVerified, [ + ['aliases' => ['1', 'true', 'verified', 'yes'], 'sql' => 'users.email_verified_at is not null'], + ['aliases' => ['0', 'false', 'unverified', 'no'], 'sql' => 'users.email_verified_at is null'], + ]); + RepoQuery::addEnumFilter($where, $params, $loginStatus, [ + ['aliases' => ['never', 'none', 'no'], 'sql' => 'users.last_login_at is null'], + ['aliases' => ['ever', 'logged', 'yes'], 'sql' => 'users.last_login_at is not 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)) : ''; + return [$whereSql, $params]; + } + + private static function buildListQuery(string $whereSql, string $order, string $dir): string + { + return 'select users.id, users.uuid, users.first_name, users.last_name, users.display_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.last_login_at, 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); + } + + private static function extractIds(array $list): array + { + $ids = []; + foreach ($list as $user) { + if (isset($user['id'])) { + $ids[] = (int) $user['id']; + } + } + return $ids; + } + + private static function collectLabels(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; + } + + private static function hydrateUserLabels(array $list, array $options): array + { + $ids = self::extractIds($list); + if (!$ids) { + return $list; + } + + $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 and r.active = 1 ' . + '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 and d.active = 1 ' . + 'where ud.user_id in (' . $placeholders . ') order by d.description asc', + ...array_map('strval', $ids) + ); + + $tenantMapByUser = []; + 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 = self::collectLabels($roleLabelRows, 'ur', 'r'); + $departmentLabelsByUser = self::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); + + return $list; + } private static function unwrap(?array $row): ?array { if (!$row) { @@ -44,262 +280,35 @@ class UserRepository 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' + 'select id, uuid, first_name, last_name, display_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 updateLastLogin(int $userId): void + { + if ($userId <= 0) { + return; + } + DB::query('update users set last_login_at = UTC_TIMESTAMP() where id = ?', (string) $userId); + } + public static function listPaged(array $options): array { - $limit = (int) ($options['limit'] ?? 10); - if ($limit < 1) { - $limit = 10; - } elseif ($limit > 100) { - $limit = 100; - } + $allowedOrder = ['id', 'uuid', 'first_name', 'last_name', 'email', 'created', 'modified', 'active', 'last_login_at']; + [$limit, $offset] = RepoQuery::sanitizeLimitOffset($options); + [$order, $dir] = RepoQuery::sanitizeOrder($options, $allowedOrder); + [$whereSql, $params] = self::buildUserFilters($options); - $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); - + $query = self::buildListQuery($whereSql, $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); - } + $list = self::hydrateUserLabels($list, $options); $result = [ 'total' => $total, @@ -311,7 +320,7 @@ class UserRepository 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', + 'select id, uuid, first_name, last_name, display_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); @@ -320,7 +329,7 @@ class UserRepository 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', + 'select id, uuid, first_name, last_name, display_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); @@ -329,12 +338,23 @@ class UserRepository 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', + 'select id, uuid, first_name, last_name, display_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 buildDisplayName(array $data): string + { + $first = trim((string) ($data['first_name'] ?? '')); + $last = trim((string) ($data['last_name'] ?? '')); + $name = trim($first . ' ' . $last); + if ($name === '') { + $name = trim((string) ($data['email'] ?? '')); + } + return $name; + } + private static function uuidV4(): string { $data = random_bytes(16); @@ -347,10 +367,11 @@ class UserRepository { $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(),?,?,?)', + 'insert into users (uuid, first_name, last_name, display_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'], + self::buildDisplayName($data), $data['email'], $data['profile_description'] ?? null, $data['job_title'] ?? null, @@ -381,6 +402,7 @@ class UserRepository $fields = [ 'first_name' => $data['first_name'], 'last_name' => $data['last_name'], + 'display_name' => self::buildDisplayName($data), 'email' => $data['email'], 'profile_description' => $data['profile_description'] ?? null, 'job_title' => $data['job_title'] ?? null, diff --git a/lib/Service/PermissionService.php b/lib/Service/Access/PermissionService.php similarity index 83% rename from lib/Service/PermissionService.php rename to lib/Service/Access/PermissionService.php index a355a89..b248d20 100644 --- a/lib/Service/PermissionService.php +++ b/lib/Service/Access/PermissionService.php @@ -1,16 +1,18 @@ false, 'status' => 404, 'error' => 'not_found']; } + if ((int) ($permission['is_system'] ?? 0) === 1) { + return ['ok' => false, 'status' => 403, 'error' => 'system_permission_protected']; + } $deleted = PermissionRepository::delete($id); if (!$deleted) { return ['ok' => false, 'status' => 500, 'error' => 'delete_failed']; @@ -164,6 +174,8 @@ class PermissionService return [ 'key' => trim((string) ($input['key'] ?? '')), 'description' => trim((string) ($input['description'] ?? '')), + 'active' => self::normalizeActive($input['active'] ?? 1), + 'is_system' => self::normalizeFlag($input['is_system'] ?? 0), ]; } @@ -177,4 +189,25 @@ class PermissionService } return $errors; } + + private static function normalizeActive($value): int + { + $value = strtolower(trim((string) $value)); + if (in_array($value, ['0', 'false', 'inactive'], true)) { + return 0; + } + return 1; + } + + private static function normalizeFlag($value): int + { + if (is_bool($value)) { + return $value ? 1 : 0; + } + $value = strtolower(trim((string) $value)); + if (in_array($value, ['1', 'true', 'yes', 'on'], true)) { + return 1; + } + return 0; + } } diff --git a/lib/Service/RoleService.php b/lib/Service/Access/RoleService.php similarity index 73% rename from lib/Service/RoleService.php rename to lib/Service/Access/RoleService.php index 910d8d5..02caac6 100644 --- a/lib/Service/RoleService.php +++ b/lib/Service/Access/RoleService.php @@ -1,9 +1,9 @@ false, 'errors' => $errors, 'form' => $form]; @@ -38,6 +43,8 @@ class RoleService $createdId = RoleRepository::create([ 'description' => $form['description'], + 'code' => $form['code'] ?: null, + 'active' => $form['active'], 'created_by' => $currentUserId > 0 ? $currentUserId : null, ]); @@ -56,7 +63,7 @@ class RoleService public static function updateFromAdmin(int $roleId, array $input, int $currentUserId = 0): array { $form = self::sanitizeBase($input); - $errors = self::validateBase($form); + $errors = self::validateBase($form, $roleId); if ($errors) { return ['ok' => false, 'errors' => $errors, 'form' => $form]; @@ -64,6 +71,8 @@ class RoleService $updated = RoleRepository::update($roleId, [ 'description' => $form['description'], + 'code' => $form['code'] ?: null, + 'active' => $form['active'], 'modified_by' => $currentUserId > 0 ? $currentUserId : null, ]); @@ -102,15 +111,30 @@ class RoleService { return [ 'description' => trim((string) ($input['description'] ?? '')), + 'code' => trim((string) ($input['code'] ?? '')), + 'active' => self::normalizeActive($input['active'] ?? 1), ]; } - private static function validateBase(array $form): array + private static function validateBase(array $form, int $excludeId): array { $errors = []; if ($form['description'] === '') { $errors[] = t('Description cannot be empty'); } + $code = $form['code'] ?? ''; + if ($code !== '' && RoleRepository::existsByCode($code, $excludeId)) { + $errors[] = t('Role code already exists'); + } return $errors; } + + private static function normalizeActive($value): int + { + $value = strtolower(trim((string) $value)); + if (in_array($value, ['0', 'false', 'inactive'], true)) { + return 0; + } + return 1; + } } diff --git a/lib/Service/AuthService.php b/lib/Service/Auth/AuthService.php similarity index 94% rename from lib/Service/AuthService.php rename to lib/Service/Auth/AuthService.php index 801d9ed..31634d3 100644 --- a/lib/Service/AuthService.php +++ b/lib/Service/Auth/AuthService.php @@ -1,15 +1,15 @@ 'login_no_active_tenant', ]; } + UserRepository::updateLastLogin($userId); } return ['ok' => true]; diff --git a/lib/Service/EmailVerificationService.php b/lib/Service/Auth/EmailVerificationService.php similarity index 97% rename from lib/Service/EmailVerificationService.php rename to lib/Service/Auth/EmailVerificationService.php index 1411e17..d00a0a2 100644 --- a/lib/Service/EmailVerificationService.php +++ b/lib/Service/Auth/EmailVerificationService.php @@ -1,9 +1,9 @@ 0) { PermissionService::getUserPermissions($userId, true); AuthService::loadTenantDataIntoSession($userId); + if (empty($_SESSION['no_active_tenant'])) { + UserRepository::updateLastLogin($userId); + } } $newToken = bin2hex(random_bytes(32)); @@ -115,6 +118,11 @@ class RememberMeService RememberTokenRepository::deleteByUserId($userId); } + public static function expireAllTokensByAdmin(): int + { + return RememberTokenRepository::expireAllByAdmin(); + } + private static function setCookie(string $selector, string $token): void { $value = $selector . ':' . $token; diff --git a/lib/Service/BrandingFaviconService.php b/lib/Service/Branding/BrandingFaviconService.php similarity index 95% rename from lib/Service/BrandingFaviconService.php rename to lib/Service/Branding/BrandingFaviconService.php index abc5430..f1c1b56 100644 --- a/lib/Service/BrandingFaviconService.php +++ b/lib/Service/Branding/BrandingFaviconService.php @@ -1,6 +1,6 @@ false, 'errors' => $errors, 'form' => $form]; + return ['ok' => false, 'errors' => $errors, 'warnings' => $warnings, 'form' => $form]; } $createdId = DepartmentRepository::create([ 'description' => $form['description'], + 'code' => $form['code'] ?: null, + 'cost_center' => $form['cost_center'] ?: null, + 'active' => $form['active'], 'created_by' => $currentUserId > 0 ? $currentUserId : null, ]); @@ -90,20 +94,24 @@ class DepartmentService if (!empty($input['is_default']) && $createdId) { SettingService::setDefaultDepartmentId((int) $createdId); } - return ['ok' => true, 'form' => $form, 'uuid' => $uuid, 'id' => (int) $createdId]; + return ['ok' => true, 'form' => $form, 'warnings' => $warnings, '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); + $warnings = self::warningsForCode($form, $departmentId); if ($errors) { - return ['ok' => false, 'errors' => $errors, 'form' => $form]; + return ['ok' => false, 'errors' => $errors, 'warnings' => $warnings, 'form' => $form]; } $updated = DepartmentRepository::update($departmentId, [ 'description' => $form['description'], + 'code' => $form['code'] ?: null, + 'cost_center' => $form['cost_center'] ?: null, + 'active' => $form['active'], 'modified_by' => $currentUserId > 0 ? $currentUserId : null, ]); @@ -111,7 +119,7 @@ class DepartmentService return ['ok' => false, 'errors' => [t('Department can not be updated')], 'form' => $form]; } - return ['ok' => true, 'form' => $form]; + return ['ok' => true, 'form' => $form, 'warnings' => $warnings]; } public static function syncTenants(int $departmentId, array $tenantIds): int @@ -147,6 +155,9 @@ class DepartmentService { return [ 'description' => trim((string) ($input['description'] ?? '')), + 'code' => trim((string) ($input['code'] ?? '')), + 'cost_center' => trim((string) ($input['cost_center'] ?? '')), + 'active' => self::normalizeActive($input['active'] ?? 1), ]; } @@ -158,4 +169,23 @@ class DepartmentService } return $errors; } + + private static function warningsForCode(array $form, int $excludeId): array + { + $warnings = []; + $code = $form['code'] ?? ''; + if ($code !== '' && DepartmentRepository::existsByCode($code, $excludeId)) { + $warnings[] = t('Department code already exists'); + } + return $warnings; + } + + private static function normalizeActive($value): int + { + $value = strtolower(trim((string) $value)); + if (in_array($value, ['0', 'false', 'inactive'], true)) { + return 0; + } + return 1; + } } diff --git a/lib/Service/SettingService.php b/lib/Service/Settings/SettingService.php similarity index 87% rename from lib/Service/SettingService.php rename to lib/Service/Settings/SettingService.php index c0dea4a..10c78f6 100644 --- a/lib/Service/SettingService.php +++ b/lib/Service/Settings/SettingService.php @@ -1,11 +1,11 @@ $label) { + $key = strtolower(trim((string) $key)); + $label = trim((string) $label); + if ($key !== '' && $label !== '') { + $keys[] = $key; + } + } + return $keys ?: ['light', 'dark']; + } + + public static function isRegistrationEnabled(): bool + { + $value = SettingRepository::getValue(self::APP_REGISTRATION_KEY); + if ($value === null || $value === '') { + return true; + } + return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true); + } + + public static function setRegistrationEnabled(bool $allowed, ?string $description = null): bool + { + $value = $allowed ? '1' : '0'; + $desc = $description ?? 'setting.app_registration'; + return SettingRepository::set(self::APP_REGISTRATION_KEY, $value, $desc); + } + public static function getAppPrimaryColor(): ?string { $value = SettingRepository::getValue(self::APP_PRIMARY_COLOR_KEY); diff --git a/lib/Service/TenantAvatarService.php b/lib/Service/Tenant/TenantAvatarService.php similarity index 99% rename from lib/Service/TenantAvatarService.php rename to lib/Service/Tenant/TenantAvatarService.php index 4769b1c..3204c95 100644 --- a/lib/Service/TenantAvatarService.php +++ b/lib/Service/Tenant/TenantAvatarService.php @@ -1,6 +1,6 @@ $id > 0); - $validIds = RoleRepository::listIds(); + $validIds = RoleRepository::listActiveIds(); if ($validIds) { $validMap = array_fill_keys($validIds, true); $ids = array_values(array_filter($ids, static fn ($id) => isset($validMap[$id]))); @@ -520,8 +520,9 @@ class UserService private static function normalizeTheme($value): string { $theme = strtolower(trim((string) $value)); - if (!in_array($theme, ['dark', 'light'], true)) { - return 'light'; + $themes = function_exists('appThemes') ? appThemes() : ['light' => 'Light', 'dark' => 'Dark']; + if ($theme === '' || !isset($themes[$theme])) { + return function_exists('appDefaultTheme') ? appDefaultTheme() : 'light'; } return $theme; } diff --git a/lib/Support/Guard.php b/lib/Support/Guard.php index 31eb747..c7d6d3c 100644 --- a/lib/Support/Guard.php +++ b/lib/Support/Guard.php @@ -3,9 +3,9 @@ namespace MintyPHP\Support; use MintyPHP\Router; -use MintyPHP\Service\PermissionService; -use MintyPHP\Service\TenantService; -use MintyPHP\Service\AuthService; +use MintyPHP\Service\Access\PermissionService; +use MintyPHP\Service\Tenant\TenantService; +use MintyPHP\Service\Auth\AuthService; use MintyPHP\Http\Request; class Guard diff --git a/lib/Support/SearchConfig.php b/lib/Support/SearchConfig.php index 3c0a30a..616fc25 100644 --- a/lib/Support/SearchConfig.php +++ b/lib/Support/SearchConfig.php @@ -2,8 +2,8 @@ namespace MintyPHP\Support; -use MintyPHP\Service\PermissionService; -use MintyPHP\Service\UserAvatarService; +use MintyPHP\Service\Access\PermissionService; +use MintyPHP\Service\User\UserAvatarService; class SearchConfig { @@ -17,97 +17,125 @@ class SearchConfig ]; } + + private static function normalizeLikeQuery(string $query): string + { + $query = trim($query); + if ($query === '') { + return ''; + } + $query = str_replace('\\', '\\\\', $query); + $query = str_replace('_', '\\_', $query); + $query = str_replace('*', '%', $query); + if (!str_starts_with($query, '%')) { + $query = '%' . $query; + } + if (!str_ends_with($query, '%')) { + $query = $query . '%'; + } + return $query; + } + + public static function normalizeScoreQuery(string $query): string + { + $query = trim($query); + if ($query == '') { + return ''; + } + return str_replace(['*', '%', '_'], '', $query); + } + public static function resources(string $query, string $locale): array { - $like = '%' . $query . '%'; + $like = self::normalizeLikeQuery($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}}', + 'countSql' => "select count(*) from users where active = 1 and (first_name like ? escape '\\\\' or last_name like ? escape '\\\\' or email like ? escape '\\\\') {{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 ?', + 'previewSql' => "select uuid, first_name, last_name, email from users where active = 1 and (first_name like ? escape '\\\\' or last_name like ? escape '\\\\' or email like ? escape '\\\\') {{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', + 'resultSql' => "select uuid, first_name, last_name, email from users where active = 1 and (first_name like ? escape '\\\\' or last_name like ? escape '\\\\' or email like ? escape '\\\\') {{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}}', + 'countSql' => "select count(*) from users where (first_name like ? escape '\\\\' or last_name like ? escape '\\\\' or email like ? escape '\\\\') {{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 ?', + 'previewSql' => "select uuid, first_name, last_name, email from users where (first_name like ? escape '\\\\' or last_name like ? escape '\\\\' or email like ? escape '\\\\') {{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', + 'resultSql' => "select uuid, first_name, last_name, email from users where (first_name like ? escape '\\\\' or last_name like ? escape '\\\\' or email like ? escape '\\\\') {{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}}', + 'countSql' => "select count(*) from tenants where description like ? escape '\\\\' {{tenantFilter}}", 'countParams' => [$like], - 'previewSql' => 'select uuid, description from tenants where description like ? {{tenantFilter}} order by description limit ?', + 'previewSql' => "select uuid, description from tenants where description like ? escape '\\\\' {{tenantFilter}} order by description limit ?", 'previewParams' => [$like], - 'resultSql' => 'select uuid, description from tenants where description like ? {{tenantFilter}} order by description', + 'resultSql' => "select uuid, description from tenants where description like ? escape '\\\\' {{tenantFilter}} order by description", 'resultParams' => [$like], ], [ 'key' => 'departments', 'label' => t('Departments'), 'permission' => PermissionService::DEPARTMENTS_VIEW, - 'countSql' => 'select count(*) from departments where description like ? {{tenantFilter}}', + 'countSql' => "select count(*) from departments where description like ? escape '\\\\' {{tenantFilter}}", 'countParams' => [$like], - 'previewSql' => 'select uuid, description from departments where description like ? {{tenantFilter}} order by description limit ?', + 'previewSql' => "select uuid, description from departments where description like ? escape '\\\\' {{tenantFilter}} order by description limit ?", 'previewParams' => [$like], - 'resultSql' => 'select uuid, description from departments where description like ? {{tenantFilter}} order by description', + 'resultSql' => "select uuid, description from departments where description like ? escape '\\\\' {{tenantFilter}} order by description", 'resultParams' => [$like], ], [ 'key' => 'roles', 'label' => t('Roles'), 'permission' => PermissionService::ROLES_VIEW, - 'countSql' => 'select count(*) from roles where description like ?', + 'countSql' => "select count(*) from roles where active = 1 and description like ? escape '\\\\'", 'countParams' => [$like], - 'previewSql' => 'select uuid, description from roles where description like ? order by description limit ?', + 'previewSql' => "select uuid, description from roles where active = 1 and description like ? escape '\\\\' order by description limit ?", 'previewParams' => [$like], - 'resultSql' => 'select uuid, description from roles where description like ? order by description', + 'resultSql' => "select uuid, description from roles where active = 1 and description like ? escape '\\\\' 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 ?', + 'countSql' => "select count(*) from permissions where active = 1 and (`key` like ? escape '\\\\' or description like ? escape '\\\\')", 'countParams' => [$like, $like], - 'previewSql' => 'select id, description, `key` from permissions where `key` like ? or description like ? order by `key` limit ?', + 'previewSql' => "select id, description, `key` from permissions where active = 1 and (`key` like ? escape '\\\\' or description like ? escape '\\\\') order by `key` limit ?", 'previewParams' => [$like, $like], - 'resultSql' => 'select id, description, `key` from permissions where `key` like ? or description like ? order by `key`', + 'resultSql' => "select id, description, `key` from permissions where active = 1 and (`key` like ? escape '\\\\' or description like ? escape '\\\\') 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 ?', + 'countSql' => "select count(*) from pages p join page_contents pc on pc.page_id = p.id and pc.locale = ? where p.slug like ? escape '\\\\' or pc.content like ? escape '\\\\'", '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 ?', + 'previewSql' => "select p.slug from pages p join page_contents pc on pc.page_id = p.id and pc.locale = ? where p.slug like ? escape '\\\\' or pc.content like ? escape '\\\\' 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', + 'resultSql' => "select p.slug from pages p join page_contents pc on pc.page_id = p.id and pc.locale = ? where p.slug like ? escape '\\\\' or pc.content like ? escape '\\\\' 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 ?', + 'countSql' => "select count(*) from settings where `key` like ? escape '\\\\' or value like ? escape '\\\\'", 'countParams' => [$like, $like], 'previewSql' => null, 'previewParams' => [], - 'resultSql' => 'select `key`, value from settings where `key` like ? or value like ? order by `key`', + 'resultSql' => "select `key`, value from settings where `key` like ? escape '\\\\' or value like ? escape '\\\\' order by `key`", 'resultParams' => [$like, $like], ], ]; diff --git a/lib/Support/Tile.php b/lib/Support/Tile.php index d1e1a93..2432ca0 100644 --- a/lib/Support/Tile.php +++ b/lib/Support/Tile.php @@ -14,6 +14,8 @@ class Tile 'iconBg' => null, 'iconColor' => null, 'class' => '', + 'tooltip' => '', + 'tooltipPos' => 'top', ]; $data = array_merge($defaults, $options); diff --git a/lib/Support/helpers/app.php b/lib/Support/helpers/app.php index 7b92a4f..36b6663 100644 --- a/lib/Support/helpers/app.php +++ b/lib/Support/helpers/app.php @@ -10,6 +10,23 @@ function asset(string $path): string return \MintyPHP\Router::getBaseUrl() . ltrim($path, '/'); } +function templatePath(string $path): string +{ + return dirname(__DIR__, 3) . '/templates/' . ltrim($path, '/'); +} + +function assetVersion(string $path): string +{ + $path = ltrim($path, '/'); + $file = dirname(__DIR__, 3) . '/web/' . $path; + $url = asset($path); + $version = @filemtime($file); + if ($version === false) { + return $url; + } + return $url . '?v=' . $version; +} + function localeBase(): string { $locale = \MintyPHP\I18n::$locale ?? \MintyPHP\I18n::$defaultLocale; @@ -58,6 +75,16 @@ function appUrl(string $path = ''): string return $base . '/' . $path; } +function currentUserDisplayName(): string +{ + $user = $_SESSION['user'] ?? []; + $name = trim((string) (($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? ''))); + if ($name !== '') { + return $name; + } + return trim((string) ($user['email'] ?? '')); +} + function appLogoUrlAbsolute(int $size = 128): string { $url = appLogoUrl($size); @@ -79,6 +106,36 @@ function appSetting(string $key): ?string return $value !== '' ? $value : null; } +function appThemes(): array +{ + $file = dirname(__DIR__, 3) . '/config/themes.php'; + if (!is_file($file)) { + return [ + 'light' => 'Light', + 'dark' => 'Dark', + ]; + } + $themes = include $file; + if (!is_array($themes)) { + return [ + 'light' => 'Light', + 'dark' => 'Dark', + ]; + } + $clean = []; + foreach ($themes as $key => $label) { + $key = strtolower(trim((string) $key)); + $label = trim((string) $label); + if ($key !== '' && $label !== '') { + $clean[$key] = $label; + } + } + return $clean ?: [ + 'light' => 'Light', + 'dark' => 'Dark', + ]; +} + function appDefaultLocale(): ?string { $locale = appSetting('app_locale'); @@ -94,13 +151,29 @@ function appDefaultLocale(): ?string function appDefaultTheme(): string { + $themes = appThemes(); $setting = appSetting('app_theme'); - if (in_array($setting, ['light', 'dark'], true)) { + if ($setting !== null && isset($themes[$setting])) { return $setting; } $envTheme = getenv('APP_THEME') ?: 'light'; $envTheme = strtolower(trim((string) $envTheme)); - return in_array($envTheme, ['light', 'dark'], true) ? $envTheme : 'light'; + return isset($themes[$envTheme]) ? $envTheme : 'light'; +} + +function currentTheme(): string +{ + $themes = appThemes(); + $theme = appDefaultTheme(); + if (!allowUserTheme()) { + return $theme; + } + $user = $_SESSION['user'] ?? []; + $userTheme = strtolower(trim((string) ($user['theme'] ?? ''))); + if ($userTheme !== '' && isset($themes[$userTheme])) { + return $userTheme; + } + return $theme; } function allowUserTheme(): bool @@ -112,6 +185,15 @@ function allowUserTheme(): bool return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true); } +function allowRegistration(): bool +{ + $value = appSetting('app_registration'); + 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; @@ -147,26 +229,40 @@ function appPrimaryCssVars(): string $g = hexdec(substr($hex, 2, 2)) / 255; $b = hexdec(substr($hex, 4, 2)) / 255; + $monoThreshold = 0.000001; + if (abs($r - $g) < $monoThreshold && abs($g - $b) < $monoThreshold) { + $l = round($r * 100, 2) . '%'; + return "--app-primary-h-base: 0; --app-primary-s-base: 0%; --app-primary-l-base: {$l}; --app-primary-h-light: 0; --app-primary-s-light: 0%; --app-primary-l-light: {$l};"; + } + $max = max($r, $g, $b); $min = min($r, $g, $b); $delta = $max - $min; + if ($delta == 0.0) { + $l = round((($max + $min) / 2) * 100, 2) . '%'; + return "--app-primary-h-base: 0; --app-primary-s-base: 0%; --app-primary-l-base: {$l}; --app-primary-h-light: 0; --app-primary-s-light: 0%; --app-primary-l-light: {$l};"; + } + $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 ($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)); + $denominator = 1 - abs(2 * $l - 1); + if ($delta === 0.0 || $denominator == 0.0) { + $s = 0.0; + } else { + $s = $delta / $denominator; + } $h = round($h, 2); $s = round($s * 100, 2) . '%'; @@ -177,7 +273,7 @@ function appPrimaryCssVars(): string function appLogoUrl(?int $size = null): string { - if (class_exists('MintyPHP\\Service\\BrandingLogoService') && \MintyPHP\Service\BrandingLogoService::hasLogo()) { + if (class_exists('MintyPHP\\Service\\Branding\\BrandingLogoService') && \MintyPHP\Service\Branding\BrandingLogoService::hasLogo()) { $query = $size ? '?size=' . (int) $size : ''; return lurl('branding/logo' . $query); } @@ -187,9 +283,9 @@ function appLogoUrl(?int $size = null): string 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, '/')); + if ($tenantUuid !== '' && class_exists('MintyPHP\\Service\\Tenant\\TenantFaviconService')) { + if (\MintyPHP\Service\Tenant\TenantFaviconService::hasFavicon($tenantUuid)) { + return asset('favicon/tenants/' . $tenantUuid . '/favicon/' . ltrim($file, '/')); } } return asset('favicon/' . ltrim($file, '/')); diff --git a/lib/Support/helpers/auth.php b/lib/Support/helpers/auth.php index c7a1db9..fa0a78b 100644 --- a/lib/Support/helpers/auth.php +++ b/lib/Support/helpers/auth.php @@ -13,12 +13,12 @@ function can(string $permissionKey): bool if ($userId <= 0) { return false; } - if (!class_exists('MintyPHP\\Service\\PermissionService')) { + if (!class_exists('MintyPHP\\Service\\Access\\PermissionService')) { return false; } - $keys = \MintyPHP\Service\PermissionService::getCachedPermissions($userId); + $keys = \MintyPHP\Service\Access\PermissionService::getCachedPermissions($userId); if (!$keys) { - $keys = \MintyPHP\Service\PermissionService::getUserPermissions($userId); + $keys = \MintyPHP\Service\Access\PermissionService::getUserPermissions($userId); if (!$keys) { return false; } diff --git a/pages/address-book/data().php b/pages/address-book/data().php index a362da5..8ecacb5 100644 --- a/pages/address-book/data().php +++ b/pages/address-book/data().php @@ -2,9 +2,9 @@ use MintyPHP\Router; use MintyPHP\Support\Guard; -use MintyPHP\Service\UserService; -use MintyPHP\Service\UserAvatarService; -use MintyPHP\Service\PermissionService; +use MintyPHP\Service\User\UserService; +use MintyPHP\Service\User\UserAvatarService; +use MintyPHP\Service\Access\PermissionService; Guard::requireLogin(); Guard::requirePermissionOrForbidden(PermissionService::ADDRESS_BOOK_VIEW); diff --git a/pages/address-book/index().php b/pages/address-book/index().php index 22349e0..03a4ead 100644 --- a/pages/address-book/index().php +++ b/pages/address-book/index().php @@ -3,11 +3,12 @@ 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; +use MintyPHP\Service\Access\PermissionService; +use MintyPHP\Service\Tenant\TenantScopeService; +use MintyPHP\Service\Tenant\TenantService; +use MintyPHP\Service\Org\DepartmentService; +use MintyPHP\Service\Access\RoleService; +use MintyPHP\Repository\Tenant\TenantDepartmentRepository; Guard::requireLogin(); Guard::requirePermissionOrForbidden(PermissionService::ADDRESS_BOOK_VIEW); @@ -39,7 +40,22 @@ $departments = $tenantIds ? DepartmentService::listByTenantIds($tenantIds) : (TenantScopeService::isStrict() ? [] : DepartmentService::list()); -$roles = RoleService::list(); +$roles = RoleService::listActive(); + +$tenantDepartmentMap = []; +if ($tenantIds) { + $departmentMapByTenantId = TenantDepartmentRepository::listDepartmentMapByTenantIds($tenantIds); + foreach ($tenants as $tenant) { + $tenantId = (int) ($tenant['id'] ?? 0); + $tenantUuid = (string) ($tenant['uuid'] ?? ''); + if ($tenantId > 0 && $tenantUuid !== '') { + $tenantDepartmentMap[$tenantUuid] = array_map( + 'strval', + $departmentMapByTenantId[$tenantId] ?? [] + ); + } + } +} $csrfKey = Session::$csrfSessionKey; $csrfToken = $_SESSION[$csrfKey] ?? ''; diff --git a/pages/address-book/index(default).phtml b/pages/address-book/index(default).phtml index 50189f5..381e932 100644 --- a/pages/address-book/index(default).phtml +++ b/pages/address-book/index(default).phtml @@ -1,9 +1,13 @@ -
- Startseite -
+ t('Home'), 'path' => 'admin'], + ['label' => t('Address book')], +]; +require templatePath('partials/app-breadcrumb.phtml'); +?>

@@ -19,7 +23,11 @@ use MintyPHP\Buffer;
-
+
- diff --git a/pages/address-book/view($id).php b/pages/address-book/view($id).php index 770d220..690abd3 100644 --- a/pages/address-book/view($id).php +++ b/pages/address-book/view($id).php @@ -5,9 +5,9 @@ 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; +use MintyPHP\Service\Access\PermissionService; +use MintyPHP\Service\Tenant\TenantScopeService; +use MintyPHP\Service\User\UserService; Guard::requireLogin(); Guard::requirePermissionOrForbidden(PermissionService::ADDRESS_BOOK_VIEW); @@ -70,7 +70,7 @@ $departmentRows = DB::select( (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', + 'select r.description as description from user_roles ur join roles r on r.id = ur.role_id and r.active = 1 where ur.user_id = ? order by r.description asc', (string) $userId ); diff --git a/pages/address-book/view(default).phtml b/pages/address-book/view(default).phtml index fdd3e4d..e32f0ee 100644 --- a/pages/address-book/view(default).phtml +++ b/pages/address-book/view(default).phtml @@ -1,6 +1,6 @@
-
- -
- - + t('Address book'), 'path' => 'address-book'], + ['label' => $displayName], + ]; + require templatePath('partials/app-breadcrumb.phtml'); + ?>
@@ -94,13 +89,8 @@ if ($profileDescription !== '') {

- - -

- - - -

+ +

@@ -207,29 +197,9 @@ if ($profileDescription !== '') { -
- -
- -

-

-

- - - - - - - - - - - - -
-
diff --git a/pages/admin/departments/_form.phtml b/pages/admin/departments/_form.phtml index d56c158..89f6e34 100644 --- a/pages/admin/departments/_form.phtml +++ b/pages/admin/departments/_form.phtml @@ -29,27 +29,54 @@ $dataDisabledAttr = $isReadOnly ? 'data-disabled="true"' : '';
+
+ + + +
+ + + + - - - - - + \ No newline at end of file diff --git a/pages/admin/departments/create().php b/pages/admin/departments/create().php index e8f5d98..1324f98 100644 --- a/pages/admin/departments/create().php +++ b/pages/admin/departments/create().php @@ -4,10 +4,10 @@ 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; +use MintyPHP\Service\Org\DepartmentService; +use MintyPHP\Service\Access\PermissionService; +use MintyPHP\Service\Tenant\TenantService; +use MintyPHP\Service\Tenant\TenantScopeService; Guard::requireLogin(); Guard::requirePermissionOrForbidden(PermissionService::DEPARTMENTS_CREATE); @@ -20,8 +20,12 @@ if (TenantScopeService::isStrict() && !$allowedTenantIds) { } $errors = []; +$warnings = []; $form = [ 'description' => '', + 'code' => '', + 'cost_center' => '', + 'active' => '1', ]; $tenants = TenantService::list(); if ($allowedTenantIds) { @@ -38,6 +42,7 @@ if (isset($_POST['description'])) { $result = DepartmentService::createFromAdmin($_POST, $currentUserId); $form = $result['form'] ?? $form; $errors = $result['errors'] ?? []; + $warnings = $result['warnings'] ?? []; $tenantIds = $_POST['tenant_ids'] ?? []; if (!is_array($tenantIds)) { $tenantIds = [$tenantIds]; @@ -56,15 +61,24 @@ if (isset($_POST['description'])) { } $action = (string) ($_POST['action'] ?? 'create'); if ($action === 'create_close') { + if ($warnings) { + Flash::info(implode(' ', $warnings), 'admin/departments', 'department_warning'); + } Flash::success('Department created', 'admin/departments', 'department_created'); Router::redirect('admin/departments'); } else { $uuid = (string) ($result['uuid'] ?? ''); if ($uuid !== '') { $target = "admin/departments/edit/{$uuid}"; + if ($warnings) { + Flash::info(implode(' ', $warnings), $target, 'department_warning'); + } Flash::success('Department created', $target, 'department_created'); Router::redirect($target); } else { + if ($warnings) { + Flash::info(implode(' ', $warnings), 'admin/departments', 'department_warning'); + } Flash::success('Department created', 'admin/departments', 'department_created'); Router::redirect('admin/departments'); } diff --git a/pages/admin/departments/create(default).phtml b/pages/admin/departments/create(default).phtml index 047c48a..eccaae0 100644 --- a/pages/admin/departments/create(default).phtml +++ b/pages/admin/departments/create(default).phtml @@ -8,10 +8,14 @@ ?>
- + t('Home'), 'path' => 'admin'], + ['label' => t('Departments'), 'path' => 'admin/departments'], + ['label' => t('Create department')], + ]; + require templatePath('partials/app-breadcrumb.phtml'); + ?>

@@ -35,6 +39,15 @@

+ +
+
    + +
  • + +
+
+ $limit, @@ -27,6 +28,7 @@ $result = DepartmentService::listPaged([ 'order' => $order, 'dir' => $dir, 'tenant' => $tenant, + 'active' => $active, 'tenantIds' => $tenantIds, 'tenantUserId' => $currentUserId, ]); @@ -40,6 +42,9 @@ foreach ($result['rows'] as $row) { 'uuid' => $row['uuid'] ?? '', 'is_default' => $departmentId > 0 && $defaultDepartmentId === $departmentId, 'description' => $row['description'] ?? '', + 'code' => $row['code'] ?? '', + 'cost_center' => $row['cost_center'] ?? '', + 'active' => $row['active'] ?? 1, 'tenants' => $row['tenant_labels'] ?? [], 'created' => dt($row['created'] ?? ''), 'modified' => dt($row['modified'] ?? ''), diff --git a/pages/admin/departments/delete($id).php b/pages/admin/departments/delete($id).php index 6f32cad..5def2c7 100644 --- a/pages/admin/departments/delete($id).php +++ b/pages/admin/departments/delete($id).php @@ -3,10 +3,10 @@ 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; +use MintyPHP\Service\Auth\AuthService; +use MintyPHP\Service\Org\DepartmentService; +use MintyPHP\Service\Access\PermissionService; +use MintyPHP\Service\Tenant\TenantScopeService; Guard::requireLogin(); Guard::requirePermissionOrForbidden(PermissionService::DEPARTMENTS_DELETE); diff --git a/pages/admin/departments/edit($id).php b/pages/admin/departments/edit($id).php index 24dada4..af3c311 100644 --- a/pages/admin/departments/edit($id).php +++ b/pages/admin/departments/edit($id).php @@ -4,13 +4,13 @@ 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; +use MintyPHP\Service\Org\DepartmentService; +use MintyPHP\Repository\Tenant\TenantDepartmentRepository; +use MintyPHP\Service\Tenant\TenantService; +use MintyPHP\Service\User\UserService; +use MintyPHP\Service\Settings\SettingService; +use MintyPHP\Service\Access\PermissionService; +use MintyPHP\Service\Tenant\TenantScopeService; Guard::requireLogin(); Guard::requirePermissionOrForbidden(PermissionService::DEPARTMENTS_VIEW); @@ -53,6 +53,7 @@ if ($modifierId > 0) { } $errors = []; +$warnings = []; $form = $department; $tenants = TenantService::list(); $selectedTenantIds = TenantDepartmentRepository::listTenantIdsByDepartmentId($departmentId); @@ -75,6 +76,7 @@ if (isset($_POST['description'])) { $result = DepartmentService::updateFromAdmin($departmentId, $_POST, $currentUserId); $form = $result['form'] ?? $form; $errors = $result['errors'] ?? []; + $warnings = $result['warnings'] ?? []; $tenantIds = $_POST['tenant_ids'] ?? []; if (!is_array($tenantIds)) { $tenantIds = [$tenantIds]; @@ -99,9 +101,15 @@ if (isset($_POST['description'])) { Flash::info(t('Department assignments cleaned: %d', $cleaned), "admin/departments/edit/{$uuid}", 'department_assignments_cleaned'); } if ($action === 'save_close') { + if ($warnings) { + Flash::info(implode(' ', $warnings), 'admin/departments', 'department_warning'); + } Flash::success('Department updated', 'admin/departments', 'department_updated'); Router::redirect('admin/departments'); } else { + if ($warnings) { + Flash::info(implode(' ', $warnings), "admin/departments/edit/{$uuid}", 'department_warning'); + } Flash::success('Department updated', "admin/departments/edit/{$uuid}", 'department_updated'); Router::redirect("admin/departments/edit/{$uuid}"); } diff --git a/pages/admin/departments/edit(default).phtml b/pages/admin/departments/edit(default).phtml index 9123041..2af39f4 100644 --- a/pages/admin/departments/edit(default).phtml +++ b/pages/admin/departments/edit(default).phtml @@ -15,10 +15,14 @@ $isReadOnly = !can('departments.update');
- + t('Home'), 'path' => 'admin'], + ['label' => t('Departments'), 'path' => 'admin/departments'], + ['label' => t('Edit department')], + ]; + require templatePath('partials/app-breadcrumb.phtml'); + ?>

@@ -64,6 +68,17 @@ $isReadOnly = !can('departments.update');

+ +
+
+
    + +
  • + +
+
+
+

-
-
-
- -

- - - -

-
+
+ + + + +
+ +
+ +


+ + +


+ + -
-
- -

-
-
- - - -

- - - +


+
+ +
+
+
+ +

+
+
+ + + +

+ + + + + + + + - + - - - - - - - -

+

+
-
-
-
- -

-
-
- - - -

- - - +

+
+ +

+
+
+ + + +

+ + + + + + + + - + - - - - - - - -

+

+
-
-
-
- - - -

- - - -

+ +
+
+ +
+
+
+ +

+ + + +

+
+
+ + + +

+ + + +

+
-
+ +
diff --git a/pages/admin/departments/index().php b/pages/admin/departments/index().php index 71313a9..9fca460 100644 --- a/pages/admin/departments/index().php +++ b/pages/admin/departments/index().php @@ -3,9 +3,9 @@ use MintyPHP\Buffer; use MintyPHP\Support\Guard; use MintyPHP\Session; -use MintyPHP\Service\PermissionService; -use MintyPHP\Service\TenantService; -use MintyPHP\Service\TenantScopeService; +use MintyPHP\Service\Access\PermissionService; +use MintyPHP\Service\Tenant\TenantService; +use MintyPHP\Service\Tenant\TenantScopeService; Guard::requireLogin(); Guard::requirePermissionOrForbidden(PermissionService::DEPARTMENTS_VIEW); diff --git a/pages/admin/departments/index(default).phtml b/pages/admin/departments/index(default).phtml index 4ae14df..87027a1 100644 --- a/pages/admin/departments/index(default).phtml +++ b/pages/admin/departments/index(default).phtml @@ -4,11 +4,16 @@ use MintyPHP\Router; $canCreateDepartments = can('departments.create'); $canDeleteDepartments = can('departments.delete'); $activeTenant = trim((string) ($_GET['tenant'] ?? '')); +$activeStatus = trim((string) ($_GET['active'] ?? '')); $showTenantTabs = isset($tenants) && is_array($tenants) && count($tenants) > 1; ?> - + t('Home'), 'path' => 'admin'], + ['label' => t('Departments')], +]; +require templatePath('partials/app-breadcrumb.phtml'); +?>

@@ -46,32 +51,51 @@ $showTenantTabs = isset($tenants) && is_array($tenants) && count($tenants) > 1;
- + +
- + \ No newline at end of file diff --git a/pages/admin/users/send-access($id).php b/pages/admin/users/send-access($id).php index 956f60c..b070d1c 100644 --- a/pages/admin/users/send-access($id).php +++ b/pages/admin/users/send-access($id).php @@ -3,10 +3,10 @@ use MintyPHP\Support\Flash; use MintyPHP\Support\Guard; use MintyPHP\Router; -use MintyPHP\Service\PermissionService; -use MintyPHP\Service\TenantScopeService; -use MintyPHP\Service\UserService; -use MintyPHP\Service\MailService; +use MintyPHP\Service\Access\PermissionService; +use MintyPHP\Service\Tenant\TenantScopeService; +use MintyPHP\Service\User\UserService; +use MintyPHP\Service\Mail\MailService; use MintyPHP\I18n; use MintyPHP\Http\Request; diff --git a/pages/admin/users/switch-tenant().php b/pages/admin/users/switch-tenant().php index 837643d..e04f375 100644 --- a/pages/admin/users/switch-tenant().php +++ b/pages/admin/users/switch-tenant().php @@ -3,7 +3,7 @@ use MintyPHP\Http\Request; use MintyPHP\Router; use MintyPHP\Session; -use MintyPHP\Service\UserService; +use MintyPHP\Service\User\UserService; use MintyPHP\Support\Guard; Guard::requireLogin(); diff --git a/pages/admin/users/theme().php b/pages/admin/users/theme().php index 682fc32..e057195 100644 --- a/pages/admin/users/theme().php +++ b/pages/admin/users/theme().php @@ -3,7 +3,7 @@ use MintyPHP\Http\Request; use MintyPHP\Router; use MintyPHP\Session; -use MintyPHP\Service\UserService; +use MintyPHP\Service\User\UserService; use MintyPHP\Support\Guard; Guard::requireLogin(); @@ -34,8 +34,9 @@ if ($userId <= 0) { return; } -$theme = trim((string) ($_POST['theme'] ?? '')); -if (!in_array($theme, ['light', 'dark'], true)) { +$theme = strtolower(trim((string) ($_POST['theme'] ?? ''))); +$themes = appThemes(); +if ($theme === '' || !isset($themes[$theme])) { http_response_code(400); if (Request::wantsJson()) { Router::json(['ok' => false, 'error' => 'invalid_theme']); diff --git a/pages/auth/forgot().php b/pages/auth/forgot().php index 5427125..6b40813 100644 --- a/pages/auth/forgot().php +++ b/pages/auth/forgot().php @@ -6,7 +6,7 @@ use MintyPHP\Session; use MintyPHP\Router; use MintyPHP\Http\Request; use MintyPHP\I18n; -use MintyPHP\Service\PasswordResetService; +use MintyPHP\Service\Auth\PasswordResetService; $errors = []; $email = trim((string) ($_POST['email'] ?? '')); diff --git a/pages/auth/login().php b/pages/auth/login().php index 2a25fd7..a03c0bf 100644 --- a/pages/auth/login().php +++ b/pages/auth/login().php @@ -1,6 +1,6 @@ -
-

?

+ +
+

?

+ diff --git a/pages/auth/logout().php b/pages/auth/logout().php index 30eb6fd..eb2e523 100644 --- a/pages/auth/logout().php +++ b/pages/auth/logout().php @@ -1,5 +1,5 @@ diff --git a/pages/auth/reset().php b/pages/auth/reset().php index 78dce4b..a9e0d2c 100644 --- a/pages/auth/reset().php +++ b/pages/auth/reset().php @@ -6,8 +6,8 @@ use MintyPHP\Session; use MintyPHP\Router; use MintyPHP\Http\Request; use MintyPHP\I18n; -use MintyPHP\Service\PasswordResetService; -use MintyPHP\Service\UserService; +use MintyPHP\Service\Auth\PasswordResetService; +use MintyPHP\Service\User\UserService; $errors = []; $password = (string) ($_POST['password'] ?? ''); diff --git a/pages/auth/verify().php b/pages/auth/verify().php index 85bb291..0c6ab0a 100644 --- a/pages/auth/verify().php +++ b/pages/auth/verify().php @@ -6,7 +6,7 @@ use MintyPHP\Session; use MintyPHP\Router; use MintyPHP\Http\Request; use MintyPHP\I18n; -use MintyPHP\Service\PasswordResetService; +use MintyPHP\Service\Auth\PasswordResetService; $errors = []; $email = trim((string) ($_POST['email'] ?? ($_SESSION['password_reset_email'] ?? ''))); diff --git a/pages/auth/verify-email().php b/pages/auth/verify-email().php index b5cd5fb..fbd863e 100644 --- a/pages/auth/verify-email().php +++ b/pages/auth/verify-email().php @@ -4,7 +4,7 @@ use MintyPHP\Buffer; use MintyPHP\Support\Flash; use MintyPHP\Session; use MintyPHP\Router; -use MintyPHP\Service\EmailVerificationService; +use MintyPHP\Service\Auth\EmailVerificationService; $errors = []; $email = trim((string) ($_POST['email'] ?? ($_SESSION['email_verification_email'] ?? ''))); diff --git a/pages/branding/logo(none).phtml b/pages/branding/logo(none).phtml index 10a684c..a5395b0 100644 --- a/pages/branding/logo(none).phtml +++ b/pages/branding/logo(none).phtml @@ -1,6 +1,8 @@ +
+ + t('Home'), 'path' => 'admin'], + ['label' => t('Keyboard shortcuts')], + ]; + require templatePath('partials/app-breadcrumb.phtml'); + ?> + +
+

+
+ +
+
+ t('Open search'), + 'mac' => 'Cmd + K', + 'win' => 'Ctrl + K', + ], + [ + 'label' => t('Toggle sidebar'), + 'mac' => 'Cmd + B', + 'win' => 'Ctrl + B', + ], + [ + 'label' => t('Switch sidebar section'), + 'mac' => 'Cmd + 1 … 0', + 'win' => 'Ctrl + 1 … 0', + ], + [ + 'label' => t('Back'), + 'mac' => 'Alt + Left', + 'win' => 'Alt + Left', + ], + [ + 'label' => t('Forward'), + 'mac' => 'Alt + Right', + 'win' => 'Alt + Right', + ], + ]; + ?> + + + + + + + + + + + + + + + + + +
+
+
+
+
diff --git a/pages/lang().php b/pages/lang().php index 1674f1b..2064a2f 100644 --- a/pages/lang().php +++ b/pages/lang().php @@ -4,7 +4,7 @@ use MintyPHP\Support\Flash; use MintyPHP\I18n; use MintyPHP\Http\Request; use MintyPHP\Router; -use MintyPHP\Service\UserService; +use MintyPHP\Service\User\UserService; $rawLocale = (string) ($_REQUEST['locale'] ?? ($_GET['lang'] ?? '')); $locale = strtolower(trim($rawLocale)); diff --git a/pages/page/copy-language().php b/pages/page/copy-language().php index ed128d0..d31b6e4 100644 --- a/pages/page/copy-language().php +++ b/pages/page/copy-language().php @@ -3,7 +3,7 @@ use MintyPHP\Router; use MintyPHP\Session; use MintyPHP\Support\Flash; -use MintyPHP\Service\PageService; +use MintyPHP\Service\Content\PageService; use MintyPHP\Http\Request; use MintyPHP\I18n; diff --git a/pages/page/index($slug).php b/pages/page/index($slug).php index c5d4d84..012833c 100644 --- a/pages/page/index($slug).php +++ b/pages/page/index($slug).php @@ -4,7 +4,7 @@ use MintyPHP\Buffer; use MintyPHP\Router; use MintyPHP\Session; use MintyPHP\Support\Flash; -use MintyPHP\Service\PageService; +use MintyPHP\Service\Content\PageService; use MintyPHP\Http\Request; use MintyPHP\I18n; diff --git a/pages/search/data().php b/pages/search/data().php index faf9aee..a7fc4e3 100644 --- a/pages/search/data().php +++ b/pages/search/data().php @@ -3,10 +3,10 @@ use MintyPHP\Router; use MintyPHP\Support\Guard; use MintyPHP\Support\SearchConfig; -use MintyPHP\Service\PermissionService; +use MintyPHP\Service\Access\PermissionService; use MintyPHP\DB; use MintyPHP\I18n; -use MintyPHP\Repository\UserTenantRepository; +use MintyPHP\Repository\Tenant\UserTenantRepository; Guard::requireLogin(); @@ -80,7 +80,8 @@ foreach ($resources as $resource) { } } -$queryLower = mb_strtolower($query); +$scoreQuery = SearchConfig::normalizeScoreQuery($query); +$queryLower = mb_strtolower($scoreQuery); $scoreItem = static function (array $item) use ($queryLower): int { $title = mb_strtolower((string) ($item['title'] ?? '')); $description = mb_strtolower((string) ($item['description'] ?? '')); diff --git a/pages/search/index(default).phtml b/pages/search/index(default).phtml index 97747a4..a6f5386 100644 --- a/pages/search/index(default).phtml +++ b/pages/search/index(default).phtml @@ -1,9 +1,13 @@ - + t('Home'), 'path' => 'admin'], + ['label' => t('Search')], +]; +require templatePath('partials/app-breadcrumb.phtml'); +?>

@@ -17,20 +21,12 @@ use MintyPHP\Buffer;
- + - <?php e($defaultTitle); ?> <?php if (Buffer::get('title')) - ; ?> + <?php e($pageTitle); ?> @@ -42,7 +31,9 @@ if (allowUserTheme() && in_array($userTheme, ['light', 'dark'], true)) { - + + + @@ -97,8 +88,7 @@ if (allowUserTheme() && in_array($userTheme, ['light', 'dark'], true)) {
- - + diff --git a/templates/login.phtml b/templates/login.phtml index 6a21932..b688454 100644 --- a/templates/login.phtml +++ b/templates/login.phtml @@ -6,6 +6,13 @@ $defaultTitle = appTitle(); $primaryVars = appPrimaryCssVars(); $theme = appDefaultTheme(); $user = $_SESSION['user'] ?? []; +$pageTitle = $defaultTitle; +ob_start(); +Buffer::get('title'); +$bufferTitle = trim((string) ob_get_clean()); +if ($bufferTitle !== '') { + $pageTitle = $bufferTitle . ' - ' . $defaultTitle; +} ?> @@ -16,7 +23,7 @@ $user = $_SESSION['user'] ?? []; - <?php e($defaultTitle); ?> <?php if (Buffer::get('title')); ?> + <?php e($pageTitle); ?> @@ -25,7 +32,9 @@ $user = $_SESSION['user'] ?? []; - + + + @@ -43,7 +52,7 @@ $user = $_SESSION['user'] ?? [];
- + diff --git a/templates/partials/app-aside-icon-bar.phtml b/templates/partials/app-aside-icon-bar.phtml index 9062c92..ddbd0ad 100644 --- a/templates/partials/app-aside-icon-bar.phtml +++ b/templates/partials/app-aside-icon-bar.phtml @@ -1,10 +1,6 @@
  • diff --git a/templates/partials/app-aside.phtml b/templates/partials/app-aside.phtml index 834c42f..06e8bc9 100644 --- a/templates/partials/app-aside.phtml +++ b/templates/partials/app-aside.phtml @@ -1,6 +1,6 @@ - + +
    -
    -
    - -
    -
    -
    @@ -229,17 +221,14 @@ $peopleGroups = $_SESSION['available_departments_by_tenant'] ?? []; && $activeAddressTenants[0] === $tenantUuid; ?>
  • -
    > - - + - - +
    • > - +
    • @@ -257,20 +246,18 @@ $peopleGroups = $_SESSION['available_departments_by_tenant'] ?? [];
    • > - + - +
    -

  • - @@ -367,13 +354,6 @@ $peopleGroups = $_SESSION['available_departments_by_tenant'] ?? []; -