add composer-unused, comprehensive docs, and project restructure

- Add icanhazstring/composer-unused as dev dependency for dependency hygiene checks
- Add German documentation (docs/) covering architecture, conventions, workflows, and developer checklists
- Add API layer (ApiAuth, ApiBootstrap, ApiResponse), audit, scheduler, custom fields, and SSO services
- Add Microsoft OIDC SSO, API token management, and user lifecycle features
- Add swagger-ui vendor integration and OpenAPI spec
- Add production Docker setup and bin/ scripts
- Update composer dependencies, config, templates, and frontend assets throughout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-22 15:27:35 +01:00
parent 3eb9cc0ac4
commit 25370a1a55
389 changed files with 40506 additions and 8071 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -1,5 +1,4 @@
APP_NAME=IMVS
APP_SLOGAN=Immobilien Makler Verkaufs Software
APP_URL=http://localhost:8080
APP_ENV=dev
APP_DEBUG=true
@@ -7,7 +6,10 @@ APP_TIMEZONE=Europe/Berlin
APP_STORAGE_PATH=./storage
APP_LOCALE=de
APP_LOCALES=de,en
APP_I18N_DOMAIN=default
APP_CRYPTO_KEY=
SESSION_NAME=IMVS
TENANT_SCOPE_STRICT=true
DB_HOST=db
DB_PORT=3306
@@ -17,3 +19,17 @@ DB_PASS=imvs
DB_ROOT_PASSWORD=imvs_root
CACHE_SERVERS=memcached:11211
FIREWALL_CONCURRENCY=10
FIREWALL_SPINLOCK_SECONDS=0.15
FIREWALL_INTERVAL_SECONDS=300
FIREWALL_CACHE_PREFIX=fw_concurrency_
FIREWALL_REVERSE_PROXY=false
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=no-reply@example.com
SMTP_PASS=
SMTP_FROM=no-reply@example.com
SMTP_FROM_NAME=IMVS
SMTP_SECURE=tls

35
.env.prod.example Normal file
View File

@@ -0,0 +1,35 @@
APP_NAME=IMVS
APP_URL=https://example.com
APP_ENV=prod
APP_DEBUG=false
APP_TIMEZONE=Europe/Berlin
APP_STORAGE_PATH=./storage
APP_LOCALE=de
APP_LOCALES=de,en
APP_I18N_DOMAIN=default
APP_CRYPTO_KEY=replace-with-64-char-hex-or-base64-key
SESSION_NAME=IMVS
TENANT_SCOPE_STRICT=true
DB_HOST=db
DB_PORT=3306
DB_NAME=imvs
DB_USER=imvs
DB_PASS=replace-db-password
DB_ROOT_PASSWORD=replace-db-root-password
CACHE_SERVERS=memcached:11211
FIREWALL_CONCURRENCY=10
FIREWALL_SPINLOCK_SECONDS=0.15
FIREWALL_INTERVAL_SECONDS=300
FIREWALL_CACHE_PREFIX=fw_concurrency_
FIREWALL_REVERSE_PROXY=false
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM=
SMTP_FROM_NAME=IMVS
SMTP_SECURE=tls

14
.gitignore vendored
View File

@@ -1,9 +1,21 @@
# IDE
.vscode/
.idea/
# macOS
.DS_Store
# Composer
composer.phar
/vendor/
# Secrets / local config
config/config.php
.env
/vendor/
# Runtime & generated
/web/debugger/
/data/
/storage/*
!/storage/.gitkeep
.phpunit.result.cache

247
README.md
View File

@@ -1,41 +1,234 @@
<h1><img alt="MintyPHP" height="50" src="web/img/minty_square.png"> MintyPHP</h1>
# IMVS (MintyPHP)
MintyPHP aims to be a full-stack PHP 7 (or 8) framework that is:
IMVS ist eine mandantenfaehige Admin-Anwendung auf Basis von MintyPHP.
Der Fokus liegt auf klarer Rollen-/Rechtestruktur, Tenant-Scope und einer konsistenten Admin-UI.
- Easy to learn
- Secure by design
- Light-weight
## Funktionsumfang
By design, it does:
- Mandantenverwaltung (inkl. Branding/Farbe/Favicon)
- Abteilungen pro Mandant
- Benutzerverwaltung mit Rollen, Sichtbarkeit und Stammdaten
- Rollen- und Berechtigungsverwaltung
- Adressbuch (tenant-scoped, read-only Profilansicht)
- Globale Suche (Admin + Address Book)
- Mail-Versand (SMTP/PHPMailer) inkl. Mail-Log
- Onboarding-PDF fuer Zugangsdaten (Single inline, Bulk als ZIP)
- Passwort-Reset und Remember-Login-Tokens
- … have one variable scope for all layers.
- … require you to write SQL queries (no ORM).
- … use PHP as a templating language.
## Tech Stack
Mainly to make it easy to learn for PHP developers.
- PHP 8.5 (Runtime im Docker-Container)
- MintyPHP Core
- MariaDB 11
- Memcached
- Nginx
- Grid.js (Tabellen)
- PHPMailer
- Dompdf (PDF Rendering)
- endroid/qr-code (QR-Code Rendering)
[Download](https://mintyphp.github.io/installation/) /
[Documentation](https://mintyphp.github.io/docs/)
## Schnellstart (Docker)
## External links
1. Umgebungsdatei anlegen
- [MintyPHP v3 is released](https://tqdev.com/2022-mintyphp-v3-is-released)
- [MintyPHP now on packagist!](https://tqdev.com/2018-mindaphp-now-on-packagist)
```bash
cp .env.example .env
```
## Quickstart (Docker)
2. Container starten
1. Copy `.env.example` to `.env` (defaults are fine for local dev).
2. Build and start the stack:
```bash
docker compose up --build -d
```
```bash
docker compose up --build
```
3. App oeffnen
3. Open the app at `http://localhost:8080`.
4. Register a user, then visit `/admin` (protected route).
5. phpMyAdmin is available at `http://localhost:8081`.
- App: [http://localhost:8080](http://localhost:8080)
- phpMyAdmin: [http://localhost:8081](http://localhost:8081)
### Notes
4. Container stoppen
- MintyPHP uses Memcached for its firewall cache; the compose stack includes a `memcached` service and the PHP container has the extension enabled.
- `config/config.php` is generated and gitignored by default; adjust `.gitignore` if you want to commit it.
```bash
docker compose down
```
## Produktion (Docker)
Es gibt eine getrennte Produktions-Compose:
- Dev: `/docker-compose.yml`
- Prod: `/docker-compose.prod.yml`
Grundschritte:
1. `.env` auf Produktionswerte setzen (siehe `/.env.prod.example`)
2. Domain und TLS in `/docker/nginx/prod.conf` anpassen
3. Zertifikate unter `/docker/nginx/certs/` bereitstellen
4. Starten:
```bash
docker compose -f docker-compose.prod.yml up --build -d
```
Details stehen in:
- `/docs/docker-lokal.md`
- `/docs/docker-produktiv.md`
- `/docs/docker-betrieb.md` (Übersicht)
## Seed-Daten
Die Initialisierung liegt in `/db/init/init.sql`.
Standard-Demo-User:
- E-Mail: `demo@user.com`
- Passwort: `Demo123`
- Tenant: `Cronus`
- Abteilung: `IT`
- Rolle: `Admin`
## Projektstruktur
- `/config` Konfiguration (Routen, Settings, Themes, Assets)
- `/db/init` Datenbankschema + Seeds
- `/lib` Backend-Code (`Repository`, `Service`, `Http`, `Support`)
- `/pages` Action-Layer + Views pro Route
- `/templates` Layouts + wiederverwendbare Partials
- `/web` oeffentliche Assets (CSS/JS/Vendor)
- `/i18n` Uebersetzungen
- `/tests` PHPUnit-Tests
- `/docs` Projekt-Dokumentation
## Architektur-Kurzregeln
- Keine DB-Zugriffe in `.phtml`-Views.
- SQL bleibt in Repositories.
- Geschaeftslogik bleibt in Services.
- `pages/.../*.php` fungiert als Action-/Controller-Schicht.
- Permissions + Tenant-Scope werden in Actions/Services erzwungen.
- Wiederkehrende UI-Bloecke als Partials zentralisieren.
## UI-Konventionen
Edit-Seiten verwenden einheitliche Tab-Struktur:
- erster Tab: `Master data`
- letzter Tab: `Visibility`
- optional letzter Tab: `Danger zone`
User-Organisation:
- Departments werden tenantweise angezeigt (ein Multi-Select je ausgewaehltem Tenant).
- Nach Tenant-Aenderungen im selben Formular werden Department-Optionen nach Speichern/Reload aktualisiert (kein Live-Update).
Zentrale Partials:
- `/templates/partials/app-details-titlebar.phtml`
- `/templates/partials/app-visibility-status-field.phtml`
- `/templates/partials/app-danger-zone-delete-field.phtml`
- `/templates/partials/app-details-aside-audit.phtml`
- `/templates/partials/app-details-aside-ids.phtml`
## Onboarding-PDF (User)
- Permission: `users.access_pdf`
- Single: `admin/users/access-pdf/{uuid}` (inline im neuen Tab)
- Bulk: `admin/users/access-pdf-bulk` (POST, ZIP mit einer PDF pro User)
- Hard limit: maximal 100 User pro Bulk-Request
- Benoetigte Extensions: `ext-zip`, `ext-dom`, `ext-mbstring`
- Bestehende Installationen: Schema-/Permission-Updates als idempotentes SQL-Update ausfuehren
## Microsoft SSO (Tenant, Entra ID)
- Zentraler Einstieg: `login` (optional mit Tenant-Hint `login?tenant={tenant-slug}`)
- OIDC-Endpunkte: `auth/microsoft/start` und `auth/microsoft/callback`
- Tenant-Config liegt in `tenant_auth_microsoft`
- Externe User-Linkung liegt in `user_external_identities`
- Optionaler Profil-Sync pro Tenant:
- `sync_profile_on_login` aktiviert Sync bei jedem Microsoft-Login
- `sync_profile_fields`: `first_name`, `last_name`, `phone`, `mobile`, `avatar`
- Default-Felder bei aktivem Sync ohne Auswahl: `first_name,last_name`
- `phone/mobile/avatar` nutzen Microsoft Graph (`User.Read`)
- Shared App Credentials werden in Settings gepflegt (optional Tenant-Overrides)
- Permission fuer Tenant-SSO-Konfiguration: `tenants.sso_manage`
- Bestandsinstallationen: Schema-/Permission-Updates als idempotentes SQL-Update ausfuehren
- Wichtig: `APP_CRYPTO_KEY` in `.env` setzen, sonst koennen SSO-Secrets nicht gespeichert/verwendet werden
## Asset- und Frontend-System
- CSS-Layer-Entrypoint: `/web/css/app-layers.css`
- Vendor-Overrides: `/web/css/vendor-overrides`
- Asset-Gruppen: `/config/assets.php`
- Template-Styles: `renderTemplateStyles('default'|'login')`
- Seiten-Styles per Action: `Buffer::set('style_groups', ...)`
- JS-Bootstrap:
- `/web/js/app-boot.js` (frueh, ohne Module)
- `/web/js/app-init.js` (Standardseiten)
- `/web/js/app-login-init.js` (Loginseiten)
## Qualitaetschecks
Alle Befehle aus dem Projekt-Root ausfuehren.
### PHPUnit
```bash
docker compose exec php vendor/bin/phpunit --bootstrap vendor/autoload.php
```
Gezielt:
```bash
docker compose exec php vendor/bin/phpunit --bootstrap vendor/autoload.php tests/I18n/TranslationKeysTest.php
docker compose exec php vendor/bin/phpunit --bootstrap vendor/autoload.php tests/Repository/RepoQueryTest.php
```
### PHPStan
```bash
docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress
```
### ESLint (Frontend-JS)
```bash
npx eslint web/js
```
## Dokumentation
- `/docs/index.md` Einstieg und Dokumentationsindex
- `/docs/architektur.md` Request-Flow und Layering
- `/docs/konventionen.md` Coding- und UI-Konventionen
- `/docs/sicherheitsmodell.md` Auth/Permission/Tenant-Scope
- `/docs/entwickler-checkliste.md` konkrete Checkliste fuer Aenderungen
- `/docs/benutzerdefinierte-felder.md` Quick Reference fuer Tenant/User-Zusatzfelder
- `/docs/api.md` API v1 Referenz (inkl. Self-Service Token-Endpunkte)
- `/docs/einstellungen-speicherung.md` Globale Settings: DB als Quelle, `config/settings.php` als Teil-Cache
- `/docs/frontend-javascript.md` Frontend-JS-Struktur
- `/docs/frontend-css.md` Frontend-CSS-Struktur
- `/docs/docker-lokal.md` Docker lokal (Entwicklung)
- `/docs/docker-produktiv.md` Docker produktiv (Serverbetrieb)
- `/docs/docker-betrieb.md` Dev/Prod Docker-Betrieb
## Troubleshooting
- Verhalten wirkt stale nach groesseren Refactors:
```bash
docker compose restart php nginx
```
- Uebersetzungs-Keys fehlen:
```bash
docker compose exec php vendor/bin/phpunit --bootstrap vendor/autoload.php tests/I18n/TranslationKeysTest.php
```
- Favicon/Logo aus Storage fehlt:
- Symlink und Tenant-Kontext pruefen (`web/favicon/tenants -> storage/tenants`).
## Lizenz
MIT (siehe `/LICENSE.md`)

39
bin/scheduler-run.php Executable file
View File

@@ -0,0 +1,39 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
chdir(__DIR__ . '/..');
require 'vendor/autoload.php';
require 'config/config.php';
require 'lib/Support/helpers.php';
use MintyPHP\DB;
use MintyPHP\Service\Scheduler\SchedulerRunService;
$exitCode = 0;
try {
$result = SchedulerRunService::runDueJobs();
if (!($result['ok'] ?? false)) {
$error = (string) ($result['error'] ?? 'unexpected_error');
fwrite(STDERR, sprintf("scheduler run failed: %s\n", $error));
$exitCode = 1;
} else {
$line = sprintf(
"scheduler completed: processed=%d success=%d failed=%d skipped=%d duration_ms=%d\n",
(int) ($result['processed'] ?? 0),
(int) ($result['success'] ?? 0),
(int) ($result['failed'] ?? 0),
(int) ($result['skipped'] ?? 0),
(int) ($result['duration_ms'] ?? 0)
);
fwrite(STDOUT, $line);
}
} catch (Throwable $exception) {
fwrite(STDERR, sprintf("scheduler run failed: unexpected_error: %s\n", $exception->getMessage()));
$exitCode = 1;
} finally {
DB::close();
}
exit($exitCode);

View File

@@ -12,13 +12,17 @@
"require": {
"php": "^8.5",
"mintyphp/core": "*",
"phpmailer/phpmailer": "^7.0"
"phpmailer/phpmailer": "^7.0",
"dompdf/dompdf": "^3.1",
"endroid/qr-code": "^6.0",
"league/commonmark": "^2.8"
},
"require-dev": {
"mintyphp/tools": "*",
"mintyphp/debugger": "*",
"phpunit/phpunit": "*",
"phpstan/phpstan": "^1.9"
"phpstan/phpstan": "^1.9",
"icanhazstring/composer-unused": "^0.9.6"
},
"autoload-dev": {
"psr-4": {

3262
composer.lock generated

File diff suppressed because it is too large Load Diff

69
config/assets.php Normal file
View File

@@ -0,0 +1,69 @@
<?php
return [
'styles' => [
'groups' => [
'base' => [
'css/app-layers.css',
'css/base/variables.base.css',
'css/base/variables.theme-dark-green.css',
'css/base/variables.contrast.css',
],
'shared' => [
'css/components/app-badges.css',
'css/layout/app-shell.css',
'css/components/app-blockquote.css',
'css/components/app-forms.css',
'css/components/app-flash.css',
'css/components/app-brand.css',
],
'default' => [
'css/vendor-overrides/multi-select.css',
'css/components/app-buttons.css',
'css/vendor-overrides/gridjs.css',
'css/layout/app-topbar.css',
'css/layout/app-sidebar.css',
'css/components/app-search.css',
'css/components/app-list-titlebar.css',
'css/components/app-details-titlebar.css',
'css/components/app-dashboard-titlebar.css',
'css/components/app-details.css',
'css/components/app-list-table.css',
'css/components/app-list-tabs.css',
'css/components/app-tabs.css',
'css/components/app-list-toolbar.css',
'css/components/app-breadcrumb.css',
'css/components/app-tile.css',
'css/components/app-tooltips.css',
'css/components/app-page-editor.css',
'css/vendor-overrides/editorjs.css',
'css/components/app-page-copy.css',
'css/layout/app-aside-icon-bar.css',
],
'login' => [
'css/pages/app-login.css',
],
'address-book' => [
'css/pages/address-book-view.css',
],
'error' => [
'css/pages/app-error.css',
],
'page-layout' => [
'css/layout/app-page-layout.css',
],
'api-docs' => [
'css/vendor-overrides/swagger-ui.css',
],
'docs' => [
'css/pages/app-docs.css',
],
],
'templates' => [
'default' => ['base', 'shared', 'default'],
'login' => ['base', 'shared', 'login'],
'error' => ['base', 'error'],
'page' => ['base', 'shared', 'page-layout'],
],
],
];

View File

@@ -9,52 +9,104 @@ use MintyPHP\I18n;
use MintyPHP\Router;
use MintyPHP\Session;
define('APP_NAME', getenv('APP_NAME') ?: 'IMVS');
define('APP_SLOGAN', getenv('APP_SLOGAN') ?: 'Immobilien Makler Verkaufs Software');
define('APP_TIMEZONE', getenv('APP_TIMEZONE') ?: 'Europe/Berlin');
define('APP_STORAGE_PATH', getenv('APP_STORAGE_PATH') ?: __DIR__ . '/../storage');
$tenantScopeEnv = getenv('TENANT_SCOPE_STRICT');
define('TENANT_SCOPE_STRICT', $tenantScopeEnv === false ? true : filter_var($tenantScopeEnv, FILTER_VALIDATE_BOOLEAN));
$envString = static function (string $key, string $default): string {
$value = getenv($key);
if ($value === false || $value === '') {
return $default;
}
return (string) $value;
};
$envInt = static function (string $key, int $default): int {
$value = getenv($key);
if ($value === false || $value === '') {
return $default;
}
$parsed = filter_var($value, FILTER_VALIDATE_INT);
return $parsed !== false ? (int) $parsed : $default;
};
$envFloat = static function (string $key, float $default): float {
$value = getenv($key);
if ($value === false || $value === '') {
return $default;
}
$parsed = filter_var($value, FILTER_VALIDATE_FLOAT);
return $parsed !== false ? (float) $parsed : $default;
};
$envBool = static function (string $key, bool $default): bool {
$value = getenv($key);
if ($value === false || $value === '') {
return $default;
}
$parsed = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
return $parsed ?? $default;
};
$envList = static function (string $key, array $default): array {
$value = getenv($key);
if ($value === false || $value === '') {
return $default;
}
return array_values(array_filter(array_map('trim', explode(',', (string) $value))));
};
define('APP_NAME', $envString('APP_NAME', 'IMVS'));
define('APP_TIMEZONE', $envString('APP_TIMEZONE', 'Europe/Berlin'));
define('APP_STORAGE_PATH', $envString('APP_STORAGE_PATH', __DIR__ . '/../storage'));
define('APP_CRYPTO_KEY', $envString('APP_CRYPTO_KEY', ''));
define('TENANT_SCOPE_STRICT', $envBool('TENANT_SCOPE_STRICT', true));
if (!defined('APP_LOCALES')) {
$locales = getenv('APP_LOCALES') ?: 'de,en';
define('APP_LOCALES', array_values(array_filter(array_map('trim', explode(',', $locales)))));
define('APP_LOCALES', $envList('APP_LOCALES', ['de', 'en']));
}
if (!defined('APP_ROUTE_DEFINITIONS')) {
$routesFile = __DIR__ . '/routes.php';
$routeDefinitions = is_file($routesFile) ? include $routesFile : [];
if (!is_array($routeDefinitions)) {
$routeDefinitions = [];
}
define('APP_ROUTE_DEFINITIONS', $routeDefinitions);
}
if (!defined('APP_PUBLIC_PATHS')) {
define('APP_PUBLIC_PATHS', [
'login',
'register',
'password/forgot',
'password/verify',
'password/reset',
'lang',
'imprint',
'privacy',
]);
$publicPaths = [];
foreach (APP_ROUTE_DEFINITIONS as $route) {
if (!empty($route['public'])) {
$path = trim((string) ($route['path'] ?? ''));
if ($path !== '') {
$publicPaths[] = $path;
}
}
}
if (!$publicPaths) {
throw new RuntimeException('No public routes configured in config/routes.php');
}
define('APP_PUBLIC_PATHS', array_values(array_unique($publicPaths)));
}
date_default_timezone_set(APP_TIMEZONE);
ini_set('date.timezone', APP_TIMEZONE);
Router::$baseUrl = '/'; // default: '/'
Router::$pageRoot = 'pages/'; // default: 'pages/'
Router::$templateRoot = 'templates/'; // default: 'templates/'
Session::$sessionName = getenv('SESSION_NAME') ?: 'MintyPHP';
Session::$sessionName = $envString('SESSION_NAME', 'MintyPHP');
Firewall::$concurrency = (int) (getenv('FIREWALL_CONCURRENCY') ?: 10);
Firewall::$spinLockSeconds = (float) (getenv('FIREWALL_SPINLOCK_SECONDS') ?: 0.15);
Firewall::$intervalSeconds = (int) (getenv('FIREWALL_INTERVAL_SECONDS') ?: 300);
Firewall::$cachePrefix = getenv('FIREWALL_CACHE_PREFIX') ?: 'fw_concurrency_';
Firewall::$reverseProxy = getenv('FIREWALL_REVERSE_PROXY') === 'true';
Firewall::$concurrency = $envInt('FIREWALL_CONCURRENCY', 10);
Firewall::$spinLockSeconds = $envFloat('FIREWALL_SPINLOCK_SECONDS', 0.15);
Firewall::$intervalSeconds = $envInt('FIREWALL_INTERVAL_SECONDS', 300);
Firewall::$cachePrefix = $envString('FIREWALL_CACHE_PREFIX', 'fw_concurrency_');
Firewall::$reverseProxy = $envBool('FIREWALL_REVERSE_PROXY', false);
Cache::$servers = getenv('CACHE_SERVERS') ?: '127.0.0.1';
Cache::$servers = $envString('CACHE_SERVERS', '127.0.0.1');
DB::$host = getenv('DB_HOST') ?: 'db';
DB::$username = getenv('DB_USER') ?: 'mintyphp';
DB::$password = getenv('DB_PASS') ?: 'mintyphp';
DB::$database = getenv('DB_NAME') ?: 'mintyphp';
DB::$port = (int) (getenv('DB_PORT') ?: 3306);
DB::$host = $envString('DB_HOST', 'db');
DB::$username = $envString('DB_USER', 'mintyphp');
DB::$password = $envString('DB_PASS', 'mintyphp');
DB::$database = $envString('DB_NAME', 'mintyphp');
DB::$port = $envInt('DB_PORT', 3306);
Auth::$usersTable = 'users';
Auth::$usernameField = 'email';
@@ -62,8 +114,7 @@ Auth::$passwordField = 'password';
Auth::$createdField = 'created';
Auth::$totpSecretField = 'totp_secret';
$debugEnv = getenv('APP_DEBUG');
Debugger::$enabled = $debugEnv === false ? true : filter_var($debugEnv, FILTER_VALIDATE_BOOLEAN);
Debugger::$enabled = $envBool('APP_DEBUG', true);
I18n::$domain = getenv('APP_I18N_DOMAIN') ?: 'default';
I18n::$locale = getenv('APP_LOCALE') ?: 'de';
I18n::$domain = $envString('APP_I18N_DOMAIN', 'default');
I18n::$locale = $envString('APP_LOCALE', 'de');

View File

@@ -2,15 +2,18 @@
use MintyPHP\Router;
// Set up redirects
$routesFile = __DIR__ . '/routes.php';
$routeDefinitions = defined('APP_ROUTE_DEFINITIONS') ? APP_ROUTE_DEFINITIONS : [];
if (!is_array($routeDefinitions) || !$routeDefinitions) {
$routeDefinitions = is_file($routesFile) ? include $routesFile : [];
}
Router::addRoute('login', 'auth/login');
Router::addRoute('register', 'auth/register');
Router::addRoute('logout', 'auth/logout');
Router::addRoute('profile', 'account/profile');
Router::addRoute('password/forgot', 'auth/forgot');
Router::addRoute('password/verify', 'auth/verify');
Router::addRoute('password/reset', 'auth/reset');
Router::addRoute('verify-email', 'auth/verify-email');
Router::addRoute('imprint', 'page/imprint');
Router::addRoute('privacy', 'page/privacy');
if (is_array($routeDefinitions)) {
foreach ($routeDefinitions as $route) {
$path = trim((string) ($route['path'] ?? ''));
$target = trim((string) ($route['target'] ?? ''));
if ($path !== '' && $target !== '') {
Router::addRoute($path, $target);
}
}
}

14
config/routes.php Normal file
View File

@@ -0,0 +1,14 @@
<?php
return [
['path' => 'login', 'target' => 'auth/login', 'public' => true],
['path' => 'register', 'target' => 'auth/register', 'public' => true],
['path' => 'logout', 'target' => 'auth/logout', 'public' => false],
['path' => 'profile', 'target' => 'account/profile', 'public' => false],
['path' => 'password/forgot', 'target' => 'auth/forgot', 'public' => true],
['path' => 'password/verify', 'target' => 'auth/verify', 'public' => true],
['path' => 'password/reset', 'target' => 'auth/reset', 'public' => true],
['path' => 'verify-email', 'target' => 'auth/verify-email', 'public' => true],
['path' => 'lang', 'target' => 'lang', 'public' => true],
['path' => 'imprint', 'target' => 'page/imprint', 'public' => true],
['path' => 'privacy', 'target' => 'page/privacy', 'public' => true],
];

View File

@@ -1,9 +1,13 @@
<?php
return array (
'app_title' => 'IVMS',
'app_locale' => 'de',
'app_theme' => 'dark',
'app_theme_user' => '1',
'app_registration' => '1',
'app_primary_color' => '#105433',
);
return [
'app_title' => 'Malphite',
'app_locale' => 'de',
'app_theme' => 'light',
'app_theme_user' => '1',
'app_registration' => '1',
'app_primary_color' => '#105433',
'api_token_default_ttl_days' => '90',
'api_token_max_ttl_days' => '365',
'api_cors_allowed_origins' => 'http://localhost:8080
http://127.0.0.1:8080',
];

View File

@@ -1,5 +1,4 @@
<?php
return [
'light' => 'Light',
'dark' => 'Dark',

View File

@@ -1,5 +1,6 @@
-- Consolidated schema (fresh install)
-- Includes all current tables, columns, indexes, and seed data.
-- Generated: 2026-02-19
-- Includes all current tables, columns, indexes and seed data.
CREATE TABLE IF NOT EXISTS `users` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -21,6 +22,7 @@ CREATE TABLE IF NOT EXISTS `users` (
`hire_date` DATE NULL,
`email_verified_at` DATETIME NULL DEFAULT NULL,
`last_login_at` DATETIME NULL DEFAULT NULL,
`last_login_provider` VARCHAR(20) NULL DEFAULT NULL,
`password` VARCHAR(255) NOT NULL,
`locale` VARCHAR(10) DEFAULT NULL,
`totp_secret` VARCHAR(255) DEFAULT NULL,
@@ -32,6 +34,7 @@ CREATE TABLE IF NOT EXISTS `users` (
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`modified` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
`active` TINYINT(1) NOT NULL DEFAULT 1,
`authz_version` INT UNSIGNED NOT NULL DEFAULT 1,
`active_changed_at` DATETIME NULL DEFAULT NULL,
`active_changed_by` INT UNSIGNED DEFAULT NULL,
PRIMARY KEY (`id`),
@@ -69,6 +72,8 @@ CREATE TABLE IF NOT EXISTS `tenants` (
`privacy_url` VARCHAR(255) NULL,
`imprint_url` VARCHAR(255) NULL,
`primary_color` VARCHAR(7) NULL,
`default_theme` VARCHAR(32) NULL,
`allow_user_theme` TINYINT(1) NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'active',
`status_changed_at` DATETIME NULL DEFAULT NULL,
`status_changed_by` INT UNSIGNED DEFAULT NULL,
@@ -123,6 +128,7 @@ CREATE TABLE IF NOT EXISTS `departments` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`uuid` CHAR(36) NOT NULL,
`description` VARCHAR(255) NOT NULL,
`tenant_id` INT UNSIGNED NOT NULL,
`code` VARCHAR(50) DEFAULT NULL,
`cost_center` VARCHAR(50) DEFAULT NULL,
`active` TINYINT(1) NOT NULL DEFAULT 1,
@@ -132,9 +138,11 @@ CREATE TABLE IF NOT EXISTS `departments` (
`modified` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_departments_uuid` (`uuid`),
KEY `idx_departments_tenant` (`tenant_id`),
KEY `idx_departments_active` (`active`),
KEY `idx_departments_created_by` (`created_by`),
KEY `idx_departments_modified_by` (`modified_by`),
CONSTRAINT `fk_departments_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT,
CONSTRAINT `fk_departments_created_by` FOREIGN KEY (`created_by`) REFERENCES `users` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_departments_modified_by` FOREIGN KEY (`modified_by`) REFERENCES `users` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -183,6 +191,42 @@ CREATE TABLE IF NOT EXISTS `settings` (
PRIMARY KEY (`key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `tenant_auth_microsoft` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`tenant_id` INT UNSIGNED NOT NULL,
`enabled` TINYINT(1) NOT NULL DEFAULT 0,
`enforce_microsoft_login` TINYINT(1) NOT NULL DEFAULT 0,
`sync_profile_on_login` TINYINT(1) NOT NULL DEFAULT 0,
`sync_profile_fields` TEXT NULL,
`entra_tenant_id` VARCHAR(64) NULL,
`allowed_domains` TEXT NULL,
`use_shared_app` TINYINT(1) NOT NULL DEFAULT 1,
`client_id_override` VARCHAR(191) NULL,
`client_secret_override_enc` TEXT NULL,
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`modified` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_tenant_auth_microsoft_tenant` (`tenant_id`),
CONSTRAINT `fk_tenant_auth_microsoft_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `user_external_identities` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` INT UNSIGNED NOT NULL,
`provider` VARCHAR(32) NOT NULL,
`oid` VARCHAR(128) NOT NULL,
`tid` VARCHAR(64) NOT NULL,
`issuer` VARCHAR(255) NOT NULL,
`subject` VARCHAR(255) NOT NULL,
`email_at_link_time` VARCHAR(255) NULL,
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_user_external_provider_tid_oid` (`provider`, `tid`, `oid`),
UNIQUE KEY `uniq_user_external_provider_iss_sub` (`provider`, `issuer`, `subject`),
KEY `idx_user_external_user` (`user_id`),
CONSTRAINT `fk_user_external_identity_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `user_tenants` (
`user_id` INT UNSIGNED NOT NULL,
`tenant_id` INT UNSIGNED NOT NULL,
@@ -226,17 +270,117 @@ CREATE TABLE IF NOT EXISTS `user_departments` (
CONSTRAINT `fk_user_departments_department` FOREIGN KEY (`department_id`) REFERENCES `departments` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `tenant_departments` (
CREATE TABLE IF NOT EXISTS `user_saved_filters` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`uuid` CHAR(36) NOT NULL,
`user_id` INT UNSIGNED NOT NULL,
`context` VARCHAR(64) NOT NULL,
`name` VARCHAR(120) NOT NULL,
`query_json` TEXT NOT NULL,
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`modified` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_user_saved_filters_uuid` (`uuid`),
KEY `idx_user_saved_filters_user_context` (`user_id`, `context`),
KEY `idx_user_saved_filters_user_context_created` (`user_id`, `context`, `created`),
CONSTRAINT `fk_user_saved_filters_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `tenant_custom_field_definitions` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`uuid` CHAR(36) NOT NULL,
`tenant_id` INT UNSIGNED NOT NULL,
`department_id` INT UNSIGNED NOT NULL,
`field_key` VARCHAR(120) NOT NULL,
`label` VARCHAR(160) NOT NULL,
`type` VARCHAR(32) NOT NULL,
`is_required` TINYINT(1) NOT NULL DEFAULT 0,
`is_filterable` TINYINT(1) NOT NULL DEFAULT 0,
`active` TINYINT(1) NOT NULL DEFAULT 1,
`sort_order` INT NOT NULL DEFAULT 100,
`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_tenant_custom_field_uuid` (`uuid`),
UNIQUE KEY `uniq_tenant_custom_field_key` (`tenant_id`, `field_key`),
KEY `idx_tenant_custom_field_tenant_active` (`tenant_id`, `active`, `sort_order`),
KEY `idx_tenant_custom_field_created_by` (`created_by`),
KEY `idx_tenant_custom_field_modified_by` (`modified_by`),
CONSTRAINT `fk_tenant_custom_field_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_tenant_custom_field_created_by` FOREIGN KEY (`created_by`) REFERENCES `users` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_tenant_custom_field_modified_by` FOREIGN KEY (`modified_by`) REFERENCES `users` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `tenant_custom_field_options` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`definition_id` INT UNSIGNED NOT NULL,
`option_key` VARCHAR(120) NOT NULL,
`label` VARCHAR(160) NOT NULL,
`active` TINYINT(1) NOT NULL DEFAULT 1,
`sort_order` INT NOT NULL DEFAULT 100,
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`modified` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_custom_field_option_key` (`definition_id`, `option_key`),
KEY `idx_custom_field_option_definition_active` (`definition_id`, `active`, `sort_order`),
CONSTRAINT `fk_custom_field_option_definition` FOREIGN KEY (`definition_id`) REFERENCES `tenant_custom_field_definitions` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `user_custom_field_values` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` INT UNSIGNED NOT NULL,
`definition_id` INT UNSIGNED NOT NULL,
`value_text` TEXT NULL,
`value_bool` TINYINT(1) NULL,
`value_date` DATE NULL,
`option_id` INT UNSIGNED NULL,
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`modified` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_user_custom_field_definition` (`user_id`, `definition_id`),
KEY `idx_ucfv_definition` (`definition_id`),
KEY `idx_ucfv_option` (`option_id`),
KEY `idx_ucfv_bool` (`definition_id`, `value_bool`),
KEY `idx_ucfv_date` (`definition_id`, `value_date`),
CONSTRAINT `fk_ucfv_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_ucfv_definition` FOREIGN KEY (`definition_id`) REFERENCES `tenant_custom_field_definitions` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_ucfv_option` FOREIGN KEY (`option_id`) REFERENCES `tenant_custom_field_options` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `user_custom_field_value_options` (
`value_id` INT UNSIGNED NOT NULL,
`option_id` INT UNSIGNED NOT NULL,
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`value_id`, `option_id`),
KEY `idx_ucfvo_option` (`option_id`),
CONSTRAINT `fk_ucfvo_value` FOREIGN KEY (`value_id`) REFERENCES `user_custom_field_values` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_ucfvo_option` FOREIGN KEY (`option_id`) REFERENCES `tenant_custom_field_options` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `user_api_tokens` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`uuid` CHAR(36) NOT NULL,
`user_id` INT UNSIGNED NOT NULL,
`name` VARCHAR(120) NOT NULL DEFAULT '',
`selector` CHAR(24) NOT NULL,
`token_hash` CHAR(64) NOT NULL,
`tenant_id` INT UNSIGNED NULL COMMENT 'Optional: scope token to specific tenant',
`last_used_at` DATETIME NULL DEFAULT NULL,
`last_ip` VARCHAR(45) NULL DEFAULT NULL,
`expires_at` DATETIME NULL DEFAULT NULL,
`revoked_at` DATETIME NULL DEFAULT NULL,
`created_by` INT UNSIGNED NULL,
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_tenant_department` (`tenant_id`, `department_id`),
KEY `idx_tenant_departments_tenant` (`tenant_id`),
KEY `idx_tenant_departments_department` (`department_id`),
CONSTRAINT `fk_tenant_departments_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_tenant_departments_department` FOREIGN KEY (`department_id`) REFERENCES `departments` (`id`) ON DELETE CASCADE
UNIQUE KEY `uniq_user_api_token_uuid` (`uuid`),
UNIQUE KEY `uniq_user_api_token_selector` (`selector`),
KEY `idx_user_api_token_user` (`user_id`),
KEY `idx_user_api_token_tenant` (`tenant_id`),
KEY `idx_user_api_token_expires` (`expires_at`),
CONSTRAINT `fk_user_api_token_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_user_api_token_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_user_api_token_created_by` FOREIGN KEY (`created_by`) REFERENCES `users` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `mail_log` (
@@ -254,6 +398,169 @@ CREATE TABLE IF NOT EXISTS `mail_log` (
KEY `idx_mail_log_to_email` (`to_email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `api_audit_log` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`request_id` CHAR(36) NOT NULL,
`method` VARCHAR(8) NOT NULL,
`path` VARCHAR(255) NOT NULL,
`query_json` TEXT NULL,
`status_code` SMALLINT UNSIGNED NOT NULL,
`duration_ms` INT UNSIGNED NULL,
`error_code` VARCHAR(100) NULL,
`user_id` INT UNSIGNED NULL,
`tenant_id` INT UNSIGNED NULL,
`api_token_id` INT UNSIGNED NULL,
`token_tenant_id` INT UNSIGNED NULL,
`ip` VARCHAR(45) NULL,
`user_agent` VARCHAR(255) NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_api_audit_request_id_created` (`request_id`, `created_at`),
KEY `idx_api_audit_created` (`created_at`),
KEY `idx_api_audit_status_created` (`status_code`, `created_at`),
KEY `idx_api_audit_user_created` (`user_id`, `created_at`),
KEY `idx_api_audit_tenant_created` (`tenant_id`, `created_at`),
KEY `idx_api_audit_path_created` (`path`, `created_at`),
KEY `idx_api_audit_error_created` (`error_code`, `created_at`),
KEY `idx_api_audit_token_created` (`api_token_id`, `created_at`),
CONSTRAINT `fk_api_audit_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_api_audit_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_api_audit_token` FOREIGN KEY (`api_token_id`) REFERENCES `user_api_tokens` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `import_audit_runs` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`run_uuid` CHAR(36) NOT NULL,
`profile_key` VARCHAR(32) NOT NULL,
`status` VARCHAR(16) NOT NULL,
`source_filename` VARCHAR(255) NULL,
`mapped_targets_csv` TEXT NULL,
`rows_total` INT UNSIGNED NOT NULL DEFAULT 0,
`created_count` INT UNSIGNED NOT NULL DEFAULT 0,
`skipped_count` INT UNSIGNED NOT NULL DEFAULT 0,
`failed_count` INT UNSIGNED NOT NULL DEFAULT 0,
`error_codes_json` TEXT NULL,
`started_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`finished_at` DATETIME NULL DEFAULT NULL,
`duration_ms` INT UNSIGNED NULL,
`user_id` INT UNSIGNED NULL,
`current_tenant_id` INT UNSIGNED NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_import_audit_run_uuid` (`run_uuid`),
KEY `idx_import_audit_started` (`started_at`),
KEY `idx_import_audit_status_started` (`status`, `started_at`),
KEY `idx_import_audit_profile_started` (`profile_key`, `started_at`),
KEY `idx_import_audit_user_started` (`user_id`, `started_at`),
CONSTRAINT `fk_import_audit_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_import_audit_tenant` FOREIGN KEY (`current_tenant_id`) REFERENCES `tenants` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `user_lifecycle_audit_log` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`run_uuid` CHAR(36) NOT NULL,
`action` VARCHAR(16) NOT NULL,
`trigger_type` VARCHAR(16) NOT NULL,
`status` VARCHAR(16) NOT NULL,
`reason_code` VARCHAR(64) NULL,
`policy_deactivate_days` INT UNSIGNED NOT NULL DEFAULT 0,
`policy_delete_days` INT UNSIGNED NOT NULL DEFAULT 0,
`actor_user_id` INT UNSIGNED NULL,
`target_user_id` INT UNSIGNED NULL,
`target_user_uuid` CHAR(36) NULL,
`target_user_email` VARCHAR(255) NULL,
`snapshot_enc` LONGTEXT NULL,
`snapshot_version` SMALLINT UNSIGNED NOT NULL DEFAULT 1,
`restored_at` DATETIME NULL DEFAULT NULL,
`restored_by_user_id` INT UNSIGNED NULL,
`restored_user_id` INT UNSIGNED NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_lifecycle_created` (`created_at`),
KEY `idx_user_lifecycle_action_created` (`action`, `created_at`),
KEY `idx_user_lifecycle_status_created` (`status`, `created_at`),
KEY `idx_user_lifecycle_target_uuid_created` (`target_user_uuid`, `created_at`),
KEY `idx_user_lifecycle_run_uuid` (`run_uuid`),
KEY `idx_user_lifecycle_restored` (`restored_at`),
CONSTRAINT `fk_user_lifecycle_actor` FOREIGN KEY (`actor_user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_user_lifecycle_target` FOREIGN KEY (`target_user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_user_lifecycle_restored_by` FOREIGN KEY (`restored_by_user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_user_lifecycle_restored_user` FOREIGN KEY (`restored_user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `scheduled_jobs` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`job_key` VARCHAR(64) NOT NULL,
`label` VARCHAR(120) NOT NULL,
`description` VARCHAR(255) NULL,
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
`timezone` VARCHAR(64) NOT NULL DEFAULT 'Europe/Berlin',
`schedule_type` VARCHAR(16) NOT NULL,
`schedule_interval` SMALLINT UNSIGNED NOT NULL DEFAULT 1,
`schedule_time` CHAR(5) NULL,
`schedule_weekdays_csv` VARCHAR(32) NULL,
`catchup_once` TINYINT(1) NOT NULL DEFAULT 1,
`next_run_at` DATETIME NULL DEFAULT NULL,
`last_run_started_at` DATETIME NULL DEFAULT NULL,
`last_run_finished_at` DATETIME NULL DEFAULT NULL,
`last_run_status` VARCHAR(16) NULL,
`last_error_code` VARCHAR(64) NULL,
`last_error_message` VARCHAR(255) NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_scheduled_jobs_job_key` (`job_key`),
KEY `idx_scheduled_jobs_enabled_next` (`enabled`, `next_run_at`),
KEY `idx_scheduled_jobs_status` (`last_run_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `scheduled_job_runs` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`run_uuid` CHAR(36) NOT NULL,
`job_id` BIGINT UNSIGNED NOT NULL,
`job_key` VARCHAR(64) NOT NULL,
`trigger_type` VARCHAR(16) NOT NULL,
`status` VARCHAR(16) NOT NULL,
`actor_user_id` INT UNSIGNED NULL,
`started_at` DATETIME NOT NULL,
`finished_at` DATETIME NULL DEFAULT NULL,
`duration_ms` INT UNSIGNED NULL,
`error_code` VARCHAR(64) NULL,
`error_message` VARCHAR(255) NULL,
`result_json` TEXT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_sched_runs_run_uuid` (`run_uuid`),
KEY `idx_sched_runs_job_created` (`job_id`, `created_at`),
KEY `idx_sched_runs_status_created` (`status`, `created_at`),
KEY `idx_sched_runs_trigger_created` (`trigger_type`, `created_at`),
CONSTRAINT `fk_sched_runs_job` FOREIGN KEY (`job_id`) REFERENCES `scheduled_jobs` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_sched_runs_actor_user` FOREIGN KEY (`actor_user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `scheduler_runtime_status` (
`id` TINYINT UNSIGNED NOT NULL,
`last_heartbeat_at` DATETIME NOT NULL,
`last_result` VARCHAR(32) NULL,
`last_error_code` VARCHAR(64) NULL,
`updated_at` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `request_rate_limits` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`scope` VARCHAR(64) NOT NULL,
`subject_hash` CHAR(64) NOT NULL,
`hits` INT UNSIGNED NOT NULL DEFAULT 0,
`window_started_at` DATETIME NOT NULL,
`blocked_until` DATETIME NULL DEFAULT NULL,
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`modified` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_request_rate_scope_subject` (`scope`, `subject_hash`),
KEY `idx_request_rate_scope_blocked` (`scope`, `blocked_until`),
KEY `idx_request_rate_modified` (`modified`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `password_resets` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` INT UNSIGNED NOT NULL,
@@ -302,9 +609,11 @@ 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`, `code`, `active`, `created`)
SELECT UUID(), 'IT', 'IT', 1, NOW()
WHERE NOT EXISTS (SELECT 1 FROM departments WHERE description = 'IT');
INSERT INTO `departments` (`uuid`, `description`, `tenant_id`, `code`, `active`, `created`)
SELECT UUID(), 'IT', t.id, 'IT', 1, NOW()
FROM tenants t
WHERE t.description = 'Cronus'
AND NOT EXISTS (SELECT 1 FROM departments WHERE description = 'IT');
INSERT INTO `roles` (`uuid`, `description`, `code`, `active`, `created`)
SELECT UUID(), 'Admin', 'ADMIN', 1, NOW()
@@ -363,15 +672,6 @@ WHERE u.email = 'demo@user.com'
SELECT 1 FROM user_departments ud WHERE ud.user_id = u.id AND ud.department_id = d.id
);
INSERT INTO `tenant_departments` (`tenant_id`, `department_id`, `created`)
SELECT t.id, d.id, NOW()
FROM tenants t
JOIN departments d ON d.description = 'IT'
WHERE t.description = 'Cronus'
AND NOT EXISTS (
SELECT 1 FROM tenant_departments td WHERE td.tenant_id = t.id AND td.department_id = d.id
);
INSERT INTO `user_roles` (`user_id`, `role_id`, `created`)
SELECT u.id, r.id, NOW()
FROM users u
@@ -399,6 +699,7 @@ VALUES
('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.access_pdf', 'Can generate onboarding access PDFs', 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),
@@ -406,11 +707,13 @@ VALUES
('tenants.view', 'Can view tenants', 1, 1),
('tenants.create', 'Can create tenants', 1, 1),
('tenants.update', 'Can update tenants', 1, 1),
('tenants.sso_manage', 'Can manage tenant Microsoft SSO settings', 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),
('departments.import', 'Can import departments', 1, 1),
('roles.view', 'Can view roles', 1, 1),
('roles.create', 'Can create roles', 1, 1),
('roles.update', 'Can update roles', 1, 1),
@@ -421,26 +724,90 @@ VALUES
('permissions.delete', 'Can delete permissions', 1, 1),
('settings.view', 'Can view settings', 1, 1),
('settings.update', 'Can update settings', 1, 1),
('imports.view', 'Can view imports', 1, 1),
('imports.audit.view', 'Can view import audit logs', 1, 1),
('jobs.view', 'Can view scheduled jobs', 1, 1),
('jobs.manage', 'Can manage scheduled jobs', 1, 1),
('jobs.run_now', 'Can run scheduled jobs manually', 1, 1),
('user_lifecycle_audit.view', 'Can view user lifecycle audit logs', 1, 1),
('users.import', 'Can import users', 1, 1),
('users.import_assignments', 'Can assign tenant, role and department during user import', 1, 1),
('users.lifecycle_restore', 'Can restore users from lifecycle audit', 1, 1),
('custom_fields.manage', 'Can manage tenant custom field definitions', 1, 1),
('custom_fields.edit_values', 'Can edit user custom field values', 1, 1),
('api_docs.view', 'Can view API documentation', 1, 1),
('docs.view', 'Can view developer documentation', 1, 1),
('mail_log.view', 'Can view mail logs', 1, 1),
('api_audit.view', 'Can view API audit 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 `scheduled_jobs` (
`job_key`,
`label`,
`description`,
`enabled`,
`timezone`,
`schedule_type`,
`schedule_interval`,
`schedule_time`,
`schedule_weekdays_csv`,
`catchup_once`,
`next_run_at`
)
VALUES (
'user_lifecycle_run',
'User lifecycle run',
'Runs automatic user deactivate/delete policy',
1,
'Europe/Berlin',
'daily',
1,
'02:15',
NULL,
1,
NULL
)
ON DUPLICATE KEY UPDATE
`label` = VALUES(`label`),
`description` = VALUES(`description`);
INSERT INTO `scheduler_runtime_status` (
`id`,
`last_heartbeat_at`,
`last_result`,
`last_error_code`
)
VALUES (
1,
NOW(),
'ok',
NULL
)
ON DUPLICATE KEY UPDATE
`id` = `id`;
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.view_meta', 'users.view_audit', 'users.create', 'users.update', 'users.delete',
'users.view', 'users.view_meta', 'users.view_audit', 'users.create', 'users.update', 'users.access_pdf', 'users.delete',
'users.self_update', 'users.update_assignments',
'address_book.view',
'tenants.view', 'tenants.create', 'tenants.update', 'tenants.delete',
'departments.view', 'departments.create', 'departments.update', 'departments.delete',
'tenants.view', 'tenants.create', 'tenants.update', 'tenants.sso_manage', 'tenants.delete',
'departments.view', 'departments.create', 'departments.update', 'departments.delete', 'departments.import',
'roles.view', 'roles.create', 'roles.update', 'roles.delete',
'permissions.view', 'permissions.create', 'permissions.update', 'permissions.delete',
'settings.view', 'settings.update',
'mail_log.view',
'imports.view', 'imports.audit.view', 'jobs.view', 'jobs.manage', 'jobs.run_now',
'user_lifecycle_audit.view', 'users.import', 'users.import_assignments',
'users.lifecycle_restore',
'custom_fields.manage', 'custom_fields.edit_values',
'api_docs.view', 'docs.view',
'mail_log.view', 'api_audit.view',
'stats.view'
)
WHERE r.description IN ('Admin', 'Administrator') OR r.id = 1

69
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,69 @@
services:
nginx:
image: nginx:1.25-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./:/var/www
- ./docker/nginx/prod.conf:/etc/nginx/conf.d/default.conf:ro
- ./docker/nginx/certs:/etc/nginx/certs:ro
depends_on:
- php
restart: unless-stopped
php:
build:
context: .
dockerfile: docker/php/Dockerfile
env_file:
- .env
environment:
APP_ENV: prod
APP_DEBUG: "0"
volumes:
- ./:/var/www
- ./docker/php/php.prod.ini:/usr/local/etc/php/conf.d/zzz-minty-dev.ini:ro
depends_on:
- db
- memcached
restart: unless-stopped
scheduler:
build:
context: .
dockerfile: docker/php/Dockerfile
env_file:
- .env
environment:
APP_ENV: prod
APP_DEBUG: "0"
volumes:
- ./:/var/www
- ./docker/php/php.prod.ini:/usr/local/etc/php/conf.d/zzz-minty-dev.ini:ro
depends_on:
- db
- memcached
command: sh -lc "while true; do php -d display_errors=0 bin/scheduler-run.php || true; sleep 60; done"
restart: unless-stopped
db:
image: mariadb:11.4
env_file:
- .env
environment:
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MARIADB_DATABASE: ${DB_NAME}
MARIADB_USER: ${DB_USER}
MARIADB_PASSWORD: ${DB_PASS}
volumes:
- db_data:/var/lib/mysql
- ./db/init:/docker-entrypoint-initdb.d:ro
restart: unless-stopped
memcached:
image: memcached:1.6-alpine
restart: unless-stopped
volumes:
db_data:

View File

@@ -21,6 +21,22 @@ services:
- db
- memcached
scheduler:
build:
context: .
dockerfile: docker/php/Dockerfile
env_file:
- .env
environment:
APP_DEBUG: "0"
volumes:
- ./:/var/www
depends_on:
- db
- memcached
command: sh -lc "while true; do php -d display_errors=0 bin/scheduler-run.php || true; sleep 60; done"
restart: unless-stopped
db:
image: mariadb:11.4
env_file:

BIN
docker/.DS_Store vendored

Binary file not shown.

View File

@@ -0,0 +1 @@

View File

@@ -2,6 +2,8 @@ server {
listen 80;
server_name _;
client_max_body_size 10M;
root /var/www/web;
index index.php;

51
docker/nginx/prod.conf Normal file
View File

@@ -0,0 +1,51 @@
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name example.com www.example.com;
client_max_body_size 10M;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
root /var/www/web;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param HTTPS on;
fastcgi_param HTTP_X_FORWARDED_PROTO https;
fastcgi_pass php:9000;
}
location ~* \.mjs$ {
default_type application/javascript;
try_files $uri =404;
expires 30d;
access_log off;
}
location ~* \.(css|js|png|jpg|jpeg|gif|svg|ico|webp)$ {
expires 30d;
access_log off;
}
}

View File

@@ -4,6 +4,7 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends \
git \
unzip \
libzip-dev \
libmemcached-dev \
zlib1g-dev \
libssl-dev \
@@ -13,7 +14,7 @@ RUN apt-get update \
&& pecl install memcached \
&& docker-php-ext-enable memcached \
&& docker-php-ext-configure gd --with-jpeg --with-webp \
&& docker-php-ext-install pdo_mysql mysqli gd \
&& docker-php-ext-install pdo_mysql mysqli gd zip \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

View File

@@ -2,3 +2,5 @@ display_errors=1
display_startup_errors=1
error_reporting=E_ALL
memory_limit=256M
upload_max_filesize=10M
post_max_size=12M

7
docker/php/php.prod.ini Normal file
View File

@@ -0,0 +1,7 @@
display_errors=0
display_startup_errors=0
error_reporting=E_ALL & ~E_DEPRECATED & ~E_STRICT
log_errors=1
memory_limit=256M
upload_max_filesize=10M
post_max_size=12M

View File

@@ -1,719 +0,0 @@
# IMVS - Architektur und Grundprinzipien
## Inhaltsverzeichnis
1. [Projektübersicht](#1-projektübersicht)
2. [Framework: MintyPHP](#2-framework-mintyphp)
3. [Verzeichnisstruktur](#3-verzeichnisstruktur)
4. [Datenbankarchitektur](#4-datenbankarchitektur)
5. [PHP-Architektur](#5-php-architektur)
6. [Frontend-Architektur](#6-frontend-architektur)
7. [Template-System](#7-template-system)
8. [Authentifizierung und Autorisierung](#8-authentifizierung-und-autorisierung)
9. [Internationalisierung](#9-internationalisierung)
10. [Best Practices](#10-best-practices)
---
## 1. Projektübersicht
**Projektname:** IMVS
**Framework:** MintyPHP v3+
**PHP-Version:** 8.5+
**Datenbank:** MySQL 5.7+
**Lizenz:** MIT
### Kernfeatures
- Multi-Tenant-Architektur
- Rollen- und Berechtigungssystem
- Mehrsprachigkeit (DE/EN)
- Dark/Light Theme
- E-Mail-Integration mit Templates
---
## 2. Framework: MintyPHP
### Philosophie
MintyPHP ist ein leichtgewichtiges PHP-Framework mit folgenden Prinzipien:
- **Easy to learn** - Einfach für PHP-Entwickler zu erlernen
- **Secure by design** - Sicherheit als Grundprinzip
- **Lightweight** - Minimaler Overhead
- **Single variable scope** - Eine Variable-Scope für alle Layer
- **SQL required** - Direktes SQL statt ORM
- **PHP as templating** - PHP selbst als Template-Sprache
### Kern-Komponenten
| Komponente | Beschreibung |
|------------|--------------|
| `Router` | URL-Routing zu Pages |
| `DB` | Datenbankzugriff mit Prepared Statements |
| `Auth` | Basis-Authentifizierung |
| `Session` | Session-Management |
| `Buffer` | Output-Buffering für Templates |
| `I18n` | Internationalisierung |
| `Cache` | Memcached-Integration |
| `Firewall` | DDoS-Schutz |
### Router-Konvention
```
URL: /admin/users/edit/abc-123
Datei: pages/admin/users/edit($id).php
Variable: $id = 'abc-123'
```
---
## 3. Verzeichnisstruktur
```
ImmobilienMaklerVerkaufssoftware/
├── config/ # Konfigurationsdateien
│ ├── config.php # Hauptkonfiguration
│ ├── router.php # Route-Definitionen
│ └── settings.php # App-Einstellungen (generiert)
├── db/ # Datenbank
│ └── init/init.sql # Schema und Seeds
├── docker/ # Docker-Konfiguration
├── docs/ # Dokumentation
├── i18n/ # Sprachdateien
│ ├── default_de.json
│ └── default_en.json
├── lib/ # Geschäftslogik
│ ├── Http/ # HTTP-Utilities
│ ├── Repository/ # Datenbankschicht
│ ├── Service/ # Business-Logic
│ └── Support/ # Helper und Guards
├── pages/ # Seiten (Controller)
│ ├── admin/ # Geschützte Admin-Seiten
│ ├── auth/ # Authentifizierung
│ ├── error/ # Fehlerseiten
│ └── page/ # CMS-Seiten
├── storage/ # Dateiablage
│ ├── branding/ # Logos, Favicons
│ ├── tenants/ # Tenant-Dateien
│ └── users/ # User-Avatare
├── templates/ # View-Templates
│ ├── partials/ # Wiederverwendbare Teile
│ └── emails/ # E-Mail-Templates
├── tests/ # PHPUnit Tests
├── vendor/ # Composer Dependencies
└── web/ # Public Root
├── css/ # Stylesheets
├── js/ # JavaScript-Module
├── vendor/ # Frontend-Libraries
└── index.php # Entry Point
```
---
## 4. Datenbankarchitektur
### Entity-Relationship-Diagramm
```
┌─────────────┐ ┌─────────────────┐ ┌─────────────┐
│ USERS │────<│ USER_TENANTS │>────│ TENANTS │
└─────────────┘ └─────────────────┘ └─────────────┘
│ │
│ ┌─────────────────┐ │
└────────────<│ USER_ROLES │ │
│ └─────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ ROLES │ │
│ └─────────────┘ │
│ │ │
│ ┌─────────────────┐ │
│ │ROLE_PERMISSIONS │ │
│ └─────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ PERMISSIONS │ │
│ └─────────────┘ │
│ │
│ ┌─────────────────┐ ┌──────┴──────┐
└────────────<│USER_DEPARTMENTS │>────│ DEPARTMENTS │
└─────────────────┘ └─────────────┘
┌──────┴──────┐
│TENANT_DEPTS │
└─────────────┘
```
### Haupttabellen
#### users
```sql
id INT PRIMARY KEY AUTO_INCREMENT
uuid VARCHAR(36) UNIQUE
first_name VARCHAR(100)
last_name VARCHAR(100)
email VARCHAR(255) UNIQUE
password VARCHAR(255) -- bcrypt hash
locale VARCHAR(10) -- 'de', 'en'
theme VARCHAR(20) -- 'light', 'dark'
active TINYINT DEFAULT 1
primary_tenant_id INT FK -> tenants.id
current_tenant_id INT FK -> tenants.id
created_by INT FK -> users.id
modified_by INT FK -> users.id
created DATETIME
modified DATETIME
```
#### tenants
```sql
id INT PRIMARY KEY AUTO_INCREMENT
uuid VARCHAR(36) UNIQUE
description VARCHAR(255) -- Name/Firma
address VARCHAR(255)
postal_code VARCHAR(20)
city VARCHAR(100)
country VARCHAR(100)
vat_id VARCHAR(50) -- MwSt-ID
tax_number VARCHAR(50)
phone VARCHAR(50)
email VARCHAR(255)
website VARCHAR(255)
created_by INT FK -> users.id
modified_by INT FK -> users.id
created DATETIME
modified DATETIME
```
#### roles
```sql
id INT PRIMARY KEY AUTO_INCREMENT
uuid VARCHAR(36) UNIQUE
description VARCHAR(255) -- Rollenname
created_by INT FK -> users.id
modified_by INT FK -> users.id
created DATETIME
modified DATETIME
```
#### permissions
```sql
id INT PRIMARY KEY AUTO_INCREMENT
key VARCHAR(100) UNIQUE -- z.B. 'users.view'
description VARCHAR(255)
created DATETIME
```
#### departments
```sql
id INT PRIMARY KEY AUTO_INCREMENT
uuid VARCHAR(36) UNIQUE
description VARCHAR(255) -- Abteilungsname
created_by INT FK -> users.id
modified_by INT FK -> users.id
created DATETIME
modified DATETIME
```
### Verknüpfungstabellen
| Tabelle | Beschreibung |
|---------|--------------|
| `user_tenants` | User ↔ Tenant (M:N) |
| `user_roles` | User ↔ Role (M:N) |
| `user_departments` | User ↔ Department (M:N) |
| `role_permissions` | Role ↔ Permission (M:N) |
| `tenant_departments` | Tenant ↔ Department (M:N) |
### Vordefinierte Permissions
```
users.view, users.create, users.update, users.delete
users.self_update, users.update_assignments
tenants.view, tenants.create, tenants.update, tenants.delete
departments.view, departments.create, departments.update, departments.delete
roles.view, roles.create, roles.update, roles.delete
permissions.view, permissions.create, permissions.update, permissions.delete
settings.view, settings.update
mail_log.view
stats.view
```
---
## 5. PHP-Architektur
### Schichten-Architektur
```
┌─────────────────────────────────────────────┐
│ Pages │
│ (Controller/Actions) │
├─────────────────────────────────────────────┤
│ Services │
│ (Business Logic, Validation) │
├─────────────────────────────────────────────┤
│ Repositories │
│ (Data Access, SQL) │
├─────────────────────────────────────────────┤
│ DB │
│ (MintyPHP Database) │
└─────────────────────────────────────────────┘
```
### Service-Layer (`lib/Service/`)
Services implementieren die Geschäftslogik:
```php
namespace MintyPHP\Service;
class UserService
{
// Listen
public static function list(): array
public static function listPaged(array $options): array
// Finder
public static function findByUuid(string $uuid): ?array
public static function findById(int $id): ?array
public static function findByEmail(string $email): ?array
// CRUD
public static function createFromAdmin(array $input, int $currentUserId): array
public static function updateFromAdmin(int $userId, array $input, int $currentUserId): array
public static function deleteByUuid(string $uuid, int $currentUserId): array
// Bulk-Operationen
public static function deleteByUuids(array $uuids, int $currentUserId): array
public static function setActiveByUuids(array $uuids, bool $active, int $currentUserId): array
}
```
**Wichtige Services:**
| Service | Verantwortung |
|---------|---------------|
| `AuthService` | Login, Logout, Session-Management |
| `UserService` | Benutzerverwaltung |
| `TenantService` | Mandantenverwaltung |
| `RoleService` | Rollenverwaltung |
| `PermissionService` | Berechtigungsprüfung und -verwaltung |
| `DepartmentService` | Abteilungsverwaltung |
| `MailService` | E-Mail-Versand |
| `SettingService` | App-Einstellungen |
### Repository-Layer (`lib/Repository/`)
Repositories kapseln Datenbankzugriffe:
```php
namespace MintyPHP\Repository;
class UserRepository
{
public static function list(): array
{
return DB::select('SELECT * FROM users ORDER BY last_name, first_name');
}
public static function find(int $id): ?array
{
return DB::selectOne('SELECT * FROM users WHERE id = ?', [$id]);
}
public static function create(array $data): int
{
return DB::insert('users', $data);
}
public static function update(int $id, array $data): bool
{
return DB::update('users', $data, ['id' => $id]) > 0;
}
}
```
### Support-Layer (`lib/Support/`)
#### Guard - Zugriffskontrolle
```php
namespace MintyPHP\Support;
class Guard
{
// Nur für angemeldete Benutzer
public static function requireLogin(string $redirect = 'login'): void
// Spezifische Berechtigung erforderlich
public static function requirePermission(string $permissionKey, string $redirect = 'admin'): void
// Berechtigung oder 403 (für AJAX)
public static function requirePermissionOrForbidden(string $permissionKey): void
}
```
#### Flash - Session-Nachrichten
```php
namespace MintyPHP\Support;
class Flash
{
public static function success(string $message, ?string $scope = null, ?string $key = null): string
public static function error(string $message, ?string $scope = null, ?string $key = null): string
public static function info(string $message, ?string $scope = null, ?string $key = null): string
public static function peek(?string $scope = null): array
public static function has(): bool
}
```
### Helper-Funktionen (`lib/Support/helpers/`)
```php
// HTML-Escape
e($string);
// Asset-URL
asset('css/style.css');
// Lokalisierte URL
lurl('admin/users');
// Übersetzung
t('Hello');
t('Welcome %s', $name);
// App-Einstellung
appSetting('app_title');
// Berechtigung prüfen
can('users.view');
```
---
## 6. Frontend-Architektur
### CSS-Struktur (`web/css/`)
```
web/css/
├── base/
│ ├── variables.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
│ ├── app-sidebar.css # Navigation
│ └── app-aside-icon-bar.css
├── components/
│ ├── app-buttons.css
│ ├── app-forms.css
│ ├── app-list-table.css
│ ├── app-badges.css
│ ├── app-tabs.css
│ ├── app-flash.css
│ └── vendor-*.css # Third-Party
└── pages/
└── app-login.css
```
### JavaScript-Module (`web/js/`)
ES-Module-Architektur:
```
web/js/
├── app-init.js # Haupt-Initialisierung
├── core/
│ └── app-grid-factory.js # Grid/Tabellen-Generator
├── components/
│ ├── app-tenant-switcher.js
│ ├── app-theme-toggle.js
│ ├── app-bulk-selection.js
│ ├── app-flash-auto-dismiss.js
│ └── ...
└── pages/
├── app-users-list.js
└── app-list-utils.js
```
### Modul-Pattern
```javascript
// web/js/components/app-tenant-switcher.js
const switchTenant = async (link) => {
const tenantId = link.dataset.switchTenant;
// ...
};
const initTenantSwitcher = () => {
const tenantLinks = document.querySelectorAll('[data-switch-tenant]');
tenantLinks.forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
switchTenant(link);
});
});
};
// Auto-Init bei DOM Ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initTenantSwitcher);
} else {
initTenantSwitcher();
}
```
### Import in app-init.js
```javascript
// web/js/app-init.js
import './components/app-tenant-switcher.js';
import './components/app-theme-toggle.js';
import './components/app-flash-auto-dismiss.js';
// ...
```
---
## 7. Template-System
### Struktur
```
templates/
├── default.phtml # Haupt-Layout
├── login.phtml # Login-Layout
├── error.phtml # Fehler-Layout
├── partials/
│ ├── app-topbar.phtml
│ ├── app-sidebar.phtml
│ ├── app-aside.phtml
│ ├── app-flash.phtml
│ └── app-footer.phtml
└── emails/
├── de/
│ ├── access_info.html
│ └── reset_code.html
└── en/
└── ...
```
### Template-Rendering
```php
<!-- templates/default.phtml -->
<!DOCTYPE html>
<html lang="<?php e(I18n::$locale); ?>">
<head>
<title><?php e(Buffer::get('title') ?? appTitle()); ?></title>
<link rel="stylesheet" href="<?php e(asset('css/app.css')); ?>">
</head>
<body>
<?php require 'partials/app-topbar.phtml'; ?>
<main>
<?php require 'partials/app-flash.phtml'; ?>
<?php echo Buffer::get('html'); ?>
</main>
<script type="module" src="<?php e(asset('js/app-init.js')); ?>"></script>
</body>
</html>
```
### Page mit View
```php
// pages/admin/users/index().php
use MintyPHP\Buffer;
use MintyPHP\Support\Guard;
Guard::requirePermissionOrForbidden('users.view');
$users = UserService::list();
Buffer::set('title', t('Users'));
// View wird automatisch geladen: pages/admin/users/index(default).phtml
```
---
## 8. Authentifizierung und Autorisierung
### Login-Ablauf
```
1. POST /login
├─> AuthService::login(email, password)
│ ├─> Auth::login() - Passwort-Verifikation
│ ├─> $_SESSION['user'] setzen
│ ├─> PermissionService::getUserPermissions()
│ │ └─> $_SESSION['permissions'] setzen
│ └─> AuthService::loadTenantDataIntoSession()
│ ├─> $_SESSION['available_tenants']
│ └─> $_SESSION['current_tenant']
└─> Router::redirect('admin')
```
### Berechtigungsprüfung
```php
// In Page
Guard::requirePermissionOrForbidden(PermissionService::USERS_VIEW);
// Im Template
<?php if (can('users.create')): ?>
<a href="admin/users/create">Neuer Benutzer</a>
<?php endif; ?>
```
### Session-Struktur
```php
$_SESSION = [
'user' => [
'id' => 1,
'uuid' => 'abc-123',
'email' => 'user@example.com',
'first_name' => 'Max',
'last_name' => 'Mustermann',
'locale' => 'de',
'theme' => 'dark',
],
'permissions' => [
'user_id' => 1,
'keys' => ['users.view', 'users.create', ...],
],
'available_tenants' => [
['id' => 1, 'uuid' => '...', 'description' => 'Tenant A'],
['id' => 2, 'uuid' => '...', 'description' => 'Tenant B'],
],
'current_tenant' => [
'id' => 1,
'uuid' => '...',
'description' => 'Tenant A',
],
];
```
---
## 9. Internationalisierung
### Sprachdateien
```json
// i18n/default_de.json
{
"Login": "Anmelden",
"Users": "Benutzer",
"At least %d characters": "Mindestens %d Zeichen"
}
// i18n/default_en.json
{
"Login": "Login",
"Users": "Users",
"At least %d characters": "At least %d characters"
}
```
### Verwendung
```php
// Einfache Übersetzung
t('Login')
// Mit Parametern
t('At least %d characters', 12)
// Im Template
<?php e(t('Users')); ?>
```
### Sprachauflösung
Priorität:
1. URL-Präfix (`/de/admin`, `/en/admin`)
2. Session (`$_SESSION['user']['locale']`)
3. Cookie (`locale=de`)
4. Default (`APP_LOCALE`)
---
## 10. Best Practices
### Service-Rückgabewerte
```php
// Erfolg
return [
'ok' => true,
'uuid' => $uuid,
'id' => $id,
];
// Fehler
return [
'ok' => false,
'error' => 'validation_error',
'errors' => ['email' => 'Email ist erforderlich'],
'form' => $sanitizedInput,
];
```
### Datenbankzugriff
```php
// RICHTIG - Prepared Statements
$user = DB::selectOne('SELECT * FROM users WHERE email = ?', [$email]);
// FALSCH - SQL-Injection
$user = DB::selectOne("SELECT * FROM users WHERE email = '$email'");
```
### Template-Ausgabe
```php
// RICHTIG - Escape
<?php e($user['email']); ?>
// FALSCH - XSS-Risiko
<?php echo $user['email']; ?>
```
### Passwort-Anforderungen
- Mindestens 12 Zeichen
- Mindestens 1 Großbuchstabe
- Mindestens 1 Kleinbuchstabe
- Mindestens 1 Ziffer
- Mindestens 1 Sonderzeichen
### Code-Organisation
1. **Services** - Geschäftslogik und Validierung
2. **Repositories** - Nur Datenbankzugriff, keine Logik
3. **Guards** - Zugriffskontrolle
4. **Helpers** - Wiederverwendbare Funktionen
5. **Pages** - Koordination, keine Logik
---
## Weiterführende Dokumentation
- [MintyPHP Framework](https://github.com/mevdschee/php-crud-api)
- [Docker Setup](../docker-compose.yml)
- [Database Schema](../db/init/init.sql)

View File

@@ -1,25 +0,0 @@
# Search + Upload TODO (Internal)
Goal: Unified search across DB data and file contents (PDF, DOCX), starting with MariaDB FULLTEXT and private file storage.
## MVP Steps
1) Create `documents` table (uuid, original_name, mime, size, storage_path, content_text, created).
2) Add FULLTEXT index on `content_text` (and later title/metadata fields if needed).
3) Build `FileUploadService`:
- Validate size/type (PDF/DOCX).
- Store outside `/web` (e.g. `storage/uploads/{uuid}/file.ext`).
- Extract text:
- PDF: `pdftotext` (poppler-utils).
- DOCX: unzip + parse `word/document.xml` (fallback: LibreOffice headless).
- Save `content_text` + metadata.
4) Add upload action + form (admin area).
5) Add search endpoint:
- Search DB entities.
- Search `documents.content_text`.
- Merge results into one list.
## Future Enhancements
- Async extraction (queue/worker) for large files.
- Dedicated search engine (Meilisearch/Typesense) when scale/relevance needs grow.
- Result ranking with field weighting (title > body).
- Optional language detection / stemming.

119
docs/anfragelimits.md Normal file
View File

@@ -0,0 +1,119 @@
# Anfragelimits (Rate Limiting)
Letzte Aktualisierung: 2026-02-21
Dieses Dokument beschreibt das aktuelle zentrale Rate-Limiting für Login und API.
## Ziel
- Brute-Force auf Login reduzieren.
- API-Missbrauch (Burst/Spam) abfangen.
- Einheitliches Verhalten mit `429` + `Retry-After`.
## Technischer Aufbau
### Storage
Tabelle: `request_rate_limits`
Spalten:
- `scope`: Technischer Bereich (z. B. `login.password.email_ip`)
- `subject_hash`: SHA-256 Hash des Subjects (keine Klartexte)
- `hits`: Treffer im aktuellen Fenster
- `window_started_at`: Beginn des aktuellen Zeitfensters (UTC)
- `blocked_until`: Block-Ende (UTC), `NULL` wenn nicht geblockt
Schema:
- Tabelle wird in `db/init/init.sql` mit angelegt.
- Für Bestandsumgebungen sollte ein idempotentes SQL-Update bereitgestellt werden.
### Service/Repository
- Service: `/lib/Service/Security/RateLimiterService.php`
- Repository: `/lib/Repository/Security/RateLimitRepository.php`
Der Service ist absichtlich **fail-open**:
- Wenn DB/Storage fehlschlägt, wird kein harter Lock erzeugt.
- Requests laufen weiter, um Verfügbarkeit nicht zu brechen.
## API Rate Limiting
Integration:
- `/lib/Http/ApiBootstrap.php` (vor Auth)
Aktuelle Limits:
1. Bearer vorhanden (`token + ip`)
- Scope: `api.token_ip`
- Key: `<selector>|<ip>`
- Limit: `300` Hits pro `60` Sekunden
- Block: `120` Sekunden
2. Kein Bearer (`ip-only`)
- Scope: `api.ip`
- Key: `<ip>`
- Limit: `120` Hits pro `60` Sekunden
- Block: `120` Sekunden
Bei überschreitung:
- Response: `429`
- Body: `{"error":"rate_limit_exceeded"}`
- Header: `Retry-After: <sekunden>`
## Login Rate Limiting
Integration:
- `/pages/auth/login().php`
Aktuelle Limits:
1. Schritt `resolve_email` (IP)
- Scope: `login.resolve.ip`
- Key: `<ip>`
- Limit: `25` Hits pro `600` Sekunden
- Block: `300` Sekunden
2. Schritt `login_password` (email + ip)
- Scope: `login.password.email_ip`
- Key: `<lowercase-email>|<ip>`
- Limit: `5` Fehlversuche pro `900` Sekunden
- Block: `900` Sekunden
Verhalten:
- Bei Block: HTTP `429` + `Retry-After`.
- UI zeigt generische Meldung: `Too many login attempts. Please wait and try again.`
- Bei erfolgreichem Passwort-Login wird der Passwort-Scope für diesen Key zurückgesetzt.
## Datenfluss pro Request
1. Key bilden (`scope` + Subject)
2. Subject hashen (`sha256`)
3. Bestehenden Datensatz lesen
4. Falls `blocked_until > now`: direkt blocken
5. Fenster prüfen (`window_started_at + windowSeconds`)
6. Hits erhöhen
7. Bei `hits > max`: `blocked_until = now + blockSeconds`
8. Ergebnis liefern (`allowed`, `retry_after`)
## Operative Hinweise
- Zeitbasis ist UTC (`gmdate`).
- Tabelle kann wachsen; in V1 gibt es noch keinen automatischen Cleanup für alte Rate-Limit-Zeilen.
- Bei Bedarf kann ein Wartungsjob alte/obsolete Zeilen periodisch löschen.
## Smoke-Test (manuell)
API:
1. Schnell >120 Requests/min ohne Bearer gegen `/api/v1/*`
2. Erwartung: `429` mit `Retry-After`
Login:
1. Mehrere falsche Passwortversuche für dieselbe Email+IP
2. Erwartung nach 5 Fehlversuchen: `429` und Sperre für 15 Minuten
## Erweiterungen (nächster Schritt)
- Limits in Settings pflegbar machen (statt Hardcode).
- Optional stricter Mode in Production (fail-closed nur für API).
- Cleanup-Job für `request_rate_limits`.
- Optional Logging/Stats für geblockte Requests (z. B. im Admin-Stats Modul).

42
docs/api.md Normal file
View File

@@ -0,0 +1,42 @@
# REST-API (v1)
Letzte Aktualisierung: 2026-02-21
## Single Source of Truth
Die API-Spezifikation wird zentral gepflegt in:
- `/docs/openapi.yaml`
Diese Datei ist die verbindliche technische Referenz für:
- Endpunkte
- Request-/Response-Schemas
- Statuscodes
- Auth-Header
## API-Doku im Admin
- UI: `/admin/api-docs`
- Zugriff: Permission `api_docs.view`
- Quelle der UI: `/docs/openapi.yaml`
- Swagger UI ist read-only (kein `Try it out`).
## Warum diese Seite kurz ist
Diese Seite bleibt bewusst knapp, damit keine doppelte Pflege entsteht.
Alle fachlichen und technischen API-Details stehen nur in `/docs/openapi.yaml`.
## Änderungsprozess (verbindlich)
1. API-Änderung immer zuerst in `/docs/openapi.yaml` pflegen.
2. `/admin/api-docs` öffnen und Rendering kurz prüfen.
3. API-Request per Client testen (z. B. Yaak/Postman/cURL).
4. Nur falls nötig hier in `api.md` organisatorische Hinweise ergänzen (keine Endpoint-Details).
## Verwandte Dokumente
- Rate Limiting: `/docs/anfragelimits.md`
- Sicherheit/Permissions: `/docs/sicherheitsmodell.md`
- Troubleshooting: `/docs/fehlerbehebung.md`

190
docs/architektur.md Normal file
View File

@@ -0,0 +1,190 @@
# Architektur
Letzte Aktualisierung: 2026-02-21
## 1) Zielbild
Die Anwendung ist eine servergerenderte Multi-Tenant-Admin-Anwendung mit klarer Trennung von:
- HTTP/Request-Flow (`/web/index.php`, `lib/Http/*`)
- Action-Layer (`/pages/**`)
- Business-Logik (`/lib/Service/**`)
- Datenzugriff (`/lib/Repository/**`)
- Rendering (`/templates/**`, `*.phtml`)
- Frontend-Verhalten (`/web/js/**`)
## 2) Request-Lebenszyklus
Zentraler Entry Point ist `/web/index.php`.
Ablauf pro Request:
1. Bootstrap (`vendor/autoload.php`, `config/config.php`, `config/router.php`, Helper)
2. Firewall + Session Start
3. Locale-Ermittlung (URL/Session/Cookie/default)
4. Remember-Me Auto-Login
5. Optionales Tenant-Snapshot-Refresh in Session
6. Redirect auf locale-prefixed URL (falls nötig)
7. Access Guard (`AccessControl`) für Public/Protected Paths
8. Action-Layer aus `pages/**` laden
9. View + Template rendern
10. Session beenden, DB schließen
## 3) Routing-Modell
- Route-Definitionen liegen in `/config/routes.php`.
- Registrierung erfolgt in `/config/router.php`.
- `public=true` in `routes.php` steuert, welche Pfade ohne Login erlaubt sind.
MintyPHP-Konvention (Dateinamensrouting):
- `pages/admin/users/edit($id).php` erwartet URL-Parameter `id`
- `pages/admin/users/data().php` für Datenendpunkte
- `...(none).phtml` für responses ohne Template-Layout
- Binarausgaben (z. B. Onboarding-PDF/ZIP) setzen `MINTY_ALLOW_OUTPUT` im Action-Endpoint.
## 4) Schichten und Verantwortungen
### Action-Layer (`/pages`)
- Eingaben lesen/validieren
- Permissions und Tenant-Scope prüfen
- Services aufrufen
- View-Daten vorbereiten
### Service-Layer (`/lib/Service`)
- Geschäftsregeln
- Cross-Cutting-Logik (z. B. Auth, Settings, Branding)
- Orchestrierung mehrerer Repositories
### Repository-Layer (`/lib/Repository`)
- SQL und Mapping
- Filter-/Paging-Mechaniken
- Keine UI-/HTTP-Logik
### Template/View-Layer (`/templates`, `*.phtml`)
- Nur Rendering
- Keine DB-Zugriffe
- Wiederkehrende UI-Teile als Partials
## 5) Datenmodell (fachlich)
Kernobjekte:
- `users`
- `tenants`
- `departments`
- `roles`
- `permissions`
Verknüpfungen:
- `user_tenants`, `user_departments`, `user_roles`
- `role_permissions`
- `tenant_auth_microsoft`, `user_external_identities`
- `tenant_custom_field_definitions`, `tenant_custom_field_options`
- `user_custom_field_values`, `user_custom_field_value_options`
Hinweis:
- `departments` ist 1:1 an `tenants` gebunden über `departments.tenant_id` (kein M:N-Mapping).
- In der User-Organisation wird die Department-Auswahl tenantweise gerendert (ein Multi-Select je zugewiesenem Tenant), gespeichert wird weiterhin flach über `user_departments`.
- Tenant-spezifische User-Zusatzfelder werden separat modelliert:
- Definitionen/Optionen pro Tenant in `tenant_custom_field_*`
- Werte pro User in `user_custom_field_*`
- Filterung im Address Book über dynamische Query-Keys `cf_*`, `cfm_*`, `cfd_*`.
- `field_key` bleibt intern (tenant-weit eindeutig), wird im Tenant-UI automatisch aus dem Label erzeugt und nicht manuell gepflegt.
- `is_filterable` ist fachlich nur für `select`, `multiselect`, `boolean`, `date` aktiv.
- User-Zusatzfeldwerte sind in V1 optional (keine Pflichtvalidierung).
- Tenant-Theme-Overrides werden direkt auf `tenants` gespeichert:
- `tenants.default_theme` (`NULL` = globales `app_theme`)
- `tenants.allow_user_theme` (`NULL` = globales `app_theme_user`).
- Auflösung immer über `$_SESSION['current_tenant']` und damit tenant-spezifisch für Multi-Tenant-User.
- Tenant-SSO für Microsoft Entra ID:
- Konfiguration pro Tenant in `tenant_auth_microsoft`.
- Externe Identitäts-Verknüpfung stabil über `user_external_identities` (`provider + tid + oid`).
- Login-Endpunkte: `login` (optional `?tenant={slug}`), `auth/microsoft/start`, `auth/microsoft/callback`.
- Shared-App-Credentials liegen global in `settings`; optionale Tenant-Overrides sind möglich.
- Profil-Sync wird tenant-spezifisch über `tenant_auth_microsoft.sync_profile_on_login` und
`tenant_auth_microsoft.sync_profile_fields` gesteuert.
- Erlaubte Sync-Felder in V1: `first_name`, `last_name`, `phone`, `mobile`, `avatar`.
- `phone/mobile/avatar` kommen über Microsoft Graph (`/me`, `/me/photo/$value`) und sind fail-open
(Graph-Fehler blockieren den Login nicht).
Sicherheits-/Audit-nahe Tabellen:
- `user_remember_tokens`
- `password_resets`
- `email_verifications`
- `mail_log`
## 6) Sicherheits- und Scope-Modell
- Auth Guard: protected by default
- Permission Checks: feature-spezifisch in Actions/Services
- Tenant Scope: datenabhängig über Tenant-Matches
- Strictness steuerbar über `TENANT_SCOPE_STRICT`
Details siehe:
`/docs/sicherheitsmodell.md`
## 7) Frontend-Architektur
JS-Struktur:
- `/web/js/core` Basismodule (DOM/Grid Factory)
- `/web/js/components` UI-Komponenten
- `/web/js/pages` seitenspezifische Helfer
Bootstrap:
- `app-boot.js` früh (no-js/js state, localStorage UI state)
- `app-init.js` für Default-Template
- `app-login-init.js` für Login-Template
CSS-Struktur:
- Layer-Entrypoint: `/web/css/app-layers.css`
- Basen/Variablen in `/web/css/base`
- Layout in `/web/css/layout`
- Komponenten in `/web/css/components`
- Seitenstile in `/web/css/pages`
- Vendor-Overrides in `/web/css/vendor-overrides`
## 8) Asset-Steuerung
Konfiguration liegt in `/config/assets.php`.
- Template-Gruppen: `default`, `login`
- Seiten-Gruppen optional via `Buffer::set('style_groups', ...)`
- Rendering über Helper: `renderTemplateStyles()` und `renderPageStyles()`
## 9) Erweiterungsstrategie
Pragmatischer Weg für neue Features:
1. Repository erweitern (SQL)
2. Service-Regel bauen
3. Action anbinden
4. View/Partial integrieren
5. JS nur falls interaktiv nötig
6. Tests + Lint/Analyse laufen lassen
So bleibt die Trennung stabil und Refactoring kontrollierbar.
## 10) Settings-Architektur (DB + Datei-Cache)
- `settings` (DB) ist die alleinige fachliche Quelle für globale Settings.
- `config/settings.php` ist ein technischer Teil-Cache für sehr häufig gelesene UI-Basiswerte.
- Zugriffsmuster:
- `SettingService::get*` / `SettingService::set*` -> DB
- `appSetting(...)` -> Datei-Cache (`SettingCacheService`)
- Konsequenz:
- Es ist korrekt, dass nicht alle DB-Settings im Datei-Cache stehen (z. B. SMTP/SSO/API-Details).
- Der Cache muss nur die Keys enthalten, die im Runtime-Helper `appSetting(...)` verwendet werden.
Details:
`/docs/einstellungen-speicherung.md`

View File

@@ -0,0 +1,110 @@
# Benutzer-Lifecycle-Policy
Letzte Aktualisierung: 2026-02-21
## Ziel
Globale Regeln für automatische Benutzer-Deaktivierung und spätere Löschung.
- Stufe 1: Benutzer wird auf `inactive` gesetzt.
- Stufe 2: Inaktiver Benutzer wird nach weiterer Frist gelöscht (Hard Delete).
## Settings
Die Regeln werden in `settings` gespeichert:
- `user_inactivity_deactivate_days`
- `user_inactivity_delete_days`
Semantik:
- Wertebereich: `0..3650`
- `0` deaktiviert die jeweilige Regel
- Löschung wird nur angewendet, wenn Deaktivierung aktiv ist (`deactivate > 0`)
Defaults:
- Deaktivierung: `180`
- Löschung: `365` (ab Inaktivsetzung)
## Referenzdaten
- Deaktivierung basiert auf `COALESCE(last_login_at, created)`.
- Löschung basiert auf `active_changed_at` (nur wenn gesetzt).
## Privilegierte Benutzer (ausgenommen)
Automatische Lifecycle-Aktionen ignorieren Benutzer mit mindestens einer aktiven Rolle, die eine der Permissions hat:
- `settings.update`
- `tenants.update`
## Ausführung
### Manueller Run (Admin UI)
- Endpoint: `POST /admin/settings/run-user-lifecycle`
- Schutz: Login + `settings.update` + CSRF
### Cron/CLI
- Bevorzugt über den zentralen Scheduler:
- Script: `bin/scheduler-run.php` (führt fällige Jobs aus, inkl. `user_lifecycle_run`)
- Beispiel (minütlich):
```bash
* * * * * docker compose exec php php bin/scheduler-run.php
```
Exit-Codes:
- `0` erfolgreich
- `1` Fehler/Lock-Konflikt
## Concurrency
Zur Vermeidung paralleler Runs wird ein MySQL-Lock verwendet:
- `GET_LOCK('user_lifecycle_maintenance', 0)`
- `RELEASE_LOCK('user_lifecycle_maintenance')`
## Session-Wirkung
Wenn ein bereits eingeloggter Benutzer automatisch deaktiviert wurde, greift die bestehende Session-Refresh-Logik beim nächsten Request und führt zum Logout.
## Audit-Log (Delete/Restore)
Lifecycle-Events werden in `user_lifecycle_audit_log` protokolliert.
- `deactivate`: erfolgreich ausgeführte automatische Deaktivierung
- `delete`: Löschversuch mit Snapshot
- `restore`: Wiederherstellung aus einem Delete-Event
Wichtige Regeln:
- Delete ist fail-closed: ohne erfolgreich gespeicherten Snapshot wird nicht gelöscht.
- Snapshot wird verschlüsselt in `snapshot_enc` gespeichert.
- Snapshot enthält nur whitelisted Profilfelder (keine Secrets/Tokens).
- Audit-Retention: 365 Tage (`POST /admin/user-lifecycle-audit/purge`).
## Wiederherstellung (ohne Assignments)
Restore ist nur für `action=delete` + `status=success` möglich und nur einmal pro Event.
Konfliktregeln (Hard fail):
- UUID existiert bereits
- E-Mail existiert bereits
Restore-Ergebnis:
- User wird neu angelegt mit Snapshot-Stammdaten
- `active=0`
- zufälliges Passwort
- keine Wiederherstellung von Tenant-/Role-/Department-Zuweisungen
## Rechte
- Ansicht Lifecycle-Protokolle: `user_lifecycle_audit.view`
- Wiederherstellen aus Lifecycle-Protokoll: `users.lifecycle_restore`
- Purge: `settings.update`

View File

@@ -0,0 +1,82 @@
# Benutzerdefinierte Felder (V1) Kurzreferenz
Letzte Aktualisierung: 2026-02-21
## Ziel
Tenant-spezifische Zusatzfelder für User:
- Definitionen und Optionen werden pro Tenant gepflegt.
- Werte werden pro User gespeichert.
- Address-Book-Filter nutzen dynamische Query-Parameter.
## Berechtigungen
- `custom_fields.manage`
- Definitionen/Optionen im Tenant-Tab pflegen.
- `custom_fields.edit_values`
- Werte im User-Tab pflegen.
## Tabellen
- `tenant_custom_field_definitions`
- Felddefinition pro Tenant (Label, Typ, aktiv, filterbar, `field_key` intern).
- `tenant_custom_field_options`
- Optionen für `select`/`multiselect` je Definition.
- `user_custom_field_values`
- Gespeicherter Wert pro `user_id + definition_id` (unique).
- `user_custom_field_value_options`
- N:M-Optionen für Multiselect-Werte.
## Typen und Verhalten
- `text`, `textarea`
- Eingabe am User möglich.
- Nicht als Address-Book-Filter zugelassen.
- `select`, `multiselect`, `boolean`, `date`
- Eingabe am User möglich.
- Optional als Address-Book-Filter (wenn `is_filterable=1`).
## Wichtige Regeln
- `field_key`
- Wird serverseitig aus Label erzeugt und tenant-weit eindeutig gehalten.
- Kein manuelles UI-Feld.
- User-Werte sind in V1 optional (keine Pflichtvalidierung).
- Bei Tenant-Wechsel am User werden Werte außerhalb der aktuellen User-Tenants bereinigt.
## Query-Parameter im Address Book
- `cf_<definition_uuid>`
- scalar/select/boolean
- `cfm_<definition_uuid>`
- multiselect (CSV mit Option-IDs)
- `cfd_<definition_uuid>_from`
- `cfd_<definition_uuid>_to`
- date-range
Unbekannte/manipulierte Parameter werden ignoriert.
## Relevante Pfade
- Tenant-Tab Formular:
- `/pages/admin/tenants/_form.phtml`
- Tenant Actions:
- `/pages/admin/tenants/custom-field-create($id).php`
- `/pages/admin/tenants/custom-field-update($id).php`
- `/pages/admin/tenants/custom-field-delete($id).php`
- User-Werte:
- `/lib/Service/CustomField/UserCustomFieldValueService.php`
- `/lib/Repository/CustomField/*`
- Address-Book-Filter:
- `/pages/address-book/index().php`
- `/pages/address-book/index(default).phtml`
## Troubleshooting (kurz)
- Multiselect speichert nichts:
- Feldname ohne doppeltes `[]` prüfen.
- Optionen sind leer:
- Label-Key für MultiSelect auf `label`/`description` prüfen.
- Filterbar ist deaktiviert:
- Typ ist nicht in `select|multiselect|boolean|date`.

View File

@@ -1,17 +0,0 @@
# Conventions (Short)
## CSS
- All project files use `app-` prefix.
- Vendor overrides use `vendor-` prefix.
- Structure: `base/`, `layout/`, `components/`, `pages/`.
## JS
- All project files use `app-` prefix.
- Vendor scripts stay in `web/vendor`.
- Structure: `core/`, `components/`, `pages/`.
## Partials
- All partials use `app-` prefix (e.g. `app-footer.phtml`).
## PHP (Lib)
- Services in `lib/Service`, repositories in `lib/Repository`.

18
docs/docker-betrieb.md Normal file
View File

@@ -0,0 +1,18 @@
# Docker-Betrieb
Letzte Aktualisierung: 2026-02-21
## Übersicht
Die Docker-Dokumentation ist in zwei klare Pfade getrennt:
1. Lokal entwickeln:
- `/docs/docker-lokal.md`
2. Produktiv betreiben:
- `/docs/docker-produktiv.md`
## Empfehlung
- Für tägliche Entwicklung immer mit `docker-compose.yml` arbeiten.
- Für Serverbetrieb ausschließlich `docker-compose.prod.yml` verwenden.
- Änderungen an Domain/TLS nur in der Produktivdoku und den Produktivdateien pflegen.

65
docs/docker-lokal.md Normal file
View File

@@ -0,0 +1,65 @@
# Docker lokal
Letzte Aktualisierung: 2026-02-21
## Ziel
Diese Anleitung bringt die Anwendung lokal schnell und stabil zum Laufen.
## Voraussetzungen
- Docker Desktop (oder Docker Engine + Compose)
- Port `8080` (App) und `8081` (phpMyAdmin) sind frei
## Start in 4 Schritten
1. ENV-Datei anlegen:
```bash
cp .env.example .env
```
2. Container bauen und starten:
```bash
docker compose up --build -d
```
3. Anwendung öffnen:
- App: `http://localhost:8080`
- phpMyAdmin: `http://localhost:8081`
4. Logs prüfen (optional):
```bash
docker compose logs -f nginx php
```
## Nützliche Befehle
Neustart:
```bash
docker compose restart nginx php
```
Stoppen:
```bash
docker compose down
```
Scheduler-Logs:
```bash
docker compose logs -f scheduler
```
## Häufige lokale Probleme
- Änderungen werden nicht sichtbar:
- `docker compose restart nginx php`
- Datenbank startet nicht:
- DB-Container-Logs prüfen: `docker compose logs -f db`
- Port-Konflikt:
- in `docker-compose.yml` lokale Port-Mappings anpassen

77
docs/docker-produktiv.md Normal file
View File

@@ -0,0 +1,77 @@
# Docker produktiv
Letzte Aktualisierung: 2026-02-21
## Ziel
Diese Anleitung beschreibt den Betrieb mit Domain auf Basis von `/docker-compose.prod.yml`.
## Voraussetzungen
- DNS zeigt auf den Server (`A/AAAA`)
- Ports `80` und `443` sind offen
- TLS-Zertifikate vorhanden
## Produktionsdateien
- Compose: `/docker-compose.prod.yml`
- Nginx: `/docker/nginx/prod.conf`
- PHP: `/docker/php/php.prod.ini`
- ENV-Vorlage: `/.env.prod.example`
## 1) Umgebung vorbereiten
`.env` auf Produktionswerte setzen:
- `APP_URL=https://deine-domain.tld`
- `APP_ENV=prod`
- `APP_DEBUG=false`
- stabile `APP_CRYPTO_KEY`
- starke DB/SMTP-Passwörter
## 2) Nginx auf Domain/TLS setzen
In `/docker/nginx/prod.conf`:
- `server_name` auf echte Domain(s) anpassen
Zertifikate ablegen:
- `/docker/nginx/certs/fullchain.pem`
- `/docker/nginx/certs/privkey.pem`
## 3) Produktion starten
```bash
docker compose -f docker-compose.prod.yml up --build -d
```
Konfiguration validieren:
```bash
docker compose -f docker-compose.prod.yml config
```
## 4) Betrieb prüfen
```bash
docker compose -f docker-compose.prod.yml logs -f nginx php scheduler
```
Prüfen:
- App über `https://...` erreichbar
- Login funktioniert
- Scheduler läuft minütlich (Logausgaben)
## 5) Bestandssysteme
Bei bestehender Datenbank:
- idempotentes SQL-Update manuell ausführen
(Auto-Init über `db/init/init.sql` greift nur beim ersten DB-Setup)
## Hinweise
- `phpmyadmin` ist in Prod absichtlich nicht enthalten.
- Wenn ein externer Reverse-Proxy TLS terminiert, kann `prod.conf` auf HTTP intern reduziert werden; `APP_URL` bleibt trotzdem `https://...`.

View File

@@ -0,0 +1,62 @@
# Dokumentation erweitern
Letzte Aktualisierung: 2026-02-21
## Ziel
Diese Seite beschreibt den Standardprozess, um die Projektdokumentation sauber, konsistent und dauerhaft wartbar zu erweitern.
## Single Source of Truth
- Für Doku-Navigation und Doku-Suche ist **`/docs/index.md`** die einzige Quelle.
- Nur Dateien, die dort eingetragen sind, erscheinen in:
- `/admin/docs/...`
- globaler Suche (Kategorie `Documentation`)
- Vollsuche `/search`
- Der sichtbare Name im Docs-Aside und in der Suche kommt aus dem **H1-Titel** der Datei.
## Standardablauf für neue Doku-Dateien
1. Neue Datei unter `docs/*.md` anlegen (Slug nur `a-z`, `0-9`, `-`).
2. Erste Zeile als H1 setzen (`# Titel`).
3. Direkt darunter Datum setzen:
- `Letzte Aktualisierung: YYYY-MM-DD`
4. Datei in `docs/index.md` unter passender Kategorie eintragen:
- Format: ``/docs/<slug>.md``
5. Kurzbeschreibung in `docs/index.md` ergänzen (1 Zeile, klarer Nutzen).
6. Relevante Querverweise in bestehenden Docs ergänzen.
## Struktur- und Stilregeln
- Pro Datei genau ein H1.
- H1 ist der kanonische Anzeigename (Navigation + Suche).
- Abschnittsstruktur über H2/H3 (nicht direkt mit H4 starten).
- Ein Abschnitt = eine klare Aussage.
- Befehle in Codeblöcken mit realen, lauffähigen Beispielen.
- Dateipfade immer als klickbare Code-Referenz (`/path/to/file`).
- Keine veralteten Relikte stehen lassen ("Legacy", alte Namen, alte Routen).
## Wichtig für die Suche
- Doku-Suche indexiert `docs/*.md` live.
- Treffer entstehen aus:
- Dokumenttitel
- Abschnittsüberschriften (H1/H2/H3)
- Abschnittstext
- Links springen auf `admin/docs/{slug}#anchor`.
- Aussagekräftige H2/H3-Überschriften verbessern Suchtreffer deutlich.
## Qualitätscheck vor Merge
1. Ist die Datei in `docs/index.md` eingetragen?
2. Ist `Letzte Aktualisierung` vorhanden und korrekt?
3. Stimmen alle genannten Pfade/Routen/Permissions mit dem Code?
4. Gibt es mindestens einen konkreten, praxisnahen Beispielaufruf?
5. Sind neue Begriffe konsistent zu bestehender Terminologie?
## Häufige Fehler
- Datei erstellt, aber nicht in `docs/index.md` registriert.
- Überschrift/Slug später umbenannt, alte Links nicht angepasst.
- Allgemeine Aussagen ohne konkrete Beispiele.
- API-/Permission-Änderung im Code, aber Doku nicht nachgezogen.

View File

@@ -0,0 +1,60 @@
# Einstellungen: Speicherung (DB + Cache)
Letzte Aktualisierung: 2026-02-21
## Kurzfassung
- **Source of truth ist immer die Tabelle `settings` in der Datenbank.**
- `config/settings.php` ist nur ein **Teil-Cache** für wenige, sehr häufig gelesene UI-Basiswerte.
- Nicht alle Settings müssen oder sollen im Datei-Cache stehen.
## Warum es beides gibt
Die Kombination ist bewusst so gebaut:
1. **DB als Wahrheit**
- Alle Settings werden persistiert in `settings`.
- Validierung und Schreiblogik laufen über `SettingService`.
2. **Datei-Cache für Hot-Path-Reads**
- `config/settings.php` wird durch `SettingCacheService` geschrieben.
- Verwendet wird er über `appSetting(...)` nur für globale Basiswerte wie:
- `app_title`
- `app_locale`
- `app_theme`
- `app_theme_user`
- `app_registration`
- `app_primary_color`
## Was **nicht** im Cache der Datei liegen muss
Sicherheits- oder Integrationswerte werden direkt aus DB gelesen, z. B.:
- SMTP (`smtp_host`, `smtp_port`, `smtp_user`, `smtp_from`, ...)
- Microsoft SSO Shared App Settings
- API-spezifische Policies
- User-Lifecycle-Policies (`user_inactivity_deactivate_days`, `user_inactivity_delete_days`)
- Scheduler-/Job-Konfiguration (`scheduled_jobs`, `scheduled_job_runs`)
Daher ist es korrekt, wenn diese Werte in `settings` (DB) vorhanden sind, aber nicht in `config/settings.php`.
## Laufzeitfluss
1. Admin speichert Settings in `admin/settings`.
2. `SettingService::set*` schreibt in DB.
3. `SettingCacheService::update(...)` aktualisiert den Datei-Cache nur für den definierten Teilbereich.
4. UI-Helfer `appSetting(...)` liest danach aus Datei-Cache.
## Wenn DB und Datei scheinbar nicht zusammenpassen
Das ist oft erwartbar, solange es um nicht-gecachte Keys geht.
Prüfregel:
- Geht es um `appSetting(...)`-Keys? Dann Datei-Cache relevant.
- Geht es um SMTP/SSO/API-Details? Dann DB ist massgeblich.
## Betriebs-Hinweis
Direkte DB-Änderungen an gecachten `appSetting(...)`-Keys werden erst wirksam, wenn der Datei-Cache aktualisiert wurde
(z. B. durch erneutes Speichern in den Settings).

View File

@@ -0,0 +1,88 @@
# Entwickler-Checkliste
Letzte Aktualisierung: 2026-02-22
## Vor dem Coden
- [ ] Betroffene Schicht klar? (`Repository`, `Service`, `pages`, `templates`, `web/js`)
- [ ] Permission- und Tenant-Scope-Auswirkungen verstanden?
- [ ] Bestehendes Partial/Helper wiederverwendbar?
## Beim Implementieren
- [ ] Keine DB-Calls in Views (`*.phtml`)
- [ ] SQL nur im Repository
- [ ] Business-Regeln nur im Service
- [ ] Actions bleiben schlank (Input/Guard/Orchestrierung)
- [ ] Neue UI-Texte mit `t('...')`
## UI/UX-Konsistenz
- [ ] Edit-Seite nutzt `app-details-titlebar`
- [ ] Titelzeile enthält nur Primäraktionen (`Save`, `Save & close`)
- [ ] Sekundäraktionen liegen im Aside-Block `Actions` (nicht als verstecktes Titlebar-Dropdown)
- [ ] Erster Tab = `Master data`
- [ ] Status über `app-visibility-status-field`
- [ ] Delete über `app-danger-zone-delete-field` (falls relevant)
- [ ] Aside-Meta über Audit/IDs-Partial (falls relevant)
- [ ] Bei Tenant-Zusatzfeldern: Definitionen nur über eigene Actions, nicht über Tenant-Hauptsave
- [ ] Bei User-Zusatzfeldern: Felder tenantweise gruppiert, Scope serverseitig geprüft, Werte optional in V1
- [ ] `is_filterable` nur für `select`, `multiselect`, `boolean`, `date` verwenden
- [ ] Bei Theme-Logik: Tenant-Overrides (`default_theme`, `allow_user_theme`) gegen globale Settings geprüft
- [ ] Bei Tenant-SSO: `tenants.sso_manage` geprüft und Secrets niemals im Klartext rendern/loggen
- [ ] Bei API-Audit: nur Metadaten loggen (keine Bodies/Tokens), Redaction aktiv, Permission `api_audit.view` geprüft
## Assets
- [ ] Neue CSS-Datei im passenden Ordner (`components`, `layout`, `pages`, `vendor-overrides`)
- [ ] Falls nötig in `config/assets.php` als Gruppe registriert
- [ ] Seitenstil per `Buffer::set('style_groups', ...)` aktiviert
## Tests und Checks
- [ ] PHPUnit:
- [ ] `docker compose exec php vendor/bin/phpunit --bootstrap vendor/autoload.php`
- [ ] PHPStan:
- [ ] `docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress`
- [ ] ESLint:
- [ ] `npx eslint web/js`
- [ ] i18n-Test:
- [ ] `docker compose exec php vendor/bin/phpunit --bootstrap vendor/autoload.php tests/I18n/TranslationKeysTest.php`
- [ ] Bei Microsoft-SSO-Änderungen:
- [ ] Für Bestandsumgebungen passendes idempotentes SQL-Update bereitgestellt und ausgeführt
- [ ] `APP_CRYPTO_KEY` in `.env` gesetzt (32 Byte key, hex oder base64)
- [ ] Wenn `phone`/`mobile`/`avatar` Sync genutzt wird: Graph `User.Read` in Entra verfügbar und consented
- [ ] Profil-Sync-Felder im Tenant-SSO-Tab geprüft (`sync_profile_on_login`, `sync_profile_fields`)
- [ ] Login Smoke-Test (`login?tenant=<slug>` -> Microsoft Start/Callback)
- [ ] Bei API-Audit-Änderungen:
- [ ] Für Bestandsumgebungen passendes idempotentes SQL-Update bereitgestellt und ausgeführt
- [ ] Audit-Logging für 2xx/4xx/5xx manuell geprüft
- [ ] Purge-Action (`admin/api-audit/purge`) prüft Permission + POST + CSRF
- [ ] Bei Global-Search-Änderungen:
- [ ] SQL-Resource in `lib/Support/Search/SearchSqlResourceProvider.php` ergänzt (`resources`, ggf. `tenantScopeFilters`)
- [ ] UI-Meta in `lib/Support/Search/SearchUiMetaProvider.php` ergänzt (`listUrl`, `iconForKey`)
- [ ] Mapping in `lib/Support/Search/SearchItemMapperProvider.php` ergänzt (`mapPreviewItem`, `mapResultItem`)
- [ ] Spezialressourcen in `lib/Support/Search/SearchSpecialResourceProvider.php` ergänzt (falls `docs`/`hotkeys`-ähnlich)
- [ ] `lib/Support/SearchConfig.php` nur als Fassade konsistent delegierend belassen
- [ ] Aside-Search-Eintrag in `templates/partials/app-main-aside.phtml` mit passendem `data-search-key` vorhanden
- [ ] Permission + Tenant-Scope geprüft (keine unerlaubten Treffer)
- [ ] Preview (`admin/search/data`) und Vollsuche (`search?search=...`) manuell geprüft
## Manuelle Smoke-Tests
- [ ] Login/Logout
- [ ] Eine Admin-Liste mit Filter + Sortierung
- [ ] Eine Edit-Seite mit Save + Danger-Zone-Aktion (falls vorhanden)
- [ ] Tenant-gebundene Sicht (Address Book / Users / Departments)
- [ ] Global Search
## Abschluss
- [ ] Dokumentation aktualisiert (`README.md`, `docs/*` bei Bedarf, inkl. `docs/frontend-javascript.md`)
- [ ] Keine toten Imports/unused Code hinzugefügt
- [ ] Änderung ist für den nächsten Entwickler in 1-2 Minuten nachvollziehbar
## Periodisch (bei Feature-Entfernung / vor Release)
- [ ] Ungenutzte Composer-Pakete prüfen:
- [ ] `docker compose exec php vendor/bin/composer-unused`

48
docs/erste-aenderung.md Normal file
View File

@@ -0,0 +1,48 @@
# Leitfaden für die erste Änderung
Letzte Aktualisierung: 2026-02-21
## Ziel
Ein neuer Entwickler soll den ersten Change strukturiert und reproduzierbar umsetzen können.
## Beispiel 1: Spalte in Admin-Liste ergänzen
### Schrittfolge
1. Data-Endpoint erweitern (z. B. `pages/admin/users/data().php`)
2. Falls nötig: neue Repository-Query/Count-Methode (z. B. `lib/Repository/User/UserRepository.php`)
3. Grid-Spalte in `pages/admin/<modul>/index(default).phtml` ergänzen (z. B. `pages/admin/users/index(default).phtml`)
4. i18n-Key für Spaltennamen setzen
5. `php -l` + manueller UI-Test
### Done-Kriterien
- Kein SQL in View-Dateien
- Neue Werte im Endpoint dokumentiert/benannt
- Sortierung und Mapping im Grid konsistent
- Bei neuen Filtern: Query-Param, Repository-Filter und Grid-State konsistent
## Beispiel 2: API-Response erweitern
### Schrittfolge
1. Endpoint in `pages/api/v1/...` anpassen (z. B. `pages/api/v1/me/index().php` oder `pages/api/v1/users/show($id).php`)
2. Permission- und Tenant-Scope-Prüfungen beibehalten
3. Optional Service/Repository ergänzen
4. `docs/openapi.yaml` aktualisieren
5. `docs/api.md` mit Beispiel aktualisieren
6. Bei neuen Berechtigungen: `PermissionService` + `init.sql` synchron halten, für Bestandsumgebungen idempotentes SQL-Update bereitstellen
### Done-Kriterien
- Keine numerischen IDs exponieren, wenn UUID bereits Contract ist
- Fehlercodes konsistent (`ApiResponse::error(...)`)
- Kein Body-Leak in Logs/Audit
- OpenAPI und tatsächlich gelieferte Response-Felder stimmen 1:1
## Finaler Check
Vor PR/Merge diese Liste gegenprüfen:
- `/docs/entwickler-checkliste.md`

80
docs/erste-schritte.md Normal file
View File

@@ -0,0 +1,80 @@
# Erste Schritte
Letzte Aktualisierung: 2026-02-21
## Ziel
Diese Seite bringt neue Entwickler in unter 45 Minuten zu einem lauffähigen lokalen Setup inklusive erstem Smoke-Test.
## Voraussetzungen
- Docker + Docker Compose
- Git
- Node.js + npm (für ESLint)
- Zugriff auf dieses Repository
## 1) Projekt lokal starten
```bash
cp .env.example .env
docker compose up --build -d
```
> Alle ENV-Variablen sind dokumentiert in /docs/konfiguration.md.
> docker compose up startet auch den globalen Scheduler-Runner (Service scheduler) im Hintergrund.
Danach erreichbar:
- App: `http://localhost:8080`
- phpMyAdmin: `http://localhost:8081`
## 2) Login mit Seed-User (Fresh-Install)
Standard-Demo-User (aus `db/init/init.sql`):
- E-Mail: `demo@user.com`
- Passwort: `Demo123`
Wichtig:
- Der Seed-User wird nur beim initialen DB-Setup angelegt.
- Wenn bereits ein bestehendes Docker-Volume (`db_data`) vorhanden ist, kann der lokale Datenstand abweichen.
- Für einen sauberen Fresh-Stand kannst du lokal neu initialisieren (Achtung: löscht lokale DB-Daten):
```bash
docker compose down -v
docker compose up --build -d
```
## 3) Schneller Funktionscheck
1. Login funktioniert
2. Admin-Navigation ist sichtbar
3. `Admin -> Benutzer` lädt
4. `Admin -> Einstellungen` lädt
5. `Admin -> API docs` lädt (mit entsprechender Permission)
## 4) API-Schnelltest
1. In der Admin-UI einen API-Token erstellen (oder vorhandenen nutzen)
2. Beispiel-Call:
```bash
curl -H "Authorization: Bearer <TOKEN>" \
http://localhost:8080/api/v1/me
```
## 5) Qualitätschecks ausführen
```bash
docker compose exec php vendor/bin/phpunit
docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress
npx eslint web/js
```
## 6) Nächste Doku-Schritte
- Täglicher Workflow: `/docs/lokale-entwicklung.md`
- Erster kleiner Change: `/docs/erste-aenderung.md`
- Architekturüberblick: `/docs/architektur.md`
- Sicherheitsmodell: `/docs/sicherheitsmodell.md`

83
docs/fehlerbehebung.md Normal file
View File

@@ -0,0 +1,83 @@
# Fehlerbehebung
Letzte Aktualisierung: 2026-02-21
## App reagiert nicht wie erwartet
### Symptom
UI zeigt alte Daten/Assets oder Verhalten wirkt stale.
### Lösung
```bash
docker compose restart php nginx
```
## Login/API verhalten sich unerwartet
### Symptom
401/403 bei API trotz gültigem Token.
### Checks
1. Authorization Header korrekt gesetzt (`Bearer <selector:secret>`)
2. Token nicht revoked/abgelaufen
3. Token hat erforderliche Permission
4. Bei tenant-scoped Token: Zugriff nur auf passende Tenant-Ressourcen
## Migration oder Schema-Probleme
### Symptom
Neue Felder/Features fehlen lokal.
### Lösung
1. Aktuelles Schema in `db/init/init.sql` prüfen
2. Für Bestandsumgebung idempotentes SQL-Update ausführen
3. Danach Container neu starten:
```bash
docker compose restart php
```
## i18n Fehler
### Symptom
Fehlende Translation-Keys oder Mixed-Language UI.
### Lösung
```bash
docker compose exec php vendor/bin/phpunit --bootstrap vendor/autoload.php tests/I18n/TranslationKeysTest.php
```
## PHPStan/Tests schlagen plötzlich fehl
### Symptom
Fehler nach Refactor, obwohl Seite lädt.
### Lösung
```bash
docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress
docker compose exec php vendor/bin/phpunit --bootstrap vendor/autoload.php
```
Dann gezielt betroffene Datei/Service prüfen.
## Swagger UI lädt ungestylt oder inkonsistent
### Symptom
API docs sehen nicht nach App-Theme aus.
### Lösung
1. Prüfen, dass `Buffer::set('style_groups', json_encode(['api-docs']))` gesetzt ist
2. Prüfen, dass `css/vendor-overrides/swagger-ui.css` über `config/assets.php` eingebunden ist
3. Hard-Reload im Browser

78
docs/frontend-css.md Normal file
View File

@@ -0,0 +1,78 @@
# Frontend CSS
Letzte Aktualisierung: 2026-02-21
## Ziel
Diese Seite beschreibt die kompakte Standardstruktur für CSS in diesem Projekt: klar, wartbar und ohne Seiteneffekte.
## Struktur
- `/web/css/app-layers.css`
- zentraler Entrypoint für Layer-Reihenfolge.
- `/web/css/base`
- Variablen, Reset, Typografie, globale Tokens.
- `/web/css/layout`
- Layout-Bausteine (Sidebar, Header, Main-Layout).
- `/web/css/components`
- wiederverwendbare UI-Teile.
- `/web/css/pages`
- seitenbezogene Styles mit engem Scope.
- `/web/css/vendor-overrides`
- gezielte Overrides für externe Libraries.
## Grundregeln
1. Neue Styles immer so lokal wie möglich einordnen:
- erst `components`/`layout`,
- `pages` nur bei echtem Seitenspezifikum.
2. Keine globalen Überschreibungen ohne Scope.
3. Bestehende CSS-Variablen bevorzugen, keine neuen Farbwerte „inline“ verteilen.
4. Vendor-Anpassungen ausschließlich in `/web/css/vendor-overrides`.
## Entscheidungsmatrix: Neue Datei oder bestehende erweitern?
| Situation | Empfehlung | Zielpfad |
| --- | --- | --- |
| Bestehende Komponente bekommt kleine visuelle Erweiterung | Bestehende Datei erweitern | `/web/css/components/<bestehend>.css` |
| Neuer wiederverwendbarer UI-Baustein (mind. auf 2 Seiten) | Neue Komponentendatei anlegen | `/web/css/components/<neu>.css` |
| Änderung betrifft globales Layout (Sidebar, Header, Main) | Bestehende Layout-Datei erweitern | `/web/css/layout/<bestehend>.css` |
| Änderung ist nur für eine konkrete Seite relevant | Neue/ bestehende Seiten-Datei nutzen | `/web/css/pages/<seite>.css` |
| Externe Library muss übersteuert werden (Grid.js, Swagger UI) | Nur Vendor-Override ändern | `/web/css/vendor-overrides/<vendor>.css` |
| Neue Farben/Abstände/Typografie-Tokens werden benötigt | Erst Variablen in Base ergänzen, dann verwenden | `/web/css/base/*` |
Kurzregel:
- **Bestehende Datei erweitern**, wenn Scope und Verantwortung klar gleich bleiben.
- **Neue Datei anlegen**, wenn ein neuer klarer Verantwortungsbereich entsteht.
## Einbindung von Styles
Asset-Gruppen werden in `/config/assets.php` registriert.
Seiten laden bei Bedarf Gruppen über `Buffer::set('style_groups', ...)`.
Beispiel (Action):
```php
Buffer::set('style_groups', json_encode(['api-docs']));
```
## Benennungs- und Scope-Konvention
- Klassen konsistent mit `app-`-Präfix halten, wenn es eigene Komponenten sind.
- Status/Varianten über bestehende Attribute/Klassen abbilden (z. B. `data-variant`), nicht über zusätzliche Einmal-Klassen.
- Selektoren kurz halten, keine unnötig tiefen Ketten.
## Grid/Vendor
- Grid.js-spezifische Anpassungen: `/web/css/vendor-overrides/gridjs.css`
- Swagger UI-spezifische Anpassungen: `/web/css/vendor-overrides/swagger-ui.css`
Regel: Vendor-Overrides nur für Bibliotheks-Selektoren; projektspezifische UI-Stile in `components`/`pages`.
## Checkliste vor Merge
1. Liegt die Datei im richtigen Ordner (`components`, `layout`, `pages` oder `vendor-overrides`)?
2. Ist die Asset-Gruppe in `/config/assets.php` korrekt?
3. Wird die Gruppe auf der Zielseite wirklich geladen (`style_groups`)?
4. Gibt es ungewollte Seiteneffekte auf anderen Seiten?
5. Sind bestehende Variablen/Konventionen eingehalten?

101
docs/frontend-javascript.md Normal file
View File

@@ -0,0 +1,101 @@
# Frontend JavaScript
Letzte Aktualisierung: 2026-02-21
## Ordnerstruktur
- `/web/js/core`
- technische Basismodule (DOM/Factories)
- `/web/js/components`
- wiederverwendbare UI-Features
- `/web/js/pages`
- seitenspezifische Hilfen (z. B. Listeninitialisierung)
## Initialisierung
- `/web/js/app-boot.js`
- wird im `<head>` geladen
- setzt früh UI-States (no-js/js, Sidebar-/Contrast-LocalStorage)
- `/web/js/app-init.js`
- Modul-Entrypoint für Standardseiten
- importiert Komponenten zentral
- `/web/js/app-login-init.js`
- reduzierter Entrypoint für Loginseiten
## Modulkonvention (components/pages)
Empfohlenes Muster:
1. `init...()` Funktion exportieren
2. Früher Guard auf fehlende Elemente
3. Keine stillen Fehler verschlucken
4. Nur DOM/UI-Verhalten, keine Fachlogik
Template für neue Komponenten:
- `/web/js/components/_template.js`
## Defensive DOM-Nutzung
Nicht jede Seite hat jedes Element.
- Vor Zugriff immer Element-Existenz prüfen.
- Optional zentrale Helper (`app-dom.js`) verwenden.
- Module sollen auf nicht-passenden Seiten sauber no-op sein.
## Grid.js-Integration
- Gemeinsame Initialisierung über Core/Page-Utilities.
- Vendor-spezifische CSS-Anpassungen in:
- `/web/css/vendor-overrides/gridjs.css`
- Bei dynamischen Rows mit Lightbox/Tooltips: nach Render Refresh triggern.
### Grid URL State Sync
`createServerGrid(...)` synchronisiert bei `urlSync: true` den Grid-Zustand zentral in die URL.
- Search + Filter (bestehendes Verhalten)
- Sortierung über `order` + `dir`
- Pagination über `page` (1-basiert)
Optionale Konfiguration:
```js
createServerGrid({
// ...
urlSync: true,
urlState: {
pageParam: 'page',
sortOrderParam: 'order',
sortDirParam: 'dir',
cleanFirstPage: true,
syncPage: true,
syncSort: true
}
});
```
Hinweise:
- `page=1` wird bei `cleanFirstPage: true` nicht in der URL gehalten.
- Übernommene URL-Werte werden validiert:
- `page >= 1`
- `dir` nur `asc|desc`
- `order` nur bekannte Sort-Key-Spalten
- Bei Search-/Filter-Änderung wird auf Seite 1 zurückgesetzt.
## Linting
Prüfen mit:
```bash
npx eslint web/js
```
Auto-fix (nur für sichere Rule-Fixes):
```bash
npx eslint web/js --fix
```
Hinweis: `--fix` behebt format-/syntaxnahe Themen (z. B. fehlende Klammern), aber nicht automatisch alle Logikprobleme.

224
docs/geplante-aufgaben.md Normal file
View File

@@ -0,0 +1,224 @@
# Geplante Aufgaben
Letzte Aktualisierung: 2026-02-21
## Ziel
Zentrale Verwaltung geplanter Aufgaben im Admin-Bereich.
- Ein zentraler Runner (`bin/scheduler-run.php`) läuft minütlich.
- Produktion typischerweise per Cron.
- Lokale Docker-Umgebung über den Compose-Service `scheduler`.
- Die konkreten Jobs sind Whitelist-basiert (kein frei ausführbarer Bash-Text aus DB).
- V1 startet mit `user_lifecycle_run`.
## Tabellen
- `scheduled_jobs`
- Konfiguration pro Job (enabled, schedule, timezone, next/last run, letzter Fehler).
- `scheduled_job_runs`
- Historie einzelner Runs (trigger, status, Dauer, error/result summary).
- `scheduler_runtime_status`
- Einzeilige Runtime-Health des globalen Runners (Heartbeat, letzter Runner-Status, letzter Fehlercode).
Run-Log-Retention:
- 90 Tage (Purge in Admin verfügbar).
## Rechte
- `jobs.view`: Bereich ansehen
- `jobs.manage`: Job-Konfiguration speichern
- `jobs.run_now`: Job manuell ausführen
Purge der Run-Logs bleibt unter `settings.update`.
## Betriebsmodell
Cron-Eintrag (Beispiel):
```bash
* * * * * docker compose exec php php bin/scheduler-run.php
```
Der Runner:
1. sichert sich globalen Lock (`GET_LOCK('scheduled_jobs_runner', 0)`) nur ein Runner aktiv
2. holt globale Due-Jobs (`enabled=1`, `next_run_at <= now`)
3. führt Jobs kontrolliert aus
4. schreibt Heartbeat in `scheduler_runtime_status`
5. schreibt Run-Log und aktualisiert Job-Status
6. berechnet `next_run_at` neu
7. gibt den Lock frei
## Runner-Heartbeat
- `scheduler_runtime_status` enthält genau eine Zeile (`id = 1`).
- Jeder Aufruf von `bin/scheduler-run.php` aktualisiert diese Zeile:
- `last_heartbeat_at`
- `last_result` (`ok`, `lock_not_acquired`, `unexpected_error`)
- `last_error_code` (optional)
- Dadurch wächst die Tabelle nicht mit der Zeit; sie ist ein laufendes Statusfenster.
Verwendung in Admin/Stats:
- Kachel **Cron runner active** ist `Aktiv`, wenn `last_heartbeat_at` jünger als 3 Minuten ist.
- Ist der Heartbeat älter oder fehlt, zeigt die Kachel `Inaktiv`.
- Manuelle Runs aus der UI (`Run now`) zählen nicht als Cron-Liveness.
## Scheduling (V1)
Unterstützte Typen:
- `hourly` (Intervall 1..24)
- `daily` (Intervall 1..365 + Uhrzeit)
- `weekly` (Intervall 1..52 + Uhrzeit + Wochentage)
`catchup_once`:
- Wenn ein Lauf verpasst wurde, wird genau ein Run nachgeholt.
- Danach wird normal in die Zukunft weitergeplant (kein Backlog-Sturm).
## Neuen Job registrieren (Entwickler-Guide)
### Architektur-überblick
Jeder Job ist eine eigene Handler-Klasse, die `ScheduledJobHandlerInterface` implementiert.
Die Registry ist eine reine Map von `job_key` zu Handler-Klasse sie enthält keine
job-spezifische Logik.
```
lib/Service/Scheduler/
Handler/
ScheduledJobHandlerInterface.php ← Vertrag für alle Handler
UserLifecycleJobHandler.php ← Referenz-Implementierung
ScheduledJobRegistry.php ← Handler-Map (einzige Datei die bei neuen Jobs angefasst wird)
```
### Schritt-für-Schritt
**1. Handler-Klasse erstellen**
Datei: `lib/Service/Scheduler/Handler/MeinJobHandler.php`
```php
<?php
namespace MintyPHP\Service\Scheduler\Handler;
use MintyPHP\Service\MeinBereich\MeinService;
class MeinJobHandler implements ScheduledJobHandlerInterface
{
public static function definition(): array
{
return [
'label' => 'Mein Job',
'description' => 'Kurze Beschreibung was der Job macht',
'default_enabled' => 1,
'default_timezone' => defined('APP_TIMEZONE') ? (string) APP_TIMEZONE : 'UTC',
'default_schedule_type' => 'daily',
'default_schedule_interval' => 1,
'default_schedule_time' => '03:00',
'default_schedule_weekdays_csv' => null,
'default_catchup_once' => 1,
'allowed_schedule_types' => ['daily', 'weekly'],
];
}
public static function execute(?int $actorUserId): array
{
$result = MeinService::run($actorUserId);
if (!($result['ok'] ?? false)) {
$errorCode = (string) ($result['error'] ?? 'job_failed');
$status = $errorCode === 'lock_not_acquired' ? 'skipped' : 'failed';
return [
'status' => $status,
'error_code' => $errorCode,
'error_message' => null,
'result' => [],
];
}
return [
'status' => 'success',
'error_code' => null,
'error_message' => null,
'result' => [
'processed_count' => (int) ($result['processed_count'] ?? 0),
'duration_ms' => (int) ($result['duration_ms'] ?? 0),
],
];
}
}
```
**2. Job in der Registry eintragen**
Datei: `lib/Service/Scheduler/ScheduledJobRegistry.php`
```php
// Konstante hinzufügen:
public const MEIN_JOB = 'mein_job';
// In handlers() eine Zeile hinzufügen:
private static function handlers(): array
{
return [
self::USER_LIFECYCLE_RUN => UserLifecycleJobHandler::class,
self::MEIN_JOB => MeinJobHandler::class, // ← neu
];
}
```
**3. Fertig.**
Keine anderen Dateien müssen angefasst werden. `ensureSystemJobs()` legt den Job beim
nächsten Scheduler-Aufruf automatisch in der Datenbank an.
### Regeln für Handler
**`definition()`**
- Kein `job_key`-Feld der Array-Key in der Registry-Map ist der Job-Key.
- `default_timezone`: Am besten `APP_TIMEZONE`-Konstante verwenden, Fallback `'UTC'`.
- `allowed_schedule_types`: Nur Typen erlauben, die der Job sinnvoll unterstützt.
Ein Job der stundlich keinen Sinn ergibt, sollte `hourly` nicht listen.
**`execute()`**
- Muss immer ein Array mit `status`, `error_code`, `error_message`, `result` zurückgeben.
- `status` darf nur `'success'`, `'failed'` oder `'skipped'` sein.
- `error_message` darf maximal 255 Zeichen lang sein (VARCHAR-Limit in DB).
- `result` wird als JSON im Run-Log gespeichert nur JSON-serialisierbare Werte.
- Wenn der zugrundeliegende Service einen eigenen Lock hat und `lock_not_acquired` meldet,
sollte der Handler `status = 'skipped'` zurückgeben (kein echter Fehler).
**Namenskonvention**
- Dateiname: `MeinJobHandler.php` (PascalCase + `Handler`-Suffix)
- Konstante in Registry: `SCREAMING_SNAKE_CASE` (z.B. `AUDIT_PURGE_RUN`)
- Job-Key in DB: `snake_case` (z.B. `audit_purge_run`)
### Referenz-Implementierung
`lib/Service/Scheduler/Handler/UserLifecycleJobHandler.php` ist die vollständige
Referenz-Implementierung mit internem Service-Lock, Fehlerbehandlung und
job-spezifischem `result`-Payload.
---
## Registrierte Jobs
| job_key | Handler-Klasse | Default-Schedule |
|----------------------|-----------------------------|---------------------|
| `user_lifecycle_run` | `UserLifecycleJobHandler` | täglich 02:15 |
---
## Troubleshooting
- `lock_not_acquired`:
- Ein anderer Runner läuft bereits.
- Job bleibt auf `running`:
- stale-running Schutz erlaubt nach 2 Stunden wieder neue Runs (Schwellwert in `ScheduledJobRepository::markRunning`).
- Kein fälliger Job:
- `next_run_at`, `enabled`, `timezone` und schedule Konfiguration prüfen.

149
docs/globale-suche.md Normal file
View File

@@ -0,0 +1,149 @@
# Globale Suche erweitern
Letzte Aktualisierung: 2026-02-21
Diese Doku erklärt, wie neue Entitäten sauber in die globale Suche integriert werden.
## Überblick
Es gibt zwei Suchausgaben mit derselben Datenquelle:
1. **Aside-Preview** (`admin/search/data`)
- zeigt je Resource Count + bis zu 5 Preview-Einträge.
2. **Vollseite Suche** (`search/data` -> `/search`)
- zeigt gemischte Trefferliste über alle Resources.
Die zentrale Fassade liegt in:
- `/lib/Support/SearchConfig.php`
Die eigentliche Logik ist in Provider aufgeteilt:
- `/lib/Support/Search/SearchSqlResourceProvider.php`
- `/lib/Support/Search/SearchUiMetaProvider.php`
- `/lib/Support/Search/SearchItemMapperProvider.php`
- `/lib/Support/Search/SearchSpecialResourceProvider.php`
- `/lib/Support/Search/SearchQueryNormalizer.php`
## Relevante Dateien
- `/lib/Support/SearchConfig.php`
- stabile Orchestrierungs-Fassade (kompatible Methoden nach außen)
- `/lib/Support/Search/SearchSqlResourceProvider.php`
- Resource-Definitionen (SQL + Permission + Label) + Tenant-Filter
- `/lib/Support/Search/SearchUiMetaProvider.php`
- URL- und Icon-Mapping pro Resource-Key
- `/lib/Support/Search/SearchItemMapperProvider.php`
- Mapping SQL-Row -> Preview-/Result-Item
- `/lib/Support/Search/SearchSpecialResourceProvider.php`
- Spezialressourcen (`docs`, `hotkeys`)
- `/lib/Support/Search/SearchQueryNormalizer.php`
- Query-Normalisierung für LIKE/Score
- `/pages/admin/search/data().php`
- Aside-Preview API (Counts + Preview)
- `/pages/search/data().php`
- Volltext-Treffer API
- `/templates/partials/app-main-aside.phtml`
- Sichtbarer Eintrag im Such-Panel (`data-search-key=...`)
- `/web/js/components/app-global-search.js`
- Render-Logik (generisch; meist keine Anpassung nötig)
- `/lib/Service/Docs/DocsCatalogService.php`
- Zentrale Katalog-/Anchor-Logik für Entwicklerdoku (Single Source of Truth)
## Standard-Vorgehen für neue Resource
### 1) Resource in `SearchSqlResourceProvider::resources()` anlegen
Neue Struktur mit:
- `key` (z. B. `scheduled-jobs`)
- `label` (übersetzbar via `t(...)`)
- `permission` (z. B. `PermissionService::JOBS_VIEW`)
- `countSql` + `countParams`
- `previewSql` + `previewParams`
- `resultSql` + `resultParams`
Regeln:
- SQL immer mit `... like ? escape '\\'`.
- Bei tenant-sensitiven Entitäten `{{tenantFilter}}` nutzen und in `SearchSqlResourceProvider::tenantScopeFilters()` ergänzen.
- Preview-Query immer mit `limit ?`.
### 2) URL-/Icon-/Mapping ergänzen
- `SearchUiMetaProvider::iconForKey()` erweitern
- `SearchUiMetaProvider::listUrl()` erweitern
- `SearchItemMapperProvider::mapPreviewItem()` ergänzen (falls Spezialformat nötig)
- `SearchItemMapperProvider::mapResultItem()` ergänzen (falls Spezialformat nötig)
Ziel:
- Preview und Volltreffer sollen sprechende Labels zeigen.
- URLs sollen direkt auf sinnvolle Zielseiten gehen.
### 3) Eintrag im Aside-Search-Panel anlegen
In `/templates/partials/app-main-aside.phtml`:
- `<li data-search-key="..." data-search-base="...">` ergänzen
- Anzeige per Permission guarden (z. B. `$canViewJobs`)
Wichtig:
- `data-search-key` muss exakt dem `key` in `SearchConfig` entsprechen.
### 4) i18n prüfen
Neue Labels in:
- `/i18n/default_de.json`
- `/i18n/default_en.json`
## Sicherheits- und Qualitätsregeln
- Permission in `SearchConfig` setzen, nicht nur im Aside.
- Tenant-Scope für tenant-bezogene Daten nicht vergessen.
- Keine sensiblen Daten im Preview-Label.
- Bei optionalen Modulen nur dann Resource aktivieren, wenn Schema vorhanden ist (oder Migration als Pflicht voraussetzen).
## Kurzes Beispiel: `scheduled-jobs`
Integration umfasst:
- Resource in `SearchSqlResourceProvider::resources()`
- Mapping auf `admin/scheduled-jobs/edit/{id}`
- Icon `bi-calendar-check`
- Aside-Search-Item mit `data-search-key="scheduled-jobs"`
- Permission `jobs.view`
## Doku-Resource (`docs`) im System
Die Entwicklerdoku ist als eigene Search-Resource integriert:
- Key: `docs`
- Permission: `docs.view`
- Quelle: `docs/*.md` (über `DocsCatalogService`, kein `openapi.yaml`)
- Aside: eigener Block `Documentation` mit Count + Preview
- Vollsuche: Treffer vom Typ `Documentation`
- Ziel-URLs: `admin/docs/{slug}#anchor` (direkter Abschnitt)
Wichtig:
- Anchor-Ermittlung für Docs-Seite und Suche läuft über denselben Service.
- Dadurch bleiben Hash-Links stabil und konsistent.
## Checkliste vor Merge
1. `php -l /lib/Support/SearchConfig.php`
2. `php -l /lib/Support/Search/*.php`
3. `php -l /templates/partials/app-main-aside.phtml`
3. Global Search manuell testen:
- Sidebar-Suche zeigt Count + Preview
- Klick auf Resource öffnet richtige Liste
- `/search?search=...` zeigt Volltreffer
4. Permission-Test:
- ohne Permission kein Eintrag/keine Treffer
5. Tenant-Scope-Test (falls relevant)
6. Bei `docs` zusätzlich:
- Preview-Link springt auf richtigen Abschnitt
- Vollsuche zeigt `Documentation`-Treffer mit sinnvollem Snippet

80
docs/importe.md Normal file
View File

@@ -0,0 +1,80 @@
# Importe
Letzte Aktualisierung: 2026-02-21
Diese Seite beschreibt den V1 CSV-Import unter `/admin/imports`.
## Scope (V1)
- Profile: `users`, `departments`.
- Create-only: bestehende Einträge werden übersprungen.
- CSV mit `,` oder `;`, UTF-8 (BOM wird toleriert).
- Limits: 10MB und 20.000 Datenzeilen.
- Fehlerausgabe nur in der UI (kein Download in V1).
## Permissions
- `imports.view`: Zugriff auf die Import-Seite.
- `imports.audit.view`: Zugriff auf Import-Protokolle unter `/admin/import-audit`.
- `users.import`: User-Import ausführen.
- `users.import_assignments`: Tenant/Role/Department aus CSV verwenden.
- `departments.import`: Department-Import ausführen.
## Ablauf
1. Upload
- Entität wählen (`Users` oder `Departments`) und CSV hochladen.
- Header/Delimiter/Zeilenlimit werden direkt geprüft.
2. Mapping
- CSV-Header auf Zielfelder mappen.
- Pflichtziele sind profilabhängig.
3. Preview (Dry run)
- Keine DB-Schreibvorgänge.
- Summary + erste Fehler (maximal 500 Einträge).
4. Commit
- Zeilenweise Verarbeitung (`best effort`).
- Gültige Zeilen werden erstellt, fehlerhafte reportet.
## Assignment-Regeln
- Users: optional `tenant`, `role`, `department` (jeweils 1:1).
- Departments: optional `tenant`.
- Identifier-Auflösung:
- Users: UUID zuerst, ID als Fallback.
- Departments: `tenant` ist UUID-only (kein ID-Fallback).
- Nur aktive Datensätze sind gültig.
- Department muss zum Tenant passen.
- Fehlen Assignment-Werte, greifen Defaults aus Settings.
- Ohne `users.import_assignments` wird ein Mapping mit Assignment-Spalten sofort abgelehnt.
## Duplikate und Skip-Verhalten
- In-File-Duplikate: erste Zeile gewinnt, spätere `duplicate_in_file`.
- Users:
- bestehende E-Mail: `email_exists`
- Departments:
- bestehender Code: `code_exists`
- bestehende Kombination aus Tenant + Description: `department_exists`
## Temporäre Dateien
- Uploads liegen unter `APP_STORAGE_PATH/imports/tmp`.
- Dateinamen werden randomisiert.
- Cleanup entfernt alte Temp-Dateien opportunistisch (älter als 24h).
## Import-Audit (V1)
- Pro Commit-Lauf wird ein Audit-Run gespeichert (`import_audit_runs`).
- Analyze/Preview werden bewusst nicht protokolliert.
- Gespeichert werden nur Metadaten:
- Profil (`users`/`departments`), Status (`success`/`partial`/`failed`)
- Laufzeit, Counter (`rows_total`, `created`, `skipped`, `failed`)
- User, aktueller Tenant, Dateiname (basename), gemappte Felder
- aggregierte Fehlercodes (kein Zeileninhalt)
- Keine CSV-Zeilenpayload oder PII-Details im Audit.
- Retention: 90 Tage, bereinigbar über `/admin/import-audit` (Purge-Action mit `settings.update`).
## Fehlercodes (V1)
- Struktur: `upload_missing`, `upload_too_large`, `invalid_file_type`, `invalid_csv_header`, `empty_csv`, `max_rows_exceeded`, `mapping_required_missing`, `assignment_permission_required`
- Zeilenvalidierung: `invalid_email`, `invalid_tenant_uuid`, `duplicate_in_file`, `email_exists`, `code_exists`, `department_exists`, `invalid_locale`, `invalid_active`, `invalid_hire_date`, `tenant_not_found`, `role_not_found`, `department_not_found`, `department_tenant_mismatch`, `assignment_out_of_scope`, `create_failed`, `unexpected_error`

71
docs/index.md Normal file
View File

@@ -0,0 +1,71 @@
# Dokumentation
Letzte Aktualisierung: 2026-02-21
Diese Dokumente sind für Entwickler gedacht, die die Anwendung warten oder erweitern.
## Start hier (neue Entwickler)
1. `/docs/erste-schritte.md`
- Setup in 30-45 Minuten, erster Login, erster Smoke-Test.
2. `/docs/lokale-entwicklung.md`
- Täglicher Workflow, Schema-Updates und Qualitätschecks.
3. `/docs/erste-aenderung.md`
- Schritt-für-Schritt für den ersten kleinen Code-Change.
4. `/docs/dokumentation-erweitern.md`
- Wie neue Doku-Dateien sauber erstellt, einsortiert und suchbar gemacht werden.
5. `/docs/entwickler-checkliste.md`
- Finale Checkliste vor Merge/Review.
## Architektur und Regeln
- `/docs/architektur.md`
- Request-Lebenszyklus, Schichtentrennung, Frontend-Aufbau.
- `/docs/sicherheitsmodell.md`
- Auth, Permissions, Tenant-Scope, Public/API-Zugriffe.
- `/docs/konventionen.md`
- Coding- und Strukturkonventionen für PHP, Templates, CSS, JS.
## Frontend
- `/docs/frontend-javascript.md`
- Frontend-JS-Aufbau, Init-Flow, Grid-Integration und URL-State-Sync.
- `/docs/frontend-css.md`
- CSS-Struktur, Layering, Vendor-Overrides und Asset-Einbindung.
## Fach- und Feature-Dokumentation
- `/docs/benutzerdefinierte-felder.md`
- Tenant/User-Zusatzfelder (Typen, Pflege, Filterbarkeit).
- `/docs/importe.md`
- CSV-Import (V1), Mapping, Fehlercodes, Permission-Modell und Import-Audit.
- `/docs/einstellungen-speicherung.md`
- Source of truth für Settings (DB) und Datei-Cache-Verhalten.
- `/docs/anfragelimits.md`
- Login/API-Limits, Scopes, Block-Verhalten.
- `/docs/benutzer-lifecycle-policy.md`
- Automatische Benutzer-Deaktivierung/Löschung (Policy, Cron, manueller Run).
- `/docs/geplante-aufgaben.md`
- Zentrale geplante Aufgaben (Scheduler), Job-Konfiguration und Runner-Betrieb.
- `/docs/globale-suche.md`
- Globale Suche erweitern (Resource, Permission, Mapping, Aside-Integration).
## API Referenz
- `/docs/api.md`
- API Quick Reference, Auth, Flows, Beispielaufrufe.
- `/docs/openapi.yaml`
- API Source of truth für Swagger UI.
## Betriebswissen
- `/docs/konfiguration.md`
- Alle ENV-Variablen mit Beschreibung, Standardwerten und Produktions-Checkliste.
- `/docs/fehlerbehebung.md`
- Häufige Fehlerbilder und schnelle Lösungen.
- `/docs/docker-lokal.md`
- Schneller lokaler Docker-Start inkl. typischer Befehle und Troubleshooting.
- `/docs/docker-produktiv.md`
- Produktionsbetrieb mit Domain, TLS und `docker-compose.prod.yml`.
- `/docs/docker-betrieb.md`
- Einstieg und Navigation zwischen lokaler und produktiver Docker-Doku.

103
docs/konfiguration.md Normal file
View File

@@ -0,0 +1,103 @@
# Konfiguration (ENV-Variablen)
Letzte Aktualisierung: 2026-02-21
Alle Konfiguration erfolgt über Umgebungsvariablen in der `.env`-Datei.
Vorlage: `.env.example` — niemals echte Credentials committen.
---
## App
| Variable | Standard | Beschreibung |
|---|---|---|
| `APP_NAME` | `App` | Anwendungsname (wird in UI-Titeln und E-Mails verwendet) |
| `APP_URL` | `http://localhost:8080` | Basis-URL der Anwendung (für Links in E-Mails und Redirects) |
| `APP_ENV` | `dev` | Laufzeitumgebung (`dev` oder `prod`) — beeinflusst Fehlerausgabe und Caching |
| `APP_DEBUG` | `true` | Debug-Modus aktivieren (`true`/`false`) — in Produktion auf `false` setzen |
| `APP_TIMEZONE` | `Europe/Berlin` | PHP-Zeitzone für `date_default_timezone_set()` |
| `APP_STORAGE_PATH` | `./storage` | Pfad zum Storage-Verzeichnis für Uploads und Caches |
| `APP_CRYPTO_KEY` | _(leer)_ | Schlüssel für symmetrische Verschlüsselung (64-stelliger Hex-Key **oder** Base64 mit 32 Byte) — **Pflichtfeld in Produktion** |
| `APP_LOCALE` | `de` | Standard-Sprache der Anwendung |
| `APP_LOCALES` | `de,en` | Komma-getrennte Liste aller unterstützten Sprachen |
| `APP_I18N_DOMAIN` | `default` | Gettext-Domain — bestimmt welche `i18n/default_*.json` geladen wird, normalerweise nicht ändern |
---
## Session
| Variable | Standard | Beschreibung |
|---|---|---|
| `SESSION_NAME` | `app_session` | Name des Session-Cookies im Browser |
---
## Tenant
| Variable | Standard | Beschreibung |
|---|---|---|
| `TENANT_SCOPE_STRICT` | `true` | Erzwingt strikte Tenant-Isolation (`true`/`false`) — in Produktion immer `true` |
---
## Datenbank
| Variable | Standard | Beschreibung |
|---|---|---|
| `DB_HOST` | `db` | Hostname des MySQL/MariaDB-Servers (Docker-Service-Name oder IP) |
| `DB_PORT` | `3306` | Port des Datenbankservers |
| `DB_NAME` | `imvs` | Name der Datenbank |
| `DB_USER` | `imvs` | Datenbankbenutzer |
| `DB_PASS` | `imvs` | Passwort des Datenbankbenutzers |
| `DB_ROOT_PASSWORD` | `imvs_root` | Root-Passwort für Docker-Compose (wird nur vom DB-Container genutzt, nicht von PHP) |
---
## Cache
| Variable | Standard | Beschreibung |
|---|---|---|
| `CACHE_SERVERS` | `memcached:11211` | Memcached-Server als `host:port` (Docker-Service-Name oder IP) |
---
## Firewall
Die Firewall begrenzt gleichzeitige Requests pro IP (Concurrency-Schutz).
| Variable | Standard | Beschreibung |
|---|---|---|
| `FIREWALL_CONCURRENCY` | `10` | Max. gleichzeitige Requests pro IP |
| `FIREWALL_SPINLOCK_SECONDS` | `0.15` | Wartezeit in Sekunden zwischen Concurrency-Prüfungen |
| `FIREWALL_INTERVAL_SECONDS` | `300` | Zeitfenster in Sekunden für die Concurrency-Messung |
| `FIREWALL_CACHE_PREFIX` | `fw_concurrency_` | Prefix für Memcached-Keys der Firewall |
| `FIREWALL_REVERSE_PROXY` | `false` | Auf `true` setzen wenn die App hinter einem Reverse Proxy (Nginx, Traefik) läuft — sonst wird die Proxy-IP statt der echten Client-IP verwendet |
---
## SMTP (E-Mail)
| Variable | Standard | Beschreibung |
|---|---|---|
| `SMTP_HOST` | `smtp.example.com` | Hostname des SMTP-Servers |
| `SMTP_PORT` | `587` | Port des SMTP-Servers (587 = STARTTLS, 465 = SSL) |
| `SMTP_USER` | `no-reply@example.com` | SMTP-Benutzername / Absenderadresse |
| `SMTP_PASS` | _(leer)_ | SMTP-Passwort |
| `SMTP_FROM` | `no-reply@example.com` | Absender-E-Mail-Adresse |
| `SMTP_FROM_NAME` | `App` | Absendername in ausgehenden E-Mails |
| `SMTP_SECURE` | `tls` | Verschlüsselungstyp: `tls` (STARTTLS) oder `ssl` |
---
## Produktions-Checkliste
Folgende Variablen **müssen** vor Go-Live angepasst werden:
- [ ] `APP_ENV=prod`
- [ ] `APP_DEBUG=false`
- [ ] `APP_CRYPTO_KEY` — z. B. 64-stelligen Hex-Key generieren: `openssl rand -hex 32`
- [ ] `APP_URL` — auf die echte Domain setzen
- [ ] `TENANT_SCOPE_STRICT=true`
- [ ] `FIREWALL_REVERSE_PROXY=true` falls hinter Nginx/Traefik
- [ ] Alle DB-Credentials auf sichere Werte setzen
- [ ] Alle SMTP-Credentials auf echte Werte setzen

126
docs/konventionen.md Normal file
View File

@@ -0,0 +1,126 @@
# Konventionen
Letzte Aktualisierung: 2026-02-22
Diese Regeln sind projektweit verbindlich, um Lesbarkeit und Wartbarkeit stabil zu halten.
## 1) Allgemein
- ASCII als Standard, außer bestehende Datei nutzt bereits Unicode.
- Keine toten Pfade/Codezweige einbauen.
- Wiederholung lieber zentralisieren (Partial/Helper/Utility) als kopieren.
- Kleine, fokussierte Commits/Changesets bevorzugen.
## 2) PHP-Schichten
### Repository
- Enthalten SQL und Filterlogik.
- Keine HTTP-/Session-/Render-Logik.
- Prepared Statements und bestehende Query-Utilities nutzen.
### Service
- Geschäftsregeln, Validierung, Orchestrierung.
- Keine HTML-Ausgabe.
- Keine direkten View-Annahmen.
- Bei globalen Settings bleibt DB die Quelle (`SettingService`); Datei-Cache nur gezielt für `appSetting(...)`.
### Action (`pages/**.php`)
- Request lesen, Zugriff prüfen, Service aufrufen, Variablen für View setzen.
- Redirects/Flash-Messages hier zentral steuern.
### Views (`*.phtml`)
- Reines Rendering.
- Keine DB-Calls.
- HTML escapen (`e(...)`) außer bewusst gewünscht.
## 3) UI-Konventionen
### Edit-Seiten
- Einheitliche Titelzeile mit `/templates/partials/app-details-titlebar.phtml`.
- In der Titelzeile nur Primäraktionen halten (typisch: `Save`, `Save & close`).
- Sekundäraktionen nicht im Drei-Punkte-Menü verstecken, sondern im Aside als einklappbarer Block `Actions` über `/templates/partials/app-details-aside-actions.phtml`.
- Partial-Naming klar trennen:
- linkes Haupt-Aside: `app-main-aside*`
- rechtes Details-Aside: `app-details-aside*`
- Tab-Reihenfolge:
- erster Tab `Master data`
- später `Visibility`
- optional letzter Tab `Danger zone`
- Statusfeld über `/templates/partials/app-visibility-status-field.phtml`.
- Delete-Aktion über `/templates/partials/app-danger-zone-delete-field.phtml`.
- Aside-Metadaten über:
- `/templates/partials/app-details-aside-audit.phtml`
- `/templates/partials/app-details-aside-ids.phtml`
- Tenant-Zusatzfelder:
- Definitionen werden im Tenant-Tab `Custom fields` über eigene POST-Actions gepflegt (nicht über Tenant-Hauptsave).
- `field_key` wird nicht im UI gepflegt; serverseitig aus Label erzeugt und tenant-weit eindeutig gehalten.
- `is_filterable` nur für `select`, `multiselect`, `boolean`, `date` anbieten.
- User-Werte werden im User-Tab `Custom fields` tenantweise gruppiert gerendert.
- User-Zusatzfelder sind optional; keine Pflichtvalidierung in V1.
### Listen
- Grid.js als Standard.
- Filter/Toolbar konsistent halten.
- Aktionsspalten nur wenn wirklich nötig; sonst Double-Click/Row-Action nutzen.
- Dynamische Address-Book-Filter für Zusatzfelder folgen festen Parametern:
- `cf_<definition_uuid>` (scalar/select/bool)
- `cfm_<definition_uuid>` (multiselect als CSV)
- `cfd_<definition_uuid>_from|to` (date range)
## 4) CSS
- Prefix `app-` für eigene Klassen/Dateien.
- Vendor-Anpassungen in `/web/css/vendor-overrides`.
- Layering über `/web/css/app-layers.css`.
- Theme-/Contrast-Werte über zentrale Variablen in `/web/css/base`.
## 5) JavaScript
- Struktur:
- `core/` für Basismodule
- `components/` für wiederverwendbare UI-Module
- `pages/` für seitenspezifische Logik
- Initialisierung zentral in `app-init.js` bzw. `app-login-init.js`.
- Defensive DOM-Checks (Element kann auf manchen Seiten fehlen).
- Keine Business-Logik im Frontend verstecken.
## 6) i18n
- Alle sichtbaren UI-Texte über `t('...')`.
- Übersetzungs-Keys in `i18n/default_de.json` und `i18n/default_en.json` pflegen.
- Test nutzen: `tests/I18n/TranslationKeysTest.php`.
## 7) Qualitätssicherung
Vor Merge mindestens:
- PHPUnit
- PHPStan
- ESLint (web/js)
- Betroffene Kern-Userflows manuell testen
Checkliste siehe:
`/docs/entwickler-checkliste.md`
### Dependency-Hygiene (periodisch, nicht bei jedem Commit)
Ungenutzte Composer-Pakete prüfen:
```bash
docker compose exec php vendor/bin/composer-unused
```
Ausführen bei: Feature-Entfernung, größerem Refactoring oder vor Releases. Pakete die fälschlicherweise als unused gemeldet werden (z.B. dynamisch geladen), können in `composer.json` unter `extra.unused` ignoriert werden.
## 8) Settings-Konvention
- Neue Settings immer zuerst sauber im `SettingService` modellieren (Validierung + Read/Write).
- Nur dann in `SettingCacheService::update(...)` aufnehmen, wenn der Key wirklich über `appSetting(...)` im Hot Path genutzt wird.
- Keine Sicherheits-/Integrationssettings (SMTP, SSO-Secrets, API-Policies) als Pflicht in den Datei-Cache drücken.
- Bei manuellen DB-Änderungen an gecachten Keys den Cache gezielt aktualisieren (z. B. über Settings-Save-Flow).

View File

@@ -0,0 +1,70 @@
# Lokale Entwicklung
Letzte Aktualisierung: 2026-02-21
## Ziel
Pragmatischer Tages-Workflow für Entwicklung, Tests und Migrationen.
## Start/Stop
```bash
docker compose up -d
docker compose down
```
Hinweis: `docker compose up -d` startet auch den Service `scheduler` (globaler Minutely-Runner).
Bei größeren Refactors oder merkwürdigem Laufzeitverhalten:
```bash
docker compose restart php nginx
```
## Datenbank und Schema-Updates
- Basis-Schema + Seeds: `db/init/init.sql`
- Das Repo nutzt aktuell ein konsolidiertes Init-Schema (keine separaten Migrationsdateien in `db/init/`).
- Für Bestandsumgebungen Schema-/Permission-Änderungen als idempotentes SQL-Update bereitstellen und manuell ausführen.
Beispiel (bestehende Umgebung, SQL-Update manuell ausführen):
```bash
docker compose exec -T db sh -lc \
'mariadb -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" < /tmp/release-update.sql'
```
## Typischer Implementierungsablauf
1. Requirement klären
2. Repository anpassen (SQL)
3. Service anpassen (Business-Regeln)
4. Action/Page anpassen
5. i18n-Keys ergänzen (`i18n/default_de.json`, `i18n/default_en.json`)
6. Doku anpassen (`docs/*`)
## Pflicht-Checks vor Commit
```bash
docker compose exec php vendor/bin/phpunit
docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress
npx eslint web/js
```
Optional gezielt:
```bash
docker compose exec php vendor/bin/phpunit tests/I18n/TranslationKeysTest.php
```
## API-spezifisch
- OpenAPI aktualisieren: `docs/openapi.yaml`
- API-Quickref aktualisieren: `docs/api.md`
- Bei Security-Änderungen auch `docs/sicherheitsmodell.md` aktualisieren
## Bei Unsicherheit
- Architektur zuerst lesen: `/docs/architektur.md`
- Sicherheitsregeln prüfen: `/docs/sicherheitsmodell.md`
- Abschluss immer gegen `/docs/entwickler-checkliste.md`

1534
docs/openapi.yaml Normal file

File diff suppressed because it is too large Load Diff

128
docs/sicherheitsmodell.md Normal file
View File

@@ -0,0 +1,128 @@
# Sicherheitsmodell
Letzte Aktualisierung: 2026-02-21
## 1) Grundprinzip
Die Anwendung kombiniert mehrere Sicherheitslayer:
1. Request-Grenze (Nginx + Minty Firewall)
2. Session/Auth-Guard
3. Permission-Checks
4. Tenant-Scope-Checks
5. Endpoint-spezifische Schutzlogik (z. B. Avatar-Dateien, Exporte, Actions)
Kein einzelner Layer ist ausreichend; die Kombination ist der Schutz.
## 2) Authentifizierung
- Session-basierter Login.
- Optionales Remember-Me über `user_remember_tokens`.
- Auto-Login aus Cookie erfolgt früh in `/web/index.php`.
- Zentraler Login-Entry ist `login` (optional `login?tenant=<slug>` für Tenant-Vorauswahl).
- Unknown-Email-Verhalten im Login bleibt generisch (keine Tenant-/Account-Details vor erfolgreicher Auflösung).
Kritisch:
- Tokens sind gehasht gespeichert.
- Tokens können zentral ablaufen gelassen werden (Settings-Gefahrenzone).
## 3) Public vs. Protected Routes
- Public Routes werden in `/config/routes.php` mit `public => true` definiert.
- Daraus wird zur Laufzeit `APP_PUBLIC_PATHS` aufgebaut.
- Alles andere ist loginpflichtig.
## 4) Permission-System
- Berechtigungen sind feingranular (`permissions` + `role_permissions`).
- Rollen vergeben Berechtigungen an User (`user_roles`).
- Actions prüfen Berechtigungen explizit.
Empfehlung:
- Rechte für View/Edit/Delete getrennt halten.
- Admin-only Endpunkte immer serverseitig absichern, nicht nur im UI verstecken.
- Onboarding-PDFs für Benutzer laufen über eigenes Recht `users.access_pdf` (Single + Bulk).
- Tenant-Zusatzfelder sind getrennt abgesichert:
- `custom_fields.manage` für Definitionen/Optionen im Tenant
- `custom_fields.edit_values` für Wertepflege am User.
- `field_key` wird serverseitig erzeugt/stabil gehalten (kein editierbares UI-Feld), damit tenant-weite Eindeutigkeit erhalten bleibt.
- Theme-Wechsel für User wird tenant-spezifisch abgesichert:
- effektive Regel kommt aus `current_tenant.allow_user_theme` (Fallback global `app_theme_user`)
- Endpoint `admin/users/theme` blockt bei deaktivierter Regel mit `403` (`theme_disabled`).
- Tenant-Microsoft-SSO ist separat abgesichert:
- `tenants.sso_manage` für Pflege der Tenant-SSO-Konfiguration.
- Shared-App-Credentials bleiben unter `settings.update`.
- Secrets werden nur verschlüsselt gespeichert (`APP_CRYPTO_KEY` + AES-256-GCM), nie im Klartext gerendert.
- Profil-Sync ist tenant-spezifisch konfigurierbar (`sync_profile_on_login`, `sync_profile_fields`).
- Erlaubte Sync-Felder: `first_name`, `last_name`, `phone`, `mobile`, `avatar`.
- Graph-basierte Felder (`phone/mobile/avatar`) laufen fail-open: Login bleibt erfolgreich, auch wenn Graph nicht verfügbar ist.
## 5) Tenant Scope
- Datenzugriff für tenant-gebundene Inhalte erfolgt nur bei Tenant-Match.
- Departments sind genau einem Tenant zugeordnet (`departments.tenant_id`).
- Beim Speichern von User-Zuordnungen werden Departments serverseitig gegen die aktuell zugewiesenen User-Tenants gefiltert; ungültige Department-Zuordnungen werden entfernt.
- User-Zusatzfeldwerte werden auf Tenant-Wechsel bereinigt (Werte außerhalb der aktuell zugewiesenen Tenants werden gelöscht).
- Filterbare Zusatzfelder sind auf sichere Typen begrenzt (`select`, `multiselect`, `boolean`, `date`); freie Texttypen werden nicht als dynamische Filter zugelassen.
- Theme-Auflösung ist tenant-spezifisch: `default_theme` und `allow_user_theme` werden je `current_tenant` aufgelöst; ohne Tenant-Snapshot greifen globale Defaults.
- SSO-Mapping bleibt tenant-scoped:
- Primär über externe Identität (`provider + tid + oid`).
- Fallback einmalig über E-Mail (create-or-link), danach feste Identity-Linkung.
- Domain-Allowlist (falls gesetzt) wird im Callback serverseitig erzwungen.
- Profil-Sync überschreibt nur konfigurierte Felder und ignoriert leere Quelle-Werte;
`email` bleibt nach JIT-Create unverändert (insert-only).
- Lokaler/SSO-Login ist nur mit mindestens einem aktiven User-Tenant erlaubt (kein Sonderfall ohne Tenant).
- Strict/Non-Strict Verhalten über `TENANT_SCOPE_STRICT`.
- Inaktiv-Status von Tenants beeinflusst Sichtbarkeit (je nach Kontext).
## 6) Verbotene Zugriffe
- Bei fehlender Permission/Tenant-Bindung: forbidden flow (403 Seite) statt stilles Durchrutschen.
- Keine sensitiven Details in Fehlermeldungen leaken.
## 7) Datei-/Media-Endpunkte
- Avatar/Favicon/Logo Endpunkte prüfen Session + Zugriffskontext.
- Storage liegt außerhalb direkter Business-Logik; Zugriff nur über kontrollierte Endpunkte/Symlinks.
## 8) CSRF und Form-Schutz
- Schreibaktionen nutzen CSRF-Token.
- Delete/Status-Aktionen nur per POST und serverseitiger Validierung.
- Bulk-Download für Onboarding-PDF (`admin/users/access-pdf-bulk`) ist POST+CSRF, Scope bleibt aktiv.
- OIDC-Loginfluss nutzt `state`, `nonce` und PKCE; Callback validiert zusätzlich `aud`, `iss`, `tid`, Signatur (JWKS), `exp/nbf`.
## 9) Operative Checks
Regelmäßig prüfen:
- Inaktive Rollen/Permissions werden nicht mehr aktiv zugewiesen/ausgewertet.
- Tenant-Entzüge invalidieren Sichtkontext erwartungsgemäß.
- Remember-Tokens können zentral invalidiert werden.
- Mail-Log enthält Fehlerdaten für Auditing.
- User-Lifecycle-Policy (falls aktiv) läuft täglich und setzt inaktive Accounts automatisch auf `inactive`
bzw. löscht sie später (2-stufig), privilegierte Admins sind ausgenommen.
- Delete schreibt vorher zwingend ein Audit-Snapshot (fail-closed).
- Snapshot ist verschlüsselt gespeichert (`snapshot_enc`) und enthält keine Secrets/Tokens.
- Restore ist separat berechtigt (`users.lifecycle_restore`) und stellt bewusst keine Assignments wieder her.
- Geplante Aufgaben (Scheduler) sind Whitelist-basiert:
- keine frei ausführbaren Shell-Kommandos aus der Datenbank.
- zentrale Rechte: `jobs.view`, `jobs.manage`, `jobs.run_now`.
- Runner-Lock verhindert parallele Mehrfach-Ausführung.
## 10) API-Audit
- Alle `/api/v1/*` Requests werden zentral mit Metadaten in `api_audit_log` protokolliert.
- V1 speichert keine Request-/Response-Bodies, nur Metadaten (Pfad, Status, Dauer, Scope-Kontext).
- Query-Parameter werden redacted gespeichert (z. B. Token/Secret/Password-Felder).
- Zugriff auf die Audit-UI ist über Permission `api_audit.view` abgesichert.
- Retention ist auf 90 Tage ausgelegt (Bereinigung per Admin-Action oder Cron).
## 11) User-Lifecycle-Audit
- Lifecycle-Audit-UI ist über `user_lifecycle_audit.view` abgesichert.
- Restore aus Delete-Snapshot ist getrennt über `users.lifecycle_restore` abgesichert.
- Ohne gültigen Snapshot wird kein Auto-Delete ausgeführt (fail-closed).
- Retention für Lifecycle-Audit-Einträge: 365 Tage.

View File

@@ -1,24 +0,0 @@
# 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.

View File

@@ -1,63 +0,0 @@
# TODO: Editor.js Image Tool Integration
## Goal
Integrate the Editor.js **Image Tool** with proper upload endpoints, storage, and access control.
## Why
- Editor.js expects uploads to respond with `{ success: 1, file: { url: "..." } }`.
- We want private storage with a controlled proxy (like avatars) and optional resizing.
## Open Decisions
- Public access? (e.g. impressum pages should be public)
- Allowed file types (recommended: PNG/JPG/WebP; avoid SVG for security)
- Resize policy (max width + WebP variants?)
## Proposed Structure
### Storage
- `storage/pages/{page_uuid}/assets/{asset_uuid}/original.<ext>`
- Optional variants: `w1280.webp`, `w640.webp`, etc.
### DB
Create `page_assets` table:
- `id`, `uuid`, `page_id`, `locale`
- `file_name`, `mime`, `size`, `width`, `height`
- `created_by`, `created`
### Endpoints
1) **Upload by file**
`pages/page/upload-image(none).phtml`
- Accepts multipart (field name `image`).
- Validates MIME/size.
- Stores file + creates DB record.
- Returns `{ success: 1, file: { url } }`.
2) **Fetch by URL** (optional)
`pages/page/fetch-image(none).phtml`
- Accepts JSON `{ url: "..." }`.
- Downloads, validates, stores like upload.
- Returns same response.
3) **Media proxy**
`pages/page/media(none).phtml?uuid=...`
- Checks permissions (public vs. loggedin).
- Reads storage file, sets headers, returns file.
### Editor.js config
Add `image` tool to `web/js/components/page-editor.js`:
```js
image: {
class: ImageTool,
config: {
endpoints: {
byFile: 'page/upload-image',
byUrl: 'page/fetch-image'
}
}
}
```
## Next Steps
- Decide public vs. private access rules.
- Implement service + migration + endpoints.
- Add tool config and i18n messages.

56
eslint.config.cjs Normal file
View File

@@ -0,0 +1,56 @@
/** @type {import('eslint').Linter.FlatConfig[]} */
module.exports = [
{
ignores: [
'web/vendor/**',
'vendor/**',
'node_modules/**',
],
},
{
files: ['web/js/**/*.js'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: {
window: 'readonly',
document: 'readonly',
navigator: 'readonly',
location: 'readonly',
history: 'readonly',
localStorage: 'readonly',
sessionStorage: 'readonly',
console: 'readonly',
URL: 'readonly',
URLSearchParams: 'readonly',
FormData: 'readonly',
MutationObserver: 'readonly',
CustomEvent: 'readonly',
Event: 'readonly',
fetch: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
setInterval: 'readonly',
clearInterval: 'readonly',
},
},
linterOptions: {
reportUnusedDisableDirectives: 'error',
},
rules: {
'no-debugger': 'error',
'no-redeclare': 'error',
'no-unused-vars': [
'error',
{
args: 'none',
caughtErrors: 'none',
ignoreRestSiblings: true,
},
],
'no-console': ['warn', { allow: ['warn', 'error'] }],
eqeqeq: ['error', 'always'],
curly: ['error', 'all'],
},
},
];

View File

@@ -1,13 +1,22 @@
{
"Login": "Login",
"Login options": "Anmeldeoptionen",
"Register": "Registrieren",
"Password": "Passwort",
"Or": "Oder",
"Forgot password": "Passwort vergessen",
"Remember me": "Angemeldet bleiben",
"Clear login tokens": "Login-Tokens löschen",
"Clear all login tokens for this user?": "Alle Login-Tokens für diesen Benutzer löschen?",
"Login tokens cleared": "Login-Tokens gelöscht",
"Send access": "Zugang versenden",
"Access PDF": "Zugangs-PDF",
"Download access PDFs": "Zugangs-PDFs herunterladen",
"Generate access PDFs for selected users?": "Zugangs-PDFs für ausgewählte Benutzer erstellen?",
"Too many users selected": "Zu viele Benutzer ausgewählt",
"No valid users selected": "Keine gültigen Benutzer ausgewählt",
"Failed to generate access PDF": "Zugangs-PDF konnte nicht erstellt werden",
"ZIP support missing (ext-zip)": "ZIP-Unterstützung fehlt (ext-zip)",
"Send access email to this user?": "Zugangsdaten an diesen Benutzer senden?",
"Access email sent": "Zugangsdaten gesendet",
"Access email failed": "Zugangsdaten konnten nicht gesendet werden",
@@ -37,8 +46,19 @@
"Logout": "Logout",
"Account": "Konto",
"Please enter your credentials": "Bitte geben Sie Ihre Zugangsdaten ein",
"Enter your email to continue": "E-Mail eingeben und weiter",
"Select your tenant to continue": "Mandant auswählen und weiter",
"Choose how you want to sign in": "Anmeldeart auswählen",
"Continue": "Weiter",
"Use different email": "Andere E-Mail verwenden",
"We could not continue with these login details": "Mit diesen Login-Daten konnte nicht fortgefahren werden",
"Cached": "Cache",
"Global settings are stored in the database. config/settings.php is only a runtime cache for selected app settings.": "Globale Einstellungen werden in der Datenbank gespeichert. config/settings.php ist nur ein Laufzeit-Cache für ausgewählte App-Einstellungen.",
"Too many login attempts. Please wait and try again.": "Zu viele Login-Versuche. Bitte warte kurz und versuche es erneut.",
"No login method is available for this tenant": "Für diesen Mandanten ist keine Login-Methode verfügbar",
"Create your account": "Erstelle dein Konto",
"First name": "Vorname",
"Name": "Name",
"Last name": "Nachname",
"Email": "E-Mail",
"Password (again)": "Passwort bestätigen",
@@ -99,6 +119,7 @@
"perm.users.view": "Benutzer anzeigen",
"perm.users.create": "Benutzer erstellen",
"perm.users.update": "Benutzer bearbeiten",
"perm.users.access_pdf": "Onboarding-PDFs erstellen",
"perm.users.delete": "Benutzer löschen",
"perm.users.self_update": "Eigenen Benutzer bearbeiten",
"perm.users.update_assignments": "Benutzerzuweisungen bearbeiten",
@@ -122,6 +143,11 @@
"perm.settings.view": "Einstellungen anzeigen",
"perm.settings.update": "Einstellungen bearbeiten",
"perm.mail_log.view": "E-Mail-Protokolle anzeigen",
"perm.jobs.view": "Geplante Aufgaben anzeigen",
"perm.jobs.manage": "Geplante Aufgaben verwalten",
"perm.jobs.run_now": "Geplante Aufgaben manuell ausführen",
"perm.user_lifecycle_audit.view": "Benutzer-Lifecycle-Protokolle anzeigen",
"perm.users.lifecycle_restore": "Benutzer aus Lifecycle-Protokoll wiederherstellen",
"Select row": "Zeile auswählen",
"Select all": "Alle auswählen",
"Activate": "Aktivieren",
@@ -226,6 +252,7 @@
"Favicon preview": "Favicon Vorschau",
"Primary color": "Primärfarbe",
"Primary color is invalid": "Primärfarbe ist ungültig",
"The primary color affects buttons and accent elements across the app.": "Die Primärfarbe beeinflusst Buttons und Akzent-Elemente in der gesamten App.",
"Show filters": "Filter anzeigen",
"Hide filters": "Filter ausblenden",
"Export CSV": "CSV exportieren",
@@ -233,15 +260,43 @@
"State": "Status",
"Edit": "Bearbeiten",
"Status": "Status",
"Visibility": "Sichtbarkeit",
"Inactive entries are hidden in lists and selections.": "Inaktive Einträge werden in Listen und Auswahlen ausgeblendet.",
"All": "Alle",
"All people": "Alle Personen",
"Explorer": "Explorer",
"Search": "Suche",
"Search...": "Suchen...",
"To start searching, type in the search field.": "Um die Suche zu starten, gib etwas in das Suchfeld ein.",
"Tip: Use {shortcut} to focus search quickly.": "Tipp: Mit {shortcut} fokussierst du die Suche schnell.",
"Created from": "Erstellt von",
"Created to": "Erstellt bis",
"Files": "Dateien",
"Settings": "Einstellungen",
"Documentation": "Dokumentation",
"Documentation navigation": "Dokumentationsnavigation",
"On this page": "Auf dieser Seite",
"Getting started": "Erste Schritte",
"Local development": "Lokale Entwicklung",
"First change": "Erster Code-Change",
"Developer checklist": "Entwickler-Checkliste",
"Architecture & rules": "Architektur & Regeln",
"Architecture": "Architektur",
"Security model": "Sicherheitsmodell",
"Conventions": "Konventionen",
"Frontend": "Frontend",
"Features": "Features",
"Settings storage": "Einstellungsspeicher",
"Rate limiting": "Rate-Limiting",
"User lifecycle": "Benutzer-Lebenszyklus",
"Global search": "Globale Suche",
"API reference": "API-Referenz",
"Operations": "Betrieb",
"Troubleshooting": "Fehlerbehebung",
"Configuration": "Konfiguration",
"Automation & integrations": "Automatisierung & Integrationen",
"Monitoring": "Überwachung",
"Logs": "Protokolle",
"System": "System",
"System permission": "Systemberechtigung",
"System permission can not be deleted": "Systemberechtigung kann nicht gelöscht werden",
@@ -286,7 +341,11 @@
"No records found": "Keine Einträge gefunden",
"An error happened while fetching the data": "Fehler beim Laden der Daten",
"Delete this user?": "Diesen Benutzer wirklich löschen?",
"Delete user": "Benutzer löschen",
"This will permanently delete this user.": "Dieser Benutzer wird dauerhaft gelöscht.",
"Delete this tenant?": "Diesen Mandanten wirklich löschen?",
"Delete tenant": "Mandant löschen",
"This will permanently delete this tenant.": "Dieser Mandant wird dauerhaft gelöscht.",
"Tenant": "Mandant",
"Default tenant": "Standard-Mandant",
"Default role": "Standard-Rolle",
@@ -311,12 +370,24 @@
"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.api_token_default_ttl_days": "Standard-Laufzeit für neue API-Tokens in Tagen (Default: 90)",
"setting.api_token_max_ttl_days": "Maximale Laufzeit für API-Tokens in Tagen (Hard Cap)",
"setting.api_cors_allowed_origins": "Erlaubte Browser-Origins für API-CORS (eine Origin pro Zeile)",
"setting.app_primary_color": "Überschreibt die Standard-Primärfarbe (Hex wie #2FA4A4)",
"API token default lifetime (days)": "Standardlaufzeit für API-Tokens (Tage)",
"API token maximum lifetime (days)": "Maximale Laufzeit für API-Tokens (Tage)",
"Allowed CORS origins": "Erlaubte CORS-Origins",
"One origin per line, e.g. https://app.example.com": "Eine Origin pro Zeile, z. B. https://app.example.com",
"Default theme": "Standard-Theme",
"Allow user theme": "Benutzer-Theme erlauben",
"Allow registration": "Registrierung erlauben",
"General": "Allgemein",
"Appearance": "Darstellung",
"API settings": "API-Einstellungen",
"API docs": "API-Doku",
"API documentation": "API-Dokumentation",
"Can view API documentation": "Kann API-Dokumentation anzeigen",
"API tokens and default lifetimes are managed here.": "API-Tokens und Standard-Laufzeiten werden hier verwaltet.",
"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.",
@@ -329,7 +400,7 @@
"Call phone": "Telefon anrufen",
"Call mobile": "Mobil anrufen",
"Choose color": "Farbe wählen",
"Be careful - this resets the tenants color": "Achtung das setzt die Mandantenfarbe zurück",
"Be careful - this resets the tenants color": "Achtung das setzt die Primärfarbe des Mandanten zurück",
"Code": "Code",
"Cost center": "Kostenstelle",
"Department code already exists": "Abteilungscode existiert bereits",
@@ -345,10 +416,12 @@
"Tenant updated": "Mandant aktualisiert",
"Tenant deleted": "Mandant gelöscht",
"Tenant not found": "Mandant nicht gefunden",
"Tenant can not be deleted while departments are assigned": "Mandant kann nicht gelöscht werden, solange Abteilungen zugewiesen sind",
"Tenant can not be created": "Mandant kann nicht erstellt werden",
"Tenant can not be updated": "Mandant kann nicht aktualisiert werden",
"Tenant image": "Mandantenbild",
"Assigned tenants": "Zugewiesene Mandanten",
"Assigned tenant": "Zugewiesener Mandant",
"Primary tenant": "Hauptmandant",
"Select primary tenant": "Hauptmandant auswählen",
"Please select a primary tenant": "Bitte einen Hauptmandanten auswählen",
@@ -357,6 +430,8 @@
"Departments & roles": "Abteilungen und Rollen",
"Department assignments cleaned: %d": "Abteilungszuweisungen bereinigt: %d",
"Select tenants": "Mandanten auswählen",
"Select tenant": "Mandant auswählen",
"Which tenant do you want to sign in to?": "Mit welchem Mandanten anmelden?",
"Assigned roles": "Zugewiesene Rollen",
"Select roles": "Rollen auswählen",
"Department": "Abteilung",
@@ -370,8 +445,14 @@
"Department can not be created": "Abteilung kann nicht erstellt werden",
"Department can not be updated": "Abteilung kann nicht aktualisiert werden",
"Delete this department?": "Diese Abteilung wirklich löschen?",
"Delete department": "Abteilung löschen",
"This will permanently delete this department.": "Diese Abteilung wird dauerhaft gelöscht.",
"Assigned departments": "Zugewiesene Abteilungen",
"Select departments": "Abteilungen auswählen",
"Departments for tenant": "Abteilungen für Mandant",
"No departments available for this tenant": "Für diesen Mandanten sind keine Abteilungen verfügbar",
"Assign tenants first to select departments": "Bitte zuerst Mandanten zuweisen, um Abteilungen auszuwählen",
"Department options refresh after save": "Abteilungsoptionen werden nach dem Speichern aktualisiert",
"Role": "Rolle",
"Roles": "Rollen",
"Create role": "Rolle anlegen",
@@ -383,6 +464,11 @@
"Role can not be created": "Rolle kann nicht erstellt werden",
"Role can not be updated": "Rolle kann nicht aktualisiert werden",
"Delete this role?": "Diese Rolle wirklich löschen?",
"Delete role": "Rolle löschen",
"Delete permission": "Berechtigung löschen",
"This will permanently delete this role.": "Diese Rolle wird dauerhaft gelöscht.",
"This will permanently delete this permission.": "Diese Berechtigung wird dauerhaft gelöscht.",
"This action cannot be undone.": "Diese Aktion kann nicht rückgängig gemacht werden.",
"Description": "Beschreibung",
"Description cannot be empty": "Beschreibung darf nicht leer sein",
"Status is invalid": "Status ist ungültig",
@@ -431,14 +517,13 @@
"You do not have permission to view this content.": "Du hast keine Berechtigung, diesen Inhalt zu sehen.",
"If you believe this is an error, please contact your system administrator.": "Wenn du glaubst, dass dies ein Fehler ist, wende dich bitte an den Systemadministrator.",
"Requested URL": "Angeforderte URL",
"Audit": "Audit",
"Forward": "Vorwärts",
"Status changed": "Status geändert",
"Status changed by": "Status geändert von",
"Page": "Seite",
"Requested path": "Angeforderter Pfad",
"Page not found": "Seite nicht gefunden",
"The page you requested does not exist or has moved.": "Die angeforderte Seite existiert nicht oder wurde verschoben.",
"Back to start": "Zur Startseite",
"Page updated": "Seite aktualisiert",
"Page can not be updated": "Seite kann nicht aktualisiert werden",
"Content is invalid": "Inhalt ist ungültig",
@@ -480,7 +565,15 @@
"SMTP from address": "Absender-Adresse",
"SMTP from name": "Absendername",
"Address book": "Adressbuch",
"All people": "Alle Personen",
"Saved filters": "Gespeicherte Filter",
"Save filter": "Filter speichern",
"Enter a name for this filter": "Gib einen Namen für diesen Filter ein",
"Filter name is required": "Filtername ist erforderlich",
"Maximum number of saved filters reached": "Maximale Anzahl gespeicherter Filter erreicht",
"Filter saved": "Filter gespeichert",
"Delete saved filter": "Gespeicherten Filter löschen",
"Filter deleted": "Filter gelöscht",
"Filter save failed": "Filter konnte nicht gespeichert werden",
"Mobile": "Mobil",
"Short dial": "Kurzwahl",
"setting.smtp_host": "SMTP-Serveradresse (z.B. smtp.example.com)",
@@ -493,12 +586,34 @@
"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",
"API": "API",
"API requests (24h)": "API Requests (24h)",
"Request trend vs previous 24h": "Request-Trend zur vorherigen 24h",
"4xx errors (24h)": "4xx Fehler (24h)",
"5xx errors (24h)": "5xx Fehler (24h)",
"P95 response time (ms)": "P95 Antwortzeit (ms)",
"Tokens expiring in 7 days": "Tokens mit Ablauf in 7 Tagen",
"Top API error codes (24h)": "Top API-Fehlercodes (24h)",
"Slowest API endpoints (avg, 24h)": "Langsamste API-Endpunkte (Durchschnitt, 24h)",
"Import runs (7d)": "Import-Läufe (7 Tage)",
"Run trend vs previous 7d": "Lauftrend vs. vorige 7 Tage",
"Created rows (7d)": "Erstellte Zeilen (7 Tage)",
"Failed rows (7d)": "Fehlgeschlagene Zeilen (7 Tage)",
"Partial or failed runs (7d)": "Teilweise/fehlgeschlagene Läufe (7 Tage)",
"Success rate (7d)": "Erfolgsquote (7 Tage)",
"Average duration (ms, 7d)": "Durchschnittsdauer (ms, 7 Tage)",
"Recent import runs": "Letzte Import-Läufe",
"Imports by profile (7d)": "Importe nach Profil (7 Tage)",
"Top import error codes (7d)": "Häufigste Import-Fehlercodes (7 Tage)",
"Runs": "Läufe",
"Created rows": "Erstellte Zeilen",
"Failed rows": "Fehlgeschlagene Zeilen",
"Success rate": "Erfolgsquote",
"Requests": "Requests",
"Average duration (ms)": "Durchschnittliche Dauer (ms)",
"Maximum duration (ms)": "Maximale Dauer (ms)",
"Error": "Fehler",
"Active tenants": "Aktive Mandanten",
"Inactive tenants": "Inaktive Mandanten",
@@ -517,13 +632,16 @@
"Recent logins": "Letzte Anmeldungen",
"Never logged in": "Nie angemeldet",
"Last login": "Letzte Anmeldung",
"Last login via": "Letzte Anmeldung über",
"Local": "Lokal",
"Microsoft": "Microsoft",
"Unknown": "Unbekannt",
"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"
,
"Email unverified": "E-Mail nicht verifiziert",
"Password resets": "Passwort-Resets",
"Requested at": "Angefordert am",
"Expires at": "Gültig bis",
@@ -538,7 +656,7 @@
"%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.",
"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 sobald die Browser-Session ausgelaufen ist.",
"Last used": "Zuletzt verwendet",
"Keyboard shortcuts": "Tastenkürzel",
"Open search": "Suche öffnen",
@@ -556,5 +674,418 @@
"Toggle sidebar": "Sidebar umschalten",
"Unverified": "Nicht verifiziert",
"Imprint": "Impressum",
"Privacy": "Datenschutz"
"Privacy": "Datenschutz",
"Custom fields": "Zusatzfelder",
"User custom fields": "Benutzerzusatzfelder",
"Affected tenants": "Betroffene Mandanten",
"If a tenant appears here, at least one custom field setup needs review (missing options, inactive fields with values, or unused active fields).": "Wenn ein Mandant hier erscheint, muss mindestens eine Zusatzfeld-Konfiguration geprüft werden (fehlende Optionen, inaktive Felder mit Werten oder ungenutzte aktive Felder).",
"Issue": "Problem",
"Count": "Anzahl",
"Selection fields without options": "Auswahlfelder ohne Optionen",
"Tenants with broken selection fields": "Mandanten mit fehlerhaften Auswahlfeldern",
"Inactive custom fields with values": "Inaktive Zusatzfelder mit Werten",
"Unused active custom fields": "Ungenutzte aktive Zusatzfelder",
"Save tenant first to manage custom fields": "Speichern Sie den Mandanten zuerst, um Zusatzfelder zu verwalten",
"Define custom fields here. They appear per user in the \"Custom fields\" tab and can be maintained there.": "Hier definieren Sie Zusatzfelder. Diese erscheinen pro Benutzer im Reiter \"Zusatzfelder\" und sind dort pflegbar.",
"Add custom field": "Zusatzfeld hinzufügen",
"The label is shown in user custom fields and in address book filters.": "Die Bezeichnung wird bei Benutzer-Zusatzfeldern und in den Adressbuch-Filtern angezeigt.",
"The field type controls input and filter behavior.": "Der Feldtyp steuert Eingabe- und Filterverhalten.",
"Field key": "Feldschlüssel",
"Field type": "Feldtyp",
"Text": "Text",
"Textarea": "Mehrzeiliger Text",
"Select": "Auswahl",
"Multiselect": "Mehrfachauswahl",
"Boolean": "Ja/Nein",
"Date": "Datum",
"Sort order": "Sortierung",
"Filterable": "Filterbar",
"Options (one per line)": "Optionen (eine pro Zeile)",
"Example: manager|Manager": "Beispiel: manager|Manager",
"One option per line. Optional format: key|Label.": "Eine Option pro Zeile. Optionales Format: key|Label.",
"No custom fields configured yet": "Noch keine Zusatzfelder konfiguriert",
"Delete custom field": "Zusatzfeld löschen",
"Custom field saved": "Zusatzfeld gespeichert",
"Custom field deleted": "Zusatzfeld gelöscht",
"Custom field can not be created": "Zusatzfeld kann nicht erstellt werden",
"Custom field can not be updated": "Zusatzfeld kann nicht aktualisiert werden",
"Custom field can not be deleted": "Zusatzfeld kann nicht gelöscht werden",
"Custom field values can not be saved": "Zusatzfeldwerte konnten nicht gespeichert werden",
"Custom field not found": "Zusatzfeld nicht gefunden",
"Label cannot be empty": "Bezeichnung darf nicht leer sein",
"Field key cannot be empty": "Feldschlüssel darf nicht leer sein",
"Field type is invalid": "Feldtyp ist ungültig",
"Field key already exists": "Feldschlüssel existiert bereits",
"Options are required for this field type": "Für diesen Feldtyp sind Optionen erforderlich",
"Custom fields update after save": "Zusatzfelder werden nach Speichern/Neuladen aktualisiert",
"Assign tenants first to edit custom fields": "Bitte zuerst Mandanten zuweisen, um Zusatzfelder zu bearbeiten",
"Custom fields for tenant": "Zusatzfelder für Mandant",
"No custom fields configured for this tenant": "Für diesen Mandanten sind keine Zusatzfelder konfiguriert",
"No custom fields configured for selected tenants": "Für die ausgewählten Mandanten sind keine Zusatzfelder konfiguriert",
"Select options": "Optionen auswählen",
"Please fill required custom field: %s": "Bitte Pflicht-Zusatzfeld ausfüllen: %s",
"Invalid value for %s": "Ungültiger Wert für %s",
"Please select": "Bitte auswählen",
"From": "Von",
"To": "Bis",
"perm.custom_fields.manage": "Mandanten-Zusatzfelder verwalten",
"perm.custom_fields.edit_values": "Benutzer-Zusatzfelder bearbeiten",
"Label": "Bezeichnung",
"Required": "Pflichtfeld",
"Required fields must be filled before saving a user.": "Pflichtfelder müssen vor dem Speichern eines Benutzers ausgefüllt werden.",
"Filterable fields are available in address book filters.": "Filterbare Felder sind im Adressbuch als Filter verfügbar.",
"Inactive fields are hidden in forms and filters.": "Inaktive Felder sind in Formularen und Filtern ausgeblendet.",
"Default theme is invalid": "Standard-Theme ist ungültig",
"User theme policy is invalid": "Benutzer-Theme-Richtlinie ist ungültig",
"Theme change is disabled for this tenant": "Theme-Wechsel ist für diesen Mandanten deaktiviert",
"Inherit global setting": "Globale Einstellung übernehmen",
"Force allow user theme": "Benutzer-Theme erlauben (erzwingen)",
"Force disallow user theme": "Benutzer-Theme verbieten (erzwingen)",
"If set, this tenant uses its own default theme instead of the global setting.": "Wenn gesetzt, verwendet dieser Mandant sein eigenes Standard-Theme statt der globalen Einstellung.",
"Controls whether users in this tenant can choose their own theme.": "Steuert, ob Benutzer in diesem Mandanten ihr eigenes Theme wählen können.",
"Tenants can override color and theme behavior in their appearance settings.": "Mandanten können Farbe und Theme-Verhalten in ihren Darstellungs-Einstellungen überschreiben.",
"User theme policy": "Benutzer-Theme-Richtlinie",
"APP_CRYPTO_KEY is missing or invalid": "APP_CRYPTO_KEY fehlt oder ist ungültig",
"Allowed email domains": "Erlaubte E-Mail-Domains",
"App credentials source": "Quelle der App-Zugangsdaten",
"Authority URL": "Authority-URL",
"Clear override secret": "Override-Secret entfernen",
"Client ID override": "Client-ID-Override",
"Client ID override is required": "Client-ID-Override ist erforderlich",
"Client secret could not be encrypted": "Client-Secret konnte nicht verschlüsselt werden",
"Client secret override": "Client-Secret-Override",
"Client secret override is required": "Client-Secret-Override ist erforderlich",
"Configure Microsoft login per tenant. Local login can stay enabled or be enforced off.": "Konfiguriere Microsoft-Login pro Mandant. Der lokale Login kann aktiv bleiben oder erzwungen deaktiviert werden.",
"Disable password login for this tenant login": "Passwort-Login für diesen Mandanten-Login deaktivieren",
"Enable Microsoft login for this tenant": "Microsoft-Login für diesen Mandanten aktivieren",
"Enforcement": "Erzwingung",
"Entra tenant ID (tid)": "Entra-Tenant-ID (tid)",
"Entra tenant ID is invalid": "Entra-Tenant-ID ist ungültig",
"Login failed": "Login fehlgeschlagen",
"Microsoft SSO": "Microsoft SSO",
"Microsoft login": "Microsoft-Login",
"Microsoft login could not be started": "Microsoft-Login konnte nicht gestartet werden",
"Microsoft login failed": "Microsoft-Login fehlgeschlagen",
"Microsoft login is not allowed for this account": "Microsoft-Login ist für dieses Konto nicht erlaubt",
"Microsoft login is not available for this tenant": "Microsoft-Login ist für diesen Mandanten nicht verfügbar",
"Microsoft login is not fully configured for this tenant.": "Microsoft-Login ist für diesen Mandanten nicht vollständig konfiguriert.",
"Microsoft login was cancelled or failed": "Microsoft-Login wurde abgebrochen oder ist fehlgeschlagen",
"Microsoft-only login requires a complete configuration": "Nur-Microsoft-Login erfordert eine vollständige Konfiguration",
"No access to this tenant": "Kein Zugriff auf diesen Mandanten",
"Shared credentials missing": "Geteilte Zugangsdaten fehlen",
"Optional credential override": "Optionale Credential-Überschreibung",
"Optional restrictions": "Optionale Einschränkungen",
"Optional allow-list, comma or newline separated (example.com).": "Optionale Allow-List, per Komma oder Zeilenumbruch getrennt (example.com).",
"Password login is disabled for this tenant. Please use Microsoft login.": "Passwort-Login ist für diesen Mandanten deaktiviert. Bitte nutze Microsoft-Login.",
"Password login is disabled for all assigned tenants": "Passwort-Login ist für alle zugewiesenen Mandanten deaktiviert.",
"Password login mode": "Passwort-Login-Modus",
"Local password + Microsoft": "Lokales Passwort + Microsoft",
"Credentials source": "Quelle der Zugangsdaten",
"Shared app": "Geteilte App",
"Tenant override": "Mandanten-Override",
"SSO configuration status": "SSO-Konfigurationsstatus",
"Configuration complete": "Konfiguration vollständig",
"Required Microsoft configuration": "Pflichtkonfiguration Microsoft",
"Required for Microsoft login.": "Für Microsoft-Login erforderlich.",
"SSO": "SSO",
"Shared Microsoft app credentials are used by tenants that enable \"Use shared app credentials\".": "Geteilte Microsoft-App-Zugangsdaten werden von Mandanten verwendet, die \"Geteilte App-Zugangsdaten verwenden\" aktivieren.",
"Shared client ID": "Geteilte Client-ID",
"Shared client secret": "Geteiltes Client-Secret",
"Sign in with Microsoft": "Mit Microsoft anmelden",
"Tenant SSO settings could not be saved": "Mandanten-SSO-Einstellungen konnten nicht gespeichert werden",
"Tenant login": "Mandanten-Login",
"Tenant login link is invalid": "Mandanten-Login-Link ist ungültig",
"Tenant login URL": "Mandanten-Login-URL",
"Tenant Microsoft settings": "Mandanten-Microsoft-Einstellungen",
"These settings are required per tenant when Microsoft login is enabled.": "Diese Einstellungen sind pro Mandant erforderlich, sobald Microsoft-Login aktiviert ist.",
"Profile sync on Microsoft login": "Profil-Sync bei Microsoft-Login",
"Profile sync (optional)": "Profil-Sync (optional)",
"Sync fields": "Sync-Felder",
"Sync first name": "Vorname synchronisieren",
"Sync last name": "Nachname synchronisieren",
"Sync phone": "Telefon synchronisieren",
"Sync mobile": "Mobil synchronisieren",
"Sync avatar image": "Avatarbild synchronisieren",
"User profile fields are global and affect all tenants of this user": "Benutzer-Profildaten sind global und wirken auf alle Mandanten dieses Benutzers.",
"Phone/mobile/avatar sync requires Microsoft Graph (User.Read)": "Telefon/Mobil/Avatar-Sync benötigt Microsoft Graph (User.Read).",
"Phone/mobile sync requires Microsoft Graph (User.Read)": "Telefon/Mobil-Sync benötigt Microsoft Graph (User.Read).",
"SSO-enabled tenants": "Mandanten mit aktivem SSO",
"SSO-enforced tenants": "Mandanten mit erzwungenem SSO",
"SSO rollout": "SSO-Rollout",
"Users with Microsoft-only login": "Benutzer mit nur Microsoft-Login",
"Microsoft profile sync gaps": "Microsoft-Profil-Sync-Lücken",
"Tenant SSO overview": "Mandanten-SSO-Übersicht",
"Microsoft SSO policy": "Microsoft-SSO-Richtlinie",
"Sync enabled": "Sync aktiv",
"Local only": "Nur lokal",
"Microsoft only": "Nur Microsoft",
"Mixed (local + Microsoft)": "Gemischt (lokal + Microsoft)",
"Missing values": "Fehlende Werte",
"Use shared app credentials from settings": "Geteilte App-Zugangsdaten aus Einstellungen verwenden",
"Override only if this tenant needs a separate Entra app.": "Override nur nutzen, wenn dieser Mandant eine eigene Entra-App benötigt.",
"Advanced app override": "Erweiterter App-Override",
"Use shared app credentials by default. Configure overrides only when this tenant needs a separate app registration.": "Standardmäßig werden geteilte App-Zugangsdaten verwendet. Overrides nur konfigurieren, wenn dieser Mandant eine eigene App-Registrierung braucht.",
"Use this URL to enter tenant-scoped login (local and/or Microsoft).": "Diese URL für den mandantenspezifischen Login verwenden (lokal und/oder Microsoft).",
"Used only when shared app credentials are disabled.": "Wird nur verwendet, wenn geteilte App-Zugangsdaten deaktiviert sind.",
"Uses tenant-specific Entra tenant ID and policy for this login entry.": "Verwendet die mandantenspezifische Entra-Tenant-ID und Richtlinie für diesen Login-Einstieg.",
"When enabled, users must use Microsoft login on the tenant login URL.": "Wenn aktiv, müssen Benutzer auf der Mandanten-Login-URL Microsoft-Login verwenden.",
"Write-only. Leave empty to keep current secret.": "Nur schreiben. Leer lassen, um das aktuelle Secret beizubehalten.",
"Client secret override is invalid": "Client-Secret-Override ist ungültig",
"Client credentials are missing": "Client-Zugangsdaten fehlen",
"setting.microsoft_shared_client_id": "Einstellungsschlüssel für geteilte Microsoft-Client-ID",
"setting.microsoft_shared_client_secret_enc": "Einstellungsschlüssel für geteiltes Microsoft-Client-Secret (verschlüsselt)",
"setting.microsoft_authority": "Basis-OpenID-Authority-URL für Microsoft-Login",
"API tokens": "API-Tokens",
"Token name": "Token-Name",
"Create API token": "API-Token erstellen",
"Revoke": "Widerrufen",
"Revoke this API token?": "Dieses API-Token widerrufen?",
"Revoked": "Widerrufen",
"Prefix": "Präfix",
"No API tokens": "Keine API-Tokens",
"New API token (copy now — shown only once):": "Neues API-Token (jetzt kopieren — wird nur einmal angezeigt):",
"API token created. Copy it now — it will not be shown again.": "API-Token erstellt. Jetzt kopieren — es wird nicht erneut angezeigt.",
"Could not create API token: %s": "API-Token konnte nicht erstellt werden: %s",
"API token revoked": "API-Token widerrufen",
"%d API tokens revoked": "%d API-Tokens widerrufen",
"%d active API tokens": "%d aktive API-Tokens",
"Revoke all API tokens": "Alle API-Tokens widerrufen",
"Revoke all API tokens?": "Alle API-Tokens widerrufen?",
"All API tokens will be revoked immediately.": "Alle API-Tokens werden sofort widerrufen.",
"API token default lifetime is invalid": "Standardlaufzeit für API-Tokens ist ungültig",
"API token max lifetime is invalid": "Maximale Laufzeit für API-Tokens ist ungültig",
"API audit": "API-Protokolle",
"API audit logs": "API-Protokolle",
"Request ID": "Request-ID",
"Method": "Methode",
"Path": "Pfad",
"Status code": "Statuscode",
"Duration (ms)": "Dauer (ms)",
"Error code": "Fehlercode",
"Token ID": "Token-ID",
"Token tenant": "Token-Mandant",
"Purge API audit logs": "API-Protokolle bereinigen",
"Purge entries older than 90 days?": "Einträge älter als 90 Tage bereinigen?",
"%d API audit entries purged": "%d API-Protokoll-Einträge bereinigt",
"API audit entry not found": "API-Protokoll-Eintrag nicht gefunden",
"View API audit entry": "API-Protokoll-Eintrag anzeigen",
"Request details": "Request-Details",
"Scope": "Geltungsbereich",
"Query": "Query",
"User agent": "User-Agent",
"All methods": "Alle Methoden",
"Tenant ID": "Mandanten-ID",
"User ID": "Benutzer-ID",
"IP": "IP",
"CORS allowed origins are invalid": "Erlaubte CORS-Origins sind ungültig",
"Token name is required": "Token-Name ist erforderlich",
"e.g. CI Pipeline": "z.B. CI-Pipeline",
"Imports": "Importe",
"Upload CSV data, map columns, preview and import.": "CSV hochladen, Spalten zuordnen, Vorschau prüfen und importieren.",
"You do not have permission to run imports.": "Du hast keine Berechtigung für Importe.",
"Entity": "Entität",
"CSV file": "CSV-Datei",
"Max size 10MB, max 20000 rows": "Maximal 10MB, maximal 20000 Zeilen",
"Download sample CSV": "Beispiel-CSV herunterladen",
"Analyze file": "Datei analysieren",
"Map CSV columns": "CSV-Spalten zuordnen",
"Required fields: first_name, last_name, email": "Pflichtfelder: first_name, last_name, email",
"CSV header": "CSV-Header",
"Target field": "Zielfeld",
"Ignore column": "Spalte ignorieren",
"Assignment columns require additional permission.": "Zuweisungsspalten benötigen eine zusätzliche Berechtigung.",
"Run preview": "Vorschau ausführen",
"Preview": "Vorschau",
"Rows total": "Zeilen gesamt",
"Valid rows": "Gültige Zeilen",
"Would create": "Würde erstellen",
"Would skip": "Würde überspringen",
"Would fail": "Würde fehlschlagen",
"Row issues": "Zeilenprobleme",
"Start import": "Import starten",
"Import finished": "Import abgeschlossen",
"Processed": "Verarbeitet",
"Skipped": "Übersprungen",
"Import another file": "Weitere Datei importieren",
"No row errors found": "Keine Zeilenfehler gefunden",
"Row": "Zeile",
"Code": "Code",
"Message": "Nachricht",
"No upload file provided": "Keine Upload-Datei übergeben",
"Upload exceeds the maximum size": "Upload überschreitet die maximale Dateigröße",
"Invalid file type": "Ungültiger Dateityp",
"CSV header is invalid": "CSV-Header ist ungültig",
"CSV file has no data rows": "CSV-Datei enthält keine Datenzeilen",
"CSV row limit exceeded": "CSV-Zeilenlimit überschritten",
"Required mapping is missing": "Erforderliches Mapping fehlt",
"Assignment import permission is required": "Berechtigung für Assignment-Import ist erforderlich",
"Email is invalid": "E-Mail ist ungültig",
"Email appears multiple times in this file": "E-Mail kommt mehrfach in dieser Datei vor",
"Email already exists": "E-Mail existiert bereits",
"Locale is invalid": "Locale ist ungültig",
"Active value is invalid": "Active-Wert ist ungültig",
"Hire date must be YYYY-MM-DD": "Eintrittsdatum muss YYYY-MM-DD sein",
"Tenant is missing, inactive or invalid": "Mandant fehlt, ist inaktiv oder ungültig",
"Role is missing, inactive or invalid": "Rolle fehlt, ist inaktiv oder ungültig",
"Department is missing, inactive or invalid": "Abteilung fehlt, ist inaktiv oder ungültig",
"Department does not belong to tenant": "Abteilung gehört nicht zum Mandanten",
"Tenant assignment is out of your scope": "Mandantenzuweisung liegt außerhalb deines Scopes",
"User could not be created": "Benutzer konnte nicht erstellt werden",
"Unexpected error during import": "Unerwarteter Fehler beim Import",
"Required field is empty: first_name": "Pflichtfeld ist leer: first_name",
"Required field is empty: last_name": "Pflichtfeld ist leer: last_name",
"User assignments can not be updated": "Benutzerzuweisungen können nicht aktualisiert werden",
"How the import works": "So funktioniert der Import",
"1. Upload CSV and click analyze": "1. CSV hochladen und analysieren klicken",
"2. Map your CSV columns to system fields": "2. CSV-Spalten auf Systemfelder mappen",
"3. Review preview counters and row errors": "3. Vorschau, Zähler und Zeilenfehler prüfen",
"4. Start import for valid rows": "4. Import für gültige Zeilen starten",
"Rules": "Regeln",
"Create-only: existing emails are skipped": "Nur erstellen: bestehende E-Mails werden übersprungen",
"Create-only: existing entries are skipped": "Nur erstellen: bestehende Einträge werden übersprungen",
"Here is a CSV template for this import profile.": "Hier ist eine CSV-Vorlage für dieses Importprofil.",
"Reference": "Referenz",
"Required fields": "Pflichtfelder",
"Required field is empty: description": "Pflichtfeld ist leer: description",
"Tenant must be a valid UUID": "Mandant muss eine gültige UUID sein",
"Code already exists": "Code existiert bereits",
"Department already exists for this tenant": "Abteilung für diesen Mandanten existiert bereits",
"Duplicate entry in CSV file": "Doppelter Eintrag in der CSV-Datei",
"Entry could not be created": "Eintrag konnte nicht erstellt werden",
"Import logs": "Import-Protokolle",
"Import audit logs": "Import-Protokolle",
"Can view import audit logs": "Kann Import-Protokolle anzeigen",
"Run UUID": "Run-UUID",
"Mapped fields": "Gemappte Felder",
"Created count": "Erstellt",
"Skipped count": "Übersprungen",
"Failed count": "Fehlgeschlagen",
"Purge import logs": "Import-Protokolle bereinigen",
"%d import audit entries purged": "%d Import-Protokoll-Einträge bereinigt",
"Import audit entry not found": "Import-Protokoll-Eintrag nicht gefunden",
"View import audit entry": "Import-Protokoll-Eintrag anzeigen",
"Import details": "Import-Details",
"Started": "Gestartet",
"Finished": "Beendet",
"File": "Datei",
"Current tenant": "Aktueller Mandant",
"Error code distribution": "Fehlercode-Verteilung",
"All profiles": "Alle Profile",
"Running": "Laufend",
"Success": "Erfolgreich",
"Partial": "Teilweise",
"Import": "Import",
"Counters": "Zähler",
"User lifecycle policy": "Benutzer-Lifecycle-Regel",
"Deactivate users after inactivity (days)": "Benutzer nach Inaktivität deaktivieren (Tage)",
"Delete inactive users after (days)": "Inaktive Benutzer löschen nach (Tagen)",
"0 disables this rule": "0 deaktiviert diese Regel",
"Deletion starts after user was set inactive": "Löschung startet erst nach Inaktivsetzung",
"Privileged admins are excluded from automatic lifecycle actions": "Privilegierte Admins sind von automatischen Lifecycle-Aktionen ausgenommen",
"Run user lifecycle now?": "Benutzer-Lifecycle jetzt ausführen?",
"Run user lifecycle now": "Benutzer-Lifecycle jetzt ausführen",
"setting.user_inactivity_deactivate_days": "Setzt Benutzer nach dieser Anzahl Tagen ohne Login automatisch auf inaktiv",
"setting.user_inactivity_delete_days": "Löscht inaktive Benutzer nach dieser zusätzlichen Anzahl Tagen automatisch",
"User lifecycle already running": "Benutzer-Lifecycle läuft bereits",
"User lifecycle failed": "Benutzer-Lifecycle fehlgeschlagen",
"User lifecycle completed: %d deactivated, %d deleted": "Benutzer-Lifecycle abgeschlossen: %d deaktiviert, %d gelöscht",
"User inactivity deactivation period is invalid": "Zeitraum für automatische Deaktivierung ist ungültig",
"User inactivity deletion period is invalid": "Zeitraum für automatische Löschung ist ungültig",
"Auto-delete is ignored while auto-deactivate is disabled": "Auto-Löschung wird ignoriert, solange Auto-Deaktivierung deaktiviert ist",
"Scheduled jobs": "Geplante Aufgaben",
"Can view scheduled jobs": "Kann geplante Aufgaben anzeigen",
"Can manage scheduled jobs": "Kann geplante Aufgaben verwalten",
"Can run scheduled jobs manually": "Kann geplante Aufgaben manuell ausführen",
"Scheduled job not found": "Geplante Aufgabe nicht gefunden",
"Scheduled job is not registered": "Geplante Aufgabe ist nicht registriert",
"Scheduled job could not be saved": "Geplante Aufgabe konnte nicht gespeichert werden",
"Scheduled job saved": "Geplante Aufgabe gespeichert",
"Edit scheduled job": "Geplante Aufgabe bearbeiten",
"Job": "Aufgabe",
"Job key": "Aufgaben-Key",
"Run history": "Laufhistorie",
"Run now": "Jetzt ausführen",
"Runtime status": "Laufstatus",
"Next run": "Nächster Lauf",
"Last run": "Letzter Lauf",
"Last error": "Letzter Fehler",
"Schedule": "Zeitplan",
"Schedule type": "Zeitplan-Typ",
"Schedule interval": "Zeitplan-Intervall",
"Schedule time": "Zeitplan-Uhrzeit",
"Weekdays": "Wochentage",
"Hourly": "Stündlich",
"Daily": "Täglich",
"Weekly": "Wöchentlich",
"Enabled": "Aktiv",
"Disabled": "Inaktiv",
"Timezone": "Zeitzone",
"Catch up once": "Verpassten Lauf einmal nachholen",
"Scheduler": "Scheduler",
"Every %d hour(s)": "Alle %d Stunde(n)",
"Every %d day(s) at %s": "Alle %d Tag(e) um %s",
"Every %d week(s) on %s at %s": "Alle %d Woche(n) an %s um %s",
"No weekdays": "Keine Wochentage",
"Mon": "Mo",
"Tue": "Di",
"Wed": "Mi",
"Thu": "Do",
"Fri": "Fr",
"Sat": "Sa",
"Sun": "So",
"Schedule type is invalid": "Zeitplan-Typ ist ungültig",
"Schedule time is required": "Zeitplan-Uhrzeit ist erforderlich",
"Timezone is invalid": "Zeitzone ist ungültig",
"Hourly interval must be between 1 and 24": "Stündliches Intervall muss zwischen 1 und 24 liegen",
"Daily interval must be between 1 and 365": "Tägliches Intervall muss zwischen 1 und 365 liegen",
"Weekly interval must be between 1 and 52": "Wöchentliches Intervall muss zwischen 1 und 52 liegen",
"At least one weekday is required for weekly schedule": "Für einen wöchentlichen Zeitplan ist mindestens ein Wochentag erforderlich",
"Scheduled job run completed": "Geplante Aufgabe wurde ausgeführt",
"Scheduled job run skipped": "Geplante Aufgabe wurde übersprungen",
"Scheduled job run failed": "Ausführung der geplanten Aufgabe fehlgeschlagen",
"Scheduler is already running": "Scheduler läuft bereits",
"Purge scheduler logs": "Scheduler-Protokolle bereinigen",
"Purge run logs older than 90 days?": "Laufprotokolle älter als 90 Tage bereinigen?",
"%d scheduler run logs purged": "%d Scheduler-Laufprotokolle bereinigt",
"User lifecycle logs": "Benutzer-Lifecycle-Protokolle",
"View user lifecycle audit entry": "Benutzer-Lifecycle-Eintrag anzeigen",
"Lifecycle audit entry not found": "Benutzer-Lifecycle-Eintrag nicht gefunden",
"Purge user lifecycle logs": "Benutzer-Lifecycle-Protokolle bereinigen",
"Purge entries older than 365 days?": "Einträge älter als 365 Tage bereinigen?",
"%d user lifecycle audit entries purged": "%d Benutzer-Lifecycle-Protokoll-Einträge bereinigt",
"Lifecycle event": "Lifecycle-Ereignis",
"Trigger": "Auslöser",
"Reason code": "Fehlercode",
"Target": "Ziel",
"Target UUID": "Ziel-UUID",
"Target email": "Ziel-E-Mail",
"Policy": "Regel",
"Snapshot summary": "Snapshot-Zusammenfassung",
"Snapshot version": "Snapshot-Version",
"Restore status": "Wiederherstellungsstatus",
"Restored at": "Wiederhergestellt am",
"Restored by": "Wiederhergestellt von",
"Restored user": "Wiederhergestellter Benutzer",
"Restore user": "Benutzer wiederherstellen",
"Restore": "Wiederherstellen",
"Restore this user from lifecycle snapshot?": "Diesen Benutzer aus dem Lifecycle-Snapshot wiederherstellen?",
"User restored": "Benutzer wiederhergestellt",
"User restore failed": "Benutzer-Wiederherstellung fehlgeschlagen",
"Lifecycle event was already restored": "Lifecycle-Ereignis wurde bereits wiederhergestellt",
"Lifecycle snapshot unavailable": "Lifecycle-Snapshot nicht verfügbar",
"Restore not possible: email already exists": "Wiederherstellung nicht möglich: E-Mail existiert bereits",
"Restore not possible: uuid already exists": "Wiederherstellung nicht möglich: UUID existiert bereits",
"All actions": "Alle Aktionen",
"All triggers": "Alle Auslöser",
"Manual": "Manuell",
"Cron": "Cron",
"Enabled jobs": "Aktive Aufgaben",
"Overdue jobs": "Überfällige Aufgaben",
"Scheduler runs (24h)": "Scheduler-Läufe (24h)",
"Failed scheduler runs (24h)": "Fehlgeschlagene Scheduler-Läufe (24h)",
"Cron runner active": "Cron-Runner aktiv",
"Last heartbeat": "Letzter Heartbeat",
"Last runner result": "Letztes Runner-Ergebnis",
"Runner lock not acquired": "Runner-Lock nicht erhalten",
"Locale": "Sprache"
}

View File

@@ -1,13 +1,22 @@
{
"Login": "Login",
"Login options": "Login options",
"Register": "Register",
"Password": "Password",
"Or": "Or",
"Forgot password": "Forgot password",
"Remember me": "Remember me",
"Clear login tokens": "Clear login tokens",
"Clear all login tokens for this user?": "Clear all login tokens for this user?",
"Login tokens cleared": "Login tokens cleared",
"Send access": "Send access",
"Access PDF": "Access PDF",
"Download access PDFs": "Download access PDFs",
"Generate access PDFs for selected users?": "Generate access PDFs for selected users?",
"Too many users selected": "Too many users selected",
"No valid users selected": "No valid users selected",
"Failed to generate access PDF": "Failed to generate access PDF",
"ZIP support missing (ext-zip)": "ZIP support missing (ext-zip)",
"Send access email to this user?": "Send access email to this user?",
"Access email sent": "Access email sent",
"Access email failed": "Access email failed",
@@ -37,8 +46,19 @@
"Logout": "Logout",
"Account": "Account",
"Please enter your credentials": "Please enter your credentials",
"Enter your email to continue": "Enter your email to continue",
"Select your tenant to continue": "Select your tenant to continue",
"Choose how you want to sign in": "Choose how you want to sign in",
"Continue": "Continue",
"Use different email": "Use different email",
"We could not continue with these login details": "We could not continue with these login details",
"Too many login attempts. Please wait and try again.": "Too many login attempts. Please wait and try again.",
"No login method is available for this tenant": "No login method is available for this tenant",
"Cached": "Cached",
"Global settings are stored in the database. config/settings.php is only a runtime cache for selected app settings.": "Global settings are stored in the database. config/settings.php is only a runtime cache for selected app settings.",
"Create your account": "Create your account",
"First name": "First name",
"Name": "Name",
"Last name": "Last name",
"Email": "Email",
"Password (again)": "Password confirm",
@@ -99,6 +119,7 @@
"perm.users.view": "View users",
"perm.users.create": "Create users",
"perm.users.update": "Update users",
"perm.users.access_pdf": "Generate onboarding PDFs",
"perm.users.delete": "Delete users",
"perm.users.self_update": "Update own user",
"perm.users.update_assignments": "Update user assignments",
@@ -122,6 +143,11 @@
"perm.settings.view": "View settings",
"perm.settings.update": "Update settings",
"perm.mail_log.view": "View mail logs",
"perm.jobs.view": "View scheduled jobs",
"perm.jobs.manage": "Manage scheduled jobs",
"perm.jobs.run_now": "Run scheduled jobs manually",
"perm.user_lifecycle_audit.view": "View user lifecycle logs",
"perm.users.lifecycle_restore": "Restore users from lifecycle audit",
"Select row": "Select row",
"Select all": "Select all",
"Activate": "Activate",
@@ -226,6 +252,7 @@
"Favicon preview": "Favicon preview",
"Primary color": "Primary color",
"Primary color is invalid": "Primary color is invalid",
"The primary color affects buttons and accent elements across the app.": "The primary color affects buttons and accent elements across the app.",
"Show filters": "Show filters",
"Hide filters": "Hide filters",
"Export CSV": "Export CSV",
@@ -233,15 +260,43 @@
"State": "State",
"Edit": "Edit",
"Status": "Status",
"Visibility": "Visibility",
"Inactive entries are hidden in lists and selections.": "Inactive entries are hidden in lists and selections.",
"All": "All",
"All people": "All people",
"Explorer": "Explorer",
"Search": "Search",
"Search...": "Search...",
"To start searching, type in the search field.": "To start searching, type in the search field.",
"Tip: Use {shortcut} to focus search quickly.": "Tip: Use {shortcut} to focus search quickly.",
"Created from": "Created from",
"Created to": "Created to",
"Files": "Files",
"Settings": "Settings",
"Documentation": "Documentation",
"Documentation navigation": "Documentation navigation",
"On this page": "On this page",
"Getting started": "Getting started",
"Local development": "Local development",
"First change": "First change",
"Developer checklist": "Developer checklist",
"Architecture & rules": "Architecture & rules",
"Architecture": "Architecture",
"Security model": "Security model",
"Conventions": "Conventions",
"Frontend": "Frontend",
"Features": "Features",
"Settings storage": "Settings storage",
"Rate limiting": "Rate limiting",
"User lifecycle": "User lifecycle",
"Global search": "Global search",
"API reference": "API reference",
"Operations": "Operations",
"Troubleshooting": "Troubleshooting",
"Configuration": "Configuration",
"Automation & integrations": "Automation & integrations",
"Monitoring": "Monitoring",
"Logs": "Logs",
"System": "System",
"System permission": "System permission",
"System permission can not be deleted": "System permission can not be deleted",
@@ -286,7 +341,11 @@
"No records found": "No records found",
"An error happened while fetching the data": "An error happened while fetching the data",
"Delete this user?": "Delete this user?",
"Delete user": "Delete user",
"This will permanently delete this user.": "This will permanently delete this user.",
"Delete this tenant?": "Delete this tenant?",
"Delete tenant": "Delete tenant",
"This will permanently delete this tenant.": "This will permanently delete this tenant.",
"Tenant": "Tenant",
"Default tenant": "Default tenant",
"Default role": "Default role",
@@ -311,12 +370,24 @@
"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.api_token_default_ttl_days": "Default lifetime for newly created API tokens in days (default: 90)",
"setting.api_token_max_ttl_days": "Maximum lifetime for API tokens in days (hard cap)",
"setting.api_cors_allowed_origins": "Allowed browser origins for API CORS (one origin per line)",
"setting.app_primary_color": "Overrides the default primary color (hex like #2FA4A4)",
"API token default lifetime (days)": "API token default lifetime (days)",
"API token maximum lifetime (days)": "API token maximum lifetime (days)",
"Allowed CORS origins": "Allowed CORS origins",
"One origin per line, e.g. https://app.example.com": "One origin per line, e.g. https://app.example.com",
"Default theme": "Default theme",
"Allow user theme": "Allow user theme",
"Allow registration": "Allow registration",
"General": "General",
"Appearance": "Appearance",
"API settings": "API settings",
"API docs": "API docs",
"API documentation": "API documentation",
"Can view API documentation": "Can view API documentation",
"API tokens and default lifetimes are managed here.": "API tokens and default lifetimes are managed here.",
"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.",
@@ -345,10 +416,12 @@
"Tenant updated": "Tenant updated",
"Tenant deleted": "Tenant deleted",
"Tenant not found": "Tenant not found",
"Tenant can not be deleted while departments are assigned": "Tenant can not be deleted while departments are assigned",
"Tenant can not be created": "Tenant can not be created",
"Tenant can not be updated": "Tenant can not be updated",
"Tenant image": "Tenant image",
"Assigned tenants": "Assigned tenants",
"Assigned tenant": "Assigned tenant",
"Primary tenant": "Primary tenant",
"Select primary tenant": "Select primary tenant",
"Please select a primary tenant": "Please select a primary tenant",
@@ -357,6 +430,8 @@
"Departments & roles": "Departments & roles",
"Department assignments cleaned: %d": "Department assignments cleaned: %d",
"Select tenants": "Select tenants",
"Select tenant": "Select tenant",
"Which tenant do you want to sign in to?": "Which tenant do you want to sign in to?",
"Assigned roles": "Assigned roles",
"Select roles": "Select roles",
"Department": "Department",
@@ -370,8 +445,14 @@
"Department can not be created": "Department can not be created",
"Department can not be updated": "Department can not be updated",
"Delete this department?": "Delete this department?",
"Delete department": "Delete department",
"This will permanently delete this department.": "This will permanently delete this department.",
"Assigned departments": "Assigned departments",
"Select departments": "Select departments",
"Departments for tenant": "Departments for tenant",
"No departments available for this tenant": "No departments available for this tenant",
"Assign tenants first to select departments": "Assign tenants first to select departments",
"Department options refresh after save": "Department options refresh after save",
"Role": "Role",
"Roles": "Roles",
"Create role": "Create role",
@@ -383,6 +464,11 @@
"Role can not be created": "Role can not be created",
"Role can not be updated": "Role can not be updated",
"Delete this role?": "Delete this role?",
"Delete role": "Delete role",
"Delete permission": "Delete permission",
"This will permanently delete this role.": "This will permanently delete this role.",
"This will permanently delete this permission.": "This will permanently delete this permission.",
"This action cannot be undone.": "This action cannot be undone.",
"Description": "Description",
"Description cannot be empty": "Description cannot be empty",
"Status is invalid": "Status is invalid",
@@ -431,14 +517,13 @@
"You do not have permission to view this content.": "You do not have permission to view this content.",
"If you believe this is an error, please contact your system administrator.": "If you believe this is an error, please contact your system administrator.",
"Requested URL": "Requested URL",
"Audit": "Audit",
"Forward": "Forward",
"Status changed": "Status changed",
"Status changed by": "Status changed by",
"Page": "Page",
"Requested path": "Requested path",
"Page not found": "Page not found",
"The page you requested does not exist or has moved.": "The page you requested does not exist or has moved.",
"Back to start": "Back to start",
"Page updated": "Page updated",
"Page can not be updated": "Page can not be updated",
"Content is invalid": "Content is invalid",
@@ -480,7 +565,15 @@
"SMTP from address": "SMTP from address",
"SMTP from name": "SMTP from name",
"Address book": "Address book",
"All people": "All people",
"Saved filters": "Saved filters",
"Save filter": "Save filter",
"Enter a name for this filter": "Enter a name for this filter",
"Filter name is required": "Filter name is required",
"Maximum number of saved filters reached": "Maximum number of saved filters reached",
"Filter saved": "Filter saved",
"Delete saved filter": "Delete saved filter",
"Filter deleted": "Filter deleted",
"Filter save failed": "Filter save failed",
"Mobile": "Mobile",
"Short dial": "Short dial",
"setting.smtp_host": "SMTP server host (e.g. smtp.example.com)",
@@ -493,12 +586,34 @@
"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",
"API": "API",
"API requests (24h)": "API requests (24h)",
"Request trend vs previous 24h": "Request trend vs previous 24h",
"4xx errors (24h)": "4xx errors (24h)",
"5xx errors (24h)": "5xx errors (24h)",
"P95 response time (ms)": "P95 response time (ms)",
"Tokens expiring in 7 days": "Tokens expiring in 7 days",
"Top API error codes (24h)": "Top API error codes (24h)",
"Slowest API endpoints (avg, 24h)": "Slowest API endpoints (avg, 24h)",
"Import runs (7d)": "Import runs (7d)",
"Run trend vs previous 7d": "Run trend vs previous 7d",
"Created rows (7d)": "Created rows (7d)",
"Failed rows (7d)": "Failed rows (7d)",
"Partial or failed runs (7d)": "Partial or failed runs (7d)",
"Success rate (7d)": "Success rate (7d)",
"Average duration (ms, 7d)": "Average duration (ms, 7d)",
"Recent import runs": "Recent import runs",
"Imports by profile (7d)": "Imports by profile (7d)",
"Top import error codes (7d)": "Top import error codes (7d)",
"Runs": "Runs",
"Created rows": "Created rows",
"Failed rows": "Failed rows",
"Success rate": "Success rate",
"Requests": "Requests",
"Average duration (ms)": "Average duration (ms)",
"Maximum duration (ms)": "Maximum duration (ms)",
"Error": "Error",
"Active tenants": "Active tenants",
"Inactive tenants": "Inactive tenants",
@@ -517,13 +632,16 @@
"Recent logins": "Recent logins",
"Never logged in": "Never logged in",
"Last login": "Last login",
"Last login via": "Last login via",
"Local": "Local",
"Microsoft": "Microsoft",
"Unknown": "Unknown",
"User": "User",
"No entries found": "No entries found",
"Login status": "Login status",
"Logged in": "Logged in",
"More filters": "More filters",
"Email unverified": "Email unverified"
,
"Email unverified": "Email unverified",
"Password resets": "Password resets",
"Requested at": "Requested at",
"Expires at": "Expires at",
@@ -556,5 +674,418 @@
"Toggle sidebar": "Toggle sidebar",
"Unverified": "Unverified",
"Imprint": "Imprint",
"Privacy": "Privacy"
"Privacy": "Privacy",
"Custom fields": "Custom fields",
"User custom fields": "User custom fields",
"Affected tenants": "Affected tenants",
"If a tenant appears here, at least one custom field setup needs review (missing options, inactive fields with values, or unused active fields).": "If a tenant appears here, at least one custom field setup needs review (missing options, inactive fields with values, or unused active fields).",
"Issue": "Issue",
"Count": "Count",
"Selection fields without options": "Selection fields without options",
"Tenants with broken selection fields": "Tenants with broken selection fields",
"Inactive custom fields with values": "Inactive custom fields with values",
"Unused active custom fields": "Unused active custom fields",
"Save tenant first to manage custom fields": "Save tenant first to manage custom fields",
"Define custom fields here. They appear per user in the \"Custom fields\" tab and can be maintained there.": "Define custom fields here. They appear per user in the \"Custom fields\" tab and can be maintained there.",
"Add custom field": "Add custom field",
"The label is shown in user custom fields and in address book filters.": "The label is shown in user custom fields and in address book filters.",
"The field type controls input and filter behavior.": "The field type controls input and filter behavior.",
"Field key": "Field key",
"Field type": "Field type",
"Text": "Text",
"Textarea": "Textarea",
"Select": "Select",
"Multiselect": "Multiselect",
"Boolean": "Boolean",
"Date": "Date",
"Sort order": "Sort order",
"Filterable": "Filterable",
"Options (one per line)": "Options (one per line)",
"Example: manager|Manager": "Example: manager|Manager",
"One option per line. Optional format: key|Label.": "One option per line. Optional format: key|Label.",
"No custom fields configured yet": "No custom fields configured yet",
"Delete custom field": "Delete custom field",
"Custom field saved": "Custom field saved",
"Custom field deleted": "Custom field deleted",
"Custom field can not be created": "Custom field can not be created",
"Custom field can not be updated": "Custom field can not be updated",
"Custom field can not be deleted": "Custom field can not be deleted",
"Custom field values can not be saved": "Custom field values can not be saved",
"Custom field not found": "Custom field not found",
"Label cannot be empty": "Label cannot be empty",
"Field key cannot be empty": "Field key cannot be empty",
"Field type is invalid": "Field type is invalid",
"Field key already exists": "Field key already exists",
"Options are required for this field type": "Options are required for this field type",
"Custom fields update after save": "Custom fields update after save",
"Assign tenants first to edit custom fields": "Assign tenants first to edit custom fields",
"Custom fields for tenant": "Custom fields for tenant",
"No custom fields configured for this tenant": "No custom fields configured for this tenant",
"No custom fields configured for selected tenants": "No custom fields configured for selected tenants",
"Select options": "Select options",
"Please fill required custom field: %s": "Please fill required custom field: %s",
"Invalid value for %s": "Invalid value for %s",
"Please select": "Please select",
"From": "From",
"To": "To",
"perm.custom_fields.manage": "Manage tenant custom fields",
"perm.custom_fields.edit_values": "Edit user custom field values",
"Label": "Label",
"Required": "Required",
"Required fields must be filled before saving a user.": "Required fields must be filled before saving a user.",
"Filterable fields are available in address book filters.": "Filterable fields are available in address book filters.",
"Inactive fields are hidden in forms and filters.": "Inactive fields are hidden in forms and filters.",
"Default theme is invalid": "Default theme is invalid",
"User theme policy is invalid": "User theme policy is invalid",
"Theme change is disabled for this tenant": "Theme change is disabled for this tenant",
"Inherit global setting": "Inherit global setting",
"Force allow user theme": "Force allow user theme",
"Force disallow user theme": "Force disallow user theme",
"If set, this tenant uses its own default theme instead of the global setting.": "If set, this tenant uses its own default theme instead of the global setting.",
"Controls whether users in this tenant can choose their own theme.": "Controls whether users in this tenant can choose their own theme.",
"Tenants can override color and theme behavior in their appearance settings.": "Tenants can override color and theme behavior in their appearance settings.",
"User theme policy": "User theme policy",
"APP_CRYPTO_KEY is missing or invalid": "APP_CRYPTO_KEY is missing or invalid",
"Allowed email domains": "Allowed email domains",
"App credentials source": "App credentials source",
"Authority URL": "Authority URL",
"Clear override secret": "Clear override secret",
"Client ID override": "Client ID override",
"Client ID override is required": "Client ID override is required",
"Client secret could not be encrypted": "Client secret could not be encrypted",
"Client secret override": "Client secret override",
"Client secret override is required": "Client secret override is required",
"Configure Microsoft login per tenant. Local login can stay enabled or be enforced off.": "Configure Microsoft login per tenant. Local login can stay enabled or be enforced off.",
"Disable password login for this tenant login": "Disable password login for this tenant login",
"Enable Microsoft login for this tenant": "Enable Microsoft login for this tenant",
"Enforcement": "Enforcement",
"Entra tenant ID (tid)": "Entra tenant ID (tid)",
"Entra tenant ID is invalid": "Entra tenant ID is invalid",
"Login failed": "Login failed",
"Microsoft SSO": "Microsoft SSO",
"Microsoft login": "Microsoft login",
"Microsoft login could not be started": "Microsoft login could not be started",
"Microsoft login failed": "Microsoft login failed",
"Microsoft login is not allowed for this account": "Microsoft login is not allowed for this account",
"Microsoft login is not available for this tenant": "Microsoft login is not available for this tenant",
"Microsoft login is not fully configured for this tenant.": "Microsoft login is not fully configured for this tenant.",
"Microsoft login was cancelled or failed": "Microsoft login was cancelled or failed",
"Microsoft-only login requires a complete configuration": "Microsoft-only login requires a complete configuration",
"No access to this tenant": "No access to this tenant",
"Shared credentials missing": "Shared credentials missing",
"Optional credential override": "Optional credential override",
"Optional restrictions": "Optional restrictions",
"Optional allow-list, comma or newline separated (example.com).": "Optional allow-list, comma or newline separated (example.com).",
"Password login is disabled for this tenant. Please use Microsoft login.": "Password login is disabled for this tenant. Please use Microsoft login.",
"Password login is disabled for all assigned tenants": "Password login is disabled for all assigned tenants",
"Password login mode": "Password login mode",
"Local password + Microsoft": "Local password + Microsoft",
"Credentials source": "Credentials source",
"Shared app": "Shared app",
"Tenant override": "Tenant override",
"SSO configuration status": "SSO configuration status",
"Configuration complete": "Configuration complete",
"Required Microsoft configuration": "Required Microsoft configuration",
"Required for Microsoft login.": "Required for Microsoft login.",
"SSO": "SSO",
"Shared Microsoft app credentials are used by tenants that enable \"Use shared app credentials\".": "Shared Microsoft app credentials are used by tenants that enable \"Use shared app credentials\".",
"Shared client ID": "Shared client ID",
"Shared client secret": "Shared client secret",
"Sign in with Microsoft": "Sign in with Microsoft",
"Tenant SSO settings could not be saved": "Tenant SSO settings could not be saved",
"Tenant login": "Tenant login",
"Tenant login link is invalid": "Tenant login link is invalid",
"Tenant login URL": "Tenant login URL",
"Tenant Microsoft settings": "Tenant Microsoft settings",
"These settings are required per tenant when Microsoft login is enabled.": "These settings are required per tenant when Microsoft login is enabled.",
"Profile sync on Microsoft login": "Profile sync on Microsoft login",
"Profile sync (optional)": "Profile sync (optional)",
"Sync fields": "Sync fields",
"Sync first name": "Sync first name",
"Sync last name": "Sync last name",
"Sync phone": "Sync phone",
"Sync mobile": "Sync mobile",
"Sync avatar image": "Sync avatar image",
"User profile fields are global and affect all tenants of this user": "User profile fields are global and affect all tenants of this user",
"Phone/mobile/avatar sync requires Microsoft Graph (User.Read)": "Phone/mobile/avatar sync requires Microsoft Graph (User.Read)",
"Phone/mobile sync requires Microsoft Graph (User.Read)": "Phone/mobile sync requires Microsoft Graph (User.Read)",
"SSO-enabled tenants": "SSO-enabled tenants",
"SSO-enforced tenants": "SSO-enforced tenants",
"SSO rollout": "SSO rollout",
"Users with Microsoft-only login": "Users with Microsoft-only login",
"Microsoft profile sync gaps": "Microsoft profile sync gaps",
"Tenant SSO overview": "Tenant SSO overview",
"Microsoft SSO policy": "Microsoft SSO policy",
"Sync enabled": "Sync enabled",
"Local only": "Local only",
"Microsoft only": "Microsoft only",
"Mixed (local + Microsoft)": "Mixed (local + Microsoft)",
"Missing values": "Missing values",
"Use shared app credentials from settings": "Use shared app credentials from settings",
"Override only if this tenant needs a separate Entra app.": "Override only if this tenant needs a separate Entra app.",
"Advanced app override": "Advanced app override",
"Use shared app credentials by default. Configure overrides only when this tenant needs a separate app registration.": "Use shared app credentials by default. Configure overrides only when this tenant needs a separate app registration.",
"Use this URL to enter tenant-scoped login (local and/or Microsoft).": "Use this URL to enter tenant-scoped login (local and/or Microsoft).",
"Used only when shared app credentials are disabled.": "Used only when shared app credentials are disabled.",
"Uses tenant-specific Entra tenant ID and policy for this login entry.": "Uses tenant-specific Entra tenant ID and policy for this login entry.",
"When enabled, users must use Microsoft login on the tenant login URL.": "When enabled, users must use Microsoft login on the tenant login URL.",
"Write-only. Leave empty to keep current secret.": "Write-only. Leave empty to keep current secret.",
"Client secret override is invalid": "Client secret override is invalid",
"Client credentials are missing": "Client credentials are missing",
"setting.microsoft_shared_client_id": "Setting key for shared Microsoft client ID",
"setting.microsoft_shared_client_secret_enc": "Setting key for shared Microsoft client secret (encrypted)",
"setting.microsoft_authority": "Base OpenID authority URL for Microsoft login",
"API tokens": "API tokens",
"Token name": "Token name",
"Create API token": "Create API token",
"Revoke": "Revoke",
"Revoke this API token?": "Revoke this API token?",
"Revoked": "Revoked",
"Prefix": "Prefix",
"No API tokens": "No API tokens",
"New API token (copy now — shown only once):": "New API token (copy now — shown only once):",
"API token created. Copy it now — it will not be shown again.": "API token created. Copy it now — it will not be shown again.",
"Could not create API token: %s": "Could not create API token: %s",
"API token revoked": "API token revoked",
"%d API tokens revoked": "%d API tokens revoked",
"%d active API tokens": "%d active API tokens",
"Revoke all API tokens": "Revoke all API tokens",
"Revoke all API tokens?": "Revoke all API tokens?",
"All API tokens will be revoked immediately.": "All API tokens will be revoked immediately.",
"API token default lifetime is invalid": "API token default lifetime is invalid",
"API token max lifetime is invalid": "API token max lifetime is invalid",
"API audit": "API logs",
"API audit logs": "API logs",
"Request ID": "Request ID",
"Method": "Method",
"Path": "Path",
"Status code": "Status code",
"Duration (ms)": "Duration (ms)",
"Error code": "Error code",
"Token ID": "Token ID",
"Token tenant": "Token tenant",
"Purge API audit logs": "Purge API logs",
"Purge entries older than 90 days?": "Purge entries older than 90 days?",
"%d API audit entries purged": "%d API log entries purged",
"API audit entry not found": "API log entry not found",
"View API audit entry": "View API log entry",
"Request details": "Request details",
"Scope": "Scope",
"Query": "Query",
"User agent": "User agent",
"All methods": "All methods",
"Tenant ID": "Tenant ID",
"User ID": "User ID",
"IP": "IP",
"CORS allowed origins are invalid": "CORS allowed origins are invalid",
"Token name is required": "Token name is required",
"e.g. CI Pipeline": "e.g. CI Pipeline",
"Imports": "Imports",
"Upload CSV data, map columns, preview and import.": "Upload CSV data, map columns, preview and import.",
"You do not have permission to run imports.": "You do not have permission to run imports.",
"Entity": "Entity",
"CSV file": "CSV file",
"Max size 10MB, max 20000 rows": "Max size 10MB, max 20000 rows",
"Download sample CSV": "Download sample CSV",
"Analyze file": "Analyze file",
"Map CSV columns": "Map CSV columns",
"Required fields: first_name, last_name, email": "Required fields: first_name, last_name, email",
"CSV header": "CSV header",
"Target field": "Target field",
"Ignore column": "Ignore column",
"Assignment columns require additional permission.": "Assignment columns require additional permission.",
"Run preview": "Run preview",
"Preview": "Preview",
"Rows total": "Rows total",
"Valid rows": "Valid rows",
"Would create": "Would create",
"Would skip": "Would skip",
"Would fail": "Would fail",
"Row issues": "Row issues",
"Start import": "Start import",
"Import finished": "Import finished",
"Processed": "Processed",
"Skipped": "Skipped",
"Import another file": "Import another file",
"No row errors found": "No row errors found",
"Row": "Row",
"Code": "Code",
"Message": "Message",
"No upload file provided": "No upload file provided",
"Upload exceeds the maximum size": "Upload exceeds the maximum size",
"Invalid file type": "Invalid file type",
"CSV header is invalid": "CSV header is invalid",
"CSV file has no data rows": "CSV file has no data rows",
"CSV row limit exceeded": "CSV row limit exceeded",
"Required mapping is missing": "Required mapping is missing",
"Assignment import permission is required": "Assignment import permission is required",
"Email is invalid": "Email is invalid",
"Email appears multiple times in this file": "Email appears multiple times in this file",
"Email already exists": "Email already exists",
"Locale is invalid": "Locale is invalid",
"Active value is invalid": "Active value is invalid",
"Hire date must be YYYY-MM-DD": "Hire date must be YYYY-MM-DD",
"Tenant is missing, inactive or invalid": "Tenant is missing, inactive or invalid",
"Role is missing, inactive or invalid": "Role is missing, inactive or invalid",
"Department is missing, inactive or invalid": "Department is missing, inactive or invalid",
"Department does not belong to tenant": "Department does not belong to tenant",
"Tenant assignment is out of your scope": "Tenant assignment is out of your scope",
"User could not be created": "User could not be created",
"Unexpected error during import": "Unexpected error during import",
"Required field is empty: first_name": "Required field is empty: first_name",
"Required field is empty: last_name": "Required field is empty: last_name",
"User assignments can not be updated": "User assignments can not be updated",
"How the import works": "How the import works",
"1. Upload CSV and click analyze": "1. Upload CSV and click analyze",
"2. Map your CSV columns to system fields": "2. Map your CSV columns to system fields",
"3. Review preview counters and row errors": "3. Review preview counters and row errors",
"4. Start import for valid rows": "4. Start import for valid rows",
"Rules": "Rules",
"Create-only: existing emails are skipped": "Create-only: existing emails are skipped",
"Create-only: existing entries are skipped": "Create-only: existing entries are skipped",
"Here is a CSV template for this import profile.": "Here is a CSV template for this import profile.",
"Reference": "Reference",
"Required fields": "Required fields",
"Required field is empty: description": "Required field is empty: description",
"Tenant must be a valid UUID": "Tenant must be a valid UUID",
"Code already exists": "Code already exists",
"Department already exists for this tenant": "Department already exists for this tenant",
"Duplicate entry in CSV file": "Duplicate entry in CSV file",
"Entry could not be created": "Entry could not be created",
"Import logs": "Import logs",
"Import audit logs": "Import audit logs",
"Can view import audit logs": "Can view import audit logs",
"Run UUID": "Run UUID",
"Mapped fields": "Mapped fields",
"Created count": "Created count",
"Skipped count": "Skipped count",
"Failed count": "Failed count",
"Purge import logs": "Purge import logs",
"%d import audit entries purged": "%d import audit entries purged",
"Import audit entry not found": "Import audit entry not found",
"View import audit entry": "View import audit entry",
"Import details": "Import details",
"Started": "Started",
"Finished": "Finished",
"File": "File",
"Current tenant": "Current tenant",
"Error code distribution": "Error code distribution",
"All profiles": "All profiles",
"Running": "Running",
"Success": "Success",
"Partial": "Partial",
"Import": "Import",
"Counters": "Counters",
"User lifecycle policy": "User lifecycle policy",
"Deactivate users after inactivity (days)": "Deactivate users after inactivity (days)",
"Delete inactive users after (days)": "Delete inactive users after (days)",
"0 disables this rule": "0 disables this rule",
"Deletion starts after user was set inactive": "Deletion starts after user was set inactive",
"Privileged admins are excluded from automatic lifecycle actions": "Privileged admins are excluded from automatic lifecycle actions",
"Run user lifecycle now?": "Run user lifecycle now?",
"Run user lifecycle now": "Run user lifecycle now",
"setting.user_inactivity_deactivate_days": "Automatically set users inactive after this many days without login",
"setting.user_inactivity_delete_days": "Automatically delete inactive users after this many additional days",
"User lifecycle already running": "User lifecycle already running",
"User lifecycle failed": "User lifecycle failed",
"User lifecycle completed: %d deactivated, %d deleted": "User lifecycle completed: %d deactivated, %d deleted",
"User inactivity deactivation period is invalid": "User inactivity deactivation period is invalid",
"User inactivity deletion period is invalid": "User inactivity deletion period is invalid",
"Auto-delete is ignored while auto-deactivate is disabled": "Auto-delete is ignored while auto-deactivate is disabled",
"Scheduled jobs": "Scheduled jobs",
"Can view scheduled jobs": "Can view scheduled jobs",
"Can manage scheduled jobs": "Can manage scheduled jobs",
"Can run scheduled jobs manually": "Can run scheduled jobs manually",
"Scheduled job not found": "Scheduled job not found",
"Scheduled job is not registered": "Scheduled job is not registered",
"Scheduled job could not be saved": "Scheduled job could not be saved",
"Scheduled job saved": "Scheduled job saved",
"Edit scheduled job": "Edit scheduled job",
"Job": "Job",
"Job key": "Job key",
"Run history": "Run history",
"Run now": "Run now",
"Runtime status": "Runtime status",
"Next run": "Next run",
"Last run": "Last run",
"Last error": "Last error",
"Schedule": "Schedule",
"Schedule type": "Schedule type",
"Schedule interval": "Schedule interval",
"Schedule time": "Schedule time",
"Weekdays": "Weekdays",
"Hourly": "Hourly",
"Daily": "Daily",
"Weekly": "Weekly",
"Enabled": "Enabled",
"Disabled": "Disabled",
"Timezone": "Timezone",
"Catch up once": "Catch up once",
"Scheduler": "Scheduler",
"Every %d hour(s)": "Every %d hour(s)",
"Every %d day(s) at %s": "Every %d day(s) at %s",
"Every %d week(s) on %s at %s": "Every %d week(s) on %s at %s",
"No weekdays": "No weekdays",
"Mon": "Mon",
"Tue": "Tue",
"Wed": "Wed",
"Thu": "Thu",
"Fri": "Fri",
"Sat": "Sat",
"Sun": "Sun",
"Schedule type is invalid": "Schedule type is invalid",
"Schedule time is required": "Schedule time is required",
"Timezone is invalid": "Timezone is invalid",
"Hourly interval must be between 1 and 24": "Hourly interval must be between 1 and 24",
"Daily interval must be between 1 and 365": "Daily interval must be between 1 and 365",
"Weekly interval must be between 1 and 52": "Weekly interval must be between 1 and 52",
"At least one weekday is required for weekly schedule": "At least one weekday is required for weekly schedule",
"Scheduled job run completed": "Scheduled job run completed",
"Scheduled job run skipped": "Scheduled job run skipped",
"Scheduled job run failed": "Scheduled job run failed",
"Scheduler is already running": "Scheduler is already running",
"Purge scheduler logs": "Purge scheduler logs",
"Purge run logs older than 90 days?": "Purge run logs older than 90 days?",
"%d scheduler run logs purged": "%d scheduler run logs purged",
"User lifecycle logs": "User lifecycle logs",
"View user lifecycle audit entry": "View user lifecycle audit entry",
"Lifecycle audit entry not found": "Lifecycle audit entry not found",
"Purge user lifecycle logs": "Purge user lifecycle logs",
"Purge entries older than 365 days?": "Purge entries older than 365 days?",
"%d user lifecycle audit entries purged": "%d user lifecycle audit entries purged",
"Lifecycle event": "Lifecycle event",
"Trigger": "Trigger",
"Reason code": "Reason code",
"Target": "Target",
"Target UUID": "Target UUID",
"Target email": "Target email",
"Policy": "Policy",
"Snapshot summary": "Snapshot summary",
"Snapshot version": "Snapshot version",
"Restore status": "Restore status",
"Restored at": "Restored at",
"Restored by": "Restored by",
"Restored user": "Restored user",
"Restore user": "Restore user",
"Restore": "Restore",
"Restore this user from lifecycle snapshot?": "Restore this user from lifecycle snapshot?",
"User restored": "User restored",
"User restore failed": "User restore failed",
"Lifecycle event was already restored": "Lifecycle event was already restored",
"Lifecycle snapshot unavailable": "Lifecycle snapshot unavailable",
"Restore not possible: email already exists": "Restore not possible: email already exists",
"Restore not possible: uuid already exists": "Restore not possible: uuid already exists",
"All actions": "All actions",
"All triggers": "All triggers",
"Manual": "Manual",
"Cron": "Cron",
"Enabled jobs": "Enabled jobs",
"Overdue jobs": "Overdue jobs",
"Scheduler runs (24h)": "Scheduler runs (24h)",
"Failed scheduler runs (24h)": "Failed scheduler runs (24h)",
"Cron runner active": "Cron runner active",
"Last heartbeat": "Last heartbeat",
"Last runner result": "Last runner result",
"Runner lock not acquired": "Runner lock not acquired",
"Locale": "Locale"
}

BIN
lib/.DS_Store vendored

Binary file not shown.

View File

@@ -10,18 +10,13 @@ use MintyPHP\Router;
*/
class AccessControl
{
/** Paths that are always public (no auth required) */
private const ALWAYS_PUBLIC_PATHS = [
'login',
'register',
'verify-email',
];
/** Prefixes that are always public */
private const ALWAYS_PUBLIC_PREFIXES = [
'password/',
'branding/',
'auth/tenant-avatar-file',
'flash/',
'auth/microsoft/',
'api/',
];
private array $configuredPublicPaths;
@@ -56,11 +51,6 @@ class AccessControl
}
}
// Check always-public paths
if (in_array($normalizedPath, self::ALWAYS_PUBLIC_PATHS, true)) {
return true;
}
// Check always-public prefixes
foreach (self::ALWAYS_PUBLIC_PREFIXES as $prefix) {
if (str_starts_with($normalizedPath, $prefix)) {

181
lib/Http/ApiAuth.php Normal file
View File

@@ -0,0 +1,181 @@
<?php
namespace MintyPHP\Http;
use MintyPHP\Repository\Access\RolePermissionRepository;
use MintyPHP\Repository\Access\UserRoleRepository;
use MintyPHP\Service\Auth\ApiTokenService;
use MintyPHP\Service\Tenant\TenantScopeService;
use MintyPHP\Service\User\UserService;
class ApiAuth
{
private static ?array $currentUser = null;
private static ?array $currentPermissions = null;
private static ?int $currentTenantId = null;
private static ?int $tokenTenantId = null;
private static ?array $currentTokenRecord = null;
private static bool $authenticated = false;
/**
* Extract the full Bearer token from the Authorization header.
*/
public static function extractBearerToken(): string
{
$header = trim((string) ($_SERVER['HTTP_AUTHORIZATION'] ?? ''));
if ($header === '') {
$header = trim((string) ($_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? ''));
}
if ($header === '' || stripos($header, 'Bearer ') !== 0) {
return '';
}
return trim(substr($header, 7));
}
/**
* Attempt to authenticate from Authorization: Bearer header.
*/
public static function authenticate(): bool
{
self::$currentUser = null;
self::$currentPermissions = null;
self::$currentTenantId = null;
self::$tokenTenantId = null;
self::$currentTokenRecord = null;
self::$authenticated = false;
$bearerToken = self::extractBearerToken();
if ($bearerToken === '') {
return false;
}
$result = ApiTokenService::validate($bearerToken);
if ($result === null) {
return false;
}
$user = $result['user'];
$userId = (int) ($user['id'] ?? 0);
// Load permissions directly (bypass session cache)
$roleIds = UserRoleRepository::listRoleIdsByUserId($userId);
$permissions = RolePermissionRepository::listPermissionKeysByRoleIds($roleIds);
// Resolve tenant context
$tokenTenantId = $result['tenant_id'];
if ($tokenTenantId !== null) {
$userTenantIds = TenantScopeService::getUserTenantIds($userId);
if (!in_array($tokenTenantId, $userTenantIds, true)) {
return false;
}
$currentTenantId = $tokenTenantId;
self::$tokenTenantId = $tokenTenantId;
} else {
$currentTenantId = UserService::getCurrentTenantId($userId);
self::$tokenTenantId = null;
}
self::$currentUser = $user;
self::$currentPermissions = $permissions;
self::$currentTenantId = $currentTenantId;
self::$currentTokenRecord = $result['token_record'];
self::$authenticated = true;
return true;
}
public static function isAuthenticated(): bool
{
return self::$authenticated;
}
public static function user(): ?array
{
return self::$currentUser;
}
public static function userId(): int
{
return (int) (self::$currentUser['id'] ?? 0);
}
public static function permissions(): array
{
return self::$currentPermissions ?? [];
}
public static function hasPermission(string $key): bool
{
return in_array($key, self::$currentPermissions ?? [], true);
}
public static function tenantId(): ?int
{
return self::$currentTenantId;
}
public static function scopedTenantId(): ?int
{
return self::$tokenTenantId;
}
public static function tokenRecord(): ?array
{
return self::$currentTokenRecord;
}
public static function tokenId(): ?int
{
$id = (int) (self::$currentTokenRecord['id'] ?? 0);
return $id > 0 ? $id : null;
}
public static function isTenantScopedToken(): bool
{
return (self::$tokenTenantId ?? 0) > 0;
}
/**
* Require both tenant-scope and token-tenant access for a resource.
*/
public static function requireResourceAccess(string $resource, int $resourceId): void
{
if (!TenantScopeService::canAccess($resource, $resourceId, self::userId())) {
ApiResponse::notFound();
}
self::requireTokenTenantAccess($resource, $resourceId);
}
public static function requireTokenTenantAccess(string $resource, int $resourceId): void
{
if (!self::isTenantScopedToken()) {
return;
}
$tenantId = (int) (self::$tokenTenantId ?? 0);
if ($tenantId <= 0) {
ApiResponse::forbidden();
}
if (!TenantScopeService::resourceBelongsToTenant($resource, $resourceId, $tenantId)) {
ApiResponse::notFound();
}
}
/**
* Check if the current user can self-manage API tokens.
*/
public static function canSelfManageTokens(): bool
{
return self::hasPermission(\MintyPHP\Service\Access\PermissionService::USERS_SELF_UPDATE)
|| self::hasPermission(\MintyPHP\Service\Access\PermissionService::API_TOKENS_MANAGE);
}
public static function requireSelfManageTokens(): void
{
if (!self::canSelfManageTokens()) {
ApiResponse::forbidden('api_tokens_self_manage_forbidden');
}
}
}

201
lib/Http/ApiBootstrap.php Normal file
View File

@@ -0,0 +1,201 @@
<?php
namespace MintyPHP\Http;
use MintyPHP\Service\Audit\ApiAuditService;
use MintyPHP\Service\Security\RateLimiterService;
use MintyPHP\Service\Settings\SettingService;
class ApiBootstrap
{
private const API_RATE_SCOPE_TOKEN = 'api.token_ip';
private const API_RATE_SCOPE_IP = 'api.ip';
private const API_RATE_LIMIT_TOKEN = 300;
private const API_RATE_LIMIT_IP = 120;
private const API_RATE_WINDOW_SECONDS = 60;
private const API_RATE_BLOCK_SECONDS = 120;
private static bool $initialized = false;
private static bool $shutdownRegistered = false;
/**
* Initialize the API request context.
*
* Call this at the top of every API page action.
* Sets MINTY_ALLOW_OUTPUT, CORS headers, authenticates via Bearer token.
*/
public static function init(): void
{
if (self::$initialized) {
return;
}
self::$initialized = true;
if (!defined('MINTY_ALLOW_OUTPUT')) {
define('MINTY_ALLOW_OUTPUT', true);
}
self::registerShutdownHandler();
ApiAuditService::startRequestContext();
self::setCorsHeaders();
if (strtoupper($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
http_response_code(204);
die();
}
self::enforceRateLimit();
$authenticated = ApiAuth::authenticate();
if (!$authenticated) {
ApiResponse::unauthorized();
}
}
private static function setCorsHeaders(): void
{
$rawOrigin = trim((string) ($_SERVER['HTTP_ORIGIN'] ?? ''));
if ($rawOrigin === '') {
return;
}
$origin = self::normalizeOrigin($rawOrigin);
if ($origin === null) {
return;
}
$allowedOrigins = array_fill_keys(SettingService::getApiCorsAllowedOrigins(), true);
if (!isset($allowedOrigins[$origin])) {
return;
}
header('Vary: Origin');
header('Access-Control-Allow-Origin: ' . $origin);
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Authorization, Content-Type, Accept');
header('Access-Control-Max-Age: 86400');
}
private static function registerShutdownHandler(): void
{
if (self::$shutdownRegistered) {
return;
}
self::$shutdownRegistered = true;
register_shutdown_function(static function (): void {
$error = error_get_last();
if (!is_array($error)) {
$statusCode = (int) http_response_code();
if ($statusCode <= 0) {
$statusCode = 200;
}
ApiAuditService::finish($statusCode);
return;
}
$type = (int) $error['type'];
$fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR];
if (!in_array($type, $fatalTypes, true)) {
$statusCode = (int) http_response_code();
if ($statusCode <= 0) {
$statusCode = 200;
}
ApiAuditService::finish($statusCode);
return;
}
$statusCode = (int) http_response_code();
if ($statusCode < 400) {
$statusCode = 500;
}
ApiAuditService::finish($statusCode, 'fatal_error');
});
}
private static function normalizeOrigin(string $origin): ?string
{
$origin = rtrim(trim($origin), '/');
if ($origin === '') {
return null;
}
$parsed = parse_url($origin);
if (!is_array($parsed)) {
return null;
}
$scheme = strtolower((string) ($parsed['scheme'] ?? ''));
$host = strtolower((string) ($parsed['host'] ?? ''));
$port = isset($parsed['port']) ? (int) $parsed['port'] : null;
if (!in_array($scheme, ['http', 'https'], true) || $host === '') {
return null;
}
if (
isset($parsed['path'])
|| isset($parsed['query'])
|| isset($parsed['fragment'])
|| isset($parsed['user'])
|| isset($parsed['pass'])
) {
return null;
}
$defaultPort = ($scheme === 'https') ? 443 : 80;
$portSuffix = ($port !== null && $port !== $defaultPort) ? ':' . $port : '';
return $scheme . '://' . $host . $portSuffix;
}
private static function enforceRateLimit(): void
{
$ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? ''));
if ($ip === '') {
$ip = 'unknown';
}
$ipResult = RateLimiterService::hit(
self::API_RATE_SCOPE_IP,
$ip,
self::API_RATE_LIMIT_IP,
self::API_RATE_WINDOW_SECONDS,
self::API_RATE_BLOCK_SECONDS
);
if (!($ipResult['allowed'] ?? true)) {
ApiResponse::tooManyRequests(max(1, (int) ($ipResult['retry_after'] ?? self::API_RATE_BLOCK_SECONDS)));
}
$selector = self::extractBearerSelector();
if ($selector === '') {
return;
}
$tokenResult = RateLimiterService::hit(
self::API_RATE_SCOPE_TOKEN,
strtolower($selector) . '|' . $ip,
self::API_RATE_LIMIT_TOKEN,
self::API_RATE_WINDOW_SECONDS,
self::API_RATE_BLOCK_SECONDS
);
if (!($tokenResult['allowed'] ?? true)) {
ApiResponse::tooManyRequests(max(1, (int) ($tokenResult['retry_after'] ?? self::API_RATE_BLOCK_SECONDS)));
}
}
private static function extractBearerSelector(): string
{
$token = ApiAuth::extractBearerToken();
if ($token === '') {
return '';
}
$parts = explode(':', $token, 2);
$selector = trim((string) ($parts[0] ?? ''));
if ($selector === '' || !preg_match('/^[a-f0-9]{24}$/i', $selector)) {
return '';
}
return $selector;
}
}

158
lib/Http/ApiResponse.php Normal file
View File

@@ -0,0 +1,158 @@
<?php
namespace MintyPHP\Http;
use MintyPHP\Service\Audit\ApiAuditService;
class ApiResponse
{
public static function success(array $data = [], int $status = 200): never
{
self::send($data, $status);
}
public static function created(array $data = []): never
{
self::success($data, 201);
}
public static function noContent(): never
{
self::send(null, 204);
}
public static function error(string $error, int $status = 400, array $extra = []): never
{
$body = array_merge(['error' => $error], $extra);
self::send($body, $status, $error);
}
public static function unauthorized(): never
{
self::error('unauthorized', 401);
}
public static function forbidden(string $detail = 'forbidden'): never
{
self::error($detail, 403);
}
public static function notFound(string $detail = 'not_found'): never
{
self::error($detail, 404);
}
public static function methodNotAllowed(): never
{
self::error('method_not_allowed', 405);
}
public static function validationError(array $errors): never
{
self::error('validation_error', 422, ['errors' => $errors]);
}
public static function tooManyRequests(int $retryAfter = 60): never
{
self::send(
['error' => 'rate_limit_exceeded'],
429,
'rate_limit_exceeded',
['Retry-After: ' . $retryAfter]
);
}
/**
* Transform a service result (['ok' => bool, ...] pattern) into an API response.
*/
public static function fromServiceResult(array $result, int $successStatus = 200): never
{
if ($result['ok'] ?? false) {
$data = $result;
unset($data['ok']);
self::success($data, $successStatus);
}
$status = (int) ($result['status'] ?? 0);
if ($status < 400) {
$status = 422;
}
$error = (string) ($result['error'] ?? 'operation_failed');
$errors = $result['errors'] ?? [];
$extra = [];
if ($errors) {
$extra['errors'] = $errors;
}
self::error($error, $status, $extra);
}
/**
* Read JSON request body into an array.
*/
public static function readJsonBody(): array
{
$raw = file_get_contents('php://input');
if ($raw === false || $raw === '') {
return [];
}
$data = json_decode($raw, true);
return is_array($data) ? $data : [];
}
/**
* Require specific HTTP method(s).
*/
public static function requireMethod(string ...$methods): void
{
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
if (!in_array($method, $methods, true)) {
self::methodNotAllowed();
}
}
/**
* Require API authentication.
*/
public static function requireAuth(): void
{
if (!ApiAuth::isAuthenticated()) {
self::unauthorized();
}
}
/**
* Require a specific permission key.
*/
public static function requirePermission(string $key): void
{
self::requireAuth();
if (!ApiAuth::hasPermission($key)) {
self::forbidden();
}
}
private static function send(?array $body, int $status, ?string $errorCode = null, array $headers = []): never
{
http_response_code($status);
foreach ($headers as $headerLine) {
header($headerLine);
}
if ($body === null) {
ApiAuditService::finish($status, $errorCode);
die();
}
$json = json_encode($body, JSON_UNESCAPED_UNICODE);
if (!is_string($json)) {
$status = 500;
http_response_code($status);
$errorCode = $errorCode ?: 'serialization_error';
$json = json_encode(['error' => 'serialization_error'], JSON_UNESCAPED_UNICODE) ?: '{"error":"serialization_error"}';
}
header('Content-Type: application/json; charset=utf-8');
ApiAuditService::finish($status, $errorCode);
die($json);
}
}

View File

@@ -3,7 +3,6 @@
namespace MintyPHP\Http;
use MintyPHP\I18n;
use MintyPHP\Router;
/**
* Handles locale detection and URL manipulation for multi-language support.
@@ -18,7 +17,7 @@ class LocaleResolver
{
$this->defaultLocale = $defaultLocale ?? I18n::$defaultLocale;
$this->availableLocales = $availableLocales ?? (defined('APP_LOCALES') ? APP_LOCALES : [$this->defaultLocale]);
$this->basePath = trim(parse_url(Router::getBaseUrl(), PHP_URL_PATH) ?: '/', '/');
$this->basePath = trim(parse_url(\MintyPHP\Router::getBaseUrl(), PHP_URL_PATH) ?: '/', '/');
}
/**
@@ -37,7 +36,7 @@ class LocaleResolver
$path = parse_url($uri, PHP_URL_PATH) ?: '';
$query = parse_url($uri, PHP_URL_QUERY) ?: '';
$relativePath = $this->stripBasePath($path);
$relativePath = Request::stripBasePath($path);
$segments = $relativePath === '' ? [] : explode('/', $relativePath);
$localeCandidate = $segments[0] ?? '';
@@ -120,21 +119,6 @@ class LocaleResolver
return $this->defaultLocale;
}
private function stripBasePath(string $path): string
{
$relativePath = ltrim($path, '/');
if ($this->basePath !== '' && strpos($relativePath, $this->basePath . '/') === 0) {
return substr($relativePath, strlen($this->basePath) + 1);
}
if ($this->basePath !== '' && $relativePath === $this->basePath) {
return '';
}
return $relativePath;
}
private function looksLikeLocale(string $candidate): bool
{
return preg_match('/^[a-z]{2}(?:-[a-z]{2})?$/i', $candidate) === 1;

View File

@@ -7,20 +7,27 @@ use MintyPHP\I18n;
class Request
{
public static function stripBasePath(string $path): string
{
$base = trim(parse_url(Router::getBaseUrl(), PHP_URL_PATH) ?: '/', '/');
$path = ltrim($path, '/');
if ($base !== '' && strpos($path, $base . '/') === 0) {
return substr($path, strlen($base) + 1);
}
if ($base !== '' && $path === $base) {
return '';
}
return $path;
}
public static function path(): string
{
$uri = $_SERVER['REQUEST_URI'] ?? '';
$path = parse_url($uri, PHP_URL_PATH) ?: '';
$base = trim(Router::getBaseUrl(), '/');
$path = ltrim($path, '/');
if ($base !== '' && strpos($path, $base . '/') === 0) {
$path = substr($path, strlen($base) + 1);
} elseif ($base !== '' && $path === $base) {
$path = '';
}
return $path;
return self::stripBasePath($path);
}
public static function safeReturnTarget(string $returnParam = ''): string
@@ -28,15 +35,8 @@ class Request
if ($returnParam !== '') {
$parts = parse_url($returnParam);
if (($parts['scheme'] ?? '') === '' && ($parts['host'] ?? '') === '') {
$path = $parts['path'] ?? '';
$path = self::stripBasePath($parts['path'] ?? '');
$query = isset($parts['query']) ? '?' . $parts['query'] : '';
$base = trim(Router::getBaseUrl(), '/');
$path = ltrim($path, '/');
if ($base !== '' && strpos($path, $base . '/') === 0) {
$path = substr($path, strlen($base) + 1);
} elseif ($base !== '' && $path === $base) {
$path = '';
}
return $path . $query;
}
}
@@ -47,15 +47,8 @@ class Request
$host = $parts['host'] ?? '';
$currentHost = $_SERVER['HTTP_HOST'] ?? '';
if ($host === '' || $host === $currentHost) {
$path = $parts['path'] ?? '';
$path = self::stripBasePath($parts['path'] ?? '');
$query = isset($parts['query']) ? '?' . $parts['query'] : '';
$base = trim(Router::getBaseUrl(), '/');
$path = ltrim($path, '/');
if ($base !== '' && strpos($path, $base . '/') === 0) {
$path = substr($path, strlen($base) + 1);
} elseif ($base !== '' && $path === $base) {
$path = '';
}
return $path . $query;
}
}

View File

@@ -22,6 +22,38 @@ class RolePermissionRepository
return array_values(array_unique($ids));
}
public static function countPermissionsByRoleIds(array $roleIds): array
{
$roleIds = array_values(array_unique(array_map('intval', $roleIds)));
$roleIds = array_values(array_filter($roleIds, static fn ($id) => $id > 0));
if (!$roleIds) {
return [];
}
$placeholders = implode(',', array_fill(0, count($roleIds), '?'));
$rows = DB::select(
'select rp.role_id, count(distinct rp.permission_id) as permission_count from role_permissions rp where rp.role_id in (' .
$placeholders .
') group by rp.role_id',
...array_map('strval', $roleIds)
);
if (!is_array($rows)) {
return [];
}
$result = [];
foreach ($rows as $row) {
$roleId = self::extractIntField($row, 'role_id');
if ($roleId <= 0) {
continue;
}
$result[$roleId] = self::extractIntField($row, 'permission_count');
}
return $result;
}
public static function replaceForRole(int $roleId, array $permissionIds): bool
{
DB::delete('delete from role_permissions where role_id = ?', (string) $roleId);
@@ -123,27 +155,43 @@ class RolePermissionRepository
$rowRp = $row['rp'] ?? ($row['role_permissions'] ?? $row);
$permId = $rowRp['permission_id'] ?? ($row['permission_id'] ?? null);
$key = (string) (($rowPerm['key'] ?? $row['key'] ?? '') ?? '');
$key = (string) ($rowPerm['key'] ?? $row['key'] ?? '');
if ($permId === null || $key === '') {
continue;
}
if (!isset($permMap[$permId])) {
$desc = (string) (($rowPerm['description'] ?? $row['description'] ?? '') ?? '');
$desc = (string) ($rowPerm['description'] ?? $row['description'] ?? '');
$permMap[$permId] = [
'key' => $key,
'description' => $desc,
'roles' => [],
];
}
$roleLabel = (string) (($rowRole['description'] ?? $rowRole['role'] ?? $row['role'] ?? '') ?? '');
$roleLabel = (string) ($rowRole['description'] ?? $rowRole['role'] ?? $row['role'] ?? '');
if ($roleLabel !== '') {
$permMap[$permId]['roles'][] = $roleLabel;
}
}
foreach ($permMap as &$item) {
$item['roles'] = array_values(array_unique($item['roles'] ?? []));
$item['roles'] = array_values(array_unique($item['roles']));
}
unset($item);
return array_values($permMap);
}
private static function extractIntField(array $row, string $field): int
{
foreach ($row as $value) {
if (!is_array($value)) {
continue;
}
if (array_key_exists($field, $value)) {
return (int) $value[$field];
}
}
if (array_key_exists($field, $row)) {
return (int) $row[$field];
}
return 0;
}
}

View File

@@ -46,6 +46,22 @@ class RoleRepository
return self::unwrapList($rows);
}
public static function listByIds(array $roleIds): array
{
$roleIds = array_values(array_unique(array_map('intval', $roleIds)));
$roleIds = array_values(array_filter($roleIds, static fn ($id) => $id > 0));
if (!$roleIds) {
return [];
}
$placeholders = implode(',', array_fill(0, count($roleIds), '?'));
$rows = DB::select(
'select id, uuid, description, code, active, created_by, modified_by, created, modified from roles where id in (' . $placeholders . ')',
...array_map('strval', $roleIds)
);
return self::unwrapList($rows);
}
public static function listIds(): array
{
$rows = DB::select('select id from roles');
@@ -143,19 +159,11 @@ class RoleRepository
return $count ? ((int) $count > 0) : false;
}
private static function uuidV4(): string
{
$data = random_bytes(16);
$data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
$data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}
public static function create(array $data)
{
return DB::insert(
'insert into roles (uuid, description, code, active, created_by, created) values (?,?,?,?,?,NOW())',
$data['uuid'] ?? self::uuidV4(),
$data['uuid'] ?? RepoQuery::uuidV4(),
$data['description'],
$data['code'] ?? null,
$data['active'] ?? 1,

View File

@@ -39,4 +39,63 @@ class UserRoleRepository
return true;
}
public static function countUsersByRoleIds(array $roleIds): array
{
return self::countByRoleIds($roleIds, false);
}
public static function countActiveUsersByRoleIds(array $roleIds): array
{
return self::countByRoleIds($roleIds, true);
}
private static function countByRoleIds(array $roleIds, bool $activeOnly): array
{
$roleIds = array_values(array_unique(array_map('intval', $roleIds)));
$roleIds = array_values(array_filter($roleIds, static fn ($id) => $id > 0));
if (!$roleIds) {
return [];
}
$placeholders = implode(',', array_fill(0, count($roleIds), '?'));
$joinUsersSql = $activeOnly ? ' join users u on u.id = ur.user_id and u.active = 1' : '';
$rows = DB::select(
'select ur.role_id, count(distinct ur.user_id) as user_count from user_roles ur' .
$joinUsersSql .
' where ur.role_id in (' . $placeholders . ') group by ur.role_id',
...array_map('strval', $roleIds)
);
if (!is_array($rows)) {
return [];
}
$result = [];
foreach ($rows as $row) {
$roleId = self::extractIntField($row, 'role_id');
if ($roleId <= 0) {
continue;
}
$result[$roleId] = self::extractIntField($row, 'user_count');
}
return $result;
}
private static function extractIntField(array $row, string $field): int
{
foreach ($row as $value) {
if (!is_array($value)) {
continue;
}
if (array_key_exists($field, $value)) {
return (int) $value[$field];
}
}
if (array_key_exists($field, $row)) {
return (int) $row[$field];
}
return 0;
}
}

View File

@@ -0,0 +1,223 @@
<?php
namespace MintyPHP\Repository\Audit;
use MintyPHP\DB;
use MintyPHP\Repository\Support\RepoQuery;
class ApiAuditLogRepository
{
public static function create(array $data): int|false
{
$id = DB::insert(
'insert into api_audit_log (
request_id, method, path, query_json, status_code, duration_ms, error_code,
user_id, tenant_id, api_token_id, token_tenant_id, ip, user_agent, created_at
) values (?,?,?,?,?,?,?,?,?,?,?,?,?,NOW())',
(string) ($data['request_id'] ?? ''),
(string) ($data['method'] ?? ''),
(string) ($data['path'] ?? ''),
$data['query_json'] ?? null,
(string) ((int) ($data['status_code'] ?? 0)),
$data['duration_ms'] !== null ? (string) ((int) $data['duration_ms']) : null,
$data['error_code'] ?? null,
$data['user_id'] !== null ? (string) ((int) $data['user_id']) : null,
$data['tenant_id'] !== null ? (string) ((int) $data['tenant_id']) : null,
$data['api_token_id'] !== null ? (string) ((int) $data['api_token_id']) : null,
$data['token_tenant_id'] !== null ? (string) ((int) $data['token_tenant_id']) : null,
$data['ip'] ?? null,
$data['user_agent'] ?? null
);
return $id ? (int) $id : false;
}
public static function listPaged(array $filters): array
{
$search = trim((string) ($filters['search'] ?? ''));
$status = strtolower(trim((string) ($filters['status'] ?? '')));
$method = strtoupper(trim((string) ($filters['method'] ?? '')));
$createdFrom = trim((string) ($filters['created_from'] ?? ''));
$createdTo = trim((string) ($filters['created_to'] ?? ''));
$tenantId = (int) ($filters['tenant_id'] ?? 0);
$userId = (int) ($filters['user_id'] ?? 0);
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($filters, 20, 1, 200, 0);
[$order, $dir] = RepoQuery::sanitizeOrder(
$filters,
['id', 'created_at', 'status_code', 'duration_ms', 'method', 'path'],
'created_at',
'desc'
);
$where = [];
$params = [];
RepoQuery::addLikeFilter(
$where,
$params,
['api_audit_log.path', 'api_audit_log.request_id', 'api_audit_log.ip', 'api_audit_log.error_code'],
$search
);
if (in_array($status, ['2xx', '4xx', '5xx'], true)) {
$rangeStart = (int) $status[0] * 100;
$where[] = 'api_audit_log.status_code between ? and ?';
$params[] = (string) $rangeStart;
$params[] = (string) ($rangeStart + 99);
} elseif ($status !== '' && ctype_digit($status)) {
$statusCode = (int) $status;
if ($statusCode >= 100 && $statusCode <= 599) {
$where[] = 'api_audit_log.status_code = ?';
$params[] = (string) $statusCode;
}
}
if (in_array($method, ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'], true)) {
$where[] = 'api_audit_log.method = ?';
$params[] = $method;
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdFrom)) {
$where[] = 'api_audit_log.created_at >= ?';
$params[] = $createdFrom . ' 00:00:00';
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdTo)) {
$where[] = 'api_audit_log.created_at <= ?';
$params[] = $createdTo . ' 23:59:59';
}
if ($tenantId > 0) {
$where[] = 'api_audit_log.tenant_id = ?';
$params[] = (string) $tenantId;
}
if ($userId > 0) {
$where[] = 'api_audit_log.user_id = ?';
$params[] = (string) $userId;
}
$whereSql = $where ? (' where ' . implode(' and ', $where)) : '';
$fromSql = ' from api_audit_log ' .
'left join users on users.id = api_audit_log.user_id ' .
'left join tenants on tenants.id = api_audit_log.tenant_id ';
$total = (int) (DB::selectValue('select count(*)' . $fromSql . $whereSql, ...$params) ?? 0);
$rows = DB::select(
'select
api_audit_log.id,
api_audit_log.request_id,
api_audit_log.method,
api_audit_log.path,
api_audit_log.query_json,
api_audit_log.status_code,
api_audit_log.duration_ms,
api_audit_log.error_code,
api_audit_log.user_id,
api_audit_log.tenant_id,
api_audit_log.api_token_id,
api_audit_log.token_tenant_id,
api_audit_log.ip,
api_audit_log.user_agent,
api_audit_log.created_at,
users.id,
users.uuid,
users.display_name,
users.email,
tenants.id,
tenants.uuid,
tenants.description
' . $fromSql . $whereSql .
sprintf(' order by api_audit_log.`%s` %s limit ? offset ?', $order, $dir),
...array_merge($params, [(string) $limit, (string) $offset])
);
$normalized = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$item = self::normalizeRow($row);
if ($item !== null) {
$normalized[] = $item;
}
}
}
return [
'total' => $total,
'rows' => $normalized,
];
}
public static function find(int $id): ?array
{
$row = DB::selectOne(
'select
api_audit_log.id,
api_audit_log.request_id,
api_audit_log.method,
api_audit_log.path,
api_audit_log.query_json,
api_audit_log.status_code,
api_audit_log.duration_ms,
api_audit_log.error_code,
api_audit_log.user_id,
api_audit_log.tenant_id,
api_audit_log.api_token_id,
api_audit_log.token_tenant_id,
api_audit_log.ip,
api_audit_log.user_agent,
api_audit_log.created_at,
users.id,
users.uuid,
users.display_name,
users.email,
tenants.id,
tenants.uuid,
tenants.description
from api_audit_log
left join users on users.id = api_audit_log.user_id
left join tenants on tenants.id = api_audit_log.tenant_id
where api_audit_log.id = ?
limit 1',
(string) $id
);
return self::normalizeRow($row);
}
public static function purgeOlderThanDays(int $days): int
{
if ($days <= 0) {
return 0;
}
$cutoff = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))
->modify('-' . $days . ' days')
->format('Y-m-d H:i:s');
$deleted = DB::delete('delete from api_audit_log where created_at < ?', $cutoff);
return is_int($deleted) ? $deleted : 0;
}
private static function normalizeRow(mixed $row): ?array
{
if (!is_array($row)) {
return null;
}
$log = $row['api_audit_log'] ?? [];
if (!is_array($log) || !isset($log['id'])) {
return null;
}
$user = is_array($row['users'] ?? null) ? $row['users'] : [];
$tenant = is_array($row['tenants'] ?? null) ? $row['tenants'] : [];
$log['user_uuid'] = (string) ($user['uuid'] ?? '');
$log['user_display_name'] = (string) ($user['display_name'] ?? '');
$log['user_email'] = (string) ($user['email'] ?? '');
$log['tenant_uuid'] = (string) ($tenant['uuid'] ?? '');
$log['tenant_description'] = (string) ($tenant['description'] ?? '');
return $log;
}
}

View File

@@ -0,0 +1,251 @@
<?php
namespace MintyPHP\Repository\Audit;
use MintyPHP\DB;
use MintyPHP\Repository\Support\RepoQuery;
class ImportAuditRunRepository
{
public static function createRunning(array $data): int|false
{
$id = DB::insert(
'insert into import_audit_runs (
run_uuid, profile_key, status, source_filename, mapped_targets_csv, started_at, user_id, current_tenant_id
) values (?,?,?,?,?,NOW(),?,?)',
(string) ($data['run_uuid'] ?? ''),
(string) ($data['profile_key'] ?? ''),
(string) ($data['status'] ?? 'running'),
$data['source_filename'] ?? null,
$data['mapped_targets_csv'] ?? null,
$data['user_id'] !== null ? (string) ((int) $data['user_id']) : null,
$data['current_tenant_id'] !== null ? (string) ((int) $data['current_tenant_id']) : null
);
return $id ? (int) $id : false;
}
public static function finishById(int $id, array $data): bool
{
if ($id <= 0) {
return false;
}
$updated = DB::update(
'update import_audit_runs
set status = ?,
rows_total = ?,
created_count = ?,
skipped_count = ?,
failed_count = ?,
error_codes_json = ?,
duration_ms = ?,
finished_at = NOW()
where id = ?',
(string) ($data['status'] ?? 'failed'),
(string) ((int) ($data['rows_total'] ?? 0)),
(string) ((int) ($data['created_count'] ?? 0)),
(string) ((int) ($data['skipped_count'] ?? 0)),
(string) ((int) ($data['failed_count'] ?? 0)),
$data['error_codes_json'] ?? null,
$data['duration_ms'] !== null ? (string) ((int) $data['duration_ms']) : null,
(string) $id
);
return (int) $updated > 0;
}
public static function listPaged(array $filters): array
{
$search = trim((string) ($filters['search'] ?? ''));
$profileKey = strtolower(trim((string) ($filters['profile_key'] ?? '')));
$status = strtolower(trim((string) ($filters['status'] ?? '')));
$createdFrom = trim((string) ($filters['created_from'] ?? ''));
$createdTo = trim((string) ($filters['created_to'] ?? ''));
$userId = (int) ($filters['user_id'] ?? 0);
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($filters, 20, 1, 200, 0);
[$order, $dir] = RepoQuery::sanitizeOrder(
$filters,
[
'id',
'started_at',
'finished_at',
'duration_ms',
'rows_total',
'created_count',
'skipped_count',
'failed_count',
'status',
'profile_key',
],
'started_at',
'desc'
);
$where = [];
$params = [];
RepoQuery::addLikeFilter(
$where,
$params,
[
'import_audit_runs.run_uuid',
'import_audit_runs.source_filename',
'import_audit_runs.error_codes_json',
],
$search
);
if (in_array($profileKey, ['users', 'departments'], true)) {
$where[] = 'import_audit_runs.profile_key = ?';
$params[] = $profileKey;
}
if (in_array($status, ['running', 'success', 'partial', 'failed'], true)) {
$where[] = 'import_audit_runs.status = ?';
$params[] = $status;
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdFrom)) {
$where[] = 'import_audit_runs.started_at >= ?';
$params[] = $createdFrom . ' 00:00:00';
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdTo)) {
$where[] = 'import_audit_runs.started_at <= ?';
$params[] = $createdTo . ' 23:59:59';
}
if ($userId > 0) {
$where[] = 'import_audit_runs.user_id = ?';
$params[] = (string) $userId;
}
$whereSql = $where ? (' where ' . implode(' and ', $where)) : '';
$fromSql = ' from import_audit_runs ' .
'left join users on users.id = import_audit_runs.user_id ' .
'left join tenants on tenants.id = import_audit_runs.current_tenant_id ';
$total = (int) (DB::selectValue('select count(*)' . $fromSql . $whereSql, ...$params) ?? 0);
$rows = DB::select(
'select
import_audit_runs.id,
import_audit_runs.run_uuid,
import_audit_runs.profile_key,
import_audit_runs.status,
import_audit_runs.source_filename,
import_audit_runs.mapped_targets_csv,
import_audit_runs.rows_total,
import_audit_runs.created_count,
import_audit_runs.skipped_count,
import_audit_runs.failed_count,
import_audit_runs.error_codes_json,
import_audit_runs.started_at,
import_audit_runs.finished_at,
import_audit_runs.duration_ms,
import_audit_runs.user_id,
import_audit_runs.current_tenant_id,
users.id,
users.uuid,
users.display_name,
users.email,
tenants.id,
tenants.uuid,
tenants.description
' . $fromSql . $whereSql .
sprintf(' order by import_audit_runs.`%s` %s limit ? offset ?', $order, $dir),
...array_merge($params, [(string) $limit, (string) $offset])
);
$normalized = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$item = self::normalizeRow($row);
if ($item !== null) {
$normalized[] = $item;
}
}
}
return [
'total' => $total,
'rows' => $normalized,
];
}
public static function find(int $id): ?array
{
if ($id <= 0) {
return null;
}
$row = DB::selectOne(
'select
import_audit_runs.id,
import_audit_runs.run_uuid,
import_audit_runs.profile_key,
import_audit_runs.status,
import_audit_runs.source_filename,
import_audit_runs.mapped_targets_csv,
import_audit_runs.rows_total,
import_audit_runs.created_count,
import_audit_runs.skipped_count,
import_audit_runs.failed_count,
import_audit_runs.error_codes_json,
import_audit_runs.started_at,
import_audit_runs.finished_at,
import_audit_runs.duration_ms,
import_audit_runs.user_id,
import_audit_runs.current_tenant_id,
users.id,
users.uuid,
users.display_name,
users.email,
tenants.id,
tenants.uuid,
tenants.description
from import_audit_runs
left join users on users.id = import_audit_runs.user_id
left join tenants on tenants.id = import_audit_runs.current_tenant_id
where import_audit_runs.id = ?
limit 1',
(string) $id
);
return self::normalizeRow($row);
}
public static function purgeOlderThanDays(int $days): int
{
if ($days <= 0) {
return 0;
}
$cutoff = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))
->modify('-' . $days . ' days')
->format('Y-m-d H:i:s');
$deleted = DB::delete('delete from import_audit_runs where started_at < ?', $cutoff);
return is_int($deleted) ? $deleted : 0;
}
private static function normalizeRow(mixed $row): ?array
{
if (!is_array($row)) {
return null;
}
$item = $row['import_audit_runs'] ?? [];
if (!is_array($item) || !isset($item['id'])) {
return null;
}
$user = is_array($row['users'] ?? null) ? $row['users'] : [];
$tenant = is_array($row['tenants'] ?? null) ? $row['tenants'] : [];
$item['user_uuid'] = (string) ($user['uuid'] ?? '');
$item['user_display_name'] = (string) ($user['display_name'] ?? '');
$item['user_email'] = (string) ($user['email'] ?? '');
$item['current_tenant_uuid'] = (string) ($tenant['uuid'] ?? '');
$item['current_tenant_description'] = (string) ($tenant['description'] ?? '');
return $item;
}
}

View File

@@ -0,0 +1,297 @@
<?php
namespace MintyPHP\Repository\Audit;
use MintyPHP\DB;
use MintyPHP\Repository\Support\RepoQuery;
class UserLifecycleAuditRepository
{
public static function create(array $row): int|false
{
$id = DB::insert(
'insert into user_lifecycle_audit_log (
run_uuid, action, trigger_type, status, reason_code,
policy_deactivate_days, policy_delete_days,
actor_user_id, target_user_id, target_user_uuid, target_user_email,
snapshot_enc, snapshot_version, created_at
) values (?,?,?,?,?,?,?,?,?,?,?,?,?,NOW())',
(string) ($row['run_uuid'] ?? ''),
(string) ($row['action'] ?? ''),
(string) ($row['trigger_type'] ?? ''),
(string) ($row['status'] ?? ''),
$row['reason_code'] ?? null,
(string) ((int) ($row['policy_deactivate_days'] ?? 0)),
(string) ((int) ($row['policy_delete_days'] ?? 0)),
$row['actor_user_id'] !== null ? (string) ((int) $row['actor_user_id']) : null,
$row['target_user_id'] !== null ? (string) ((int) $row['target_user_id']) : null,
$row['target_user_uuid'] ?? null,
$row['target_user_email'] ?? null,
$row['snapshot_enc'] ?? null,
(string) ((int) ($row['snapshot_version'] ?? 1))
);
return $id ? (int) $id : false;
}
public static function updateStatus(int $id, string $status, ?string $reasonCode = null): bool
{
if ($id <= 0) {
return false;
}
$status = trim(strtolower($status));
if (!in_array($status, ['success', 'skipped', 'failed'], true)) {
return false;
}
$updated = DB::update(
'update user_lifecycle_audit_log set status = ?, reason_code = ? where id = ?',
$status,
$reasonCode,
(string) $id
);
return $updated !== false;
}
public static function listPaged(array $filters): array
{
$search = trim((string) ($filters['search'] ?? ''));
$action = strtolower(trim((string) ($filters['action'] ?? '')));
$status = strtolower(trim((string) ($filters['status'] ?? '')));
$triggerType = strtolower(trim((string) ($filters['trigger_type'] ?? '')));
$createdFrom = trim((string) ($filters['created_from'] ?? ''));
$createdTo = trim((string) ($filters['created_to'] ?? ''));
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($filters, 20, 1, 200, 0);
[$order, $dir] = RepoQuery::sanitizeOrder(
$filters,
['id', 'created_at', 'action', 'trigger_type', 'status'],
'created_at',
'desc'
);
$where = [];
$params = [];
RepoQuery::addLikeFilter(
$where,
$params,
[
'user_lifecycle_audit_log.run_uuid',
'user_lifecycle_audit_log.target_user_uuid',
'user_lifecycle_audit_log.target_user_email',
'user_lifecycle_audit_log.reason_code',
],
$search
);
if (in_array($action, ['deactivate', 'delete', 'restore'], true)) {
$where[] = 'user_lifecycle_audit_log.action = ?';
$params[] = $action;
}
if (in_array($status, ['success', 'skipped', 'failed'], true)) {
$where[] = 'user_lifecycle_audit_log.status = ?';
$params[] = $status;
}
if (in_array($triggerType, ['manual', 'cron', 'system'], true)) {
$where[] = 'user_lifecycle_audit_log.trigger_type = ?';
$params[] = $triggerType;
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdFrom)) {
$where[] = 'user_lifecycle_audit_log.created_at >= ?';
$params[] = $createdFrom . ' 00:00:00';
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdTo)) {
$where[] = 'user_lifecycle_audit_log.created_at <= ?';
$params[] = $createdTo . ' 23:59:59';
}
$whereSql = $where ? (' where ' . implode(' and ', $where)) : '';
$fromSql = ' from user_lifecycle_audit_log ' .
'left join users actor_user on actor_user.id = user_lifecycle_audit_log.actor_user_id ' .
'left join users restored_by_user on restored_by_user.id = user_lifecycle_audit_log.restored_by_user_id ' .
'left join users restored_user on restored_user.id = user_lifecycle_audit_log.restored_user_id ';
$total = (int) (DB::selectValue('select count(*)' . $fromSql . $whereSql, ...$params) ?? 0);
$rows = DB::select(
'select
user_lifecycle_audit_log.id,
user_lifecycle_audit_log.run_uuid,
user_lifecycle_audit_log.action,
user_lifecycle_audit_log.trigger_type,
user_lifecycle_audit_log.status,
user_lifecycle_audit_log.reason_code,
user_lifecycle_audit_log.policy_deactivate_days,
user_lifecycle_audit_log.policy_delete_days,
user_lifecycle_audit_log.actor_user_id,
user_lifecycle_audit_log.target_user_id,
user_lifecycle_audit_log.target_user_uuid,
user_lifecycle_audit_log.target_user_email,
user_lifecycle_audit_log.snapshot_version,
user_lifecycle_audit_log.restored_at,
user_lifecycle_audit_log.restored_by_user_id,
user_lifecycle_audit_log.restored_user_id,
user_lifecycle_audit_log.created_at,
actor_user.uuid,
actor_user.display_name,
actor_user.email,
restored_by_user.uuid,
restored_by_user.display_name,
restored_by_user.email,
restored_user.uuid,
restored_user.display_name,
restored_user.email
' . $fromSql . $whereSql .
sprintf(' order by user_lifecycle_audit_log.`%s` %s limit ? offset ?', $order, $dir),
...array_merge($params, [(string) $limit, (string) $offset])
);
$normalized = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$item = self::normalizeRow($row, false);
if ($item !== null) {
$normalized[] = $item;
}
}
}
return ['total' => $total, 'rows' => $normalized];
}
public static function find(int $id): ?array
{
if ($id <= 0) {
return null;
}
$row = DB::selectOne(
'select
user_lifecycle_audit_log.id,
user_lifecycle_audit_log.run_uuid,
user_lifecycle_audit_log.action,
user_lifecycle_audit_log.trigger_type,
user_lifecycle_audit_log.status,
user_lifecycle_audit_log.reason_code,
user_lifecycle_audit_log.policy_deactivate_days,
user_lifecycle_audit_log.policy_delete_days,
user_lifecycle_audit_log.actor_user_id,
user_lifecycle_audit_log.target_user_id,
user_lifecycle_audit_log.target_user_uuid,
user_lifecycle_audit_log.target_user_email,
user_lifecycle_audit_log.snapshot_enc,
user_lifecycle_audit_log.snapshot_version,
user_lifecycle_audit_log.restored_at,
user_lifecycle_audit_log.restored_by_user_id,
user_lifecycle_audit_log.restored_user_id,
user_lifecycle_audit_log.created_at,
actor_user.uuid,
actor_user.display_name,
actor_user.email,
restored_by_user.uuid,
restored_by_user.display_name,
restored_by_user.email,
restored_user.uuid,
restored_user.display_name,
restored_user.email
from user_lifecycle_audit_log
left join users actor_user on actor_user.id = user_lifecycle_audit_log.actor_user_id
left join users restored_by_user on restored_by_user.id = user_lifecycle_audit_log.restored_by_user_id
left join users restored_user on restored_user.id = user_lifecycle_audit_log.restored_user_id
where user_lifecycle_audit_log.id = ?
limit 1',
(string) $id
);
return self::normalizeRow($row, true);
}
public static function findDeleteEventForRestore(int $id, bool $forUpdate = false): ?array
{
if ($id <= 0) {
return null;
}
$query = 'select
id, run_uuid, action, trigger_type, status, reason_code,
policy_deactivate_days, policy_delete_days, actor_user_id,
target_user_id, target_user_uuid, target_user_email,
snapshot_enc, snapshot_version, restored_at,
restored_by_user_id, restored_user_id, created_at
from user_lifecycle_audit_log
where id = ?
and action = \'delete\'
and status = \'success\'
limit 1';
if ($forUpdate) {
$query .= ' for update';
}
$row = DB::selectOne($query, (string) $id);
if (!is_array($row)) {
return null;
}
$item = $row['user_lifecycle_audit_log'] ?? $row;
return is_array($item) ? $item : null;
}
public static function markRestored(int $id, int $restoredBy, int $restoredUserId): bool
{
if ($id <= 0 || $restoredBy <= 0 || $restoredUserId <= 0) {
return false;
}
$updated = DB::update(
'update user_lifecycle_audit_log
set restored_at = NOW(),
restored_by_user_id = ?,
restored_user_id = ?
where id = ? and restored_at is null',
(string) $restoredBy,
(string) $restoredUserId,
(string) $id
);
return (int) $updated > 0;
}
public static function purgeOlderThanDays(int $days): int
{
if ($days <= 0) {
return 0;
}
$cutoff = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))
->modify('-' . $days . ' days')
->format('Y-m-d H:i:s');
$deleted = DB::delete('delete from user_lifecycle_audit_log where created_at < ?', $cutoff);
return is_int($deleted) ? $deleted : 0;
}
private static function normalizeRow(mixed $row, bool $includeSnapshot): ?array
{
if (!is_array($row)) {
return null;
}
$item = $row['user_lifecycle_audit_log'] ?? [];
if (!is_array($item) || !isset($item['id'])) {
return null;
}
$actor = is_array($row['actor_user'] ?? null) ? $row['actor_user'] : [];
$restoredBy = is_array($row['restored_by_user'] ?? null) ? $row['restored_by_user'] : [];
$restoredUser = is_array($row['restored_user'] ?? null) ? $row['restored_user'] : [];
$item['actor_user_uuid'] = (string) ($actor['uuid'] ?? '');
$item['actor_user_display_name'] = (string) ($actor['display_name'] ?? '');
$item['actor_user_email'] = (string) ($actor['email'] ?? '');
$item['restored_by_user_uuid'] = (string) ($restoredBy['uuid'] ?? '');
$item['restored_by_user_display_name'] = (string) ($restoredBy['display_name'] ?? '');
$item['restored_by_user_email'] = (string) ($restoredBy['email'] ?? '');
$item['restored_user_uuid'] = (string) ($restoredUser['uuid'] ?? '');
$item['restored_user_display_name'] = (string) ($restoredUser['display_name'] ?? '');
$item['restored_user_email'] = (string) ($restoredUser['email'] ?? '');
if (!$includeSnapshot) {
unset($item['snapshot_enc']);
}
return $item;
}
}

View File

@@ -0,0 +1,222 @@
<?php
namespace MintyPHP\Repository\Auth;
use MintyPHP\DB;
use MintyPHP\Repository\Support\RepoQuery;
class ApiTokenRepository
{
private const UUID_REGEX = '/^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i';
private static function unwrapList($rows): array
{
if (!is_array($rows)) {
return [];
}
$list = [];
foreach ($rows as $row) {
$data = $row['user_api_tokens'] ?? $row;
if (is_array($data)) {
$list[] = $data;
}
}
return $list;
}
public static function isUuid(string $value): bool
{
return (bool) preg_match(self::UUID_REGEX, trim($value));
}
public static function create(
int $userId,
string $name,
string $selector,
string $tokenHash,
?int $tenantId,
?string $expiresAt,
?int $createdBy
): ?int {
$id = DB::insert(
'insert into user_api_tokens (uuid, user_id, name, selector, token_hash, tenant_id, expires_at, created_by, created) values (?,?,?,?,?,?,?,?,NOW())',
RepoQuery::uuidV4(),
(string) $userId,
$name,
$selector,
$tokenHash,
$tenantId !== null ? (string) $tenantId : null,
$expiresAt,
$createdBy !== null ? (string) $createdBy : null
);
return $id ? (int) $id : null;
}
public static function findBySelector(string $selector): ?array
{
$row = DB::selectOne(
'select id, uuid, user_id, name, selector, token_hash, tenant_id, last_used_at, last_ip, expires_at, revoked_at, created_by, created from user_api_tokens where selector = ? limit 1',
$selector
);
if (!$row || !isset($row['user_api_tokens'])) {
return null;
}
return $row['user_api_tokens'];
}
public static function find(int $id): ?array
{
$row = DB::selectOne(
'select id, uuid, user_id, name, selector, tenant_id, last_used_at, last_ip, expires_at, revoked_at, created_by, created from user_api_tokens where id = ? limit 1',
(string) $id
);
if (!$row || !isset($row['user_api_tokens'])) {
return null;
}
return $row['user_api_tokens'];
}
public static function findByUuid(string $uuid): ?array
{
$uuid = trim($uuid);
if (!self::isUuid($uuid)) {
return null;
}
$row = DB::selectOne(
'select id, uuid, user_id, name, selector, tenant_id, last_used_at, last_ip, expires_at, revoked_at, created_by, created from user_api_tokens where uuid = ? limit 1',
$uuid
);
if (!$row || !isset($row['user_api_tokens'])) {
return null;
}
return $row['user_api_tokens'];
}
public static function findByUuidForUser(string $uuid, int $userId): ?array
{
$uuid = trim($uuid);
if ($userId <= 0 || !self::isUuid($uuid)) {
return null;
}
$row = DB::selectOne(
'select id, uuid, user_id, name, selector, tenant_id, last_used_at, last_ip, expires_at, revoked_at, created_by, created from user_api_tokens where uuid = ? and user_id = ? limit 1',
$uuid,
(string) $userId
);
if (!$row || !isset($row['user_api_tokens'])) {
return null;
}
return $row['user_api_tokens'];
}
public static function updateLastUsed(int $id, string $ip): bool
{
$result = DB::update(
'update user_api_tokens set last_used_at = NOW(), last_ip = ? where id = ?',
$ip,
(string) $id
);
return $result !== false;
}
public static function revoke(int $id): bool
{
$result = DB::update(
'update user_api_tokens set revoked_at = NOW() where id = ? and revoked_at is null',
(string) $id
);
return $result !== false;
}
public static function revokeByUuidForUser(string $uuid, int $userId): bool
{
$uuid = trim($uuid);
if ($userId <= 0 || !self::isUuid($uuid)) {
return false;
}
$result = DB::update(
'update user_api_tokens set revoked_at = NOW() where uuid = ? and user_id = ? and revoked_at is null',
$uuid,
(string) $userId
);
return $result !== false;
}
public static function revokeAllForUser(int $userId, ?int $tenantId = null): int
{
if ($tenantId !== null && $tenantId > 0) {
$result = DB::update(
'update user_api_tokens set revoked_at = NOW() where user_id = ? and (tenant_id = ? or tenant_id is null) and revoked_at is null',
(string) $userId,
(string) $tenantId
);
} else {
$result = DB::update(
'update user_api_tokens set revoked_at = NOW() where user_id = ? and revoked_at is null',
(string) $userId
);
}
return $result !== false ? (int) $result : 0;
}
public static function revokeAllActiveByAdmin(): int
{
$result = DB::update(
'update user_api_tokens set revoked_at = NOW() where revoked_at is null and (expires_at is null or expires_at > NOW())'
);
return $result !== false ? (int) $result : 0;
}
public static function listByUserId(int $userId, int $limit = 25): array
{
if ($userId <= 0) {
return [];
}
if ($limit < 1) {
$limit = 25;
} elseif ($limit > 100) {
$limit = 100;
}
$rows = DB::select(
'select id, uuid, user_id, name, selector, tenant_id, last_used_at, last_ip, expires_at, revoked_at, created_by, created from user_api_tokens where user_id = ? order by id desc limit ?',
(string) $userId,
(string) $limit
);
return self::unwrapList($rows);
}
public static function countActiveForUser(int $userId): int
{
$count = DB::selectValue(
'select count(*) from user_api_tokens where user_id = ? and revoked_at is null and (expires_at is null or expires_at > NOW())',
(string) $userId
);
return $count ? (int) $count : 0;
}
public static function countActiveForUserExcludingId(int $userId, int $excludeId): int
{
if ($userId <= 0) {
return 0;
}
$count = DB::selectValue(
'select count(*) from user_api_tokens where user_id = ? and id != ? and revoked_at is null and (expires_at is null or expires_at > NOW())',
(string) $userId,
(string) $excludeId
);
return $count ? (int) $count : 0;
}
public static function countActive(): int
{
$count = DB::selectValue(
'select count(*) from user_api_tokens where revoked_at is null and (expires_at is null or expires_at > NOW())'
);
return $count ? (int) $count : 0;
}
}

View File

@@ -0,0 +1,174 @@
<?php
namespace MintyPHP\Repository\CustomField;
use MintyPHP\DB;
use MintyPHP\Repository\Support\RepoQuery;
class TenantCustomFieldDefinitionRepository
{
private static function unwrap(?array $row): ?array
{
if (!$row) {
return null;
}
return $row['tenant_custom_field_definitions'] ?? null;
}
private static function unwrapList($rows): array
{
if (!is_array($rows)) {
return [];
}
$list = [];
foreach ($rows as $row) {
$item = $row['tenant_custom_field_definitions'] ?? null;
if (is_array($item)) {
$list[] = $item;
}
}
return $list;
}
public static function listByTenantId(int $tenantId, bool $onlyActive = true): array
{
if ($tenantId <= 0) {
return [];
}
$query = 'select id, uuid, tenant_id, field_key, label, type, is_required, is_filterable, active, sort_order, created_by, modified_by, created, modified ' .
'from tenant_custom_field_definitions where tenant_id = ?';
$params = [(string) $tenantId];
if ($onlyActive) {
$query .= ' and active = 1';
}
$query .= ' order by sort_order asc, label asc, id asc';
return self::unwrapList(DB::select($query, ...$params));
}
public static function listByTenantIds(array $tenantIds, bool $onlyActive = true): array
{
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
$tenantIds = array_values(array_filter($tenantIds, static fn ($id) => $id > 0));
if (!$tenantIds) {
return [];
}
$query = 'select id, uuid, tenant_id, field_key, label, type, is_required, is_filterable, active, sort_order, created_by, modified_by, created, modified ' .
'from tenant_custom_field_definitions where tenant_id in (???)';
$params = [$tenantIds];
if ($onlyActive) {
$query .= ' and active = 1';
}
$query .= ' order by tenant_id asc, sort_order asc, label asc, id asc';
return self::unwrapList(call_user_func_array(['MintyPHP\\DB', 'select'], array_merge([$query], $params)));
}
public static function listFilterableByTenantIds(array $tenantIds): array
{
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
$tenantIds = array_values(array_filter($tenantIds, static fn ($id) => $id > 0));
if (!$tenantIds) {
return [];
}
$query = 'select id, uuid, tenant_id, field_key, label, type, is_required, is_filterable, active, sort_order, created_by, modified_by, created, modified ' .
'from tenant_custom_field_definitions ' .
'where tenant_id in (???) and active = 1 and is_filterable = 1 ' .
"and type in ('select', 'multiselect', 'boolean', 'date') " .
'order by tenant_id asc, sort_order asc, label asc, id asc';
return self::unwrapList(call_user_func_array(['MintyPHP\\DB', 'select'], [$query, $tenantIds]));
}
public static function findByUuid(string $uuid): ?array
{
$row = DB::selectOne(
'select id, uuid, tenant_id, field_key, label, type, is_required, is_filterable, active, sort_order, created_by, modified_by, created, modified ' .
'from tenant_custom_field_definitions where uuid = ? limit 1',
$uuid
);
return self::unwrap($row);
}
public static function findById(int $id): ?array
{
$row = DB::selectOne(
'select id, uuid, tenant_id, field_key, label, type, is_required, is_filterable, active, sort_order, created_by, modified_by, created, modified ' .
'from tenant_custom_field_definitions where id = ? limit 1',
(string) $id
);
return self::unwrap($row);
}
public static function findByTenantIdAndKey(int $tenantId, string $fieldKey): ?array
{
if ($tenantId <= 0 || $fieldKey === '') {
return null;
}
$row = DB::selectOne(
'select id, uuid, tenant_id, field_key, label, type, is_required, is_filterable, active, sort_order, created_by, modified_by, created, modified ' .
'from tenant_custom_field_definitions where tenant_id = ? and field_key = ? limit 1',
(string) $tenantId,
$fieldKey
);
return self::unwrap($row);
}
public static function create(array $data): int|false
{
return DB::insert(
'insert into tenant_custom_field_definitions ' .
'(uuid, tenant_id, field_key, label, type, is_required, is_filterable, active, sort_order, created_by, created) ' .
'values (?,?,?,?,?,?,?,?,?,?,NOW())',
$data['uuid'] ?? RepoQuery::uuidV4(),
(string) ($data['tenant_id'] ?? 0),
(string) ($data['field_key'] ?? ''),
(string) ($data['label'] ?? ''),
(string) ($data['type'] ?? ''),
(string) ((int) ($data['is_required'] ?? 0)),
(string) ((int) ($data['is_filterable'] ?? 0)),
(string) ((int) ($data['active'] ?? 1)),
(string) ((int) ($data['sort_order'] ?? 100)),
$data['created_by'] ?? null
);
}
public static function update(int $id, array $data): bool
{
if ($id <= 0) {
return false;
}
$fields = [
'field_key' => (string) ($data['field_key'] ?? ''),
'label' => (string) ($data['label'] ?? ''),
'type' => (string) ($data['type'] ?? ''),
'is_required' => (int) ($data['is_required'] ?? 0),
'is_filterable' => (int) ($data['is_filterable'] ?? 0),
'active' => (int) ($data['active'] ?? 1),
'sort_order' => (int) ($data['sort_order'] ?? 100),
];
if (array_key_exists('modified_by', $data)) {
$fields['modified_by'] = $data['modified_by'];
}
$set = [];
$params = [];
foreach ($fields as $field => $value) {
$set[] = sprintf('`%s` = ?', $field);
$params[] = (string) $value;
}
$params[] = (string) $id;
$result = DB::update(
'update tenant_custom_field_definitions set ' . implode(', ', $set) . ' where id = ?',
...$params
);
return $result !== false;
}
public static function delete(int $id): bool
{
if ($id <= 0) {
return false;
}
$result = DB::delete('delete from tenant_custom_field_definitions where id = ?', (string) $id);
return $result !== false;
}
}

View File

@@ -0,0 +1,127 @@
<?php
namespace MintyPHP\Repository\CustomField;
use MintyPHP\DB;
class TenantCustomFieldOptionRepository
{
private static function unwrapList($rows): array
{
if (!is_array($rows)) {
return [];
}
$list = [];
foreach ($rows as $row) {
$item = $row['tenant_custom_field_options'] ?? null;
if (is_array($item)) {
$list[] = $item;
}
}
return $list;
}
public static function listByDefinitionIds(array $definitionIds, bool $onlyActive = true): array
{
$definitionIds = array_values(array_unique(array_map('intval', $definitionIds)));
$definitionIds = array_values(array_filter($definitionIds, static fn ($id) => $id > 0));
if (!$definitionIds) {
return [];
}
$query = 'select id, definition_id, option_key, label, active, sort_order, created, modified ' .
'from tenant_custom_field_options where definition_id in (???)';
$params = [$definitionIds];
if ($onlyActive) {
$query .= ' and active = 1';
}
$query .= ' order by definition_id asc, sort_order asc, label asc, id asc';
return self::unwrapList(call_user_func_array(['MintyPHP\\DB', 'select'], array_merge([$query], $params)));
}
public static function replaceForDefinition(int $definitionId, array $options): bool
{
if ($definitionId <= 0) {
return false;
}
$existing = self::listByDefinitionIds([$definitionId], false);
$existingByKey = [];
foreach ($existing as $row) {
$key = (string) ($row['option_key'] ?? '');
if ($key !== '') {
$existingByKey[$key] = $row;
}
}
$keepIds = [];
foreach ($options as $option) {
if (!is_array($option)) {
continue;
}
$optionKey = trim((string) ($option['option_key'] ?? ''));
$label = trim((string) ($option['label'] ?? ''));
if ($optionKey === '' || $label === '') {
continue;
}
$active = !empty($option['active']) ? 1 : 0;
$sortOrder = (int) ($option['sort_order'] ?? 100);
if (isset($existingByKey[$optionKey]['id'])) {
$optionId = (int) $existingByKey[$optionKey]['id'];
$keepIds[] = $optionId;
$updated = DB::update(
'update tenant_custom_field_options set label = ?, active = ?, sort_order = ? where id = ?',
$label,
(string) $active,
(string) $sortOrder,
(string) $optionId
);
if ($updated === false) {
return false;
}
continue;
}
$insertId = DB::insert(
'insert into tenant_custom_field_options (definition_id, option_key, label, active, sort_order, created) values (?,?,?,?,?,NOW())',
(string) $definitionId,
$optionKey,
$label,
(string) $active,
(string) $sortOrder
);
if ($insertId === false) {
return false;
}
if ($insertId) {
$keepIds[] = (int) $insertId;
}
}
if (!$keepIds) {
$deleted = DB::delete('delete from tenant_custom_field_options where definition_id = ?', (string) $definitionId);
if ($deleted === false) {
return false;
}
return true;
}
$deleted = DB::delete(
'delete from tenant_custom_field_options where definition_id = ? and id not in (???)',
(string) $definitionId,
$keepIds
);
if ($deleted === false) {
return false;
}
return true;
}
public static function deleteByDefinitionId(int $definitionId): bool
{
if ($definitionId <= 0) {
return false;
}
$result = DB::delete('delete from tenant_custom_field_options where definition_id = ?', (string) $definitionId);
return $result !== false;
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace MintyPHP\Repository\CustomField;
use MintyPHP\DB;
class UserCustomFieldValueOptionRepository
{
public static function replaceForValueId(int $valueId, array $optionIds): bool
{
if ($valueId <= 0) {
return false;
}
$deleted = DB::delete('delete from user_custom_field_value_options where value_id = ?', (string) $valueId);
if ($deleted === false) {
return false;
}
$optionIds = array_values(array_unique(array_map('intval', $optionIds)));
$optionIds = array_values(array_filter($optionIds, static fn ($id) => $id > 0));
if (!$optionIds) {
return true;
}
foreach ($optionIds as $optionId) {
$inserted = DB::insert(
'insert into user_custom_field_value_options (value_id, option_id, created) values (?,?,NOW())',
(string) $valueId,
(string) $optionId
);
if ($inserted === false) {
return false;
}
}
return true;
}
public static function listOptionIdsByValueIds(array $valueIds): array
{
$valueIds = array_values(array_unique(array_map('intval', $valueIds)));
$valueIds = array_values(array_filter($valueIds, static fn ($id) => $id > 0));
if (!$valueIds) {
return [];
}
$rows = DB::select(
'select value_id, option_id from user_custom_field_value_options where value_id in (???)',
$valueIds
);
if (!is_array($rows)) {
return [];
}
$map = [];
foreach ($rows as $row) {
$data = $row['user_custom_field_value_options'] ?? $row;
if (!is_array($data)) {
continue;
}
$valueId = (int) ($data['value_id'] ?? 0);
$optionId = (int) ($data['option_id'] ?? 0);
if ($valueId <= 0 || $optionId <= 0) {
continue;
}
$map[$valueId] ??= [];
$map[$valueId][] = $optionId;
}
foreach ($map as &$ids) {
$ids = array_values(array_unique(array_map('intval', $ids)));
sort($ids, SORT_NUMERIC);
}
unset($ids);
return $map;
}
}

View File

@@ -0,0 +1,120 @@
<?php
namespace MintyPHP\Repository\CustomField;
use MintyPHP\DB;
class UserCustomFieldValueRepository
{
private static function unwrapList($rows): array
{
if (!is_array($rows)) {
return [];
}
$list = [];
foreach ($rows as $row) {
$item = $row['user_custom_field_values'] ?? null;
if (is_array($item)) {
$list[] = $item;
}
}
return $list;
}
public static function listByUserAndDefinitionIds(int $userId, array $definitionIds): array
{
if ($userId <= 0) {
return [];
}
$definitionIds = array_values(array_unique(array_map('intval', $definitionIds)));
$definitionIds = array_values(array_filter($definitionIds, static fn ($id) => $id > 0));
if (!$definitionIds) {
return [];
}
$rows = DB::select(
'select id, user_id, definition_id, value_text, value_bool, value_date, option_id, created, modified ' .
'from user_custom_field_values where user_id = ? and definition_id in (???)',
(string) $userId,
$definitionIds
);
return self::unwrapList($rows);
}
public static function upsertScalarValue(int $userId, int $definitionId, array $typedValue): int|false
{
if ($userId <= 0 || $definitionId <= 0) {
return false;
}
$existingId = DB::selectValue(
'select id from user_custom_field_values where user_id = ? and definition_id = ? limit 1',
(string) $userId,
(string) $definitionId
);
$valueText = array_key_exists('value_text', $typedValue) ? $typedValue['value_text'] : null;
$valueBool = array_key_exists('value_bool', $typedValue) ? $typedValue['value_bool'] : null;
$valueDate = array_key_exists('value_date', $typedValue) ? $typedValue['value_date'] : null;
$optionId = array_key_exists('option_id', $typedValue) ? $typedValue['option_id'] : null;
if ($existingId) {
$updated = DB::update(
'update user_custom_field_values set value_text = ?, value_bool = ?, value_date = ?, option_id = ? where id = ?',
$valueText,
$valueBool !== null ? (string) ((int) $valueBool) : null,
$valueDate,
$optionId !== null ? (string) ((int) $optionId) : null,
(string) ((int) $existingId)
);
return $updated !== false ? (int) $existingId : false;
}
return DB::insert(
'insert into user_custom_field_values (user_id, definition_id, value_text, value_bool, value_date, option_id, created) values (?,?,?,?,?,?,NOW())',
(string) $userId,
(string) $definitionId,
$valueText,
$valueBool !== null ? (string) ((int) $valueBool) : null,
$valueDate,
$optionId !== null ? (string) ((int) $optionId) : null
);
}
public static function deleteByUserAndDefinitionIds(int $userId, array $definitionIds): bool
{
if ($userId <= 0) {
return false;
}
$definitionIds = array_values(array_unique(array_map('intval', $definitionIds)));
$definitionIds = array_values(array_filter($definitionIds, static fn ($id) => $id > 0));
if (!$definitionIds) {
return true;
}
$result = DB::delete(
'delete from user_custom_field_values where user_id = ? and definition_id in (???)',
(string) $userId,
$definitionIds
);
return $result !== false;
}
public static function deleteByUserOutsideTenantIds(int $userId, array $tenantIds): bool
{
if ($userId <= 0) {
return false;
}
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
$tenantIds = array_values(array_filter($tenantIds, static fn ($id) => $id > 0));
if (!$tenantIds) {
$result = DB::delete('delete from user_custom_field_values where user_id = ?', (string) $userId);
return $result !== false;
}
$result = DB::delete(
'delete ucfv from user_custom_field_values ucfv ' .
'join tenant_custom_field_definitions d on d.id = ucfv.definition_id ' .
'where ucfv.user_id = ? and d.tenant_id not in (???)',
(string) $userId,
$tenantIds
);
return $result !== false;
}
}

View File

@@ -33,7 +33,7 @@ class DepartmentRepository
public static function list(): array
{
$rows = DB::select(
'select id, uuid, description, code, cost_center, active, created_by, modified_by, created, modified from departments order by id desc'
'select id, uuid, description, tenant_id, code, cost_center, active, created_by, modified_by, created, modified from departments order by id desc'
);
return self::unwrapList($rows);
}
@@ -54,6 +54,31 @@ class DepartmentRepository
return array_values(array_unique($ids));
}
public static function listActiveIdsByTenantIds(array $tenantIds): array
{
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
$tenantIds = array_filter($tenantIds, static fn ($id) => $id > 0);
if (!$tenantIds) {
return [];
}
$placeholders = implode(',', array_fill(0, count($tenantIds), '?'));
$rows = DB::select(
'select id from departments where active = 1 and tenant_id in (' . $placeholders . ')',
...array_map('strval', $tenantIds)
);
if (!is_array($rows)) {
return [];
}
$ids = [];
foreach ($rows as $row) {
$data = $row['departments'] ?? $row;
if (is_array($data) && isset($data['id'])) {
$ids[] = (int) $data['id'];
}
}
return array_values(array_unique($ids));
}
public static function listByTenantIds(array $tenantIds): array
{
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
@@ -63,17 +88,16 @@ class DepartmentRepository
}
$placeholders = implode(',', array_fill(0, count($tenantIds), '?'));
$rows = DB::select(
'select departments.id, departments.uuid, departments.description, departments.code, departments.cost_center, departments.active, departments.created_by, departments.modified_by, departments.created, departments.modified ' .
'from departments departments join tenant_departments td on td.department_id = departments.id ' .
'where departments.active = 1 and td.tenant_id in (' . $placeholders . ') ' .
'group by departments.id, departments.uuid, departments.description, departments.code, departments.cost_center, departments.active, departments.created_by, departments.modified_by, departments.created, departments.modified ' .
'select departments.id, departments.uuid, departments.description, departments.tenant_id, departments.code, departments.cost_center, departments.active, departments.created_by, departments.modified_by, departments.created, departments.modified ' .
"from departments departments join tenants t on t.id = departments.tenant_id and t.status = 'active' " .
'where departments.active = 1 and departments.tenant_id in (' . $placeholders . ') ' .
'order by departments.description asc',
...array_map('strval', $tenantIds)
);
return self::unwrapList($rows);
}
public static function listByIds(array $departmentIds): array
public static function listByIds(array $departmentIds, bool $includeInactive = false): array
{
$departmentIds = array_values(array_unique(array_map('intval', $departmentIds)));
$departmentIds = array_filter($departmentIds, static fn ($id) => $id > 0);
@@ -81,8 +105,9 @@ class DepartmentRepository
return [];
}
$placeholders = implode(',', array_fill(0, count($departmentIds), '?'));
$activeSql = $includeInactive ? '' : 'active = 1 and ';
$rows = DB::select(
'select id, uuid, description, code, cost_center, active, created_by, modified_by, created, modified from departments where active = 1 and id in (' . $placeholders . ') order by description asc',
'select id, uuid, description, tenant_id, code, cost_center, active, created_by, modified_by, created, modified from departments where ' . $activeSql . 'id in (' . $placeholders . ') order by description asc',
...array_map('strval', $departmentIds)
);
return self::unwrapList($rows);
@@ -113,44 +138,25 @@ class DepartmentRepository
$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 = ?)"
"exists (select 1 from tenants t where t.id = departments.tenant_id and t.status = 'active' and t.uuid = ?)"
);
if (!empty($options['tenantUserId'])) {
$tenantUserId = (int) $options['tenantUserId'];
if ($tenantUserId > 0) {
if (defined('TENANT_SCOPE_STRICT') && TENANT_SCOPE_STRICT) {
$where[] = 'exists (select 1 from tenant_departments td ' .
"join tenants t on t.id = td.tenant_id and t.status = 'active' " .
'join user_tenants ut on ut.tenant_id = td.tenant_id ' .
'where ut.user_id = ? and td.department_id = departments.id)';
$params[] = (string) $tenantUserId;
} else {
$where[] = '(not exists (select 1 from tenant_departments td where td.department_id = departments.id) ' .
'or exists (select 1 from tenant_departments td ' .
"join tenants t on t.id = td.tenant_id and t.status = 'active' " .
'join user_tenants ut on ut.tenant_id = td.tenant_id ' .
'where ut.user_id = ? and td.department_id = departments.id))';
$params[] = (string) $tenantUserId;
}
$where[] = 'exists (select 1 from user_tenants ut ' .
"join tenants t on t.id = ut.tenant_id and t.status = 'active' " .
'where ut.user_id = ? and ut.tenant_id = departments.tenant_id)';
$params[] = (string) $tenantUserId;
}
} elseif (array_key_exists('tenantIds', $options)) {
$tenantIds = $options['tenantIds'];
$tenantIds = is_array($tenantIds) ? array_values(array_unique(array_map('intval', $tenantIds))) : [];
if ($tenantIds) {
if (defined('TENANT_SCOPE_STRICT') && TENANT_SCOPE_STRICT) {
$where[] = 'exists (select 1 from tenant_departments td where td.department_id = departments.id and td.tenant_id in (???))';
$params[] = $tenantIds;
} else {
$where[] = '(not exists (select 1 from tenant_departments td where td.department_id = departments.id) ' .
'or exists (select 1 from tenant_departments td where td.department_id = departments.id and td.tenant_id in (???)))';
$params[] = $tenantIds;
}
$where[] = 'departments.tenant_id in (???)';
$params[] = $tenantIds;
} else {
if (defined('TENANT_SCOPE_STRICT') && TENANT_SCOPE_STRICT) {
$where[] = '1=0';
} else {
$where[] = 'not exists (select 1 from tenant_departments td where td.department_id = departments.id)';
}
$where[] = '1=0';
}
}
@@ -158,7 +164,7 @@ class DepartmentRepository
$count = DB::selectValue('select count(*) from departments' . $whereSql, ...$params);
$total = $count ? (int) $count : 0;
$query = 'select id, uuid, description, code, cost_center, active, created_by, modified_by, created, modified from departments' .
$query = 'select id, uuid, description, tenant_id, code, cost_center, active, created_by, modified_by, created, modified from departments' .
$whereSql .
sprintf(' order by `%s` %s limit ? offset ?', $order, $dir);
@@ -176,48 +182,28 @@ class DepartmentRepository
if ($ids) {
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$labelRows = DB::select(
"select td.department_id as department_id, t.description as description from tenant_departments td join tenants t on t.id = td.tenant_id and t.status = 'active' " .
'where td.department_id in (' . $placeholders . ') order by t.description asc',
'select d.id as department_id, t.description as description from departments d join tenants t on t.id = d.tenant_id ' .
'where d.id in (' . $placeholders . ') order by t.description asc',
...array_map('strval', $ids)
);
$collectLabels = static function (array $rows): array {
$labelsByDepartment = [];
foreach ($rows as $row) {
$departmentId = 0;
$label = '';
if (isset($row['td']) && is_array($row['td'])) {
$departmentId = (int) ($row['td']['department_id'] ?? 0);
foreach ((array) $labelRows as $row) {
$departmentId = 0;
$label = '';
foreach ((array) $row as $table) {
if (!is_array($table)) {
continue;
}
if (isset($row['t']) && is_array($row['t'])) {
$label = (string) ($row['t']['description'] ?? '');
if ($departmentId === 0 && isset($table['department_id'])) {
$departmentId = (int) $table['department_id'];
}
if ($departmentId === 0 || $label === '') {
foreach ($row as $value) {
if (!is_array($value)) {
continue;
}
if ($departmentId === 0 && isset($value['department_id'])) {
$departmentId = (int) $value['department_id'];
}
if ($label === '' && isset($value['description'])) {
$label = (string) $value['description'];
}
}
}
if ($departmentId === 0 && isset($row['department_id'])) {
$departmentId = (int) $row['department_id'];
}
if ($label === '' && isset($row['description'])) {
$label = (string) $row['description'];
}
if ($departmentId > 0 && $label !== '') {
$labelsByDepartment[$departmentId] ??= [];
$labelsByDepartment[$departmentId][] = $label;
if ($label === '' && isset($table['description'])) {
$label = (string) $table['description'];
}
}
return $labelsByDepartment;
};
$tenantLabelsByDepartment = $collectLabels($labelRows);
if ($departmentId > 0 && $label !== '') {
$tenantLabelsByDepartment[$departmentId] = [$label];
}
}
}
foreach ($list as &$department) {
$departmentId = (int) ($department['id'] ?? 0);
@@ -234,7 +220,7 @@ class DepartmentRepository
public static function find(int $id): ?array
{
$row = DB::selectOne(
'select id, uuid, description, code, cost_center, active, created_by, modified_by, created, modified from departments where id = ? limit 1',
'select id, uuid, description, tenant_id, code, cost_center, active, created_by, modified_by, created, modified from departments where id = ? limit 1',
(string) $id
);
return self::unwrap($row);
@@ -243,7 +229,7 @@ class DepartmentRepository
public static function findByUuid(string $uuid): ?array
{
$row = DB::selectOne(
'select id, uuid, description, code, cost_center, active, created_by, modified_by, created, modified from departments where uuid = ? limit 1',
'select id, uuid, description, tenant_id, code, cost_center, active, created_by, modified_by, created, modified from departments where uuid = ? limit 1',
$uuid
);
return self::unwrap($row);
@@ -263,20 +249,48 @@ class DepartmentRepository
return (int) $count > 0;
}
private static function uuidV4(): string
public static function existsByTenantAndDescription(int $tenantId, string $description, int $excludeId = 0): bool
{
$data = random_bytes(16);
$data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
$data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
$tenantId = (int) $tenantId;
$description = trim($description);
if ($tenantId <= 0 || $description === '') {
return false;
}
if ($excludeId > 0) {
$count = DB::selectValue(
'select count(*) from departments where tenant_id = ? and lower(description) = lower(?) and id != ?',
(string) $tenantId,
$description,
(string) $excludeId
);
} else {
$count = DB::selectValue(
'select count(*) from departments where tenant_id = ? and lower(description) = lower(?)',
(string) $tenantId,
$description
);
}
return (int) $count > 0;
}
public static function countByTenantId(int $tenantId): int
{
if ($tenantId <= 0) {
return 0;
}
$count = DB::selectValue('select count(*) from departments where tenant_id = ?', (string) $tenantId);
return $count ? (int) $count : 0;
}
public static function create(array $data)
{
return DB::insert(
'insert into departments (uuid, description, code, cost_center, active, created_by, created) values (?,?,?,?,?,?,NOW())',
$data['uuid'] ?? self::uuidV4(),
'insert into departments (uuid, description, tenant_id, code, cost_center, active, created_by, created) values (?,?,?,?,?,?,?,NOW())',
$data['uuid'] ?? RepoQuery::uuidV4(),
$data['description'],
(string) ($data['tenant_id'] ?? 0),
$data['code'] ?? null,
$data['cost_center'] ?? null,
$data['active'] ?? 1,
@@ -288,6 +302,7 @@ class DepartmentRepository
{
$fields = [
'description' => $data['description'],
'tenant_id' => (string) ($data['tenant_id'] ?? 0),
'code' => $data['code'] ?? null,
'cost_center' => $data['cost_center'] ?? null,
'active' => $data['active'] ?? 1,
@@ -309,6 +324,19 @@ class DepartmentRepository
return $result !== false;
}
public static function setTenant(int $id, int $tenantId): bool
{
if ($id <= 0 || $tenantId <= 0) {
return false;
}
$result = DB::update(
'update departments set tenant_id = ? where id = ?',
(string) $tenantId,
(string) $id
);
return $result !== false;
}
public static function delete(int $id): bool
{
$result = DB::delete('delete from departments where id = ?', (string) $id);

View File

@@ -45,10 +45,69 @@ class UserDepartmentRepository
$query = 'delete ud from user_departments ud ' .
'where ud.department_id = ? and not exists (' .
'select 1 from user_tenants ut ' .
'join tenant_departments td on td.tenant_id = ut.tenant_id ' .
'where ut.user_id = ud.user_id and td.department_id = ud.department_id' .
'join departments d on d.id = ud.department_id ' .
'where ut.user_id = ud.user_id and ut.tenant_id = d.tenant_id' .
')';
$result = DB::delete($query, (string) $departmentId);
return $result !== false ? (int) $result : 0;
}
public static function countUsersByDepartmentIds(array $departmentIds): array
{
return self::countByDepartmentIds($departmentIds, false);
}
public static function countActiveUsersByDepartmentIds(array $departmentIds): array
{
return self::countByDepartmentIds($departmentIds, true);
}
private static function countByDepartmentIds(array $departmentIds, bool $activeOnly): array
{
$departmentIds = array_values(array_unique(array_map('intval', $departmentIds)));
$departmentIds = array_values(array_filter($departmentIds, static fn ($id) => $id > 0));
if (!$departmentIds) {
return [];
}
$placeholders = implode(',', array_fill(0, count($departmentIds), '?'));
$joinUsersSql = $activeOnly ? ' join users u on u.id = ud.user_id and u.active = 1' : '';
$rows = DB::select(
'select ud.department_id, count(distinct ud.user_id) as user_count from user_departments ud' .
$joinUsersSql .
' where ud.department_id in (' . $placeholders . ') group by ud.department_id',
...array_map('strval', $departmentIds)
);
if (!is_array($rows)) {
return [];
}
$result = [];
foreach ($rows as $row) {
$departmentId = self::extractIntField($row, 'department_id');
if ($departmentId <= 0) {
continue;
}
$result[$departmentId] = self::extractIntField($row, 'user_count');
}
return $result;
}
private static function extractIntField(array $row, string $field): int
{
foreach ($row as $value) {
if (!is_array($value)) {
continue;
}
if (array_key_exists($field, $value)) {
return (int) $value[$field];
}
}
if (array_key_exists($field, $row)) {
return (int) $row[$field];
}
return 0;
}
}

View File

@@ -0,0 +1,252 @@
<?php
namespace MintyPHP\Repository\Scheduler;
use MintyPHP\DB;
use MintyPHP\Repository\Support\RepoQuery;
class ScheduledJobRepository
{
public static function create(array $data): int|false
{
$id = DB::insert(
'insert into scheduled_jobs (
job_key, label, description, enabled, timezone,
schedule_type, schedule_interval, schedule_time, schedule_weekdays_csv,
catchup_once, next_run_at
) values (?,?,?,?,?,?,?,?,?,?,?)',
(string) ($data['job_key'] ?? ''),
(string) ($data['label'] ?? ''),
$data['description'] ?? null,
(string) ((int) ($data['enabled'] ?? 0)),
(string) ($data['timezone'] ?? ''),
(string) ($data['schedule_type'] ?? 'daily'),
(string) ((int) ($data['schedule_interval'] ?? 1)),
$data['schedule_time'] ?? null,
$data['schedule_weekdays_csv'] ?? null,
(string) ((int) ($data['catchup_once'] ?? 1)),
$data['next_run_at'] ?? null
);
return $id ? (int) $id : false;
}
public static function find(int $id): ?array
{
if ($id <= 0) {
return null;
}
$row = DB::selectOne('select * from scheduled_jobs where id = ? limit 1', (string) $id);
return self::normalizeRow($row);
}
public static function findByKey(string $jobKey): ?array
{
$jobKey = trim($jobKey);
if ($jobKey === '') {
return null;
}
$row = DB::selectOne('select * from scheduled_jobs where job_key = ? limit 1', $jobKey);
return self::normalizeRow($row);
}
public static function listPaged(array $filters): array
{
$search = trim((string) ($filters['search'] ?? ''));
$enabled = trim((string) ($filters['enabled'] ?? ''));
$status = strtolower(trim((string) ($filters['status'] ?? '')));
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($filters, 20, 1, 200, 0);
[$order, $dir] = RepoQuery::sanitizeOrder(
$filters,
['id', 'job_key', 'label', 'enabled', 'next_run_at', 'last_run_finished_at', 'last_run_status', 'updated_at'],
'job_key',
'asc'
);
$where = [];
$params = [];
RepoQuery::addLikeFilter(
$where,
$params,
['job_key', 'label', 'description', 'last_error_code', 'last_error_message'],
$search
);
if (in_array($enabled, ['0', '1'], true)) {
$where[] = 'enabled = ?';
$params[] = $enabled;
}
if (in_array($status, ['success', 'failed', 'running', 'skipped'], true)) {
$where[] = 'last_run_status = ?';
$params[] = $status;
}
$whereSql = $where ? (' where ' . implode(' and ', $where)) : '';
$total = (int) (DB::selectValue('select count(*) from scheduled_jobs' . $whereSql, ...$params) ?? 0);
$rows = DB::select(
'select * from scheduled_jobs' .
$whereSql .
sprintf(' order by `%s` %s limit ? offset ?', $order, $dir),
...array_merge($params, [(string) $limit, (string) $offset])
);
$normalized = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$item = self::normalizeRow($row);
if ($item !== null) {
$normalized[] = $item;
}
}
}
return [
'total' => $total,
'rows' => $normalized,
];
}
public static function listDueJobs(string $nowUtc, int $limit = 20): array
{
$limit = max(1, min(200, $limit));
$rows = DB::select(
'select * from scheduled_jobs
where enabled = 1
and next_run_at is not null
and next_run_at <= ?
order by next_run_at asc
limit ?',
$nowUtc,
(string) $limit
);
$normalized = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$item = self::normalizeRow($row);
if ($item !== null) {
$normalized[] = $item;
}
}
}
return $normalized;
}
public static function updateJobMeta(int $id, array $data): bool
{
if ($id <= 0) {
return false;
}
$updated = DB::update(
'update scheduled_jobs
set label = ?,
description = ?,
enabled = ?,
timezone = ?,
schedule_type = ?,
schedule_interval = ?,
schedule_time = ?,
schedule_weekdays_csv = ?,
catchup_once = ?,
next_run_at = ?
where id = ?',
(string) ($data['label'] ?? ''),
$data['description'] ?? null,
(string) ((int) ($data['enabled'] ?? 0)),
(string) ($data['timezone'] ?? ''),
(string) ($data['schedule_type'] ?? 'daily'),
(string) ((int) ($data['schedule_interval'] ?? 1)),
$data['schedule_time'] ?? null,
$data['schedule_weekdays_csv'] ?? null,
(string) ((int) ($data['catchup_once'] ?? 1)),
$data['next_run_at'] ?? null,
(string) $id
);
return $updated !== false;
}
/**
* Atomically marks a job as running if it is not already in a running state.
*
* Includes stale-running protection: if a job has been stuck in 'running' status
* for more than 2 hours, it is considered abandoned (e.g. the runner process died)
* and a new run is allowed to proceed. This prevents a crashed runner from blocking
* the job permanently.
*
* Returns true only when the UPDATE affected a row, i.e. the job was successfully
* claimed by this runner. Returns false if the job is already running (not stale).
*/
public static function markRunning(int $id, string $startedAtUtc): bool
{
if ($id <= 0) {
return false;
}
// A job that has been 'running' for longer than this threshold is treated as stale.
$staleBefore = (new \DateTimeImmutable($startedAtUtc, new \DateTimeZone('UTC')))
->modify('-2 hours')
->format('Y-m-d H:i:s');
$updated = DB::update(
'update scheduled_jobs
set last_run_status = ?,
last_run_started_at = ?,
last_run_finished_at = null,
last_error_code = null,
last_error_message = null
where id = ?
and (last_run_status is null
or last_run_status <> ?
or (last_run_started_at is not null and last_run_started_at < ?))',
'running',
$startedAtUtc,
(string) $id,
'running',
$staleBefore
);
return (int) $updated > 0;
}
public static function finishRun(
int $id,
string $status,
string $startedAtUtc,
string $finishedAtUtc,
?string $nextRunAtUtc,
?string $errorCode,
?string $errorMessage
): bool {
if ($id <= 0) {
return false;
}
$updated = DB::update(
'update scheduled_jobs
set last_run_status = ?,
last_run_started_at = ?,
last_run_finished_at = ?,
next_run_at = ?,
last_error_code = ?,
last_error_message = ?
where id = ?',
$status,
$startedAtUtc,
$finishedAtUtc,
$nextRunAtUtc,
$errorCode,
$errorMessage,
(string) $id
);
return $updated !== false;
}
private static function normalizeRow(mixed $row): ?array
{
if (!is_array($row)) {
return null;
}
$item = $row['scheduled_jobs'] ?? $row;
if (!is_array($item) || !isset($item['id'])) {
return null;
}
return $item;
}
}

View File

@@ -0,0 +1,137 @@
<?php
namespace MintyPHP\Repository\Scheduler;
use MintyPHP\DB;
use MintyPHP\Repository\Support\RepoQuery;
class ScheduledJobRunRepository
{
public static function create(array $data): int|false
{
$id = DB::insert(
'insert into scheduled_job_runs (
run_uuid, job_id, job_key, trigger_type, status, actor_user_id,
started_at, finished_at, duration_ms, error_code, error_message, result_json
) values (?,?,?,?,?,?,?,?,?,?,?,?)',
(string) ($data['run_uuid'] ?? ''),
(string) ((int) ($data['job_id'] ?? 0)),
(string) ($data['job_key'] ?? ''),
(string) ($data['trigger_type'] ?? 'scheduler'),
(string) ($data['status'] ?? 'failed'),
($data['actor_user_id'] ?? null) !== null ? (string) ((int) $data['actor_user_id']) : null,
(string) ($data['started_at'] ?? ''),
$data['finished_at'] ?? null,
($data['duration_ms'] ?? null) !== null ? (string) ((int) $data['duration_ms']) : null,
$data['error_code'] ?? null,
$data['error_message'] ?? null,
$data['result_json'] ?? null
);
return $id ? (int) $id : false;
}
public static function listPagedByJobId(int $jobId, array $filters): array
{
if ($jobId <= 0) {
return ['total' => 0, 'rows' => []];
}
$search = trim((string) ($filters['search'] ?? ''));
$status = strtolower(trim((string) ($filters['status'] ?? '')));
$triggerType = strtolower(trim((string) ($filters['trigger_type'] ?? '')));
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($filters, 20, 1, 200, 0);
[$order, $dir] = RepoQuery::sanitizeOrder(
$filters,
['id', 'started_at', 'finished_at', 'duration_ms', 'status', 'trigger_type'],
'started_at',
'desc'
);
$where = ['scheduled_job_runs.job_id = ?'];
$params = [(string) $jobId];
RepoQuery::addLikeFilter(
$where,
$params,
['scheduled_job_runs.run_uuid', 'scheduled_job_runs.error_code', 'scheduled_job_runs.error_message'],
$search
);
if (in_array($status, ['success', 'failed', 'skipped'], true)) {
$where[] = 'scheduled_job_runs.status = ?';
$params[] = $status;
}
if (in_array($triggerType, ['scheduler', 'manual'], true)) {
$where[] = 'scheduled_job_runs.trigger_type = ?';
$params[] = $triggerType;
}
$whereSql = ' where ' . implode(' and ', $where);
$fromSql = ' from scheduled_job_runs left join users on users.id = scheduled_job_runs.actor_user_id ';
$total = (int) (DB::selectValue('select count(*)' . $fromSql . $whereSql, ...$params) ?? 0);
$rows = DB::select(
'select
scheduled_job_runs.id,
scheduled_job_runs.run_uuid,
scheduled_job_runs.job_id,
scheduled_job_runs.job_key,
scheduled_job_runs.trigger_type,
scheduled_job_runs.status,
scheduled_job_runs.actor_user_id,
scheduled_job_runs.started_at,
scheduled_job_runs.finished_at,
scheduled_job_runs.duration_ms,
scheduled_job_runs.error_code,
scheduled_job_runs.error_message,
scheduled_job_runs.result_json,
scheduled_job_runs.created_at,
users.id,
users.uuid,
users.display_name,
users.email
' . $fromSql . $whereSql .
sprintf(' order by scheduled_job_runs.`%s` %s limit ? offset ?', $order, $dir),
...array_merge($params, [(string) $limit, (string) $offset])
);
$normalized = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$item = self::normalizeRow($row);
if ($item !== null) {
$normalized[] = $item;
}
}
}
return ['total' => $total, 'rows' => $normalized];
}
public static function purgeOlderThanDays(int $days): int
{
if ($days <= 0) {
return 0;
}
$cutoff = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))
->modify('-' . $days . ' days')
->format('Y-m-d H:i:s');
$deleted = DB::delete('delete from scheduled_job_runs where created_at < ?', $cutoff);
return is_int($deleted) ? $deleted : 0;
}
private static function normalizeRow(mixed $row): ?array
{
if (!is_array($row)) {
return null;
}
$item = $row['scheduled_job_runs'] ?? [];
if (!is_array($item) || !isset($item['id'])) {
return null;
}
$user = is_array($row['users'] ?? null) ? $row['users'] : [];
$item['actor_user_uuid'] = (string) ($user['uuid'] ?? '');
$item['actor_user_display_name'] = (string) ($user['display_name'] ?? '');
$item['actor_user_email'] = (string) ($user['email'] ?? '');
return $item;
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace MintyPHP\Repository\Scheduler;
use MintyPHP\DB;
class SchedulerRuntimeRepository
{
public static function touchHeartbeat(string $result, ?string $errorCode = null, ?string $heartbeatAtUtc = null): bool
{
$heartbeatAtUtc = trim((string) ($heartbeatAtUtc ?? ''));
if ($heartbeatAtUtc === '') {
$heartbeatAtUtc = gmdate('Y-m-d H:i:s');
}
$result = trim($result);
if ($result === '') {
$result = 'ok';
}
$errorCode = self::nullableTrim($errorCode);
$updated = DB::update(
'insert into scheduler_runtime_status (id, last_heartbeat_at, last_result, last_error_code) ' .
'values (1, ?, ?, ?) ' .
'on duplicate key update ' .
'last_heartbeat_at = values(last_heartbeat_at), ' .
'last_result = values(last_result), ' .
'last_error_code = values(last_error_code)',
$heartbeatAtUtc,
$result,
$errorCode
);
return $updated !== false;
}
public static function findStatus(): ?array
{
$row = DB::selectOne('select * from scheduler_runtime_status where id = 1 limit 1');
if (!is_array($row)) {
return null;
}
$item = $row['scheduler_runtime_status'] ?? $row;
return is_array($item) ? $item : null;
}
private static function nullableTrim(?string $value): ?string
{
$value = trim((string) $value);
return $value !== '' ? $value : null;
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace MintyPHP\Repository\Security;
use MintyPHP\DB;
class RateLimitRepository
{
public static function findByScopeAndHash(string $scope, string $subjectHash): ?array
{
$row = DB::selectOne(
'select id, scope, subject_hash, hits, window_started_at, blocked_until, created, modified from request_rate_limits where scope = ? and subject_hash = ? limit 1',
$scope,
$subjectHash
);
if (!$row || !isset($row['request_rate_limits']) || !is_array($row['request_rate_limits'])) {
return null;
}
return $row['request_rate_limits'];
}
public static function create(
string $scope,
string $subjectHash,
int $hits,
string $windowStartedAt,
?string $blockedUntil
): bool {
$result = DB::insert(
'insert into request_rate_limits (scope, subject_hash, hits, window_started_at, blocked_until, created) values (?,?,?,?,?,NOW())',
$scope,
$subjectHash,
(string) max(0, $hits),
$windowStartedAt,
$blockedUntil
);
return $result !== false;
}
public static function updateStateById(
int $id,
int $hits,
string $windowStartedAt,
?string $blockedUntil
): bool {
if ($id <= 0) {
return false;
}
$result = DB::update(
'update request_rate_limits set hits = ?, window_started_at = ?, blocked_until = ? where id = ?',
(string) max(0, $hits),
$windowStartedAt,
$blockedUntil,
(string) $id
);
return $result !== false;
}
public static function deleteByScopeAndHash(string $scope, string $subjectHash): bool
{
$result = DB::delete(
'delete from request_rate_limits where scope = ? and subject_hash = ?',
$scope,
$subjectHash
);
return $result !== false;
}
}

View File

@@ -104,6 +104,14 @@ class RepoQuery
}
}
public static function uuidV4(): string
{
$data = random_bytes(16);
$data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
$data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}
public static function addEqualsFilter(array &$where, array &$params, $value, string $sql, bool $allowEmpty = false): void
{
// Use for simple "= ?" filters; skip when empty to keep WHERE clean.
@@ -117,4 +125,27 @@ class RepoQuery
$where[] = $sql;
$params[] = $normalized;
}
public static function normalizeIdList($value): array
{
if (is_string($value)) {
$value = array_filter(array_map('trim', explode(',', $value)));
} elseif (!is_array($value)) {
return [];
}
$flat = [];
array_walk_recursive($value, static function ($item) use (&$flat): void {
$flat[] = $item;
});
$ids = [];
foreach ($flat as $item) {
$id = (int) trim((string) $item);
if ($id > 0) {
$ids[] = $id;
}
}
$ids = array_values(array_unique($ids));
sort($ids, SORT_NUMERIC);
return $ids;
}
}

View File

@@ -1,102 +0,0 @@
<?php
namespace MintyPHP\Repository\Tenant;
use MintyPHP\DB;
class TenantDepartmentRepository
{
public static function listTenantIdsByDepartmentId(int $departmentId): array
{
$rows = DB::select('select tenant_id from tenant_departments where department_id = ?', (string) $departmentId);
if (!is_array($rows)) {
return [];
}
$ids = [];
foreach ($rows as $row) {
$data = $row['tenant_departments'] ?? $row;
if (is_array($data) && isset($data['tenant_id'])) {
$ids[] = (int) $data['tenant_id'];
}
}
return array_values(array_unique($ids));
}
public static function listDepartmentIdsByTenantIds(array $tenantIds): array
{
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
$tenantIds = array_filter($tenantIds, static fn ($id) => $id > 0);
if (!$tenantIds) {
return [];
}
$placeholders = implode(',', array_fill(0, count($tenantIds), '?'));
$rows = DB::select(
'select department_id from tenant_departments where tenant_id in (' . $placeholders . ')',
...array_map('strval', $tenantIds)
);
if (!is_array($rows)) {
return [];
}
$ids = [];
foreach ($rows as $row) {
$data = $row['tenant_departments'] ?? $row;
if (is_array($data) && isset($data['department_id'])) {
$ids[] = (int) $data['department_id'];
}
}
return array_values(array_unique($ids));
}
public static function listDepartmentMapByTenantIds(array $tenantIds): array
{
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
$tenantIds = array_filter($tenantIds, static fn ($id) => $id > 0);
if (!$tenantIds) {
return [];
}
$placeholders = implode(',', array_fill(0, count($tenantIds), '?'));
$rows = DB::select(
'select 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);
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
$tenantIds = array_filter($tenantIds, static fn ($id) => $id > 0);
if (!$tenantIds) {
return true;
}
foreach ($tenantIds as $tenantId) {
DB::insert(
'insert into tenant_departments (tenant_id, department_id, created) values (?,?,NOW())',
(string) $tenantId,
(string) $departmentId
);
}
return true;
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace MintyPHP\Repository\Tenant;
use MintyPHP\DB;
class TenantMicrosoftAuthRepository
{
private static function unwrap($row): ?array
{
if (!$row || !is_array($row)) {
return null;
}
if (isset($row['tenant_auth_microsoft']) && is_array($row['tenant_auth_microsoft'])) {
return $row['tenant_auth_microsoft'];
}
return $row;
}
public static function findByTenantId(int $tenantId): ?array
{
$row = DB::selectOne(
'select id, tenant_id, enabled, enforce_microsoft_login, sync_profile_on_login, sync_profile_fields, entra_tenant_id, allowed_domains, use_shared_app, client_id_override, client_secret_override_enc, created, modified from tenant_auth_microsoft where tenant_id = ? limit 1',
(string) $tenantId
);
return self::unwrap($row);
}
public static function listByTenantIds(array $tenantIds): array
{
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
$tenantIds = array_values(array_filter($tenantIds, static fn ($id) => $id > 0));
if (!$tenantIds) {
return [];
}
$placeholders = implode(',', array_fill(0, count($tenantIds), '?'));
$rows = DB::select(
'select id, tenant_id, enabled, enforce_microsoft_login, sync_profile_on_login, sync_profile_fields, entra_tenant_id, allowed_domains, use_shared_app, client_id_override, client_secret_override_enc, created, modified from tenant_auth_microsoft where tenant_id in (' . $placeholders . ')',
...array_map('strval', $tenantIds)
);
if (!is_array($rows)) {
return [];
}
$result = [];
foreach ($rows as $row) {
$item = self::unwrap($row);
if (!is_array($item)) {
continue;
}
$tenantId = (int) ($item['tenant_id'] ?? 0);
if ($tenantId <= 0) {
continue;
}
$result[$tenantId] = $item;
}
return $result;
}
public static function upsertByTenantId(int $tenantId, array $data): bool
{
$result = DB::update(
'insert into tenant_auth_microsoft (tenant_id, enabled, enforce_microsoft_login, sync_profile_on_login, sync_profile_fields, entra_tenant_id, allowed_domains, use_shared_app, client_id_override, client_secret_override_enc, created) values (?,?,?,?,?,?,?,?,?,?,NOW()) ' .
'on duplicate key update enabled = values(enabled), enforce_microsoft_login = values(enforce_microsoft_login), sync_profile_on_login = values(sync_profile_on_login), sync_profile_fields = values(sync_profile_fields), entra_tenant_id = values(entra_tenant_id), ' .
'allowed_domains = values(allowed_domains), use_shared_app = values(use_shared_app), client_id_override = values(client_id_override), ' .
'client_secret_override_enc = values(client_secret_override_enc), modified = NOW()',
(string) $tenantId,
!empty($data['enabled']) ? '1' : '0',
!empty($data['enforce_microsoft_login']) ? '1' : '0',
!empty($data['sync_profile_on_login']) ? '1' : '0',
$data['sync_profile_fields'] ?? null,
$data['entra_tenant_id'] ?? null,
$data['allowed_domains'] ?? null,
!empty($data['use_shared_app']) ? '1' : '0',
$data['client_id_override'] ?? null,
$data['client_secret_override_enc'] ?? null
);
return $result !== false;
}
}

View File

@@ -33,7 +33,7 @@ class TenantRepository
public static function list(): array
{
$rows = DB::select(
'select id, uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants order by id desc'
'select id, uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, default_theme, allow_user_theme, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants order by id desc'
);
return self::unwrapList($rows);
}
@@ -54,6 +54,22 @@ class TenantRepository
return array_values(array_unique($ids));
}
public static function listByIds(array $tenantIds): array
{
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
$tenantIds = array_values(array_filter($tenantIds, static fn ($id) => $id > 0));
if (!$tenantIds) {
return [];
}
$placeholders = implode(',', array_fill(0, count($tenantIds), '?'));
$rows = DB::select(
'select id, uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, default_theme, allow_user_theme, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants where id in (' . $placeholders . ')',
...array_map('strval', $tenantIds)
);
return self::unwrapList($rows);
}
public static function listActiveIdsByIds(array $tenantIds): array
{
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
@@ -85,7 +101,7 @@ class TenantRepository
public static function listPaged(array $options): array
{
$search = trim((string) ($options['search'] ?? ''));
$allowedOrder = ['id', 'uuid', 'description', 'created', 'modified'];
$allowedOrder = ['id', 'uuid', 'description', 'status', 'created', 'modified'];
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($options);
[$order, $dir] = RepoQuery::sanitizeOrder($options, $allowedOrder);
@@ -99,12 +115,23 @@ class TenantRepository
$params[] = (string) $tenantUserId;
}
}
if (array_key_exists('tenantIds', $options)) {
$tenantIds = $options['tenantIds'];
$tenantIds = is_array($tenantIds) ? array_values(array_unique(array_map('intval', $tenantIds))) : [];
$tenantIds = array_values(array_filter($tenantIds, static fn ($id) => $id > 0));
if ($tenantIds) {
$where[] = 'tenants.id in (???)';
$params[] = $tenantIds;
} else {
$where[] = '1=0';
}
}
$whereSql = $where ? (' where ' . implode(' and ', $where)) : '';
$count = DB::selectValue('select count(*) from tenants' . $whereSql, ...$params);
$total = $count ? (int) $count : 0;
$query = 'select id, uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants' .
$query = 'select id, uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, default_theme, allow_user_theme, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants' .
$whereSql .
sprintf(' order by `%s` %s limit ? offset ?', $order, $dir);
@@ -120,7 +147,7 @@ class TenantRepository
public static function find(int $id): ?array
{
$row = DB::selectOne(
'select id, uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants where id = ? limit 1',
'select id, uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, default_theme, allow_user_theme, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants where id = ? limit 1',
(string) $id
);
return self::unwrap($row);
@@ -129,25 +156,17 @@ class TenantRepository
public static function findByUuid(string $uuid): ?array
{
$row = DB::selectOne(
'select id, uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants where uuid = ? limit 1',
'select id, uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, default_theme, allow_user_theme, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants where uuid = ? limit 1',
$uuid
);
return self::unwrap($row);
}
private static function uuidV4(): string
{
$data = random_bytes(16);
$data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
$data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}
public static function create(array $data)
{
return DB::insert(
'insert into tenants (uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, status, status_changed_at, status_changed_by, created_by, created) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW())',
$data['uuid'] ?? self::uuidV4(),
'insert into tenants (uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, default_theme, allow_user_theme, status, status_changed_at, status_changed_by, created_by, created) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW())',
$data['uuid'] ?? RepoQuery::uuidV4(),
$data['description'],
$data['address'] ?? null,
$data['postal_code'] ?? null,
@@ -166,6 +185,8 @@ class TenantRepository
$data['privacy_url'] ?? null,
$data['imprint_url'] ?? null,
$data['primary_color'] ?? null,
$data['default_theme'] ?? null,
$data['allow_user_theme'] ?? null,
$data['status'] ?? 'active',
$data['status_changed_at'] ?? null,
$data['status_changed_by'] ?? null,
@@ -194,6 +215,8 @@ class TenantRepository
'privacy_url' => $data['privacy_url'] ?? null,
'imprint_url' => $data['imprint_url'] ?? null,
'primary_color' => $data['primary_color'] ?? null,
'default_theme' => $data['default_theme'] ?? null,
'allow_user_theme' => $data['allow_user_theme'] ?? null,
'status' => $data['status'] ?? 'active',
];
if (array_key_exists('modified_by', $data)) {

View File

@@ -39,4 +39,64 @@ class UserTenantRepository
return true;
}
public static function countUsersByTenantIds(array $tenantIds): array
{
return self::countByTenantIds($tenantIds, false);
}
public static function countActiveUsersByTenantIds(array $tenantIds): array
{
return self::countByTenantIds($tenantIds, true);
}
private static function countByTenantIds(array $tenantIds, bool $activeOnly): array
{
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
$tenantIds = array_values(array_filter($tenantIds, static fn ($id) => $id > 0));
if (!$tenantIds) {
return [];
}
$placeholders = implode(',', array_fill(0, count($tenantIds), '?'));
$joinUsersSql = $activeOnly ? ' join users u on u.id = ut.user_id and u.active = 1' : '';
$rows = DB::select(
'select ut.tenant_id, count(distinct ut.user_id) as user_count from user_tenants ut' .
$joinUsersSql .
' where ut.tenant_id in (' . $placeholders . ') group by ut.tenant_id',
...array_map('strval', $tenantIds)
);
if (!is_array($rows)) {
return [];
}
$result = [];
foreach ($rows as $row) {
$tenantId = self::extractIntField($row, 'tenant_id');
if ($tenantId <= 0) {
continue;
}
$result[$tenantId] = self::extractIntField($row, 'user_count');
}
return $result;
}
private static function extractIntField(array $row, string $field): int
{
foreach ($row as $value) {
if (!is_array($value)) {
continue;
}
if (array_key_exists($field, $value)) {
return (int) $value[$field];
}
}
if (array_key_exists($field, $row)) {
return (int) $row[$field];
}
return 0;
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace MintyPHP\Repository\User;
use MintyPHP\DB;
class UserExternalIdentityRepository
{
private static function unwrap($row): ?array
{
if (!$row || !is_array($row)) {
return null;
}
if (isset($row['user_external_identities']) && is_array($row['user_external_identities'])) {
return $row['user_external_identities'];
}
return $row;
}
public static function findByProviderTidOid(string $provider, string $tid, string $oid): ?array
{
$row = DB::selectOne(
'select id, user_id, provider, oid, tid, issuer, subject, email_at_link_time, created from user_external_identities where provider = ? and tid = ? and oid = ? limit 1',
$provider,
$tid,
$oid
);
return self::unwrap($row);
}
public static function findByProviderIssuerSubject(string $provider, string $issuer, string $subject): ?array
{
$row = DB::selectOne(
'select id, user_id, provider, oid, tid, issuer, subject, email_at_link_time, created from user_external_identities where provider = ? and issuer = ? and subject = ? limit 1',
$provider,
$issuer,
$subject
);
return self::unwrap($row);
}
public static function createLink(array $data)
{
return DB::insert(
'insert into user_external_identities (user_id, provider, oid, tid, issuer, subject, email_at_link_time, created) values (?,?,?,?,?,?,?,NOW())',
(string) ((int) ($data['user_id'] ?? 0)),
(string) ($data['provider'] ?? ''),
(string) ($data['oid'] ?? ''),
(string) ($data['tid'] ?? ''),
(string) ($data['issuer'] ?? ''),
(string) ($data['subject'] ?? ''),
$data['email_at_link_time'] ?? null
);
}
}

View File

@@ -15,10 +15,16 @@ class UserRepository
$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'] ?? []);
$roleIds = RepoQuery::normalizeIdList($options['roles'] ?? []);
$departmentIds = RepoQuery::normalizeIdList($options['departments'] ?? []);
$emailVerified = $options['email_verified'] ?? null;
$loginStatus = $options['login_status'] ?? null;
$customFieldFilterSpec = is_array($options['customFieldFilterSpec'] ?? null)
? $options['customFieldFilterSpec']
: [];
$customFieldFilters = is_array($customFieldFilterSpec['filters'] ?? null)
? $customFieldFilterSpec['filters']
: [];
$where = [];
$params = [];
@@ -34,11 +40,11 @@ class UserRepository
['aliases' => ['0', 'false', 'inactive'], 'sql' => 'users.active = ?', 'params' => ['0']],
]);
if ($createdFrom !== '') {
if ($createdFrom !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdFrom)) {
$where[] = 'users.created >= ?';
$params[] = $createdFrom . ' 00:00:00';
}
if ($createdTo !== '') {
if ($createdTo !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdTo)) {
$where[] = 'users.created <= ?';
$params[] = $createdTo . ' 23:59:59';
}
@@ -69,6 +75,68 @@ class UserRepository
['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($customFieldFilters['select']) && is_array($customFieldFilters['select'])) {
foreach ($customFieldFilters['select'] as $definitionId => $optionId) {
$definitionId = (int) $definitionId;
$optionId = (int) $optionId;
if ($definitionId <= 0 || $optionId <= 0) {
continue;
}
$where[] = 'exists (select 1 from user_custom_field_values ucfv ' .
'where ucfv.user_id = users.id and ucfv.definition_id = ? and ucfv.option_id = ?)';
$params[] = (string) $definitionId;
$params[] = (string) $optionId;
}
}
if (!empty($customFieldFilters['boolean']) && is_array($customFieldFilters['boolean'])) {
foreach ($customFieldFilters['boolean'] as $definitionId => $boolValue) {
$definitionId = (int) $definitionId;
$boolValue = (int) $boolValue;
if ($definitionId <= 0 || ($boolValue !== 0 && $boolValue !== 1)) {
continue;
}
$where[] = 'exists (select 1 from user_custom_field_values ucfv ' .
'where ucfv.user_id = users.id and ucfv.definition_id = ? and ucfv.value_bool = ?)';
$params[] = (string) $definitionId;
$params[] = (string) $boolValue;
}
}
if (!empty($customFieldFilters['multiselect']) && is_array($customFieldFilters['multiselect'])) {
foreach ($customFieldFilters['multiselect'] as $definitionId => $optionIds) {
$definitionId = (int) $definitionId;
$optionIds = RepoQuery::normalizeIdList($optionIds);
if ($definitionId <= 0 || !$optionIds) {
continue;
}
$where[] = 'exists (select 1 from user_custom_field_values ucfv ' .
'join user_custom_field_value_options ucfvo on ucfvo.value_id = ucfv.id ' .
'where ucfv.user_id = users.id and ucfv.definition_id = ? and ucfvo.option_id in (???))';
$params[] = (string) $definitionId;
$params[] = array_map('strval', $optionIds);
}
}
if (!empty($customFieldFilters['date']) && is_array($customFieldFilters['date'])) {
foreach ($customFieldFilters['date'] as $definitionId => $bounds) {
$definitionId = (int) $definitionId;
$from = is_array($bounds) ? trim((string) ($bounds['from'] ?? '')) : '';
$to = is_array($bounds) ? trim((string) ($bounds['to'] ?? '')) : '';
if ($definitionId <= 0 || ($from === '' && $to === '')) {
continue;
}
if ($from !== '') {
$where[] = 'exists (select 1 from user_custom_field_values ucfv ' .
'where ucfv.user_id = users.id and ucfv.definition_id = ? and ucfv.value_date >= ?)';
$params[] = (string) $definitionId;
$params[] = $from;
}
if ($to !== '') {
$where[] = 'exists (select 1 from user_custom_field_values ucfv ' .
'where ucfv.user_id = users.id and ucfv.definition_id = ? and ucfv.value_date <= ?)';
$params[] = (string) $definitionId;
$params[] = $to;
}
}
}
if (!empty($options['tenantUserId'])) {
$tenantUserId = (int) $options['tenantUserId'];
if ($tenantUserId > 0) {
@@ -93,7 +161,7 @@ class UserRepository
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, ' .
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.last_login_provider, 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 .
@@ -157,27 +225,48 @@ class UserRepository
return $list;
}
$scopeToActiveTenants = !empty($options['tenantUserId']);
$scopeUserId = (int) ($options['tenantUserId'] ?? 0);
$scopeToActiveTenants = $scopeUserId > 0;
$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), '?'));
$tenantScopeSql = '';
$tenantScopeParams = [];
if ($scopeToActiveTenants) {
$tenantScopeSql = ' and exists (select 1 from user_tenants uts ' .
"join tenants ts on ts.id = uts.tenant_id and ts.status = 'active' " .
'where uts.user_id = ? and uts.tenant_id = ut.tenant_id)';
$tenantScopeParams[] = (string) $scopeUserId;
}
$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(
$labelSql = '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 . ')' . $tenantScopeSql . ' order by t.description asc';
$labelRows = call_user_func_array(['MintyPHP\\DB', 'select'], array_merge(
[$labelSql],
array_merge(array_map('strval', $ids), $tenantScopeParams)
));
$roleRows = 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)
);
$departmentScopeSql = '';
$departmentScopeParams = [];
if ($scopeToActiveTenants) {
$departmentScopeSql = ' and exists (select 1 from user_tenants uts ' .
"join tenants ts on ts.id = uts.tenant_id and ts.status = 'active' " .
'where uts.user_id = ? and uts.tenant_id = d.tenant_id)';
$departmentScopeParams[] = (string) $scopeUserId;
}
$departmentLabelSql = '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 . ')' . $departmentScopeSql . ' order by d.description asc';
$departmentRows = call_user_func_array(['MintyPHP\\DB', 'select'], array_merge(
[$departmentLabelSql],
array_merge(array_map('strval', $ids), $departmentScopeParams)
));
$tenantMapByUser = [];
foreach ($labelRows as $row) {
@@ -225,8 +314,8 @@ class UserRepository
foreach ($tenantMapByUser as $userId => $map) {
$tenantLabelsByUser[$userId] = array_values($map);
}
$roleLabelsByUser = self::collectLabels($roleLabelRows, 'ur', 'r');
$departmentLabelsByUser = self::collectLabels($departmentLabelRows, 'ud', 'd');
$roleLabelsByUser = self::collectLabels($roleRows, 'ur', 'r');
$departmentLabelsByUser = self::collectLabels($departmentRows, 'ud', 'd');
foreach ($list as &$user) {
$userId = (int) ($user['id'] ?? 0);
@@ -242,6 +331,7 @@ class UserRepository
return $list;
}
private static function unwrap(?array $row): ?array
{
if (!$row) {
@@ -277,25 +367,66 @@ class UserRepository
return $list;
}
public static function list(): array
{
$rows = DB::select(
'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
public static function updateLastLogin(int $userId, string $provider = 'local'): void
{
if ($userId <= 0) {
return;
}
DB::query('update users set last_login_at = UTC_TIMESTAMP() where id = ?', (string) $userId);
$provider = trim(strtolower($provider));
if (!in_array($provider, ['local', 'microsoft'], true)) {
$provider = 'local';
}
DB::query('update users set last_login_at = UTC_TIMESTAMP(), last_login_provider = ? where id = ?', $provider, (string) $userId);
}
public static function findAuthzSnapshot(int $userId): ?array
{
if ($userId <= 0) {
return null;
}
$row = DB::selectOne(
'select id, active, authz_version, locale, theme, current_tenant_id from users where id = ? limit 1',
(string) $userId
);
return self::unwrap($row);
}
public static function bumpAuthzVersion(int $userId): bool
{
if ($userId <= 0) {
return false;
}
$result = DB::update(
'update users set authz_version = authz_version + 1 where id = ?',
(string) $userId
);
return $result !== false;
}
public static function bumpAuthzVersionByUserIds(array $userIds): int
{
$userIds = array_values(array_unique(array_map('intval', $userIds)));
$userIds = array_values(array_filter($userIds, static fn ($id) => $id > 0));
if (!$userIds) {
return 0;
}
$placeholders = implode(',', array_fill(0, count($userIds), '?'));
$result = DB::update(
"update users set authz_version = authz_version + 1 where id in ($placeholders)",
...array_map('strval', $userIds)
);
return $result !== false ? (int) $result : 0;
}
public static function listPaged(array $options): array
{
$allowedOrder = ['id', 'uuid', 'first_name', 'last_name', 'email', 'created', 'modified', 'active', 'last_login_at'];
$allowedOrder = ['id', 'uuid', 'first_name', 'last_name', 'display_name', 'email', 'created', 'modified', 'active', 'last_login_at'];
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($options);
[$order, $dir] = RepoQuery::sanitizeOrder($options, $allowedOrder);
[$whereSql, $params] = self::buildUserFilters($options);
@@ -320,7 +451,7 @@ class UserRepository
public static function find(int $id): ?array
{
$row = DB::selectOne(
'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',
'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, last_login_at, last_login_provider, password, locale, totp_secret, theme, primary_tenant_id, current_tenant_id, created_by, modified_by, created, modified, active, authz_version from users where id = ? limit 1',
(string) $id
);
return self::unwrap($row);
@@ -329,7 +460,7 @@ class UserRepository
public static function findByUuid(string $uuid): ?array
{
$row = DB::selectOne(
'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',
'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, last_login_at, last_login_provider, password, locale, totp_secret, theme, primary_tenant_id, current_tenant_id, created_by, modified_by, created, modified, active, authz_version, active_changed_at, active_changed_by from users where uuid = ? limit 1',
$uuid
);
return self::unwrap($row);
@@ -338,7 +469,7 @@ class UserRepository
public static function findByEmail(string $email): ?array
{
$row = DB::selectOne(
'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',
'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, last_login_at, last_login_provider, password, locale, totp_secret, theme, primary_tenant_id, current_tenant_id, created_by, modified_by, created, modified, active, authz_version, active_changed_at, active_changed_by from users where email = ? limit 1',
$email
);
return self::unwrap($row);
@@ -355,20 +486,12 @@ class UserRepository
return $name;
}
private static function uuidV4(): string
{
$data = random_bytes(16);
$data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
$data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}
public static function create(array $data)
public static function create(array $data): int|false
{
$hash = password_hash($data['password'], PASSWORD_DEFAULT);
return DB::insert(
'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['uuid'] ?? RepoQuery::uuidV4(),
$data['first_name'],
$data['last_name'],
self::buildDisplayName($data),
@@ -491,6 +614,118 @@ class UserRepository
return $result !== false;
}
public static function listPrivilegedUserIdsByPermissionKeys(array $permissionKeys): array
{
$keys = array_values(array_unique(array_filter(array_map(
static fn ($key) => trim((string) $key),
$permissionKeys
))));
if (!$keys) {
return [];
}
$placeholders = implode(',', array_fill(0, count($keys), '?'));
$rows = DB::select(
'select distinct ur.user_id from user_roles ur ' .
'join roles r on r.id = ur.role_id and r.active = 1 ' .
'join role_permissions rp on rp.role_id = ur.role_id ' .
'join permissions p on p.id = rp.permission_id and p.active = 1 ' .
"where p.`key` in ($placeholders)",
...$keys
);
if (!is_array($rows)) {
return [];
}
$ids = [];
foreach ($rows as $row) {
$data = $row['ur'] ?? $row['user_roles'] ?? $row;
$userId = (int) ($data['user_id'] ?? $row['user_id'] ?? 0);
if ($userId > 0) {
$ids[] = $userId;
}
}
return array_values(array_unique($ids));
}
public static function listIdsForAutoDeactivate(int $days, array $excludedUserIds, int $limit = 500): array
{
if ($days <= 0 || $limit <= 0) {
return [];
}
$excluded = array_values(array_unique(array_filter(array_map('intval', $excludedUserIds), static fn ($id) => $id > 0)));
$query = 'select id from users where active = 1 and coalesce(last_login_at, created) <= DATE_SUB(UTC_TIMESTAMP(), INTERVAL ' .
(int) $days .
' DAY)';
$params = [];
if ($excluded) {
$placeholders = implode(',', array_fill(0, count($excluded), '?'));
$query .= " and id not in ($placeholders)";
$params = array_map('strval', $excluded);
}
$query .= ' order by coalesce(last_login_at, created) asc limit ?';
$params[] = (string) $limit;
$rows = DB::select($query, ...$params);
if (!is_array($rows)) {
return [];
}
return self::extractIdList($rows);
}
public static function listIdsForAutoDelete(int $days, array $excludedUserIds, int $limit = 500): array
{
if ($days <= 0 || $limit <= 0) {
return [];
}
$excluded = array_values(array_unique(array_filter(array_map('intval', $excludedUserIds), static fn ($id) => $id > 0)));
$query = 'select id from users where active = 0 and active_changed_at is not null and active_changed_at <= DATE_SUB(UTC_TIMESTAMP(), INTERVAL ' .
(int) $days .
' DAY)';
$params = [];
if ($excluded) {
$placeholders = implode(',', array_fill(0, count($excluded), '?'));
$query .= " and id not in ($placeholders)";
$params = array_map('strval', $excluded);
}
$query .= ' order by active_changed_at asc limit ?';
$params[] = (string) $limit;
$rows = DB::select($query, ...$params);
if (!is_array($rows)) {
return [];
}
return self::extractIdList($rows);
}
public static function setInactiveByIds(array $userIds, ?int $changedBy = null): int
{
$ids = array_values(array_unique(array_filter(array_map('intval', $userIds), static fn ($id) => $id > 0)));
if (!$ids) {
return 0;
}
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$query = "update users set active = 0, modified_by = ?, active_changed_at = UTC_TIMESTAMP(), active_changed_by = ? where active = 1 and id in ($placeholders)";
$params = array_merge([$changedBy, $changedBy], array_map('strval', $ids));
$result = DB::update($query, ...$params);
return $result !== false ? (int) $result : 0;
}
public static function deleteByIds(array $userIds): int
{
$ids = array_values(array_unique(array_filter(array_map('intval', $userIds), static fn ($id) => $id > 0)));
if (!$ids) {
return 0;
}
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$result = DB::delete(
"delete from users where id in ($placeholders)",
...array_map('strval', $ids)
);
return $result !== false ? (int) $result : 0;
}
public static function setLocale(int $id, string $locale): bool
{
$result = DB::update('update users set locale = ? where id = ?', $locale, (string) $id);
@@ -503,6 +738,62 @@ class UserRepository
return $result !== false;
}
public static function updateProfileFieldsFromSso(int $id, array $fields): bool
{
if ($id <= 0) {
return false;
}
$existing = self::find($id);
if (!$existing) {
return false;
}
$allowed = ['first_name', 'last_name', 'phone', 'mobile'];
$updates = [];
foreach ($allowed as $field) {
if (!array_key_exists($field, $fields)) {
continue;
}
$value = trim((string) $fields[$field]);
if ($value === '') {
continue;
}
if ((string) ($existing[$field] ?? '') === $value) {
continue;
}
$updates[$field] = $value;
}
if (!$updates) {
return true;
}
if (isset($updates['first_name']) || isset($updates['last_name'])) {
$displayData = [
'first_name' => $updates['first_name'] ?? (string) ($existing['first_name'] ?? ''),
'last_name' => $updates['last_name'] ?? (string) ($existing['last_name'] ?? ''),
'email' => (string) ($existing['email'] ?? ''),
];
$updates['display_name'] = self::buildDisplayName($displayData);
}
$setParts = [];
$params = [];
foreach ($updates as $field => $value) {
$setParts[] = sprintf('`%s` = ?', $field);
$params[] = $value;
}
$params[] = (string) $id;
$result = DB::update(
'update users set ' . implode(', ', $setParts) . ' where id = ?',
...$params
);
return $result !== false;
}
public static function setPassword(int $id, string $password): bool
{
$hash = password_hash($password, PASSWORD_DEFAULT);
@@ -534,23 +825,17 @@ class UserRepository
return $result !== false;
}
private static function normalizeIdList($value): array
private static function extractIdList(array $rows): array
{
if (is_string($value)) {
$value = array_filter(array_map('trim', explode(',', $value)));
} elseif (!is_array($value)) {
return [];
}
$ids = [];
foreach ($value as $item) {
if ($item === '' || $item === null) {
continue;
foreach ($rows as $row) {
$data = $row['users'] ?? $row;
$id = (int) ($data['id'] ?? $row['id'] ?? 0);
if ($id > 0) {
$ids[] = $id;
}
$ids[] = (int) $item;
}
$ids = array_values(array_filter(array_unique($ids), static function ($id) {
return $id > 0;
}));
return $ids;
return array_values(array_unique($ids));
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace MintyPHP\Repository\User;
use MintyPHP\DB;
use MintyPHP\Repository\Support\RepoQuery;
class UserSavedFilterRepository
{
private static function unwrapList($rows): array
{
if (!is_array($rows)) {
return [];
}
$list = [];
foreach ($rows as $row) {
$data = $row['user_saved_filters'] ?? $row;
if (is_array($data)) {
$list[] = $data;
}
}
return $list;
}
public static function listByUserAndContext(int $userId, string $context): array
{
if ($userId <= 0 || $context === '') {
return [];
}
$rows = DB::select(
'select id, uuid, user_id, context, name, query_json, created, modified from user_saved_filters where user_id = ? and context = ? order by created desc, id desc',
(string) $userId,
$context
);
return self::unwrapList($rows);
}
public static function countByUserAndContext(int $userId, string $context): int
{
if ($userId <= 0 || $context === '') {
return 0;
}
$count = DB::selectValue(
'select count(*) from user_saved_filters where user_id = ? and context = ?',
(string) $userId,
$context
);
return $count ? (int) $count : 0;
}
public static function create(array $data): int|false
{
$userId = (int) ($data['user_id'] ?? 0);
$context = trim((string) ($data['context'] ?? ''));
$name = trim((string) ($data['name'] ?? ''));
$queryJson = (string) ($data['query_json'] ?? '');
if ($userId <= 0 || $context === '' || $name === '' || $queryJson === '') {
return false;
}
return DB::insert(
'insert into user_saved_filters (uuid, user_id, context, name, query_json, created) values (?,?,?,?,?,NOW())',
$data['uuid'] ?? RepoQuery::uuidV4(),
(string) $userId,
$context,
$name,
$queryJson
);
}
public static function deleteByUuidForUserAndContext(string $uuid, int $userId, string $context): bool
{
$uuid = trim($uuid);
$context = trim($context);
if ($uuid === '' || $userId <= 0 || $context === '') {
return false;
}
$deleted = DB::delete(
'delete from user_saved_filters where uuid = ? and user_id = ? and context = ?',
$uuid,
(string) $userId,
$context
);
return $deleted !== false && (int) $deleted > 0;
}
}

View File

@@ -8,23 +8,28 @@ use MintyPHP\Repository\Access\PermissionRepository;
class PermissionService
{
private static array $apiPermissionCache = [];
public const USERS_CREATE = 'users.create';
public const USERS_DELETE = 'users.delete';
public const USERS_VIEW = 'users.view';
public const USERS_VIEW_META = 'users.view_meta';
public const USERS_VIEW_AUDIT = 'users.view_audit';
public const USERS_UPDATE = 'users.update';
public const USERS_ACCESS_PDF = 'users.access_pdf';
public const USERS_SELF_UPDATE = 'users.self_update';
public const USERS_UPDATE_ASSIGNMENTS = 'users.update_assignments';
public const ADDRESS_BOOK_VIEW = 'address_book.view';
public const TENANTS_VIEW = 'tenants.view';
public const TENANTS_CREATE = 'tenants.create';
public const TENANTS_UPDATE = 'tenants.update';
public const TENANTS_SSO_MANAGE = 'tenants.sso_manage';
public const TENANTS_DELETE = 'tenants.delete';
public const DEPARTMENTS_VIEW = 'departments.view';
public const DEPARTMENTS_CREATE = 'departments.create';
public const DEPARTMENTS_UPDATE = 'departments.update';
public const DEPARTMENTS_DELETE = 'departments.delete';
public const DEPARTMENTS_IMPORT = 'departments.import';
public const ROLES_VIEW = 'roles.view';
public const ROLES_CREATE = 'roles.create';
public const ROLES_UPDATE = 'roles.update';
@@ -35,8 +40,23 @@ class PermissionService
public const PERMISSIONS_DELETE = 'permissions.delete';
public const SETTINGS_VIEW = 'settings.view';
public const SETTINGS_UPDATE = 'settings.update';
public const IMPORTS_VIEW = 'imports.view';
public const IMPORTS_AUDIT_VIEW = 'imports.audit.view';
public const JOBS_VIEW = 'jobs.view';
public const JOBS_MANAGE = 'jobs.manage';
public const JOBS_RUN_NOW = 'jobs.run_now';
public const USER_LIFECYCLE_AUDIT_VIEW = 'user_lifecycle_audit.view';
public const USERS_IMPORT = 'users.import';
public const USERS_IMPORT_ASSIGNMENTS = 'users.import_assignments';
public const USERS_LIFECYCLE_RESTORE = 'users.lifecycle_restore';
public const CUSTOM_FIELDS_MANAGE = 'custom_fields.manage';
public const CUSTOM_FIELDS_EDIT_VALUES = 'custom_fields.edit_values';
public const API_DOCS_VIEW = 'api_docs.view';
public const DOCS_VIEW = 'docs.view';
public const MAIL_LOG_VIEW = 'mail_log.view';
public const API_AUDIT_VIEW = 'api_audit.view';
public const STATS_VIEW = 'stats.view';
public const API_TOKENS_MANAGE = 'api_tokens.manage';
public static function userHas(int $userId, string $permissionKey): bool
{
@@ -53,6 +73,18 @@ class PermissionService
if ($userId <= 0) {
return [];
}
if (self::isApiRequest()) {
if (!$refresh && isset(self::$apiPermissionCache[$userId])) {
return self::$apiPermissionCache[$userId];
}
$roleIds = UserRoleRepository::listRoleIdsByUserId($userId);
$keys = RolePermissionRepository::listPermissionKeysByRoleIds($roleIds);
self::$apiPermissionCache[$userId] = $keys;
return $keys;
}
$cache = $_SESSION['permissions'] ?? null;
if ($refresh) {
$cache = null;
@@ -77,6 +109,11 @@ class PermissionService
if ($userId <= 0) {
return [];
}
if (self::isApiRequest()) {
return self::$apiPermissionCache[$userId] ?? [];
}
$cache = $_SESSION['permissions'] ?? null;
if (is_array($cache) && (int) ($cache['user_id'] ?? 0) === $userId) {
$keys = $cache['keys'] ?? [];
@@ -87,12 +124,22 @@ class PermissionService
public static function clearUserCache(int $userId): void
{
if (self::isApiRequest()) {
unset(self::$apiPermissionCache[$userId]);
return;
}
$cache = $_SESSION['permissions'] ?? null;
if (is_array($cache) && (int) ($cache['user_id'] ?? 0) === $userId) {
unset($_SESSION['permissions']);
}
}
private static function isApiRequest(): bool
{
return defined('MINTY_API_REQUEST');
}
public static function list(): array
{
return PermissionRepository::list();

View File

@@ -54,7 +54,7 @@ class RoleService
$createdRole = RoleRepository::find((int) $createdId);
$uuid = $createdRole['uuid'] ?? null;
if (!empty($input['is_default']) && $createdId) {
if (!empty($input['is_default'])) {
SettingService::setDefaultRoleId((int) $createdId);
}
return ['ok' => true, 'form' => $form, 'uuid' => $uuid, 'id' => (int) $createdId];

View File

@@ -0,0 +1,220 @@
<?php
namespace MintyPHP\Service\Audit;
use MintyPHP\Http\ApiAuth;
use MintyPHP\Repository\Audit\ApiAuditLogRepository;
use MintyPHP\Repository\Support\RepoQuery;
class ApiAuditService
{
private const RETENTION_DAYS = 90;
private const MAX_USER_AGENT_LENGTH = 255;
private const MAX_STRING_LENGTH = 500;
private const MAX_ARRAY_ITEMS = 30;
private static ?array $context = null;
public static function startRequestContext(): void
{
if (self::$context !== null) {
return;
}
$method = strtoupper(trim((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')));
if ($method === 'OPTIONS') {
self::$context = ['active' => false, 'finished' => true];
return;
}
$path = parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH);
$path = is_string($path) ? trim($path) : '';
if ($path === '') {
self::$context = ['active' => false, 'finished' => true];
return;
}
self::$context = [
'active' => true,
'finished' => false,
'started_at' => microtime(true),
'request_id' => RepoQuery::uuidV4(),
'method' => substr($method !== '' ? $method : 'GET', 0, 8),
'path' => substr($path, 0, 255),
'query_json' => self::buildRedactedQueryJson($_GET),
];
}
public static function finish(int $statusCode, ?string $errorCode = null): void
{
if (!is_array(self::$context)) {
return;
}
if (!empty(self::$context['finished'])) {
return;
}
self::$context['finished'] = true;
if (empty(self::$context['active'])) {
return;
}
$durationMs = null;
if (isset(self::$context['started_at'])) {
$durationMs = (int) round((microtime(true) - (float) self::$context['started_at']) * 1000);
if ($durationMs < 0) {
$durationMs = 0;
}
}
$statusCode = ($statusCode >= 100 && $statusCode <= 999) ? $statusCode : 500;
$normalizedErrorCode = trim((string) ($errorCode ?? ''));
if ($normalizedErrorCode === '') {
$normalizedErrorCode = null;
} elseif (strlen($normalizedErrorCode) > 100) {
$normalizedErrorCode = substr($normalizedErrorCode, 0, 100);
}
$ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? ''));
if ($ip === '') {
$ip = null;
} elseif (strlen($ip) > 45) {
$ip = substr($ip, 0, 45);
}
$userAgent = trim((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''));
if ($userAgent === '') {
$userAgent = null;
} elseif (strlen($userAgent) > self::MAX_USER_AGENT_LENGTH) {
$userAgent = substr($userAgent, 0, self::MAX_USER_AGENT_LENGTH);
}
$userId = ApiAuth::isAuthenticated() ? ApiAuth::userId() : 0;
$tenantId = ApiAuth::isAuthenticated() ? (int) (ApiAuth::tenantId() ?? 0) : 0;
$apiTokenId = ApiAuth::isAuthenticated() ? (int) (ApiAuth::tokenId() ?? 0) : 0;
$tokenTenantId = ApiAuth::isAuthenticated() ? (int) (ApiAuth::scopedTenantId() ?? 0) : 0;
$payload = [
'request_id' => (string) (self::$context['request_id'] ?? RepoQuery::uuidV4()),
'method' => (string) (self::$context['method'] ?? ''),
'path' => (string) (self::$context['path'] ?? ''),
'query_json' => self::$context['query_json'] ?? null,
'status_code' => $statusCode,
'duration_ms' => $durationMs,
'error_code' => $normalizedErrorCode,
'user_id' => $userId > 0 ? $userId : null,
'tenant_id' => $tenantId > 0 ? $tenantId : null,
'api_token_id' => $apiTokenId > 0 ? $apiTokenId : null,
'token_tenant_id' => $tokenTenantId > 0 ? $tokenTenantId : null,
'ip' => $ip,
'user_agent' => $userAgent,
];
try {
ApiAuditLogRepository::create($payload);
} catch (\Throwable $exception) {
// Never break API responses because of audit logging failures.
}
}
public static function listPaged(array $filters): array
{
return ApiAuditLogRepository::listPaged($filters);
}
public static function find(int $id): ?array
{
return ApiAuditLogRepository::find($id);
}
public static function purgeExpired(): int
{
return ApiAuditLogRepository::purgeOlderThanDays(self::RETENTION_DAYS);
}
private static function buildRedactedQueryJson(array $query): ?string
{
if (!$query) {
return null;
}
$redacted = self::redactArray($query);
if ($redacted === []) {
return null;
}
$encoded = json_encode($redacted, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return is_string($encoded) ? $encoded : null;
}
private static function redactArray(array $data): array
{
$result = [];
$i = 0;
foreach ($data as $rawKey => $value) {
if ($i >= self::MAX_ARRAY_ITEMS) {
break;
}
$key = trim((string) $rawKey);
if ($key === '') {
continue;
}
if (self::isSensitiveKey($key)) {
$result[$key] = '[REDACTED]';
} else {
$result[$key] = self::normalizeValue($value);
}
$i++;
}
return $result;
}
private static function normalizeValue(mixed $value): mixed
{
if (is_array($value)) {
return self::redactArray($value);
}
if (is_bool($value) || is_int($value) || is_float($value) || $value === null) {
return $value;
}
$string = trim((string) $value);
if ($string === '') {
return '';
}
if (strlen($string) > self::MAX_STRING_LENGTH) {
return substr($string, 0, self::MAX_STRING_LENGTH);
}
return $string;
}
private static function isSensitiveKey(string $key): bool
{
$key = strtolower(trim($key));
if ($key === '') {
return false;
}
if (in_array($key, [
'token',
'access_token',
'refresh_token',
'id_token',
'secret',
'password',
'authorization',
'api_key',
'client_secret',
'key',
], true)) {
return true;
}
return str_contains($key, 'token')
|| str_contains($key, 'secret')
|| str_contains($key, 'password')
|| str_contains($key, 'authorization')
|| str_ends_with($key, '_key');
}
}

View File

@@ -0,0 +1,202 @@
<?php
namespace MintyPHP\Service\Audit;
use MintyPHP\Repository\Audit\ImportAuditRunRepository;
use MintyPHP\Repository\Support\RepoQuery;
class ImportAuditService
{
private const RETENTION_DAYS = 90;
/**
* @var array<int, float>
*/
private static array $startedAtByRunId = [];
public static function startRun(
string $profileKey,
array $mappedTargets,
?string $sourceFilename,
int $userId,
?int $currentTenantId
): ?int {
$profileKey = trim($profileKey);
if ($profileKey === '') {
return null;
}
try {
$runId = ImportAuditRunRepository::createRunning([
'run_uuid' => RepoQuery::uuidV4(),
'profile_key' => $profileKey,
'status' => 'running',
'source_filename' => self::normalizeSourceFilename($sourceFilename),
'mapped_targets_csv' => self::normalizeMappedTargetsCsv($mappedTargets),
'user_id' => $userId > 0 ? $userId : null,
'current_tenant_id' => ($currentTenantId ?? 0) > 0 ? (int) $currentTenantId : null,
]);
} catch (\Throwable $exception) {
return null;
}
if (!$runId) {
return null;
}
self::$startedAtByRunId[(int) $runId] = microtime(true);
return (int) $runId;
}
public static function finishRun(?int $runId, array $result, ?string $forcedStatus = null): void
{
$runId = (int) ($runId ?? 0);
if ($runId <= 0) {
return;
}
$rowsTotal = max(0, (int) ($result['processed'] ?? 0));
$createdCount = max(0, (int) ($result['created'] ?? 0));
$skippedCount = max(0, (int) ($result['skipped'] ?? 0));
$failedCount = max(0, (int) ($result['failed'] ?? 0));
$status = self::normalizeStatus($forcedStatus);
if ($status === null) {
if (array_key_exists('ok', $result) && !($result['ok'] ?? false)) {
$status = 'failed';
} elseif ($failedCount <= 0) {
$status = 'success';
} elseif ($createdCount > 0 || $skippedCount > 0) {
$status = 'partial';
} else {
$status = 'failed';
}
}
$durationMs = null;
if (isset(self::$startedAtByRunId[$runId])) {
$durationMs = (int) round((microtime(true) - self::$startedAtByRunId[$runId]) * 1000);
if ($durationMs < 0) {
$durationMs = 0;
}
unset(self::$startedAtByRunId[$runId]);
}
$errorCodesJson = self::encodeErrorCounts($result);
try {
ImportAuditRunRepository::finishById($runId, [
'status' => $status,
'rows_total' => $rowsTotal,
'created_count' => $createdCount,
'skipped_count' => $skippedCount,
'failed_count' => $failedCount,
'error_codes_json' => $errorCodesJson,
'duration_ms' => $durationMs,
]);
} catch (\Throwable $exception) {
// Fail-open: import flow must not fail because audit logging fails.
}
}
public static function listPaged(array $filters): array
{
return ImportAuditRunRepository::listPaged($filters);
}
public static function find(int $id): ?array
{
return ImportAuditRunRepository::find($id);
}
public static function purgeExpired(): int
{
return ImportAuditRunRepository::purgeOlderThanDays(self::RETENTION_DAYS);
}
/**
* @param array<int, string> $mappedTargets
*/
private static function normalizeMappedTargetsCsv(array $mappedTargets): ?string
{
$normalized = [];
foreach ($mappedTargets as $target) {
$value = trim((string) $target);
if ($value === '') {
continue;
}
$normalized[$value] = true;
}
if (!$normalized) {
return null;
}
$list = array_keys($normalized);
sort($list, SORT_STRING);
return implode(',', $list);
}
private static function normalizeSourceFilename(?string $sourceFilename): ?string
{
$sourceFilename = trim((string) ($sourceFilename ?? ''));
if ($sourceFilename === '') {
return null;
}
$base = basename(str_replace("\0", '', $sourceFilename));
if ($base === '' || $base === '.' || $base === '..') {
return null;
}
return strlen($base) > 255 ? substr($base, 0, 255) : $base;
}
private static function normalizeStatus(?string $status): ?string
{
$status = strtolower(trim((string) ($status ?? '')));
if ($status === '') {
return null;
}
return in_array($status, ['running', 'success', 'partial', 'failed'], true) ? $status : null;
}
private static function encodeErrorCounts(array $result): ?string
{
$counts = [];
$fromResult = $result['error_counts'] ?? null;
if (is_array($fromResult)) {
foreach ($fromResult as $rawCode => $rawCount) {
$code = trim((string) $rawCode);
$count = (int) $rawCount;
if ($code === '' || $count <= 0) {
continue;
}
$counts[$code] = ($counts[$code] ?? 0) + $count;
}
}
if (!$counts) {
$errors = $result['errors'] ?? [];
if (is_array($errors)) {
foreach ($errors as $error) {
if (!is_array($error)) {
continue;
}
$code = trim((string) ($error['code'] ?? ''));
if ($code === '') {
continue;
}
$counts[$code] = ($counts[$code] ?? 0) + 1;
}
}
}
if (!$counts) {
return null;
}
ksort($counts, SORT_STRING);
$encoded = json_encode($counts, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return is_string($encoded) ? $encoded : null;
}
}

View File

@@ -0,0 +1,251 @@
<?php
namespace MintyPHP\Service\Audit;
use MintyPHP\Repository\Audit\UserLifecycleAuditRepository;
use MintyPHP\Support\Crypto;
class UserLifecycleAuditService
{
private const RETENTION_DAYS = 365;
private const SNAPSHOT_VERSION = 1;
private const SNAPSHOT_FIELDS = [
'uuid',
'first_name',
'last_name',
'display_name',
'email',
'profile_description',
'job_title',
'phone',
'mobile',
'short_dial',
'address',
'postal_code',
'city',
'region',
'country',
'hire_date',
'locale',
'theme',
'email_verified_at',
'last_login_at',
'last_login_provider',
'primary_tenant_id',
'current_tenant_id',
'created',
'modified',
'active_changed_at',
];
public static function logDeactivate(
string $runUuid,
string $triggerType,
array $policy,
?int $actorUserId,
array $targetUser,
string $status = 'success',
?string $reasonCode = null
): bool {
try {
return UserLifecycleAuditRepository::create(self::buildBaseRow(
$runUuid,
'deactivate',
$triggerType,
$status,
$reasonCode,
$policy,
$actorUserId,
$targetUser
)) !== false;
} catch (\Throwable $exception) {
return false;
}
}
public static function logDeleteWithSnapshot(
string $runUuid,
string $triggerType,
array $policy,
?int $actorUserId,
array $targetUser
): int|false {
try {
$snapshot = self::buildSnapshot($targetUser);
$json = json_encode($snapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($json) || $json === '') {
return false;
}
$row = self::buildBaseRow(
$runUuid,
'delete',
$triggerType,
'success',
null,
$policy,
$actorUserId,
$targetUser
);
$row['snapshot_enc'] = Crypto::encryptString($json);
$row['snapshot_version'] = self::SNAPSHOT_VERSION;
return UserLifecycleAuditRepository::create($row);
} catch (\Throwable $exception) {
return false;
}
}
public static function logDeleteFailure(
string $runUuid,
string $triggerType,
array $policy,
?int $actorUserId,
array $targetUser,
string $reasonCode
): bool {
try {
return UserLifecycleAuditRepository::create(self::buildBaseRow(
$runUuid,
'delete',
$triggerType,
'failed',
$reasonCode,
$policy,
$actorUserId,
$targetUser
)) !== false;
} catch (\Throwable $exception) {
return false;
}
}
public static function logRestore(
string $runUuid,
string $triggerType,
array $policy,
?int $actorUserId,
array $targetUser,
string $status = 'success',
?string $reasonCode = null
): bool {
try {
return UserLifecycleAuditRepository::create(self::buildBaseRow(
$runUuid,
'restore',
$triggerType,
$status,
$reasonCode,
$policy,
$actorUserId,
$targetUser
)) !== false;
} catch (\Throwable $exception) {
return false;
}
}
public static function markDeleteEventRestored(int $auditId, int $restoredByUserId, int $restoredUserId): bool
{
try {
return UserLifecycleAuditRepository::markRestored($auditId, $restoredByUserId, $restoredUserId);
} catch (\Throwable $exception) {
return false;
}
}
public static function listPaged(array $filters): array
{
return UserLifecycleAuditRepository::listPaged($filters);
}
public static function find(int $id): ?array
{
return UserLifecycleAuditRepository::find($id);
}
public static function findDeleteEventForRestore(int $id, bool $forUpdate = false): ?array
{
return UserLifecycleAuditRepository::findDeleteEventForRestore($id, $forUpdate);
}
public static function decryptSnapshot(array $event): ?array
{
$enc = trim((string) ($event['snapshot_enc'] ?? ''));
if ($enc === '') {
return null;
}
try {
$json = Crypto::decryptString($enc);
$decoded = json_decode($json, true);
return is_array($decoded) ? $decoded : null;
} catch (\Throwable $exception) {
return null;
}
}
public static function updateStatus(int $id, string $status, ?string $reasonCode = null): bool
{
try {
return UserLifecycleAuditRepository::updateStatus($id, $status, $reasonCode);
} catch (\Throwable $exception) {
return false;
}
}
public static function purgeExpired(): int
{
return UserLifecycleAuditRepository::purgeOlderThanDays(self::RETENTION_DAYS);
}
private static function buildBaseRow(
string $runUuid,
string $action,
string $triggerType,
string $status,
?string $reasonCode,
array $policy,
?int $actorUserId,
array $targetUser
): array {
$triggerType = strtolower(trim($triggerType));
if (!in_array($triggerType, ['manual', 'cron', 'system'], true)) {
$triggerType = 'system';
}
$status = strtolower(trim($status));
if (!in_array($status, ['success', 'skipped', 'failed'], true)) {
$status = 'failed';
}
return [
'run_uuid' => trim($runUuid),
'action' => $action,
'trigger_type' => $triggerType,
'status' => $status,
'reason_code' => $reasonCode !== null ? trim($reasonCode) : null,
'policy_deactivate_days' => max(0, (int) ($policy['deactivate_days'] ?? 0)),
'policy_delete_days' => max(0, (int) ($policy['delete_days'] ?? 0)),
'actor_user_id' => ($actorUserId ?? 0) > 0 ? (int) $actorUserId : null,
'target_user_id' => ((int) ($targetUser['id'] ?? 0)) > 0 ? (int) $targetUser['id'] : null,
'target_user_uuid' => self::stringOrNull($targetUser['uuid'] ?? null),
'target_user_email' => self::stringOrNull($targetUser['email'] ?? null),
'snapshot_enc' => null,
'snapshot_version' => self::SNAPSHOT_VERSION,
];
}
private static function buildSnapshot(array $targetUser): array
{
$snapshot = [];
foreach (self::SNAPSHOT_FIELDS as $field) {
$snapshot[$field] = $targetUser[$field] ?? null;
}
return $snapshot;
}
private static function stringOrNull(mixed $value): ?string
{
$value = trim((string) $value);
return $value !== '' ? $value : null;
}
}

View File

@@ -0,0 +1,300 @@
<?php
namespace MintyPHP\Service\Auth;
use MintyPHP\DB;
use MintyPHP\Repository\Auth\ApiTokenRepository;
use MintyPHP\Service\Settings\SettingService;
use MintyPHP\Repository\User\UserRepository;
use MintyPHP\Service\Tenant\TenantScopeService;
class ApiTokenService
{
private const MAX_TOKENS_PER_USER = 10;
private static function isTokenExpired(?string $expiresAt): bool
{
$expiresAt = trim((string) ($expiresAt ?? ''));
if ($expiresAt === '') {
return false;
}
try {
$expiry = new \DateTimeImmutable($expiresAt, new \DateTimeZone('UTC'));
return $expiry <= new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
} catch (\Exception $e) {
return true;
}
}
/**
* Generate a new API token for a user.
*
* Returns ['ok' => true, 'token' => 'selector:plaintext', 'id' => int] on success.
* The plain-text token is shown ONCE and never stored.
*/
public static function create(
int $userId,
string $name,
?int $tenantId = null,
?string $expiresAt = null,
?int $createdBy = null
): array {
$name = trim($name);
if ($name === '') {
return ['ok' => false, 'error' => 'name_required'];
}
if ($userId <= 0) {
return ['ok' => false, 'error' => 'invalid_user'];
}
$user = UserRepository::find($userId);
if (!$user || !((int) ($user['active'] ?? 0))) {
return ['ok' => false, 'error' => 'user_inactive'];
}
$activeCount = ApiTokenRepository::countActiveForUser($userId);
if ($activeCount >= self::MAX_TOKENS_PER_USER) {
return ['ok' => false, 'error' => 'max_tokens_reached'];
}
if ($tenantId !== null && $tenantId > 0) {
$userTenantIds = TenantScopeService::getUserTenantIds($userId);
if (!in_array($tenantId, $userTenantIds, true)) {
return ['ok' => false, 'error' => 'tenant_not_assigned'];
}
}
$resolvedExpiresAt = self::resolveExpiresAt($expiresAt);
if ($resolvedExpiresAt === null) {
return ['ok' => false, 'error' => 'invalid_expires_at'];
}
$selector = bin2hex(random_bytes(12));
$plainToken = bin2hex(random_bytes(32));
$tokenHash = hash('sha256', $plainToken);
$id = ApiTokenRepository::create(
$userId,
$name,
$selector,
$tokenHash,
($tenantId !== null && $tenantId > 0) ? $tenantId : null,
$resolvedExpiresAt,
$createdBy
);
if (!$id) {
return ['ok' => false, 'error' => 'create_failed'];
}
$createdToken = ApiTokenRepository::find($id);
if (!$createdToken) {
return ['ok' => false, 'error' => 'create_failed'];
}
$fullToken = $selector . ':' . $plainToken;
return [
'ok' => true,
'token' => $fullToken,
'id' => $id,
'uuid' => (string) ($createdToken['uuid'] ?? ''),
'selector' => $selector,
'expires_at' => $resolvedExpiresAt,
];
}
public static function rotateCurrentToken(int $userId, int $currentTokenId, ?string $expiresAt = null): array
{
if ($userId <= 0 || $currentTokenId <= 0) {
return ['ok' => false, 'error' => 'current_token_not_found'];
}
$currentToken = ApiTokenRepository::find($currentTokenId);
if (!$currentToken || (int) ($currentToken['user_id'] ?? 0) !== $userId) {
return ['ok' => false, 'error' => 'current_token_not_found'];
}
if (!empty($currentToken['revoked_at']) || self::isTokenExpired($currentToken['expires_at'] ?? null)) {
return ['ok' => false, 'error' => 'current_token_invalid'];
}
$activeCountExcludingCurrent = ApiTokenRepository::countActiveForUserExcludingId($userId, $currentTokenId);
if ($activeCountExcludingCurrent >= self::MAX_TOKENS_PER_USER) {
return ['ok' => false, 'error' => 'max_tokens_reached'];
}
$name = trim((string) ($currentToken['name'] ?? ''));
if ($name === '') {
$name = 'API token';
}
$tenantId = isset($currentToken['tenant_id']) ? (int) ($currentToken['tenant_id']) : 0;
if ($tenantId <= 0) {
$tenantId = null;
}
$db = DB::handle();
$transactionStarted = false;
try {
$db->begin_transaction();
$transactionStarted = true;
if (!ApiTokenRepository::revoke($currentTokenId)) {
$db->rollback();
return ['ok' => false, 'error' => 'rotate_failed'];
}
$created = self::create($userId, $name, $tenantId, $expiresAt, $userId);
if (!($created['ok'] ?? false)) {
$db->rollback();
$error = (string) ($created['error'] ?? 'rotate_failed');
if ($error === 'invalid_expires_at') {
return ['ok' => false, 'error' => 'invalid_expires_at'];
}
if ($error === 'max_tokens_reached') {
return ['ok' => false, 'error' => 'max_tokens_reached'];
}
return ['ok' => false, 'error' => 'rotate_failed'];
}
$db->commit();
$created['tenant_id'] = $tenantId;
return $created;
} catch (\Throwable $exception) {
if ($transactionStarted) {
try {
$db->rollback();
} catch (\Throwable $rollbackException) {
// Ignore rollback error and return rotate_failed below.
}
}
return ['ok' => false, 'error' => 'rotate_failed'];
}
}
private static function resolveExpiresAt(?string $expiresAt): ?string
{
$nowUtc = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
$maxTtlDays = SettingService::getApiTokenMaxTtlDays();
$maxExpiryUtc = $nowUtc->modify('+' . $maxTtlDays . ' days');
$raw = trim((string) ($expiresAt ?? ''));
if ($raw === '') {
$defaultDays = SettingService::getApiTokenDefaultTtlDays();
$defaultExpiry = $nowUtc->modify('+' . $defaultDays . ' days');
if ($defaultExpiry > $maxExpiryUtc) {
$defaultExpiry = $maxExpiryUtc;
}
return $defaultExpiry->format('Y-m-d H:i:s');
}
try {
$parsed = new \DateTimeImmutable($raw);
} catch (\Exception $exception) {
return null;
}
$expiryUtc = $parsed->setTimezone(new \DateTimeZone('UTC'));
if ($expiryUtc <= $nowUtc) {
return null;
}
if ($expiryUtc > $maxExpiryUtc) {
return null;
}
return $expiryUtc->format('Y-m-d H:i:s');
}
/**
* Validate a Bearer token string.
*
* @return array{user: array, token_record: array, tenant_id: ?int}|null
*/
public static function validate(string $bearerToken): ?array
{
if ($bearerToken === '' || strpos($bearerToken, ':') === false) {
return null;
}
[$selector, $plainToken] = explode(':', $bearerToken, 2);
$selector = trim($selector);
$plainToken = trim($plainToken);
if ($selector === '' || $plainToken === '') {
return null;
}
$record = ApiTokenRepository::findBySelector($selector);
if (!$record) {
// Perform a dummy hash to prevent timing-based selector enumeration.
hash('sha256', $plainToken);
return null;
}
if (!empty($record['revoked_at'])) {
return null;
}
if (self::isTokenExpired($record['expires_at'] ?? null)) {
return null;
}
$storedHash = (string) ($record['token_hash'] ?? '');
if ($storedHash === '' || !hash_equals($storedHash, hash('sha256', $plainToken))) {
return null;
}
$userId = (int) ($record['user_id'] ?? 0);
if ($userId <= 0) {
return null;
}
$user = UserRepository::find($userId);
if (!$user || !((int) ($user['active'] ?? 0))) {
return null;
}
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
ApiTokenRepository::updateLastUsed((int) $record['id'], $ip);
return [
'user' => $user,
'token_record' => $record,
'tenant_id' => !empty($record['tenant_id']) ? (int) $record['tenant_id'] : null,
];
}
/**
* Revoke a specific token by ID.
*/
public static function revoke(int $tokenId): array
{
$token = ApiTokenRepository::find($tokenId);
if (!$token) {
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
}
ApiTokenRepository::revoke($tokenId);
return ['ok' => true];
}
/**
* Revoke all active tokens for a user.
*/
public static function revokeAllForUser(int $userId, ?int $tenantId = null): array
{
$count = ApiTokenRepository::revokeAllForUser($userId, $tenantId);
return ['ok' => true, 'count' => $count];
}
public static function revokeAllByAdmin(): int
{
return ApiTokenRepository::revokeAllActiveByAdmin();
}
/**
* List tokens for a user (for admin UI display -- never exposes hashes).
*/
public static function listForUser(int $userId): array
{
return ApiTokenRepository::listByUserId($userId);
}
}

View File

@@ -3,12 +3,15 @@
namespace MintyPHP\Service\Auth;
use MintyPHP\Auth;
use MintyPHP\Session;
use MintyPHP\Router;
use MintyPHP\Support\Flash;
use MintyPHP\Service\User\UserService;
use MintyPHP\Service\User\UserSavedFilterService;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Auth\RememberMeService;
use MintyPHP\Service\Auth\EmailVerificationService;
use MintyPHP\Service\Auth\TenantSsoService;
use MintyPHP\Repository\User\UserRepository;
class AuthService
@@ -49,6 +52,14 @@ class AuthService
}
$userId = (int) ($_SESSION['user']['id'] ?? 0);
if ($userId > 0 && !self::isLocalPasswordLoginAllowedForUser($userId)) {
Auth::logout();
return [
'ok' => false,
'message' => 'Password login is disabled for all assigned tenants',
'flash_key' => 'login_password_disabled_all_tenants',
];
}
if ($userId > 0) {
PermissionService::getUserPermissions($userId, true);
self::loadTenantDataIntoSession($userId);
@@ -60,12 +71,86 @@ class AuthService
'flash_key' => 'login_no_active_tenant',
];
}
UserRepository::updateLastLogin($userId);
UserRepository::updateLastLogin($userId, 'local');
}
return ['ok' => true];
}
private static function isLocalPasswordLoginAllowedForUser(int $userId): bool
{
if ($userId <= 0) {
return false;
}
$availableTenants = UserService::getAvailableTenants($userId);
if (!$availableTenants) {
return true;
}
foreach ($availableTenants as $tenant) {
$tenantId = (int) ($tenant['id'] ?? 0);
if ($tenantId <= 0) {
continue;
}
if (TenantSsoService::isLocalPasswordLoginAllowed($tenantId)) {
return true;
}
}
return false;
}
public static function canLoginToTenant(int $userId, int $tenantId): bool
{
if ($userId <= 0 || $tenantId <= 0) {
return false;
}
foreach (UserService::getAvailableTenants($userId) as $tenant) {
if ((int) ($tenant['id'] ?? 0) === $tenantId) {
return true;
}
}
return false;
}
public static function loginUserById(int $userId, ?int $preferredTenantId = null, string $loginProvider = 'local'): array
{
if ($userId <= 0) {
return ['ok' => false, 'error' => 'user_not_found'];
}
$user = UserRepository::find($userId);
if (!$user) {
return ['ok' => false, 'error' => 'user_not_found'];
}
if (!((int) ($user['active'] ?? 1) === 1)) {
return ['ok' => false, 'error' => 'user_inactive'];
}
Session::regenerate();
$_SESSION['user'] = $user;
if ($preferredTenantId !== null && $preferredTenantId > 0) {
$setTenant = UserService::setCurrentTenant($userId, $preferredTenantId);
if ($setTenant['ok'] ?? false) {
$_SESSION['user']['current_tenant_id'] = $preferredTenantId;
}
}
PermissionService::getUserPermissions($userId, true);
self::loadTenantDataIntoSession($userId);
if (!empty($_SESSION['no_active_tenant'])) {
Auth::logout();
return ['ok' => false, 'error' => 'login_no_active_tenant'];
}
UserRepository::updateLastLogin($userId, $loginProvider);
return ['ok' => true, 'user' => $user];
}
public static function loginAndRedirect(
string $email,
string $password,
@@ -130,6 +215,69 @@ class AuthService
Router::redirect($target);
}
public static function refreshSessionAuthState(int $userId): array
{
if ($userId <= 0) {
return [
'ok' => false,
'logout_required' => true,
'reason' => 'user_missing',
];
}
$snapshot = UserRepository::findAuthzSnapshot($userId);
if (!$snapshot) {
return [
'ok' => false,
'logout_required' => true,
'reason' => 'user_not_found',
];
}
if ((int) ($snapshot['active'] ?? 0) !== 1) {
return [
'ok' => false,
'logout_required' => true,
'reason' => 'inactive',
];
}
$sessionVersion = (int) ($_SESSION['user']['authz_version'] ?? 0);
$dbVersion = max(1, (int) ($snapshot['authz_version'] ?? 1));
$refreshed = false;
if ($sessionVersion !== $dbVersion) {
$user = UserRepository::find($userId);
if (!$user || (int) ($user['active'] ?? 0) !== 1) {
return [
'ok' => false,
'logout_required' => true,
'reason' => 'inactive',
];
}
$_SESSION['user'] = $user;
PermissionService::getUserPermissions($userId, true);
self::loadTenantDataIntoSession($userId);
$refreshed = true;
}
if (!empty($_SESSION['no_active_tenant'])) {
return [
'ok' => false,
'logout_required' => true,
'reason' => 'no_active_tenant',
'refreshed' => $refreshed,
];
}
return [
'ok' => true,
'logout_required' => false,
'refreshed' => $refreshed,
];
}
public static function loadTenantDataIntoSession(int $userId): void
{
if ($userId <= 0) {
@@ -140,11 +288,9 @@ class AuthService
$availableTenants = UserService::getAvailableTenants($userId);
$_SESSION['available_tenants'] = $availableTenants;
$_SESSION['available_departments_by_tenant'] = UserService::getAvailableDepartmentsByTenant($userId);
$_SESSION['address_book_saved_filters'] = UserSavedFilterService::listForAddressBook($userId);
$hasTenantAdminAccess = PermissionService::userHas($userId, PermissionService::TENANTS_UPDATE)
|| PermissionService::userHas($userId, PermissionService::SETTINGS_UPDATE);
if (!$availableTenants && !$hasTenantAdminAccess) {
if (!$availableTenants) {
$_SESSION['no_active_tenant'] = true;
unset($_SESSION['current_tenant']);
return;
@@ -153,7 +299,7 @@ class AuthService
// Load current tenant (with fallback to first available)
$currentTenant = UserService::getCurrentTenant($userId);
if (!$currentTenant && !empty($availableTenants)) {
if (!$currentTenant) {
// Fallback: use first available tenant
$currentTenant = $availableTenants[0];
}

View File

@@ -6,6 +6,7 @@ use MintyPHP\Repository\Auth\EmailVerificationRepository;
use MintyPHP\Repository\User\UserRepository;
use MintyPHP\I18n;
use MintyPHP\Http\Request;
use MintyPHP\Service\Mail\MailService;
class EmailVerificationService
{

View File

@@ -0,0 +1,540 @@
<?php
namespace MintyPHP\Service\Auth;
use MintyPHP\Curl;
use MintyPHP\Http\Request;
use MintyPHP\I18n;
use MintyPHP\Repository\Tenant\TenantRepository;
class MicrosoftOidcService
{
private const SESSION_KEY = 'microsoft_oidc_states';
private const STATE_TTL = 600;
private const CLOCK_SKEW = 120;
public static function startAuthorization(array $tenant, array $providerConfig): array
{
$tenantId = (int) ($tenant['id'] ?? 0);
if ($tenantId <= 0) {
return ['ok' => false, 'error' => 'tenant_not_found'];
}
$oidc = self::fetchOpenIdConfiguration((string) ($providerConfig['authority'] ?? ''));
if (!($oidc['ok'] ?? false)) {
return ['ok' => false, 'error' => 'oidc_discovery_failed'];
}
$metadata = $oidc['config'] ?? [];
$state = self::randomString(32);
$nonce = self::randomString(32);
$codeVerifier = self::randomString(64);
$codeChallenge = self::base64UrlEncode(hash('sha256', $codeVerifier, true));
$locale = I18n::$locale ?? I18n::$defaultLocale ?? 'de';
$redirectUri = appUrl(Request::withLocale('auth/microsoft/callback', $locale));
self::pruneStates();
$_SESSION[self::SESSION_KEY][$state] = [
'tenant_id' => $tenantId,
'nonce' => $nonce,
'code_verifier' => $codeVerifier,
'locale' => $locale,
'redirect_uri' => $redirectUri,
'created_at' => time(),
];
$params = [
'client_id' => (string) ($providerConfig['client_id'] ?? ''),
'response_type' => 'code',
'redirect_uri' => $redirectUri,
'response_mode' => 'query',
'scope' => self::buildAuthorizationScope($providerConfig),
'state' => $state,
'nonce' => $nonce,
'code_challenge' => $codeChallenge,
'code_challenge_method' => 'S256',
];
$authorizeEndpoint = (string) ($metadata['authorization_endpoint'] ?? '');
if ($authorizeEndpoint === '') {
return ['ok' => false, 'error' => 'authorize_endpoint_missing'];
}
return [
'ok' => true,
'url' => $authorizeEndpoint . '?' . http_build_query($params),
];
}
public static function handleCallback(string $state, string $code): array
{
$state = trim($state);
$code = trim($code);
if ($state === '' || $code === '') {
return ['ok' => false, 'error' => 'callback_invalid'];
}
$states = $_SESSION[self::SESSION_KEY] ?? [];
$entry = is_array($states[$state] ?? null) ? $states[$state] : null;
if (!$entry) {
return ['ok' => false, 'error' => 'state_invalid'];
}
unset($_SESSION[self::SESSION_KEY][$state]);
$createdAt = (int) ($entry['created_at'] ?? 0);
if ($createdAt <= 0 || (time() - $createdAt) > self::STATE_TTL) {
return ['ok' => false, 'error' => 'state_expired'];
}
$tenantId = (int) ($entry['tenant_id'] ?? 0);
$tenant = TenantRepository::find($tenantId);
if (!$tenant || (string) ($tenant['status'] ?? 'active') !== 'active') {
return ['ok' => false, 'error' => 'tenant_not_found'];
}
$configResult = TenantSsoService::getEffectiveMicrosoftProviderConfig($tenant);
if (!($configResult['ok'] ?? false)) {
return ['ok' => false, 'error' => (string) ($configResult['error'] ?? 'sso_config_invalid')];
}
$providerConfig = $configResult['config'] ?? [];
$oidc = self::fetchOpenIdConfiguration((string) ($providerConfig['authority'] ?? ''));
if (!($oidc['ok'] ?? false)) {
return ['ok' => false, 'error' => 'oidc_discovery_failed'];
}
$metadata = $oidc['config'] ?? [];
$tokenEndpoint = (string) ($metadata['token_endpoint'] ?? '');
if ($tokenEndpoint === '') {
return ['ok' => false, 'error' => 'token_endpoint_missing'];
}
$tokenResponse = Curl::call('POST', $tokenEndpoint, [
'grant_type' => 'authorization_code',
'client_id' => (string) ($providerConfig['client_id'] ?? ''),
'client_secret' => (string) ($providerConfig['client_secret'] ?? ''),
'code' => $code,
'redirect_uri' => (string) ($entry['redirect_uri'] ?? ''),
'code_verifier' => (string) ($entry['code_verifier'] ?? ''),
], [
'Accept' => 'application/json',
]);
if ((int) ($tokenResponse['status'] ?? 0) < 200 || (int) ($tokenResponse['status'] ?? 0) >= 300) {
return ['ok' => false, 'error' => 'token_exchange_failed'];
}
$tokenData = json_decode((string) ($tokenResponse['data'] ?? ''), true);
if (!is_array($tokenData)) {
return ['ok' => false, 'error' => 'token_response_invalid'];
}
$idToken = trim((string) ($tokenData['id_token'] ?? ''));
if ($idToken === '') {
return ['ok' => false, 'error' => 'id_token_missing'];
}
$accessToken = trim((string) ($tokenData['access_token'] ?? ''));
$validated = self::validateIdToken(
$idToken,
$providerConfig,
$metadata,
(string) ($entry['nonce'] ?? '')
);
if (!($validated['ok'] ?? false)) {
return ['ok' => false, 'error' => (string) ($validated['error'] ?? 'id_token_invalid')];
}
$claims = $validated['claims'] ?? [];
$email = self::extractEmailFromClaims($claims);
if ($email === '') {
return ['ok' => false, 'error' => 'email_missing'];
}
$allowedDomainsCsv = (string) ($providerConfig['allowed_domains'] ?? '');
$allowedDomains = array_values(array_filter(array_map('trim', explode(',', $allowedDomainsCsv))));
if (!TenantSsoService::isEmailDomainAllowed($email, $allowedDomains)) {
return ['ok' => false, 'error' => 'email_domain_not_allowed'];
}
$syncEnabled = !empty($providerConfig['sync_profile_on_login']);
$syncFields = TenantSsoService::normalizeProfileSyncFields($providerConfig['sync_profile_fields'] ?? []);
if ($syncEnabled && !$syncFields) {
$syncFields = TenantSsoService::defaultProfileSyncFields();
}
$graphProfile = [];
if ($syncEnabled && TenantSsoService::profileSyncFieldsRequireGraph($syncFields) && $accessToken !== '') {
$graph = self::fetchMicrosoftGraphProfile($accessToken);
if (!empty($graph['ok'])) {
$graphProfile = is_array($graph['profile'] ?? null) ? $graph['profile'] : [];
}
}
$graphAvatar = [];
if ($syncEnabled && in_array('avatar', $syncFields, true) && $accessToken !== '') {
$avatar = self::fetchMicrosoftGraphAvatar($accessToken);
if (!empty($avatar['ok'])) {
$graphAvatar = is_array($avatar['avatar'] ?? null) ? $avatar['avatar'] : [];
}
}
return [
'ok' => true,
'tenant' => $tenant,
'locale' => (string) ($entry['locale'] ?? (I18n::$locale ?? I18n::$defaultLocale ?? 'de')),
'graph_profile' => $graphProfile,
'graph_avatar' => $graphAvatar,
'sync_config' => [
'enabled' => $syncEnabled,
'fields' => $syncFields,
],
'claims' => [
'tid' => (string) ($claims['tid'] ?? ''),
'oid' => (string) ($claims['oid'] ?? ''),
'issuer' => (string) ($claims['iss'] ?? ''),
'subject' => (string) ($claims['sub'] ?? ''),
'email' => $email,
'name' => trim((string) ($claims['name'] ?? '')),
'given_name' => trim((string) ($claims['given_name'] ?? '')),
'family_name' => trim((string) ($claims['family_name'] ?? '')),
'preferred_language' => trim((string) ($claims['preferred_language'] ?? '')),
'graph_given_name' => trim((string) ($graphProfile['given_name'] ?? '')),
'graph_family_name' => trim((string) ($graphProfile['family_name'] ?? '')),
'graph_phone' => trim((string) ($graphProfile['phone'] ?? '')),
'graph_mobile' => trim((string) ($graphProfile['mobile'] ?? '')),
'graph_avatar_mime' => trim((string) ($graphAvatar['mime'] ?? '')),
'graph_avatar_data_base64' => trim((string) ($graphAvatar['data_base64'] ?? '')),
'sync_profile_on_login' => $syncEnabled ? 1 : 0,
'sync_profile_fields' => $syncFields,
],
];
}
private static function buildAuthorizationScope(array $providerConfig): string
{
$scopes = ['openid', 'profile', 'email'];
$syncEnabled = !empty($providerConfig['sync_profile_on_login']);
$syncFields = TenantSsoService::normalizeProfileSyncFields($providerConfig['sync_profile_fields'] ?? []);
if ($syncEnabled && TenantSsoService::profileSyncFieldsRequireGraph($syncFields)) {
$scopes[] = 'User.Read';
}
$scopes = array_values(array_unique($scopes));
return implode(' ', $scopes);
}
private static function fetchOpenIdConfiguration(string $authority): array
{
$authority = trim($authority);
if ($authority === '') {
return ['ok' => false];
}
$url = rtrim($authority, '/') . '/.well-known/openid-configuration';
$response = Curl::call('GET', $url, '', ['Accept' => 'application/json']);
if ((int) ($response['status'] ?? 0) < 200 || (int) ($response['status'] ?? 0) >= 300) {
return ['ok' => false];
}
$config = json_decode((string) ($response['data'] ?? ''), true);
if (!is_array($config)) {
return ['ok' => false];
}
return ['ok' => true, 'config' => $config];
}
private static function validateIdToken(
string $idToken,
array $providerConfig,
array $metadata,
string $expectedNonce
): array {
$parts = explode('.', $idToken);
if (count($parts) !== 3) {
return ['ok' => false, 'error' => 'id_token_format_invalid'];
}
$header = json_decode(self::base64UrlDecode($parts[0]), true);
$claims = json_decode(self::base64UrlDecode($parts[1]), true);
$signature = self::base64UrlDecode($parts[2]);
if (!is_array($header) || !is_array($claims) || $signature === '') {
return ['ok' => false, 'error' => 'id_token_parse_failed'];
}
$alg = strtoupper(trim((string) ($header['alg'] ?? '')));
if ($alg !== 'RS256') {
return ['ok' => false, 'error' => 'id_token_alg_invalid'];
}
$jwksUri = trim((string) ($metadata['jwks_uri'] ?? ''));
if ($jwksUri === '') {
return ['ok' => false, 'error' => 'jwks_uri_missing'];
}
$jwksResponse = Curl::call('GET', $jwksUri, '', ['Accept' => 'application/json']);
if ((int) ($jwksResponse['status'] ?? 0) < 200 || (int) ($jwksResponse['status'] ?? 0) >= 300) {
return ['ok' => false, 'error' => 'jwks_fetch_failed'];
}
$jwks = json_decode((string) ($jwksResponse['data'] ?? ''), true);
if (!is_array($jwks) || !is_array($jwks['keys'] ?? null)) {
return ['ok' => false, 'error' => 'jwks_invalid'];
}
$kid = (string) ($header['kid'] ?? '');
$jwk = null;
foreach ($jwks['keys'] as $key) {
if (!is_array($key)) {
continue;
}
if ((string) ($key['kid'] ?? '') === $kid) {
$jwk = $key;
break;
}
}
if (!$jwk) {
return ['ok' => false, 'error' => 'jwk_not_found'];
}
$pem = self::jwkToPem($jwk);
if ($pem === null) {
return ['ok' => false, 'error' => 'jwk_invalid'];
}
$verified = openssl_verify(
$parts[0] . '.' . $parts[1],
$signature,
$pem,
OPENSSL_ALGO_SHA256
);
if ($verified !== 1) {
return ['ok' => false, 'error' => 'id_token_signature_invalid'];
}
$now = time();
$exp = (int) ($claims['exp'] ?? 0);
if ($exp <= 0 || $exp < ($now - self::CLOCK_SKEW)) {
return ['ok' => false, 'error' => 'id_token_expired'];
}
$nbf = (int) ($claims['nbf'] ?? 0);
if ($nbf > 0 && $nbf > ($now + self::CLOCK_SKEW)) {
return ['ok' => false, 'error' => 'id_token_not_yet_valid'];
}
$audience = $claims['aud'] ?? '';
$clientId = (string) ($providerConfig['client_id'] ?? '');
$audValid = false;
if (is_array($audience)) {
$audValid = in_array($clientId, $audience, true);
} else {
$audValid = (string) $audience === $clientId;
}
if (!$audValid) {
return ['ok' => false, 'error' => 'id_token_aud_invalid'];
}
if ((string) ($claims['nonce'] ?? '') !== $expectedNonce) {
return ['ok' => false, 'error' => 'id_token_nonce_invalid'];
}
$tid = strtolower((string) ($claims['tid'] ?? ''));
$expectedTid = strtolower((string) ($providerConfig['tenant_id'] ?? ''));
if ($expectedTid === '' || $tid !== $expectedTid) {
return ['ok' => false, 'error' => 'id_token_tid_invalid'];
}
$iss = strtolower((string) ($claims['iss'] ?? ''));
$issuerTemplate = strtolower((string) ($metadata['issuer'] ?? ''));
if ($issuerTemplate !== '') {
$expectedIssuer = str_replace('{tenantid}', $tid, $issuerTemplate);
if ($iss !== $expectedIssuer) {
return ['ok' => false, 'error' => 'id_token_iss_invalid'];
}
}
if ((string) ($claims['oid'] ?? '') === '' || (string) ($claims['sub'] ?? '') === '') {
return ['ok' => false, 'error' => 'id_token_identity_invalid'];
}
return ['ok' => true, 'claims' => $claims];
}
private static function extractEmailFromClaims(array $claims): string
{
$email = trim((string) ($claims['email'] ?? ''));
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
return strtolower($email);
}
$upn = trim((string) ($claims['preferred_username'] ?? ''));
if ($upn !== '' && filter_var($upn, FILTER_VALIDATE_EMAIL)) {
return strtolower($upn);
}
return '';
}
private static function fetchMicrosoftGraphProfile(string $accessToken): array
{
$url = 'https://graph.microsoft.com/v1.0/me?$select=givenName,surname,mobilePhone,businessPhones';
$response = Curl::call('GET', $url, '', [
'Accept' => 'application/json',
'Authorization' => 'Bearer ' . $accessToken,
]);
$status = (int) ($response['status'] ?? 0);
if ($status < 200 || $status >= 300) {
return ['ok' => false];
}
$data = json_decode((string) ($response['data'] ?? ''), true);
if (!is_array($data)) {
return ['ok' => false];
}
$phone = '';
$businessPhones = $data['businessPhones'] ?? [];
if (is_array($businessPhones) && isset($businessPhones[0])) {
$phone = trim((string) $businessPhones[0]);
}
return [
'ok' => true,
'profile' => [
'given_name' => trim((string) ($data['givenName'] ?? '')),
'family_name' => trim((string) ($data['surname'] ?? '')),
'mobile' => trim((string) ($data['mobilePhone'] ?? '')),
'phone' => $phone,
],
];
}
private static function fetchMicrosoftGraphAvatar(string $accessToken): array
{
$url = 'https://graph.microsoft.com/v1.0/me/photo/$value';
$response = Curl::call('GET', $url, '', [
'Accept' => 'image/*',
'Authorization' => 'Bearer ' . $accessToken,
]);
$status = (int) ($response['status'] ?? 0);
if ($status < 200 || $status >= 300) {
return ['ok' => false];
}
$data = $response['data'] ?? null;
if (!is_string($data) || $data === '') {
return ['ok' => false];
}
$contentType = self::headerValue($response, 'Content-Type');
$contentType = strtolower(trim(explode(';', $contentType, 2)[0]));
if (!in_array($contentType, ['image/jpeg', 'image/png', 'image/webp'], true)) {
return ['ok' => false];
}
return [
'ok' => true,
'avatar' => [
'mime' => $contentType,
'data_base64' => base64_encode($data),
],
];
}
private static function headerValue(array $response, string $name): string
{
$headers = is_array($response['headers'] ?? null) ? $response['headers'] : [];
foreach ($headers as $key => $value) {
if (strcasecmp((string) $key, $name) === 0) {
return is_string($value) ? $value : '';
}
}
return '';
}
private static function pruneStates(): void
{
$states = $_SESSION[self::SESSION_KEY] ?? [];
if (!is_array($states)) {
$_SESSION[self::SESSION_KEY] = [];
return;
}
$now = time();
foreach ($states as $state => $payload) {
$createdAt = (int) (($payload['created_at'] ?? 0));
if ($createdAt <= 0 || ($now - $createdAt) > self::STATE_TTL) {
unset($states[$state]);
}
}
$_SESSION[self::SESSION_KEY] = $states;
}
private static function randomString(int $bytes): string
{
return self::base64UrlEncode(random_bytes($bytes));
}
private static function base64UrlEncode(string $value): string
{
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
}
private static function base64UrlDecode(string $value): string
{
$value = strtr($value, '-_', '+/');
$pad = strlen($value) % 4;
if ($pad > 0) {
$value .= str_repeat('=', 4 - $pad);
}
$decoded = base64_decode($value, true);
return is_string($decoded) ? $decoded : '';
}
private static function jwkToPem(array $jwk): ?string
{
$n = self::base64UrlDecode((string) ($jwk['n'] ?? ''));
$e = self::base64UrlDecode((string) ($jwk['e'] ?? ''));
if ($n === '' || $e === '') {
return null;
}
$modulus = self::asn1Integer($n);
$publicExponent = self::asn1Integer($e);
$rsaPublicKey = self::asn1Sequence($modulus . $publicExponent);
$algo = self::asn1Sequence(
self::asn1ObjectIdentifier("\x2A\x86\x48\x86\xF7\x0D\x01\x01\x01") .
self::asn1Null()
);
$bitString = "\x03" . self::asn1Length(strlen($rsaPublicKey) + 1) . "\x00" . $rsaPublicKey;
$subjectPublicKeyInfo = self::asn1Sequence($algo . $bitString);
$pem = "-----BEGIN PUBLIC KEY-----\n";
$pem .= chunk_split(base64_encode($subjectPublicKeyInfo), 64, "\n");
$pem .= "-----END PUBLIC KEY-----\n";
return $pem;
}
private static function asn1Integer(string $bytes): string
{
if (ord($bytes[0]) > 0x7F) {
$bytes = "\x00" . $bytes;
}
return "\x02" . self::asn1Length(strlen($bytes)) . $bytes;
}
private static function asn1Sequence(string $bytes): string
{
return "\x30" . self::asn1Length(strlen($bytes)) . $bytes;
}
private static function asn1ObjectIdentifier(string $bytes): string
{
return "\x06" . self::asn1Length(strlen($bytes)) . $bytes;
}
private static function asn1Null(): string
{
return "\x05\x00";
}
private static function asn1Length(int $length): string
{
if ($length < 128) {
return chr($length);
}
$temp = ltrim(pack('N', $length), "\x00");
return chr(0x80 | strlen($temp)) . $temp;
}
}

View File

@@ -7,6 +7,8 @@ use MintyPHP\Repository\User\UserRepository;
use MintyPHP\I18n;
use MintyPHP\Http\Request;
use MintyPHP\Service\Auth\RememberMeService;
use MintyPHP\Service\Mail\MailService;
use MintyPHP\Service\User\UserService;
class PasswordResetService
{

View File

@@ -79,13 +79,16 @@ class RememberMeService
if (!empty($user['locale'])) {
I18n::$locale = (string) $user['locale'];
}
$userId = (int) ($user['id'] ?? 0);
$userId = (int) $user['id'];
if ($userId > 0) {
PermissionService::getUserPermissions($userId, true);
AuthService::loadTenantDataIntoSession($userId);
if (empty($_SESSION['no_active_tenant'])) {
UserRepository::updateLastLogin($userId);
if (!empty($_SESSION['no_active_tenant'])) {
// Enforce the same tenant policy as interactive login.
AuthService::logout();
return false;
}
UserRepository::updateLastLogin($userId, 'local');
}
$newToken = bin2hex(random_bytes(32));

Some files were not shown because too many files have changed in this diff Show More