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:
18
.env.example
18
.env.example
@@ -1,5 +1,4 @@
|
|||||||
APP_NAME=IMVS
|
APP_NAME=IMVS
|
||||||
APP_SLOGAN=Immobilien Makler Verkaufs Software
|
|
||||||
APP_URL=http://localhost:8080
|
APP_URL=http://localhost:8080
|
||||||
APP_ENV=dev
|
APP_ENV=dev
|
||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
@@ -7,7 +6,10 @@ APP_TIMEZONE=Europe/Berlin
|
|||||||
APP_STORAGE_PATH=./storage
|
APP_STORAGE_PATH=./storage
|
||||||
APP_LOCALE=de
|
APP_LOCALE=de
|
||||||
APP_LOCALES=de,en
|
APP_LOCALES=de,en
|
||||||
|
APP_I18N_DOMAIN=default
|
||||||
|
APP_CRYPTO_KEY=
|
||||||
SESSION_NAME=IMVS
|
SESSION_NAME=IMVS
|
||||||
|
TENANT_SCOPE_STRICT=true
|
||||||
|
|
||||||
DB_HOST=db
|
DB_HOST=db
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
@@ -17,3 +19,17 @@ DB_PASS=imvs
|
|||||||
DB_ROOT_PASSWORD=imvs_root
|
DB_ROOT_PASSWORD=imvs_root
|
||||||
|
|
||||||
CACHE_SERVERS=memcached:11211
|
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
35
.env.prod.example
Normal 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
14
.gitignore
vendored
@@ -1,9 +1,21 @@
|
|||||||
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Composer
|
||||||
composer.phar
|
composer.phar
|
||||||
|
/vendor/
|
||||||
|
|
||||||
|
# Secrets / local config
|
||||||
config/config.php
|
config/config.php
|
||||||
.env
|
.env
|
||||||
/vendor/
|
|
||||||
|
# Runtime & generated
|
||||||
/web/debugger/
|
/web/debugger/
|
||||||
/data/
|
/data/
|
||||||
/storage/*
|
/storage/*
|
||||||
!/storage/.gitkeep
|
!/storage/.gitkeep
|
||||||
|
.phpunit.result.cache
|
||||||
|
|||||||
249
README.md
249
README.md
@@ -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
|
## Funktionsumfang
|
||||||
- Secure by design
|
|
||||||
- Light-weight
|
|
||||||
|
|
||||||
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.
|
## Tech Stack
|
||||||
- … require you to write SQL queries (no ORM).
|
|
||||||
- … use PHP as a templating language.
|
|
||||||
|
|
||||||
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/) /
|
## Schnellstart (Docker)
|
||||||
[Documentation](https://mintyphp.github.io/docs/)
|
|
||||||
|
|
||||||
## 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)
|
|
||||||
|
|
||||||
## Quickstart (Docker)
|
|
||||||
|
|
||||||
1. Copy `.env.example` to `.env` (defaults are fine for local dev).
|
|
||||||
2. Build and start the stack:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up --build
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Open the app at `http://localhost:8080`.
|
2. Container starten
|
||||||
4. Register a user, then visit `/admin` (protected route).
|
|
||||||
5. phpMyAdmin is available at `http://localhost:8081`.
|
|
||||||
|
|
||||||
### Notes
|
```bash
|
||||||
|
docker compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
- MintyPHP uses Memcached for its firewall cache; the compose stack includes a `memcached` service and the PHP container has the extension enabled.
|
3. App oeffnen
|
||||||
- `config/config.php` is generated and gitignored by default; adjust `.gitignore` if you want to commit it.
|
|
||||||
|
- App: [http://localhost:8080](http://localhost:8080)
|
||||||
|
- phpMyAdmin: [http://localhost:8081](http://localhost:8081)
|
||||||
|
|
||||||
|
4. Container stoppen
|
||||||
|
|
||||||
|
```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
39
bin/scheduler-run.php
Executable 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);
|
||||||
@@ -12,13 +12,17 @@
|
|||||||
"require": {
|
"require": {
|
||||||
"php": "^8.5",
|
"php": "^8.5",
|
||||||
"mintyphp/core": "*",
|
"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": {
|
"require-dev": {
|
||||||
"mintyphp/tools": "*",
|
"mintyphp/tools": "*",
|
||||||
"mintyphp/debugger": "*",
|
"mintyphp/debugger": "*",
|
||||||
"phpunit/phpunit": "*",
|
"phpunit/phpunit": "*",
|
||||||
"phpstan/phpstan": "^1.9"
|
"phpstan/phpstan": "^1.9",
|
||||||
|
"icanhazstring/composer-unused": "^0.9.6"
|
||||||
},
|
},
|
||||||
"autoload-dev": {
|
"autoload-dev": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
|
|||||||
3262
composer.lock
generated
3262
composer.lock
generated
File diff suppressed because it is too large
Load Diff
69
config/assets.php
Normal file
69
config/assets.php
Normal 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'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -9,52 +9,104 @@ use MintyPHP\I18n;
|
|||||||
use MintyPHP\Router;
|
use MintyPHP\Router;
|
||||||
use MintyPHP\Session;
|
use MintyPHP\Session;
|
||||||
|
|
||||||
define('APP_NAME', getenv('APP_NAME') ?: 'IMVS');
|
$envString = static function (string $key, string $default): string {
|
||||||
define('APP_SLOGAN', getenv('APP_SLOGAN') ?: 'Immobilien Makler Verkaufs Software');
|
$value = getenv($key);
|
||||||
define('APP_TIMEZONE', getenv('APP_TIMEZONE') ?: 'Europe/Berlin');
|
if ($value === false || $value === '') {
|
||||||
define('APP_STORAGE_PATH', getenv('APP_STORAGE_PATH') ?: __DIR__ . '/../storage');
|
return $default;
|
||||||
$tenantScopeEnv = getenv('TENANT_SCOPE_STRICT');
|
}
|
||||||
define('TENANT_SCOPE_STRICT', $tenantScopeEnv === false ? true : filter_var($tenantScopeEnv, FILTER_VALIDATE_BOOLEAN));
|
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')) {
|
if (!defined('APP_LOCALES')) {
|
||||||
$locales = getenv('APP_LOCALES') ?: 'de,en';
|
define('APP_LOCALES', $envList('APP_LOCALES', ['de', 'en']));
|
||||||
define('APP_LOCALES', array_values(array_filter(array_map('trim', explode(',', $locales)))));
|
}
|
||||||
|
|
||||||
|
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')) {
|
if (!defined('APP_PUBLIC_PATHS')) {
|
||||||
define('APP_PUBLIC_PATHS', [
|
$publicPaths = [];
|
||||||
'login',
|
foreach (APP_ROUTE_DEFINITIONS as $route) {
|
||||||
'register',
|
if (!empty($route['public'])) {
|
||||||
'password/forgot',
|
$path = trim((string) ($route['path'] ?? ''));
|
||||||
'password/verify',
|
if ($path !== '') {
|
||||||
'password/reset',
|
$publicPaths[] = $path;
|
||||||
'lang',
|
}
|
||||||
'imprint',
|
}
|
||||||
'privacy',
|
}
|
||||||
]);
|
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);
|
date_default_timezone_set(APP_TIMEZONE);
|
||||||
ini_set('date.timezone', APP_TIMEZONE);
|
|
||||||
|
|
||||||
Router::$baseUrl = '/'; // default: '/'
|
Router::$baseUrl = '/'; // default: '/'
|
||||||
Router::$pageRoot = 'pages/'; // default: 'pages/'
|
Router::$pageRoot = 'pages/'; // default: 'pages/'
|
||||||
Router::$templateRoot = 'templates/'; // default: 'templates/'
|
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::$concurrency = $envInt('FIREWALL_CONCURRENCY', 10);
|
||||||
Firewall::$spinLockSeconds = (float) (getenv('FIREWALL_SPINLOCK_SECONDS') ?: 0.15);
|
Firewall::$spinLockSeconds = $envFloat('FIREWALL_SPINLOCK_SECONDS', 0.15);
|
||||||
Firewall::$intervalSeconds = (int) (getenv('FIREWALL_INTERVAL_SECONDS') ?: 300);
|
Firewall::$intervalSeconds = $envInt('FIREWALL_INTERVAL_SECONDS', 300);
|
||||||
Firewall::$cachePrefix = getenv('FIREWALL_CACHE_PREFIX') ?: 'fw_concurrency_';
|
Firewall::$cachePrefix = $envString('FIREWALL_CACHE_PREFIX', 'fw_concurrency_');
|
||||||
Firewall::$reverseProxy = getenv('FIREWALL_REVERSE_PROXY') === 'true';
|
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::$host = $envString('DB_HOST', 'db');
|
||||||
DB::$username = getenv('DB_USER') ?: 'mintyphp';
|
DB::$username = $envString('DB_USER', 'mintyphp');
|
||||||
DB::$password = getenv('DB_PASS') ?: 'mintyphp';
|
DB::$password = $envString('DB_PASS', 'mintyphp');
|
||||||
DB::$database = getenv('DB_NAME') ?: 'mintyphp';
|
DB::$database = $envString('DB_NAME', 'mintyphp');
|
||||||
DB::$port = (int) (getenv('DB_PORT') ?: 3306);
|
DB::$port = $envInt('DB_PORT', 3306);
|
||||||
|
|
||||||
Auth::$usersTable = 'users';
|
Auth::$usersTable = 'users';
|
||||||
Auth::$usernameField = 'email';
|
Auth::$usernameField = 'email';
|
||||||
@@ -62,8 +114,7 @@ Auth::$passwordField = 'password';
|
|||||||
Auth::$createdField = 'created';
|
Auth::$createdField = 'created';
|
||||||
Auth::$totpSecretField = 'totp_secret';
|
Auth::$totpSecretField = 'totp_secret';
|
||||||
|
|
||||||
$debugEnv = getenv('APP_DEBUG');
|
Debugger::$enabled = $envBool('APP_DEBUG', true);
|
||||||
Debugger::$enabled = $debugEnv === false ? true : filter_var($debugEnv, FILTER_VALIDATE_BOOLEAN);
|
|
||||||
|
|
||||||
I18n::$domain = getenv('APP_I18N_DOMAIN') ?: 'default';
|
I18n::$domain = $envString('APP_I18N_DOMAIN', 'default');
|
||||||
I18n::$locale = getenv('APP_LOCALE') ?: 'de';
|
I18n::$locale = $envString('APP_LOCALE', 'de');
|
||||||
|
|||||||
@@ -2,15 +2,18 @@
|
|||||||
|
|
||||||
use MintyPHP\Router;
|
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');
|
if (is_array($routeDefinitions)) {
|
||||||
Router::addRoute('register', 'auth/register');
|
foreach ($routeDefinitions as $route) {
|
||||||
Router::addRoute('logout', 'auth/logout');
|
$path = trim((string) ($route['path'] ?? ''));
|
||||||
Router::addRoute('profile', 'account/profile');
|
$target = trim((string) ($route['target'] ?? ''));
|
||||||
Router::addRoute('password/forgot', 'auth/forgot');
|
if ($path !== '' && $target !== '') {
|
||||||
Router::addRoute('password/verify', 'auth/verify');
|
Router::addRoute($path, $target);
|
||||||
Router::addRoute('password/reset', 'auth/reset');
|
}
|
||||||
Router::addRoute('verify-email', 'auth/verify-email');
|
}
|
||||||
Router::addRoute('imprint', 'page/imprint');
|
}
|
||||||
Router::addRoute('privacy', 'page/privacy');
|
|
||||||
|
|||||||
14
config/routes.php
Normal file
14
config/routes.php
Normal 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],
|
||||||
|
];
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
return array (
|
return [
|
||||||
'app_title' => 'IVMS',
|
'app_title' => 'Malphite',
|
||||||
'app_locale' => 'de',
|
'app_locale' => 'de',
|
||||||
'app_theme' => 'dark',
|
'app_theme' => 'light',
|
||||||
'app_theme_user' => '1',
|
'app_theme_user' => '1',
|
||||||
'app_registration' => '1',
|
'app_registration' => '1',
|
||||||
'app_primary_color' => '#105433',
|
'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',
|
||||||
|
];
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'light' => 'Light',
|
'light' => 'Light',
|
||||||
'dark' => 'Dark',
|
'dark' => 'Dark',
|
||||||
|
|||||||
415
db/init/init.sql
415
db/init/init.sql
@@ -1,5 +1,6 @@
|
|||||||
-- Consolidated schema (fresh install)
|
-- 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` (
|
CREATE TABLE IF NOT EXISTS `users` (
|
||||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
@@ -21,6 +22,7 @@ CREATE TABLE IF NOT EXISTS `users` (
|
|||||||
`hire_date` DATE NULL,
|
`hire_date` DATE NULL,
|
||||||
`email_verified_at` DATETIME NULL DEFAULT NULL,
|
`email_verified_at` DATETIME NULL DEFAULT NULL,
|
||||||
`last_login_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,
|
`password` VARCHAR(255) NOT NULL,
|
||||||
`locale` VARCHAR(10) DEFAULT NULL,
|
`locale` VARCHAR(10) DEFAULT NULL,
|
||||||
`totp_secret` VARCHAR(255) 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,
|
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
`modified` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
`modified` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||||
`active` TINYINT(1) NOT NULL DEFAULT 1,
|
`active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
`authz_version` INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
`active_changed_at` DATETIME NULL DEFAULT NULL,
|
`active_changed_at` DATETIME NULL DEFAULT NULL,
|
||||||
`active_changed_by` INT UNSIGNED DEFAULT NULL,
|
`active_changed_by` INT UNSIGNED DEFAULT NULL,
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
@@ -69,6 +72,8 @@ CREATE TABLE IF NOT EXISTS `tenants` (
|
|||||||
`privacy_url` VARCHAR(255) NULL,
|
`privacy_url` VARCHAR(255) NULL,
|
||||||
`imprint_url` VARCHAR(255) NULL,
|
`imprint_url` VARCHAR(255) NULL,
|
||||||
`primary_color` VARCHAR(7) 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` VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||||
`status_changed_at` DATETIME NULL DEFAULT NULL,
|
`status_changed_at` DATETIME NULL DEFAULT NULL,
|
||||||
`status_changed_by` INT UNSIGNED 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,
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
`uuid` CHAR(36) NOT NULL,
|
`uuid` CHAR(36) NOT NULL,
|
||||||
`description` VARCHAR(255) NOT NULL,
|
`description` VARCHAR(255) NOT NULL,
|
||||||
|
`tenant_id` INT UNSIGNED NOT NULL,
|
||||||
`code` VARCHAR(50) DEFAULT NULL,
|
`code` VARCHAR(50) DEFAULT NULL,
|
||||||
`cost_center` VARCHAR(50) DEFAULT NULL,
|
`cost_center` VARCHAR(50) DEFAULT NULL,
|
||||||
`active` TINYINT(1) NOT NULL DEFAULT 1,
|
`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,
|
`modified` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
UNIQUE KEY `uniq_departments_uuid` (`uuid`),
|
UNIQUE KEY `uniq_departments_uuid` (`uuid`),
|
||||||
|
KEY `idx_departments_tenant` (`tenant_id`),
|
||||||
KEY `idx_departments_active` (`active`),
|
KEY `idx_departments_active` (`active`),
|
||||||
KEY `idx_departments_created_by` (`created_by`),
|
KEY `idx_departments_created_by` (`created_by`),
|
||||||
KEY `idx_departments_modified_by` (`modified_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_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
|
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;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -183,6 +191,42 @@ CREATE TABLE IF NOT EXISTS `settings` (
|
|||||||
PRIMARY KEY (`key`)
|
PRIMARY KEY (`key`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
) 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` (
|
CREATE TABLE IF NOT EXISTS `user_tenants` (
|
||||||
`user_id` INT UNSIGNED NOT NULL,
|
`user_id` INT UNSIGNED NOT NULL,
|
||||||
`tenant_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
|
CONSTRAINT `fk_user_departments_department` FOREIGN KEY (`department_id`) REFERENCES `departments` (`id`) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) 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,
|
`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,
|
`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,
|
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
UNIQUE KEY `uniq_tenant_department` (`tenant_id`, `department_id`),
|
UNIQUE KEY `uniq_user_api_token_uuid` (`uuid`),
|
||||||
KEY `idx_tenant_departments_tenant` (`tenant_id`),
|
UNIQUE KEY `uniq_user_api_token_selector` (`selector`),
|
||||||
KEY `idx_tenant_departments_department` (`department_id`),
|
KEY `idx_user_api_token_user` (`user_id`),
|
||||||
CONSTRAINT `fk_tenant_departments_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE CASCADE,
|
KEY `idx_user_api_token_tenant` (`tenant_id`),
|
||||||
CONSTRAINT `fk_tenant_departments_department` FOREIGN KEY (`department_id`) REFERENCES `departments` (`id`) ON DELETE CASCADE
|
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;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `mail_log` (
|
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`)
|
KEY `idx_mail_log_to_email` (`to_email`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) 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` (
|
CREATE TABLE IF NOT EXISTS `password_resets` (
|
||||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
`user_id` INT UNSIGNED NOT NULL,
|
`user_id` INT UNSIGNED NOT NULL,
|
||||||
@@ -302,9 +609,11 @@ INSERT INTO `tenants` (`uuid`, `description`, `created`)
|
|||||||
SELECT UUID(), 'Cronus', NOW()
|
SELECT UUID(), 'Cronus', NOW()
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM tenants WHERE description = 'Cronus');
|
WHERE NOT EXISTS (SELECT 1 FROM tenants WHERE description = 'Cronus');
|
||||||
|
|
||||||
INSERT INTO `departments` (`uuid`, `description`, `code`, `active`, `created`)
|
INSERT INTO `departments` (`uuid`, `description`, `tenant_id`, `code`, `active`, `created`)
|
||||||
SELECT UUID(), 'IT', 'IT', 1, NOW()
|
SELECT UUID(), 'IT', t.id, 'IT', 1, NOW()
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM departments WHERE description = 'IT');
|
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`)
|
INSERT INTO `roles` (`uuid`, `description`, `code`, `active`, `created`)
|
||||||
SELECT UUID(), 'Admin', 'ADMIN', 1, NOW()
|
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
|
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`)
|
INSERT INTO `user_roles` (`user_id`, `role_id`, `created`)
|
||||||
SELECT u.id, r.id, NOW()
|
SELECT u.id, r.id, NOW()
|
||||||
FROM users u
|
FROM users u
|
||||||
@@ -399,6 +699,7 @@ VALUES
|
|||||||
('users.view_audit', 'Can view user audit log', 1, 1),
|
('users.view_audit', 'Can view user audit log', 1, 1),
|
||||||
('users.create', 'Can create users', 1, 1),
|
('users.create', 'Can create users', 1, 1),
|
||||||
('users.update', 'Can update 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.delete', 'Can delete users', 1, 1),
|
||||||
('users.self_update', 'Can update own user', 1, 1),
|
('users.self_update', 'Can update own user', 1, 1),
|
||||||
('users.update_assignments', 'Can update user assignments', 1, 1),
|
('users.update_assignments', 'Can update user assignments', 1, 1),
|
||||||
@@ -406,11 +707,13 @@ VALUES
|
|||||||
('tenants.view', 'Can view tenants', 1, 1),
|
('tenants.view', 'Can view tenants', 1, 1),
|
||||||
('tenants.create', 'Can create tenants', 1, 1),
|
('tenants.create', 'Can create tenants', 1, 1),
|
||||||
('tenants.update', 'Can update 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),
|
('tenants.delete', 'Can delete tenants', 1, 1),
|
||||||
('departments.view', 'Can view departments', 1, 1),
|
('departments.view', 'Can view departments', 1, 1),
|
||||||
('departments.create', 'Can create departments', 1, 1),
|
('departments.create', 'Can create departments', 1, 1),
|
||||||
('departments.update', 'Can update departments', 1, 1),
|
('departments.update', 'Can update departments', 1, 1),
|
||||||
('departments.delete', 'Can delete 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.view', 'Can view roles', 1, 1),
|
||||||
('roles.create', 'Can create roles', 1, 1),
|
('roles.create', 'Can create roles', 1, 1),
|
||||||
('roles.update', 'Can update roles', 1, 1),
|
('roles.update', 'Can update roles', 1, 1),
|
||||||
@@ -421,26 +724,90 @@ VALUES
|
|||||||
('permissions.delete', 'Can delete permissions', 1, 1),
|
('permissions.delete', 'Can delete permissions', 1, 1),
|
||||||
('settings.view', 'Can view settings', 1, 1),
|
('settings.view', 'Can view settings', 1, 1),
|
||||||
('settings.update', 'Can update 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),
|
('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)
|
('stats.view', 'Can view statistics', 1, 1)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
`description` = VALUES(`description`),
|
`description` = VALUES(`description`),
|
||||||
`active` = VALUES(`active`),
|
`active` = VALUES(`active`),
|
||||||
`is_system` = VALUES(`is_system`);
|
`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`)
|
INSERT INTO `role_permissions` (`role_id`, `permission_id`, `created`)
|
||||||
SELECT r.id, p.id, NOW()
|
SELECT r.id, p.id, NOW()
|
||||||
FROM roles r
|
FROM roles r
|
||||||
JOIN permissions p ON p.`key` IN (
|
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',
|
'users.self_update', 'users.update_assignments',
|
||||||
'address_book.view',
|
'address_book.view',
|
||||||
'tenants.view', 'tenants.create', 'tenants.update', 'tenants.delete',
|
'tenants.view', 'tenants.create', 'tenants.update', 'tenants.sso_manage', 'tenants.delete',
|
||||||
'departments.view', 'departments.create', 'departments.update', 'departments.delete',
|
'departments.view', 'departments.create', 'departments.update', 'departments.delete', 'departments.import',
|
||||||
'roles.view', 'roles.create', 'roles.update', 'roles.delete',
|
'roles.view', 'roles.create', 'roles.update', 'roles.delete',
|
||||||
'permissions.view', 'permissions.create', 'permissions.update', 'permissions.delete',
|
'permissions.view', 'permissions.create', 'permissions.update', 'permissions.delete',
|
||||||
'settings.view', 'settings.update',
|
'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'
|
'stats.view'
|
||||||
)
|
)
|
||||||
WHERE r.description IN ('Admin', 'Administrator') OR r.id = 1
|
WHERE r.description IN ('Admin', 'Administrator') OR r.id = 1
|
||||||
|
|||||||
69
docker-compose.prod.yml
Normal file
69
docker-compose.prod.yml
Normal 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:
|
||||||
@@ -21,6 +21,22 @@ services:
|
|||||||
- db
|
- db
|
||||||
- memcached
|
- 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:
|
db:
|
||||||
image: mariadb:11.4
|
image: mariadb:11.4
|
||||||
env_file:
|
env_file:
|
||||||
|
|||||||
BIN
docker/.DS_Store
vendored
BIN
docker/.DS_Store
vendored
Binary file not shown.
1
docker/nginx/certs/.gitkeep
Normal file
1
docker/nginx/certs/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -2,6 +2,8 @@ server {
|
|||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
|
client_max_body_size 10M;
|
||||||
|
|
||||||
root /var/www/web;
|
root /var/www/web;
|
||||||
index index.php;
|
index index.php;
|
||||||
|
|
||||||
|
|||||||
51
docker/nginx/prod.conf
Normal file
51
docker/nginx/prod.conf
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ RUN apt-get update \
|
|||||||
&& apt-get install -y --no-install-recommends \
|
&& apt-get install -y --no-install-recommends \
|
||||||
git \
|
git \
|
||||||
unzip \
|
unzip \
|
||||||
|
libzip-dev \
|
||||||
libmemcached-dev \
|
libmemcached-dev \
|
||||||
zlib1g-dev \
|
zlib1g-dev \
|
||||||
libssl-dev \
|
libssl-dev \
|
||||||
@@ -13,7 +14,7 @@ RUN apt-get update \
|
|||||||
&& pecl install memcached \
|
&& pecl install memcached \
|
||||||
&& docker-php-ext-enable memcached \
|
&& docker-php-ext-enable memcached \
|
||||||
&& docker-php-ext-configure gd --with-jpeg --with-webp \
|
&& 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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||||
|
|||||||
@@ -2,3 +2,5 @@ display_errors=1
|
|||||||
display_startup_errors=1
|
display_startup_errors=1
|
||||||
error_reporting=E_ALL
|
error_reporting=E_ALL
|
||||||
memory_limit=256M
|
memory_limit=256M
|
||||||
|
upload_max_filesize=10M
|
||||||
|
post_max_size=12M
|
||||||
|
|||||||
7
docker/php/php.prod.ini
Normal file
7
docker/php/php.prod.ini
Normal 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
|
||||||
@@ -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)
|
|
||||||
@@ -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
119
docs/anfragelimits.md
Normal 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
42
docs/api.md
Normal 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
190
docs/architektur.md
Normal 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`
|
||||||
110
docs/benutzer-lifecycle-policy.md
Normal file
110
docs/benutzer-lifecycle-policy.md
Normal 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`
|
||||||
82
docs/benutzerdefinierte-felder.md
Normal file
82
docs/benutzerdefinierte-felder.md
Normal 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`.
|
||||||
@@ -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
18
docs/docker-betrieb.md
Normal 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
65
docs/docker-lokal.md
Normal 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
77
docs/docker-produktiv.md
Normal 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://...`.
|
||||||
62
docs/dokumentation-erweitern.md
Normal file
62
docs/dokumentation-erweitern.md
Normal 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.
|
||||||
60
docs/einstellungen-speicherung.md
Normal file
60
docs/einstellungen-speicherung.md
Normal 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).
|
||||||
88
docs/entwickler-checkliste.md
Normal file
88
docs/entwickler-checkliste.md
Normal 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
48
docs/erste-aenderung.md
Normal 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
80
docs/erste-schritte.md
Normal 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
83
docs/fehlerbehebung.md
Normal 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
78
docs/frontend-css.md
Normal 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
101
docs/frontend-javascript.md
Normal 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
224
docs/geplante-aufgaben.md
Normal 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
149
docs/globale-suche.md
Normal 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
80
docs/importe.md
Normal 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
71
docs/index.md
Normal 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
103
docs/konfiguration.md
Normal 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
126
docs/konventionen.md
Normal 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).
|
||||||
70
docs/lokale-entwicklung.md
Normal file
70
docs/lokale-entwicklung.md
Normal 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
1534
docs/openapi.yaml
Normal file
File diff suppressed because it is too large
Load Diff
128
docs/sicherheitsmodell.md
Normal file
128
docs/sicherheitsmodell.md
Normal 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.
|
||||||
@@ -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.
|
|
||||||
@@ -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. logged‑in).
|
|
||||||
- Reads storage file, sets headers, returns file.
|
|
||||||
|
|
||||||
### Editor.js config
|
|
||||||
Add `image` tool to `web/js/components/page-editor.js`:
|
|
||||||
```js
|
|
||||||
image: {
|
|
||||||
class: ImageTool,
|
|
||||||
config: {
|
|
||||||
endpoints: {
|
|
||||||
byFile: 'page/upload-image',
|
|
||||||
byUrl: 'page/fetch-image'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
- Decide public vs. private access rules.
|
|
||||||
- Implement service + migration + endpoints.
|
|
||||||
- Add tool config and i18n messages.
|
|
||||||
56
eslint.config.cjs
Normal file
56
eslint.config.cjs
Normal 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'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -1,13 +1,22 @@
|
|||||||
{
|
{
|
||||||
"Login": "Login",
|
"Login": "Login",
|
||||||
|
"Login options": "Anmeldeoptionen",
|
||||||
"Register": "Registrieren",
|
"Register": "Registrieren",
|
||||||
"Password": "Passwort",
|
"Password": "Passwort",
|
||||||
|
"Or": "Oder",
|
||||||
"Forgot password": "Passwort vergessen",
|
"Forgot password": "Passwort vergessen",
|
||||||
"Remember me": "Angemeldet bleiben",
|
"Remember me": "Angemeldet bleiben",
|
||||||
"Clear login tokens": "Login-Tokens löschen",
|
"Clear login tokens": "Login-Tokens löschen",
|
||||||
"Clear all login tokens for this user?": "Alle Login-Tokens für diesen Benutzer 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",
|
"Login tokens cleared": "Login-Tokens gelöscht",
|
||||||
"Send access": "Zugang versenden",
|
"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?",
|
"Send access email to this user?": "Zugangsdaten an diesen Benutzer senden?",
|
||||||
"Access email sent": "Zugangsdaten gesendet",
|
"Access email sent": "Zugangsdaten gesendet",
|
||||||
"Access email failed": "Zugangsdaten konnten nicht gesendet werden",
|
"Access email failed": "Zugangsdaten konnten nicht gesendet werden",
|
||||||
@@ -37,8 +46,19 @@
|
|||||||
"Logout": "Logout",
|
"Logout": "Logout",
|
||||||
"Account": "Konto",
|
"Account": "Konto",
|
||||||
"Please enter your credentials": "Bitte geben Sie Ihre Zugangsdaten ein",
|
"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",
|
"Create your account": "Erstelle dein Konto",
|
||||||
"First name": "Vorname",
|
"First name": "Vorname",
|
||||||
|
"Name": "Name",
|
||||||
"Last name": "Nachname",
|
"Last name": "Nachname",
|
||||||
"Email": "E-Mail",
|
"Email": "E-Mail",
|
||||||
"Password (again)": "Passwort bestätigen",
|
"Password (again)": "Passwort bestätigen",
|
||||||
@@ -99,6 +119,7 @@
|
|||||||
"perm.users.view": "Benutzer anzeigen",
|
"perm.users.view": "Benutzer anzeigen",
|
||||||
"perm.users.create": "Benutzer erstellen",
|
"perm.users.create": "Benutzer erstellen",
|
||||||
"perm.users.update": "Benutzer bearbeiten",
|
"perm.users.update": "Benutzer bearbeiten",
|
||||||
|
"perm.users.access_pdf": "Onboarding-PDFs erstellen",
|
||||||
"perm.users.delete": "Benutzer löschen",
|
"perm.users.delete": "Benutzer löschen",
|
||||||
"perm.users.self_update": "Eigenen Benutzer bearbeiten",
|
"perm.users.self_update": "Eigenen Benutzer bearbeiten",
|
||||||
"perm.users.update_assignments": "Benutzerzuweisungen bearbeiten",
|
"perm.users.update_assignments": "Benutzerzuweisungen bearbeiten",
|
||||||
@@ -122,6 +143,11 @@
|
|||||||
"perm.settings.view": "Einstellungen anzeigen",
|
"perm.settings.view": "Einstellungen anzeigen",
|
||||||
"perm.settings.update": "Einstellungen bearbeiten",
|
"perm.settings.update": "Einstellungen bearbeiten",
|
||||||
"perm.mail_log.view": "E-Mail-Protokolle anzeigen",
|
"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 row": "Zeile auswählen",
|
||||||
"Select all": "Alle auswählen",
|
"Select all": "Alle auswählen",
|
||||||
"Activate": "Aktivieren",
|
"Activate": "Aktivieren",
|
||||||
@@ -226,6 +252,7 @@
|
|||||||
"Favicon preview": "Favicon Vorschau",
|
"Favicon preview": "Favicon Vorschau",
|
||||||
"Primary color": "Primärfarbe",
|
"Primary color": "Primärfarbe",
|
||||||
"Primary color is invalid": "Primärfarbe ist ungültig",
|
"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",
|
"Show filters": "Filter anzeigen",
|
||||||
"Hide filters": "Filter ausblenden",
|
"Hide filters": "Filter ausblenden",
|
||||||
"Export CSV": "CSV exportieren",
|
"Export CSV": "CSV exportieren",
|
||||||
@@ -233,15 +260,43 @@
|
|||||||
"State": "Status",
|
"State": "Status",
|
||||||
"Edit": "Bearbeiten",
|
"Edit": "Bearbeiten",
|
||||||
"Status": "Status",
|
"Status": "Status",
|
||||||
|
"Visibility": "Sichtbarkeit",
|
||||||
|
"Inactive entries are hidden in lists and selections.": "Inaktive Einträge werden in Listen und Auswahlen ausgeblendet.",
|
||||||
"All": "Alle",
|
"All": "Alle",
|
||||||
"All people": "Alle Personen",
|
"All people": "Alle Personen",
|
||||||
"Explorer": "Explorer",
|
"Explorer": "Explorer",
|
||||||
"Search": "Suche",
|
"Search": "Suche",
|
||||||
"Search...": "Suchen...",
|
"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 from": "Erstellt von",
|
||||||
"Created to": "Erstellt bis",
|
"Created to": "Erstellt bis",
|
||||||
"Files": "Dateien",
|
"Files": "Dateien",
|
||||||
"Settings": "Einstellungen",
|
"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": "System",
|
||||||
"System permission": "Systemberechtigung",
|
"System permission": "Systemberechtigung",
|
||||||
"System permission can not be deleted": "Systemberechtigung kann nicht gelöscht werden",
|
"System permission can not be deleted": "Systemberechtigung kann nicht gelöscht werden",
|
||||||
@@ -286,7 +341,11 @@
|
|||||||
"No records found": "Keine Einträge gefunden",
|
"No records found": "Keine Einträge gefunden",
|
||||||
"An error happened while fetching the data": "Fehler beim Laden der Daten",
|
"An error happened while fetching the data": "Fehler beim Laden der Daten",
|
||||||
"Delete this user?": "Diesen Benutzer wirklich löschen?",
|
"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 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",
|
"Tenant": "Mandant",
|
||||||
"Default tenant": "Standard-Mandant",
|
"Default tenant": "Standard-Mandant",
|
||||||
"Default role": "Standard-Rolle",
|
"Default role": "Standard-Rolle",
|
||||||
@@ -311,12 +370,24 @@
|
|||||||
"setting.app_theme": "Standard-Theme, wenn der Benutzer keine Präferenz hat",
|
"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_theme_user": "Benutzer dürfen ihr eigenes Theme wählen",
|
||||||
"setting.app_registration": "Öffentliche Selbstregistrierung erlauben",
|
"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)",
|
"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",
|
"Default theme": "Standard-Theme",
|
||||||
"Allow user theme": "Benutzer-Theme erlauben",
|
"Allow user theme": "Benutzer-Theme erlauben",
|
||||||
"Allow registration": "Registrierung erlauben",
|
"Allow registration": "Registrierung erlauben",
|
||||||
"General": "Allgemein",
|
"General": "Allgemein",
|
||||||
"Appearance": "Darstellung",
|
"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",
|
"Use default color": "Standardfarbe verwenden",
|
||||||
"Tenants can override this color in their appearance settings.": "Mandanten können diese Farbe in ihren Darstellungseinstellungen überschreiben.",
|
"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.",
|
"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 phone": "Telefon anrufen",
|
||||||
"Call mobile": "Mobil anrufen",
|
"Call mobile": "Mobil anrufen",
|
||||||
"Choose color": "Farbe wählen",
|
"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",
|
"Code": "Code",
|
||||||
"Cost center": "Kostenstelle",
|
"Cost center": "Kostenstelle",
|
||||||
"Department code already exists": "Abteilungscode existiert bereits",
|
"Department code already exists": "Abteilungscode existiert bereits",
|
||||||
@@ -345,10 +416,12 @@
|
|||||||
"Tenant updated": "Mandant aktualisiert",
|
"Tenant updated": "Mandant aktualisiert",
|
||||||
"Tenant deleted": "Mandant gelöscht",
|
"Tenant deleted": "Mandant gelöscht",
|
||||||
"Tenant not found": "Mandant nicht gefunden",
|
"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 created": "Mandant kann nicht erstellt werden",
|
||||||
"Tenant can not be updated": "Mandant kann nicht aktualisiert werden",
|
"Tenant can not be updated": "Mandant kann nicht aktualisiert werden",
|
||||||
"Tenant image": "Mandantenbild",
|
"Tenant image": "Mandantenbild",
|
||||||
"Assigned tenants": "Zugewiesene Mandanten",
|
"Assigned tenants": "Zugewiesene Mandanten",
|
||||||
|
"Assigned tenant": "Zugewiesener Mandant",
|
||||||
"Primary tenant": "Hauptmandant",
|
"Primary tenant": "Hauptmandant",
|
||||||
"Select primary tenant": "Hauptmandant auswählen",
|
"Select primary tenant": "Hauptmandant auswählen",
|
||||||
"Please select a primary tenant": "Bitte einen Hauptmandanten auswählen",
|
"Please select a primary tenant": "Bitte einen Hauptmandanten auswählen",
|
||||||
@@ -357,6 +430,8 @@
|
|||||||
"Departments & roles": "Abteilungen und Rollen",
|
"Departments & roles": "Abteilungen und Rollen",
|
||||||
"Department assignments cleaned: %d": "Abteilungszuweisungen bereinigt: %d",
|
"Department assignments cleaned: %d": "Abteilungszuweisungen bereinigt: %d",
|
||||||
"Select tenants": "Mandanten auswählen",
|
"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",
|
"Assigned roles": "Zugewiesene Rollen",
|
||||||
"Select roles": "Rollen auswählen",
|
"Select roles": "Rollen auswählen",
|
||||||
"Department": "Abteilung",
|
"Department": "Abteilung",
|
||||||
@@ -370,8 +445,14 @@
|
|||||||
"Department can not be created": "Abteilung kann nicht erstellt werden",
|
"Department can not be created": "Abteilung kann nicht erstellt werden",
|
||||||
"Department can not be updated": "Abteilung kann nicht aktualisiert werden",
|
"Department can not be updated": "Abteilung kann nicht aktualisiert werden",
|
||||||
"Delete this department?": "Diese Abteilung wirklich löschen?",
|
"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",
|
"Assigned departments": "Zugewiesene Abteilungen",
|
||||||
"Select departments": "Abteilungen auswählen",
|
"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",
|
"Role": "Rolle",
|
||||||
"Roles": "Rollen",
|
"Roles": "Rollen",
|
||||||
"Create role": "Rolle anlegen",
|
"Create role": "Rolle anlegen",
|
||||||
@@ -383,6 +464,11 @@
|
|||||||
"Role can not be created": "Rolle kann nicht erstellt werden",
|
"Role can not be created": "Rolle kann nicht erstellt werden",
|
||||||
"Role can not be updated": "Rolle kann nicht aktualisiert werden",
|
"Role can not be updated": "Rolle kann nicht aktualisiert werden",
|
||||||
"Delete this role?": "Diese Rolle wirklich löschen?",
|
"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": "Beschreibung",
|
||||||
"Description cannot be empty": "Beschreibung darf nicht leer sein",
|
"Description cannot be empty": "Beschreibung darf nicht leer sein",
|
||||||
"Status is invalid": "Status ist ungültig",
|
"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.",
|
"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.",
|
"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",
|
"Requested URL": "Angeforderte URL",
|
||||||
"Audit": "Audit",
|
|
||||||
"Forward": "Vorwärts",
|
"Forward": "Vorwärts",
|
||||||
"Status changed": "Status geändert",
|
"Status changed": "Status geändert",
|
||||||
"Status changed by": "Status geändert von",
|
"Status changed by": "Status geändert von",
|
||||||
"Page": "Seite",
|
|
||||||
"Requested path": "Angeforderter Pfad",
|
"Requested path": "Angeforderter Pfad",
|
||||||
"Page not found": "Seite nicht gefunden",
|
"Page not found": "Seite nicht gefunden",
|
||||||
"The page you requested does not exist or has moved.": "Die angeforderte Seite existiert nicht oder wurde verschoben.",
|
"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 updated": "Seite aktualisiert",
|
||||||
"Page can not be updated": "Seite kann nicht aktualisiert werden",
|
"Page can not be updated": "Seite kann nicht aktualisiert werden",
|
||||||
"Content is invalid": "Inhalt ist ungültig",
|
"Content is invalid": "Inhalt ist ungültig",
|
||||||
@@ -480,7 +565,15 @@
|
|||||||
"SMTP from address": "Absender-Adresse",
|
"SMTP from address": "Absender-Adresse",
|
||||||
"SMTP from name": "Absendername",
|
"SMTP from name": "Absendername",
|
||||||
"Address book": "Adressbuch",
|
"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",
|
"Mobile": "Mobil",
|
||||||
"Short dial": "Kurzwahl",
|
"Short dial": "Kurzwahl",
|
||||||
"setting.smtp_host": "SMTP-Serveradresse (z.B. smtp.example.com)",
|
"setting.smtp_host": "SMTP-Serveradresse (z.B. smtp.example.com)",
|
||||||
@@ -493,12 +586,34 @@
|
|||||||
"High contrast": "Hoher Kontrast",
|
"High contrast": "Hoher Kontrast",
|
||||||
"Toggle contrast": "Kontrast umschalten",
|
"Toggle contrast": "Kontrast umschalten",
|
||||||
"View in address book": "Im Adressbuch ansehen",
|
"View in address book": "Im Adressbuch ansehen",
|
||||||
"Overview": "Übersicht",
|
|
||||||
"Organization & Quality": "Organisation & Qualität",
|
"Organization & Quality": "Organisation & Qualität",
|
||||||
"Security": "Sicherheit",
|
|
||||||
"Communication": "Kommunikation",
|
|
||||||
"Email security": "E-Mail Sicherheit",
|
"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",
|
"Error": "Fehler",
|
||||||
"Active tenants": "Aktive Mandanten",
|
"Active tenants": "Aktive Mandanten",
|
||||||
"Inactive tenants": "Inaktive Mandanten",
|
"Inactive tenants": "Inaktive Mandanten",
|
||||||
@@ -517,13 +632,16 @@
|
|||||||
"Recent logins": "Letzte Anmeldungen",
|
"Recent logins": "Letzte Anmeldungen",
|
||||||
"Never logged in": "Nie angemeldet",
|
"Never logged in": "Nie angemeldet",
|
||||||
"Last login": "Letzte Anmeldung",
|
"Last login": "Letzte Anmeldung",
|
||||||
|
"Last login via": "Letzte Anmeldung über",
|
||||||
|
"Local": "Lokal",
|
||||||
|
"Microsoft": "Microsoft",
|
||||||
|
"Unknown": "Unbekannt",
|
||||||
"User": "Benutzer",
|
"User": "Benutzer",
|
||||||
"No entries found": "Keine Einträge gefunden",
|
"No entries found": "Keine Einträge gefunden",
|
||||||
"Login status": "Anmeldestatus",
|
"Login status": "Anmeldestatus",
|
||||||
"Logged in": "Angemeldet",
|
"Logged in": "Angemeldet",
|
||||||
"More filters": "Weitere Filter",
|
"More filters": "Weitere Filter",
|
||||||
"Email unverified": "E-Mail nicht verifiziert"
|
"Email unverified": "E-Mail nicht verifiziert",
|
||||||
,
|
|
||||||
"Password resets": "Passwort-Resets",
|
"Password resets": "Passwort-Resets",
|
||||||
"Requested at": "Angefordert am",
|
"Requested at": "Angefordert am",
|
||||||
"Expires at": "Gültig bis",
|
"Expires at": "Gültig bis",
|
||||||
@@ -538,7 +656,7 @@
|
|||||||
"%d active login tokens": "%d aktive Login-Tokens",
|
"%d active login tokens": "%d aktive Login-Tokens",
|
||||||
"%d login tokens expired": "%d Login-Tokens abgelaufen",
|
"%d login tokens expired": "%d Login-Tokens abgelaufen",
|
||||||
"Danger zone": "Gefahrenzone",
|
"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",
|
"Last used": "Zuletzt verwendet",
|
||||||
"Keyboard shortcuts": "Tastenkürzel",
|
"Keyboard shortcuts": "Tastenkürzel",
|
||||||
"Open search": "Suche öffnen",
|
"Open search": "Suche öffnen",
|
||||||
@@ -556,5 +674,418 @@
|
|||||||
"Toggle sidebar": "Sidebar umschalten",
|
"Toggle sidebar": "Sidebar umschalten",
|
||||||
"Unverified": "Nicht verifiziert",
|
"Unverified": "Nicht verifiziert",
|
||||||
"Imprint": "Impressum",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
{
|
{
|
||||||
"Login": "Login",
|
"Login": "Login",
|
||||||
|
"Login options": "Login options",
|
||||||
"Register": "Register",
|
"Register": "Register",
|
||||||
"Password": "Password",
|
"Password": "Password",
|
||||||
|
"Or": "Or",
|
||||||
"Forgot password": "Forgot password",
|
"Forgot password": "Forgot password",
|
||||||
"Remember me": "Remember me",
|
"Remember me": "Remember me",
|
||||||
"Clear login tokens": "Clear login tokens",
|
"Clear login tokens": "Clear login tokens",
|
||||||
"Clear all login tokens for this user?": "Clear all login tokens for this user?",
|
"Clear all login tokens for this user?": "Clear all login tokens for this user?",
|
||||||
"Login tokens cleared": "Login tokens cleared",
|
"Login tokens cleared": "Login tokens cleared",
|
||||||
"Send access": "Send access",
|
"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?",
|
"Send access email to this user?": "Send access email to this user?",
|
||||||
"Access email sent": "Access email sent",
|
"Access email sent": "Access email sent",
|
||||||
"Access email failed": "Access email failed",
|
"Access email failed": "Access email failed",
|
||||||
@@ -37,8 +46,19 @@
|
|||||||
"Logout": "Logout",
|
"Logout": "Logout",
|
||||||
"Account": "Account",
|
"Account": "Account",
|
||||||
"Please enter your credentials": "Please enter your credentials",
|
"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",
|
"Create your account": "Create your account",
|
||||||
"First name": "First name",
|
"First name": "First name",
|
||||||
|
"Name": "Name",
|
||||||
"Last name": "Last name",
|
"Last name": "Last name",
|
||||||
"Email": "Email",
|
"Email": "Email",
|
||||||
"Password (again)": "Password confirm",
|
"Password (again)": "Password confirm",
|
||||||
@@ -99,6 +119,7 @@
|
|||||||
"perm.users.view": "View users",
|
"perm.users.view": "View users",
|
||||||
"perm.users.create": "Create users",
|
"perm.users.create": "Create users",
|
||||||
"perm.users.update": "Update users",
|
"perm.users.update": "Update users",
|
||||||
|
"perm.users.access_pdf": "Generate onboarding PDFs",
|
||||||
"perm.users.delete": "Delete users",
|
"perm.users.delete": "Delete users",
|
||||||
"perm.users.self_update": "Update own user",
|
"perm.users.self_update": "Update own user",
|
||||||
"perm.users.update_assignments": "Update user assignments",
|
"perm.users.update_assignments": "Update user assignments",
|
||||||
@@ -122,6 +143,11 @@
|
|||||||
"perm.settings.view": "View settings",
|
"perm.settings.view": "View settings",
|
||||||
"perm.settings.update": "Update settings",
|
"perm.settings.update": "Update settings",
|
||||||
"perm.mail_log.view": "View mail logs",
|
"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 row": "Select row",
|
||||||
"Select all": "Select all",
|
"Select all": "Select all",
|
||||||
"Activate": "Activate",
|
"Activate": "Activate",
|
||||||
@@ -226,6 +252,7 @@
|
|||||||
"Favicon preview": "Favicon preview",
|
"Favicon preview": "Favicon preview",
|
||||||
"Primary color": "Primary color",
|
"Primary color": "Primary color",
|
||||||
"Primary color is invalid": "Primary color is invalid",
|
"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",
|
"Show filters": "Show filters",
|
||||||
"Hide filters": "Hide filters",
|
"Hide filters": "Hide filters",
|
||||||
"Export CSV": "Export CSV",
|
"Export CSV": "Export CSV",
|
||||||
@@ -233,15 +260,43 @@
|
|||||||
"State": "State",
|
"State": "State",
|
||||||
"Edit": "Edit",
|
"Edit": "Edit",
|
||||||
"Status": "Status",
|
"Status": "Status",
|
||||||
|
"Visibility": "Visibility",
|
||||||
|
"Inactive entries are hidden in lists and selections.": "Inactive entries are hidden in lists and selections.",
|
||||||
"All": "All",
|
"All": "All",
|
||||||
"All people": "All people",
|
"All people": "All people",
|
||||||
"Explorer": "Explorer",
|
"Explorer": "Explorer",
|
||||||
"Search": "Search",
|
"Search": "Search",
|
||||||
"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 from": "Created from",
|
||||||
"Created to": "Created to",
|
"Created to": "Created to",
|
||||||
"Files": "Files",
|
"Files": "Files",
|
||||||
"Settings": "Settings",
|
"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": "System",
|
||||||
"System permission": "System permission",
|
"System permission": "System permission",
|
||||||
"System permission can not be deleted": "System permission can not be deleted",
|
"System permission can not be deleted": "System permission can not be deleted",
|
||||||
@@ -286,7 +341,11 @@
|
|||||||
"No records found": "No records found",
|
"No records found": "No records found",
|
||||||
"An error happened while fetching the data": "An error happened while fetching the data",
|
"An error happened while fetching the data": "An error happened while fetching the data",
|
||||||
"Delete this user?": "Delete this user?",
|
"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 this tenant?": "Delete this tenant?",
|
||||||
|
"Delete tenant": "Delete tenant",
|
||||||
|
"This will permanently delete this tenant.": "This will permanently delete this tenant.",
|
||||||
"Tenant": "Tenant",
|
"Tenant": "Tenant",
|
||||||
"Default tenant": "Default tenant",
|
"Default tenant": "Default tenant",
|
||||||
"Default role": "Default role",
|
"Default role": "Default role",
|
||||||
@@ -311,12 +370,24 @@
|
|||||||
"setting.app_theme": "Default theme used when a user has no theme preference",
|
"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_theme_user": "Allow users to choose their own theme",
|
||||||
"setting.app_registration": "Allow public self-registration",
|
"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)",
|
"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",
|
"Default theme": "Default theme",
|
||||||
"Allow user theme": "Allow user theme",
|
"Allow user theme": "Allow user theme",
|
||||||
"Allow registration": "Allow registration",
|
"Allow registration": "Allow registration",
|
||||||
"General": "General",
|
"General": "General",
|
||||||
"Appearance": "Appearance",
|
"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",
|
"Use default color": "Use default color",
|
||||||
"Tenants can override this color in their appearance settings.": "Tenants can override this color in their appearance settings.",
|
"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.",
|
"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 updated": "Tenant updated",
|
||||||
"Tenant deleted": "Tenant deleted",
|
"Tenant deleted": "Tenant deleted",
|
||||||
"Tenant not found": "Tenant not found",
|
"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 created": "Tenant can not be created",
|
||||||
"Tenant can not be updated": "Tenant can not be updated",
|
"Tenant can not be updated": "Tenant can not be updated",
|
||||||
"Tenant image": "Tenant image",
|
"Tenant image": "Tenant image",
|
||||||
"Assigned tenants": "Assigned tenants",
|
"Assigned tenants": "Assigned tenants",
|
||||||
|
"Assigned tenant": "Assigned tenant",
|
||||||
"Primary tenant": "Primary tenant",
|
"Primary tenant": "Primary tenant",
|
||||||
"Select primary tenant": "Select primary tenant",
|
"Select primary tenant": "Select primary tenant",
|
||||||
"Please select a primary tenant": "Please select a primary tenant",
|
"Please select a primary tenant": "Please select a primary tenant",
|
||||||
@@ -357,6 +430,8 @@
|
|||||||
"Departments & roles": "Departments & roles",
|
"Departments & roles": "Departments & roles",
|
||||||
"Department assignments cleaned: %d": "Department assignments cleaned: %d",
|
"Department assignments cleaned: %d": "Department assignments cleaned: %d",
|
||||||
"Select tenants": "Select tenants",
|
"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",
|
"Assigned roles": "Assigned roles",
|
||||||
"Select roles": "Select roles",
|
"Select roles": "Select roles",
|
||||||
"Department": "Department",
|
"Department": "Department",
|
||||||
@@ -370,8 +445,14 @@
|
|||||||
"Department can not be created": "Department can not be created",
|
"Department can not be created": "Department can not be created",
|
||||||
"Department can not be updated": "Department can not be updated",
|
"Department can not be updated": "Department can not be updated",
|
||||||
"Delete this department?": "Delete this department?",
|
"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",
|
"Assigned departments": "Assigned departments",
|
||||||
"Select departments": "Select 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",
|
"Role": "Role",
|
||||||
"Roles": "Roles",
|
"Roles": "Roles",
|
||||||
"Create role": "Create role",
|
"Create role": "Create role",
|
||||||
@@ -383,6 +464,11 @@
|
|||||||
"Role can not be created": "Role can not be created",
|
"Role can not be created": "Role can not be created",
|
||||||
"Role can not be updated": "Role can not be updated",
|
"Role can not be updated": "Role can not be updated",
|
||||||
"Delete this role?": "Delete this role?",
|
"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": "Description",
|
||||||
"Description cannot be empty": "Description cannot be empty",
|
"Description cannot be empty": "Description cannot be empty",
|
||||||
"Status is invalid": "Status is invalid",
|
"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.",
|
"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.",
|
"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",
|
"Requested URL": "Requested URL",
|
||||||
"Audit": "Audit",
|
|
||||||
"Forward": "Forward",
|
"Forward": "Forward",
|
||||||
"Status changed": "Status changed",
|
"Status changed": "Status changed",
|
||||||
"Status changed by": "Status changed by",
|
"Status changed by": "Status changed by",
|
||||||
"Page": "Page",
|
|
||||||
"Requested path": "Requested path",
|
"Requested path": "Requested path",
|
||||||
"Page not found": "Page not found",
|
"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.",
|
"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 updated": "Page updated",
|
||||||
"Page can not be updated": "Page can not be updated",
|
"Page can not be updated": "Page can not be updated",
|
||||||
"Content is invalid": "Content is invalid",
|
"Content is invalid": "Content is invalid",
|
||||||
@@ -480,7 +565,15 @@
|
|||||||
"SMTP from address": "SMTP from address",
|
"SMTP from address": "SMTP from address",
|
||||||
"SMTP from name": "SMTP from name",
|
"SMTP from name": "SMTP from name",
|
||||||
"Address book": "Address book",
|
"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",
|
"Mobile": "Mobile",
|
||||||
"Short dial": "Short dial",
|
"Short dial": "Short dial",
|
||||||
"setting.smtp_host": "SMTP server host (e.g. smtp.example.com)",
|
"setting.smtp_host": "SMTP server host (e.g. smtp.example.com)",
|
||||||
@@ -493,12 +586,34 @@
|
|||||||
"High contrast": "High contrast",
|
"High contrast": "High contrast",
|
||||||
"Toggle contrast": "Toggle contrast",
|
"Toggle contrast": "Toggle contrast",
|
||||||
"View in address book": "View in address book",
|
"View in address book": "View in address book",
|
||||||
"Overview": "Overview",
|
|
||||||
"Organization & Quality": "Organization & Quality",
|
"Organization & Quality": "Organization & Quality",
|
||||||
"Security": "Security",
|
|
||||||
"Communication": "Communication",
|
|
||||||
"Email security": "Email security",
|
"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",
|
"Error": "Error",
|
||||||
"Active tenants": "Active tenants",
|
"Active tenants": "Active tenants",
|
||||||
"Inactive tenants": "Inactive tenants",
|
"Inactive tenants": "Inactive tenants",
|
||||||
@@ -517,13 +632,16 @@
|
|||||||
"Recent logins": "Recent logins",
|
"Recent logins": "Recent logins",
|
||||||
"Never logged in": "Never logged in",
|
"Never logged in": "Never logged in",
|
||||||
"Last login": "Last login",
|
"Last login": "Last login",
|
||||||
|
"Last login via": "Last login via",
|
||||||
|
"Local": "Local",
|
||||||
|
"Microsoft": "Microsoft",
|
||||||
|
"Unknown": "Unknown",
|
||||||
"User": "User",
|
"User": "User",
|
||||||
"No entries found": "No entries found",
|
"No entries found": "No entries found",
|
||||||
"Login status": "Login status",
|
"Login status": "Login status",
|
||||||
"Logged in": "Logged in",
|
"Logged in": "Logged in",
|
||||||
"More filters": "More filters",
|
"More filters": "More filters",
|
||||||
"Email unverified": "Email unverified"
|
"Email unverified": "Email unverified",
|
||||||
,
|
|
||||||
"Password resets": "Password resets",
|
"Password resets": "Password resets",
|
||||||
"Requested at": "Requested at",
|
"Requested at": "Requested at",
|
||||||
"Expires at": "Expires at",
|
"Expires at": "Expires at",
|
||||||
@@ -556,5 +674,418 @@
|
|||||||
"Toggle sidebar": "Toggle sidebar",
|
"Toggle sidebar": "Toggle sidebar",
|
||||||
"Unverified": "Unverified",
|
"Unverified": "Unverified",
|
||||||
"Imprint": "Imprint",
|
"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
BIN
lib/.DS_Store
vendored
Binary file not shown.
@@ -10,18 +10,13 @@ use MintyPHP\Router;
|
|||||||
*/
|
*/
|
||||||
class AccessControl
|
class AccessControl
|
||||||
{
|
{
|
||||||
/** Paths that are always public (no auth required) */
|
|
||||||
private const ALWAYS_PUBLIC_PATHS = [
|
|
||||||
'login',
|
|
||||||
'register',
|
|
||||||
'verify-email',
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Prefixes that are always public */
|
/** Prefixes that are always public */
|
||||||
private const ALWAYS_PUBLIC_PREFIXES = [
|
private const ALWAYS_PUBLIC_PREFIXES = [
|
||||||
'password/',
|
|
||||||
'branding/',
|
'branding/',
|
||||||
|
'auth/tenant-avatar-file',
|
||||||
'flash/',
|
'flash/',
|
||||||
|
'auth/microsoft/',
|
||||||
|
'api/',
|
||||||
];
|
];
|
||||||
|
|
||||||
private array $configuredPublicPaths;
|
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
|
// Check always-public prefixes
|
||||||
foreach (self::ALWAYS_PUBLIC_PREFIXES as $prefix) {
|
foreach (self::ALWAYS_PUBLIC_PREFIXES as $prefix) {
|
||||||
if (str_starts_with($normalizedPath, $prefix)) {
|
if (str_starts_with($normalizedPath, $prefix)) {
|
||||||
|
|||||||
181
lib/Http/ApiAuth.php
Normal file
181
lib/Http/ApiAuth.php
Normal 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
201
lib/Http/ApiBootstrap.php
Normal 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
158
lib/Http/ApiResponse.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
namespace MintyPHP\Http;
|
namespace MintyPHP\Http;
|
||||||
|
|
||||||
use MintyPHP\I18n;
|
use MintyPHP\I18n;
|
||||||
use MintyPHP\Router;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles locale detection and URL manipulation for multi-language support.
|
* Handles locale detection and URL manipulation for multi-language support.
|
||||||
@@ -18,7 +17,7 @@ class LocaleResolver
|
|||||||
{
|
{
|
||||||
$this->defaultLocale = $defaultLocale ?? I18n::$defaultLocale;
|
$this->defaultLocale = $defaultLocale ?? I18n::$defaultLocale;
|
||||||
$this->availableLocales = $availableLocales ?? (defined('APP_LOCALES') ? APP_LOCALES : [$this->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) ?: '';
|
$path = parse_url($uri, PHP_URL_PATH) ?: '';
|
||||||
$query = parse_url($uri, PHP_URL_QUERY) ?: '';
|
$query = parse_url($uri, PHP_URL_QUERY) ?: '';
|
||||||
|
|
||||||
$relativePath = $this->stripBasePath($path);
|
$relativePath = Request::stripBasePath($path);
|
||||||
$segments = $relativePath === '' ? [] : explode('/', $relativePath);
|
$segments = $relativePath === '' ? [] : explode('/', $relativePath);
|
||||||
|
|
||||||
$localeCandidate = $segments[0] ?? '';
|
$localeCandidate = $segments[0] ?? '';
|
||||||
@@ -120,21 +119,6 @@ class LocaleResolver
|
|||||||
return $this->defaultLocale;
|
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
|
private function looksLikeLocale(string $candidate): bool
|
||||||
{
|
{
|
||||||
return preg_match('/^[a-z]{2}(?:-[a-z]{2})?$/i', $candidate) === 1;
|
return preg_match('/^[a-z]{2}(?:-[a-z]{2})?$/i', $candidate) === 1;
|
||||||
|
|||||||
@@ -7,20 +7,27 @@ use MintyPHP\I18n;
|
|||||||
|
|
||||||
class Request
|
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
|
public static function path(): string
|
||||||
{
|
{
|
||||||
$uri = $_SERVER['REQUEST_URI'] ?? '';
|
$uri = $_SERVER['REQUEST_URI'] ?? '';
|
||||||
$path = parse_url($uri, PHP_URL_PATH) ?: '';
|
$path = parse_url($uri, PHP_URL_PATH) ?: '';
|
||||||
$base = trim(Router::getBaseUrl(), '/');
|
|
||||||
$path = ltrim($path, '/');
|
|
||||||
|
|
||||||
if ($base !== '' && strpos($path, $base . '/') === 0) {
|
return self::stripBasePath($path);
|
||||||
$path = substr($path, strlen($base) + 1);
|
|
||||||
} elseif ($base !== '' && $path === $base) {
|
|
||||||
$path = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return $path;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function safeReturnTarget(string $returnParam = ''): string
|
public static function safeReturnTarget(string $returnParam = ''): string
|
||||||
@@ -28,15 +35,8 @@ class Request
|
|||||||
if ($returnParam !== '') {
|
if ($returnParam !== '') {
|
||||||
$parts = parse_url($returnParam);
|
$parts = parse_url($returnParam);
|
||||||
if (($parts['scheme'] ?? '') === '' && ($parts['host'] ?? '') === '') {
|
if (($parts['scheme'] ?? '') === '' && ($parts['host'] ?? '') === '') {
|
||||||
$path = $parts['path'] ?? '';
|
$path = self::stripBasePath($parts['path'] ?? '');
|
||||||
$query = isset($parts['query']) ? '?' . $parts['query'] : '';
|
$query = isset($parts['query']) ? '?' . $parts['query'] : '';
|
||||||
$base = trim(Router::getBaseUrl(), '/');
|
|
||||||
$path = ltrim($path, '/');
|
|
||||||
if ($base !== '' && strpos($path, $base . '/') === 0) {
|
|
||||||
$path = substr($path, strlen($base) + 1);
|
|
||||||
} elseif ($base !== '' && $path === $base) {
|
|
||||||
$path = '';
|
|
||||||
}
|
|
||||||
return $path . $query;
|
return $path . $query;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,15 +47,8 @@ class Request
|
|||||||
$host = $parts['host'] ?? '';
|
$host = $parts['host'] ?? '';
|
||||||
$currentHost = $_SERVER['HTTP_HOST'] ?? '';
|
$currentHost = $_SERVER['HTTP_HOST'] ?? '';
|
||||||
if ($host === '' || $host === $currentHost) {
|
if ($host === '' || $host === $currentHost) {
|
||||||
$path = $parts['path'] ?? '';
|
$path = self::stripBasePath($parts['path'] ?? '');
|
||||||
$query = isset($parts['query']) ? '?' . $parts['query'] : '';
|
$query = isset($parts['query']) ? '?' . $parts['query'] : '';
|
||||||
$base = trim(Router::getBaseUrl(), '/');
|
|
||||||
$path = ltrim($path, '/');
|
|
||||||
if ($base !== '' && strpos($path, $base . '/') === 0) {
|
|
||||||
$path = substr($path, strlen($base) + 1);
|
|
||||||
} elseif ($base !== '' && $path === $base) {
|
|
||||||
$path = '';
|
|
||||||
}
|
|
||||||
return $path . $query;
|
return $path . $query;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,38 @@ class RolePermissionRepository
|
|||||||
return array_values(array_unique($ids));
|
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
|
public static function replaceForRole(int $roleId, array $permissionIds): bool
|
||||||
{
|
{
|
||||||
DB::delete('delete from role_permissions where role_id = ?', (string) $roleId);
|
DB::delete('delete from role_permissions where role_id = ?', (string) $roleId);
|
||||||
@@ -123,27 +155,43 @@ class RolePermissionRepository
|
|||||||
$rowRp = $row['rp'] ?? ($row['role_permissions'] ?? $row);
|
$rowRp = $row['rp'] ?? ($row['role_permissions'] ?? $row);
|
||||||
|
|
||||||
$permId = $rowRp['permission_id'] ?? ($row['permission_id'] ?? null);
|
$permId = $rowRp['permission_id'] ?? ($row['permission_id'] ?? null);
|
||||||
$key = (string) (($rowPerm['key'] ?? $row['key'] ?? '') ?? '');
|
$key = (string) ($rowPerm['key'] ?? $row['key'] ?? '');
|
||||||
if ($permId === null || $key === '') {
|
if ($permId === null || $key === '') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!isset($permMap[$permId])) {
|
if (!isset($permMap[$permId])) {
|
||||||
$desc = (string) (($rowPerm['description'] ?? $row['description'] ?? '') ?? '');
|
$desc = (string) ($rowPerm['description'] ?? $row['description'] ?? '');
|
||||||
$permMap[$permId] = [
|
$permMap[$permId] = [
|
||||||
'key' => $key,
|
'key' => $key,
|
||||||
'description' => $desc,
|
'description' => $desc,
|
||||||
'roles' => [],
|
'roles' => [],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
$roleLabel = (string) (($rowRole['description'] ?? $rowRole['role'] ?? $row['role'] ?? '') ?? '');
|
$roleLabel = (string) ($rowRole['description'] ?? $rowRole['role'] ?? $row['role'] ?? '');
|
||||||
if ($roleLabel !== '') {
|
if ($roleLabel !== '') {
|
||||||
$permMap[$permId]['roles'][] = $roleLabel;
|
$permMap[$permId]['roles'][] = $roleLabel;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
foreach ($permMap as &$item) {
|
foreach ($permMap as &$item) {
|
||||||
$item['roles'] = array_values(array_unique($item['roles'] ?? []));
|
$item['roles'] = array_values(array_unique($item['roles']));
|
||||||
}
|
}
|
||||||
unset($item);
|
unset($item);
|
||||||
return array_values($permMap);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,22 @@ class RoleRepository
|
|||||||
return self::unwrapList($rows);
|
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
|
public static function listIds(): array
|
||||||
{
|
{
|
||||||
$rows = DB::select('select id from roles');
|
$rows = DB::select('select id from roles');
|
||||||
@@ -143,19 +159,11 @@ class RoleRepository
|
|||||||
return $count ? ((int) $count > 0) : false;
|
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)
|
public static function create(array $data)
|
||||||
{
|
{
|
||||||
return DB::insert(
|
return DB::insert(
|
||||||
'insert into roles (uuid, description, code, active, created_by, created) values (?,?,?,?,?,NOW())',
|
'insert into roles (uuid, description, code, active, created_by, created) values (?,?,?,?,?,NOW())',
|
||||||
$data['uuid'] ?? self::uuidV4(),
|
$data['uuid'] ?? RepoQuery::uuidV4(),
|
||||||
$data['description'],
|
$data['description'],
|
||||||
$data['code'] ?? null,
|
$data['code'] ?? null,
|
||||||
$data['active'] ?? 1,
|
$data['active'] ?? 1,
|
||||||
|
|||||||
@@ -39,4 +39,63 @@ class UserRoleRepository
|
|||||||
|
|
||||||
return true;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
223
lib/Repository/Audit/ApiAuditLogRepository.php
Normal file
223
lib/Repository/Audit/ApiAuditLogRepository.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
251
lib/Repository/Audit/ImportAuditRunRepository.php
Normal file
251
lib/Repository/Audit/ImportAuditRunRepository.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
297
lib/Repository/Audit/UserLifecycleAuditRepository.php
Normal file
297
lib/Repository/Audit/UserLifecycleAuditRepository.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
222
lib/Repository/Auth/ApiTokenRepository.php
Normal file
222
lib/Repository/Auth/ApiTokenRepository.php
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
127
lib/Repository/CustomField/TenantCustomFieldOptionRepository.php
Normal file
127
lib/Repository/CustomField/TenantCustomFieldOptionRepository.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
120
lib/Repository/CustomField/UserCustomFieldValueRepository.php
Normal file
120
lib/Repository/CustomField/UserCustomFieldValueRepository.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ class DepartmentRepository
|
|||||||
public static function list(): array
|
public static function list(): array
|
||||||
{
|
{
|
||||||
$rows = DB::select(
|
$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);
|
return self::unwrapList($rows);
|
||||||
}
|
}
|
||||||
@@ -54,6 +54,31 @@ class DepartmentRepository
|
|||||||
return array_values(array_unique($ids));
|
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
|
public static function listByTenantIds(array $tenantIds): array
|
||||||
{
|
{
|
||||||
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
|
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
|
||||||
@@ -63,17 +88,16 @@ class DepartmentRepository
|
|||||||
}
|
}
|
||||||
$placeholders = implode(',', array_fill(0, count($tenantIds), '?'));
|
$placeholders = implode(',', array_fill(0, count($tenantIds), '?'));
|
||||||
$rows = DB::select(
|
$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 ' .
|
'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 tenant_departments td on td.department_id = departments.id ' .
|
"from departments departments join tenants t on t.id = departments.tenant_id and t.status = 'active' " .
|
||||||
'where departments.active = 1 and td.tenant_id in (' . $placeholders . ') ' .
|
'where departments.active = 1 and departments.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 ' .
|
|
||||||
'order by departments.description asc',
|
'order by departments.description asc',
|
||||||
...array_map('strval', $tenantIds)
|
...array_map('strval', $tenantIds)
|
||||||
);
|
);
|
||||||
return self::unwrapList($rows);
|
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_values(array_unique(array_map('intval', $departmentIds)));
|
||||||
$departmentIds = array_filter($departmentIds, static fn ($id) => $id > 0);
|
$departmentIds = array_filter($departmentIds, static fn ($id) => $id > 0);
|
||||||
@@ -81,8 +105,9 @@ class DepartmentRepository
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
$placeholders = implode(',', array_fill(0, count($departmentIds), '?'));
|
$placeholders = implode(',', array_fill(0, count($departmentIds), '?'));
|
||||||
|
$activeSql = $includeInactive ? '' : 'active = 1 and ';
|
||||||
$rows = DB::select(
|
$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)
|
...array_map('strval', $departmentIds)
|
||||||
);
|
);
|
||||||
return self::unwrapList($rows);
|
return self::unwrapList($rows);
|
||||||
@@ -113,44 +138,25 @@ class DepartmentRepository
|
|||||||
$where,
|
$where,
|
||||||
$params,
|
$params,
|
||||||
$tenant,
|
$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'])) {
|
if (!empty($options['tenantUserId'])) {
|
||||||
$tenantUserId = (int) $options['tenantUserId'];
|
$tenantUserId = (int) $options['tenantUserId'];
|
||||||
if ($tenantUserId > 0) {
|
if ($tenantUserId > 0) {
|
||||||
if (defined('TENANT_SCOPE_STRICT') && TENANT_SCOPE_STRICT) {
|
$where[] = 'exists (select 1 from user_tenants ut ' .
|
||||||
$where[] = 'exists (select 1 from tenant_departments td ' .
|
"join tenants t on t.id = ut.tenant_id and t.status = 'active' " .
|
||||||
"join tenants t on t.id = td.tenant_id and t.status = 'active' " .
|
'where ut.user_id = ? and ut.tenant_id = departments.tenant_id)';
|
||||||
'join user_tenants ut on ut.tenant_id = td.tenant_id ' .
|
|
||||||
'where ut.user_id = ? and td.department_id = departments.id)';
|
|
||||||
$params[] = (string) $tenantUserId;
|
$params[] = (string) $tenantUserId;
|
||||||
} else {
|
|
||||||
$where[] = '(not exists (select 1 from tenant_departments td where td.department_id = departments.id) ' .
|
|
||||||
'or exists (select 1 from tenant_departments td ' .
|
|
||||||
"join tenants t on t.id = td.tenant_id and t.status = 'active' " .
|
|
||||||
'join user_tenants ut on ut.tenant_id = td.tenant_id ' .
|
|
||||||
'where ut.user_id = ? and td.department_id = departments.id))';
|
|
||||||
$params[] = (string) $tenantUserId;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} elseif (array_key_exists('tenantIds', $options)) {
|
} elseif (array_key_exists('tenantIds', $options)) {
|
||||||
$tenantIds = $options['tenantIds'];
|
$tenantIds = $options['tenantIds'];
|
||||||
$tenantIds = is_array($tenantIds) ? array_values(array_unique(array_map('intval', $tenantIds))) : [];
|
$tenantIds = is_array($tenantIds) ? array_values(array_unique(array_map('intval', $tenantIds))) : [];
|
||||||
if ($tenantIds) {
|
if ($tenantIds) {
|
||||||
if (defined('TENANT_SCOPE_STRICT') && TENANT_SCOPE_STRICT) {
|
$where[] = 'departments.tenant_id in (???)';
|
||||||
$where[] = 'exists (select 1 from tenant_departments td where td.department_id = departments.id and td.tenant_id in (???))';
|
|
||||||
$params[] = $tenantIds;
|
$params[] = $tenantIds;
|
||||||
} else {
|
} else {
|
||||||
$where[] = '(not exists (select 1 from tenant_departments td where td.department_id = departments.id) ' .
|
|
||||||
'or exists (select 1 from tenant_departments td where td.department_id = departments.id and td.tenant_id in (???)))';
|
|
||||||
$params[] = $tenantIds;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (defined('TENANT_SCOPE_STRICT') && TENANT_SCOPE_STRICT) {
|
|
||||||
$where[] = '1=0';
|
$where[] = '1=0';
|
||||||
} else {
|
|
||||||
$where[] = 'not exists (select 1 from tenant_departments td where td.department_id = departments.id)';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +164,7 @@ class DepartmentRepository
|
|||||||
$count = DB::selectValue('select count(*) from departments' . $whereSql, ...$params);
|
$count = DB::selectValue('select count(*) from departments' . $whereSql, ...$params);
|
||||||
$total = $count ? (int) $count : 0;
|
$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 .
|
$whereSql .
|
||||||
sprintf(' order by `%s` %s limit ? offset ?', $order, $dir);
|
sprintf(' order by `%s` %s limit ? offset ?', $order, $dir);
|
||||||
|
|
||||||
@@ -176,48 +182,28 @@ class DepartmentRepository
|
|||||||
if ($ids) {
|
if ($ids) {
|
||||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||||
$labelRows = DB::select(
|
$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' " .
|
'select d.id as department_id, t.description as description from departments d join tenants t on t.id = d.tenant_id ' .
|
||||||
'where td.department_id in (' . $placeholders . ') order by t.description asc',
|
'where d.id in (' . $placeholders . ') order by t.description asc',
|
||||||
...array_map('strval', $ids)
|
...array_map('strval', $ids)
|
||||||
);
|
);
|
||||||
$collectLabels = static function (array $rows): array {
|
foreach ((array) $labelRows as $row) {
|
||||||
$labelsByDepartment = [];
|
|
||||||
foreach ($rows as $row) {
|
|
||||||
$departmentId = 0;
|
$departmentId = 0;
|
||||||
$label = '';
|
$label = '';
|
||||||
if (isset($row['td']) && is_array($row['td'])) {
|
foreach ((array) $row as $table) {
|
||||||
$departmentId = (int) ($row['td']['department_id'] ?? 0);
|
if (!is_array($table)) {
|
||||||
}
|
|
||||||
if (isset($row['t']) && is_array($row['t'])) {
|
|
||||||
$label = (string) ($row['t']['description'] ?? '');
|
|
||||||
}
|
|
||||||
if ($departmentId === 0 || $label === '') {
|
|
||||||
foreach ($row as $value) {
|
|
||||||
if (!is_array($value)) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if ($departmentId === 0 && isset($value['department_id'])) {
|
if ($departmentId === 0 && isset($table['department_id'])) {
|
||||||
$departmentId = (int) $value['department_id'];
|
$departmentId = (int) $table['department_id'];
|
||||||
}
|
}
|
||||||
if ($label === '' && isset($value['description'])) {
|
if ($label === '' && isset($table['description'])) {
|
||||||
$label = (string) $value['description'];
|
$label = (string) $table['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 !== '') {
|
if ($departmentId > 0 && $label !== '') {
|
||||||
$labelsByDepartment[$departmentId] ??= [];
|
$tenantLabelsByDepartment[$departmentId] = [$label];
|
||||||
$labelsByDepartment[$departmentId][] = $label;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return $labelsByDepartment;
|
|
||||||
};
|
|
||||||
$tenantLabelsByDepartment = $collectLabels($labelRows);
|
|
||||||
}
|
}
|
||||||
foreach ($list as &$department) {
|
foreach ($list as &$department) {
|
||||||
$departmentId = (int) ($department['id'] ?? 0);
|
$departmentId = (int) ($department['id'] ?? 0);
|
||||||
@@ -234,7 +220,7 @@ class DepartmentRepository
|
|||||||
public static function find(int $id): ?array
|
public static function find(int $id): ?array
|
||||||
{
|
{
|
||||||
$row = DB::selectOne(
|
$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
|
(string) $id
|
||||||
);
|
);
|
||||||
return self::unwrap($row);
|
return self::unwrap($row);
|
||||||
@@ -243,7 +229,7 @@ class DepartmentRepository
|
|||||||
public static function findByUuid(string $uuid): ?array
|
public static function findByUuid(string $uuid): ?array
|
||||||
{
|
{
|
||||||
$row = DB::selectOne(
|
$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
|
$uuid
|
||||||
);
|
);
|
||||||
return self::unwrap($row);
|
return self::unwrap($row);
|
||||||
@@ -263,20 +249,48 @@ class DepartmentRepository
|
|||||||
return (int) $count > 0;
|
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);
|
$tenantId = (int) $tenantId;
|
||||||
$data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
|
$description = trim($description);
|
||||||
$data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
|
if ($tenantId <= 0 || $description === '') {
|
||||||
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
|
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)
|
public static function create(array $data)
|
||||||
{
|
{
|
||||||
return DB::insert(
|
return DB::insert(
|
||||||
'insert into departments (uuid, description, code, cost_center, active, created_by, created) values (?,?,?,?,?,?,NOW())',
|
'insert into departments (uuid, description, tenant_id, code, cost_center, active, created_by, created) values (?,?,?,?,?,?,?,NOW())',
|
||||||
$data['uuid'] ?? self::uuidV4(),
|
$data['uuid'] ?? RepoQuery::uuidV4(),
|
||||||
$data['description'],
|
$data['description'],
|
||||||
|
(string) ($data['tenant_id'] ?? 0),
|
||||||
$data['code'] ?? null,
|
$data['code'] ?? null,
|
||||||
$data['cost_center'] ?? null,
|
$data['cost_center'] ?? null,
|
||||||
$data['active'] ?? 1,
|
$data['active'] ?? 1,
|
||||||
@@ -288,6 +302,7 @@ class DepartmentRepository
|
|||||||
{
|
{
|
||||||
$fields = [
|
$fields = [
|
||||||
'description' => $data['description'],
|
'description' => $data['description'],
|
||||||
|
'tenant_id' => (string) ($data['tenant_id'] ?? 0),
|
||||||
'code' => $data['code'] ?? null,
|
'code' => $data['code'] ?? null,
|
||||||
'cost_center' => $data['cost_center'] ?? null,
|
'cost_center' => $data['cost_center'] ?? null,
|
||||||
'active' => $data['active'] ?? 1,
|
'active' => $data['active'] ?? 1,
|
||||||
@@ -309,6 +324,19 @@ class DepartmentRepository
|
|||||||
return $result !== false;
|
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
|
public static function delete(int $id): bool
|
||||||
{
|
{
|
||||||
$result = DB::delete('delete from departments where id = ?', (string) $id);
|
$result = DB::delete('delete from departments where id = ?', (string) $id);
|
||||||
|
|||||||
@@ -45,10 +45,69 @@ class UserDepartmentRepository
|
|||||||
$query = 'delete ud from user_departments ud ' .
|
$query = 'delete ud from user_departments ud ' .
|
||||||
'where ud.department_id = ? and not exists (' .
|
'where ud.department_id = ? and not exists (' .
|
||||||
'select 1 from user_tenants ut ' .
|
'select 1 from user_tenants ut ' .
|
||||||
'join tenant_departments td on td.tenant_id = ut.tenant_id ' .
|
'join departments d on d.id = ud.department_id ' .
|
||||||
'where ut.user_id = ud.user_id and td.department_id = ud.department_id' .
|
'where ut.user_id = ud.user_id and ut.tenant_id = d.tenant_id' .
|
||||||
')';
|
')';
|
||||||
$result = DB::delete($query, (string) $departmentId);
|
$result = DB::delete($query, (string) $departmentId);
|
||||||
return $result !== false ? (int) $result : 0;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
252
lib/Repository/Scheduler/ScheduledJobRepository.php
Normal file
252
lib/Repository/Scheduler/ScheduledJobRepository.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
137
lib/Repository/Scheduler/ScheduledJobRunRepository.php
Normal file
137
lib/Repository/Scheduler/ScheduledJobRunRepository.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
52
lib/Repository/Scheduler/SchedulerRuntimeRepository.php
Normal file
52
lib/Repository/Scheduler/SchedulerRuntimeRepository.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
69
lib/Repository/Security/RateLimitRepository.php
Normal file
69
lib/Repository/Security/RateLimitRepository.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
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.
|
// Use for simple "= ?" filters; skip when empty to keep WHERE clean.
|
||||||
@@ -117,4 +125,27 @@ class RepoQuery
|
|||||||
$where[] = $sql;
|
$where[] = $sql;
|
||||||
$params[] = $normalized;
|
$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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
85
lib/Repository/Tenant/TenantMicrosoftAuthRepository.php
Normal file
85
lib/Repository/Tenant/TenantMicrosoftAuthRepository.php
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ class TenantRepository
|
|||||||
public static function list(): array
|
public static function list(): array
|
||||||
{
|
{
|
||||||
$rows = DB::select(
|
$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);
|
return self::unwrapList($rows);
|
||||||
}
|
}
|
||||||
@@ -54,6 +54,22 @@ class TenantRepository
|
|||||||
return array_values(array_unique($ids));
|
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
|
public static function listActiveIdsByIds(array $tenantIds): array
|
||||||
{
|
{
|
||||||
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
|
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
|
||||||
@@ -85,7 +101,7 @@ class TenantRepository
|
|||||||
public static function listPaged(array $options): array
|
public static function listPaged(array $options): array
|
||||||
{
|
{
|
||||||
$search = trim((string) ($options['search'] ?? ''));
|
$search = trim((string) ($options['search'] ?? ''));
|
||||||
$allowedOrder = ['id', 'uuid', 'description', 'created', 'modified'];
|
$allowedOrder = ['id', 'uuid', 'description', 'status', 'created', 'modified'];
|
||||||
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($options);
|
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($options);
|
||||||
[$order, $dir] = RepoQuery::sanitizeOrder($options, $allowedOrder);
|
[$order, $dir] = RepoQuery::sanitizeOrder($options, $allowedOrder);
|
||||||
|
|
||||||
@@ -99,12 +115,23 @@ class TenantRepository
|
|||||||
$params[] = (string) $tenantUserId;
|
$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)) : '';
|
$whereSql = $where ? (' where ' . implode(' and ', $where)) : '';
|
||||||
$count = DB::selectValue('select count(*) from tenants' . $whereSql, ...$params);
|
$count = DB::selectValue('select count(*) from tenants' . $whereSql, ...$params);
|
||||||
$total = $count ? (int) $count : 0;
|
$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 .
|
$whereSql .
|
||||||
sprintf(' order by `%s` %s limit ? offset ?', $order, $dir);
|
sprintf(' order by `%s` %s limit ? offset ?', $order, $dir);
|
||||||
|
|
||||||
@@ -120,7 +147,7 @@ class TenantRepository
|
|||||||
public static function find(int $id): ?array
|
public static function find(int $id): ?array
|
||||||
{
|
{
|
||||||
$row = DB::selectOne(
|
$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
|
(string) $id
|
||||||
);
|
);
|
||||||
return self::unwrap($row);
|
return self::unwrap($row);
|
||||||
@@ -129,25 +156,17 @@ class TenantRepository
|
|||||||
public static function findByUuid(string $uuid): ?array
|
public static function findByUuid(string $uuid): ?array
|
||||||
{
|
{
|
||||||
$row = DB::selectOne(
|
$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
|
$uuid
|
||||||
);
|
);
|
||||||
return self::unwrap($row);
|
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)
|
public static function create(array $data)
|
||||||
{
|
{
|
||||||
return DB::insert(
|
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())',
|
'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'] ?? self::uuidV4(),
|
$data['uuid'] ?? RepoQuery::uuidV4(),
|
||||||
$data['description'],
|
$data['description'],
|
||||||
$data['address'] ?? null,
|
$data['address'] ?? null,
|
||||||
$data['postal_code'] ?? null,
|
$data['postal_code'] ?? null,
|
||||||
@@ -166,6 +185,8 @@ class TenantRepository
|
|||||||
$data['privacy_url'] ?? null,
|
$data['privacy_url'] ?? null,
|
||||||
$data['imprint_url'] ?? null,
|
$data['imprint_url'] ?? null,
|
||||||
$data['primary_color'] ?? null,
|
$data['primary_color'] ?? null,
|
||||||
|
$data['default_theme'] ?? null,
|
||||||
|
$data['allow_user_theme'] ?? null,
|
||||||
$data['status'] ?? 'active',
|
$data['status'] ?? 'active',
|
||||||
$data['status_changed_at'] ?? null,
|
$data['status_changed_at'] ?? null,
|
||||||
$data['status_changed_by'] ?? null,
|
$data['status_changed_by'] ?? null,
|
||||||
@@ -194,6 +215,8 @@ class TenantRepository
|
|||||||
'privacy_url' => $data['privacy_url'] ?? null,
|
'privacy_url' => $data['privacy_url'] ?? null,
|
||||||
'imprint_url' => $data['imprint_url'] ?? null,
|
'imprint_url' => $data['imprint_url'] ?? null,
|
||||||
'primary_color' => $data['primary_color'] ?? 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',
|
'status' => $data['status'] ?? 'active',
|
||||||
];
|
];
|
||||||
if (array_key_exists('modified_by', $data)) {
|
if (array_key_exists('modified_by', $data)) {
|
||||||
|
|||||||
@@ -39,4 +39,64 @@ class UserTenantRepository
|
|||||||
|
|
||||||
return true;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
55
lib/Repository/User/UserExternalIdentityRepository.php
Normal file
55
lib/Repository/User/UserExternalIdentityRepository.php
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,10 +15,16 @@ class UserRepository
|
|||||||
$createdTo = trim((string) ($options['created_to'] ?? ''));
|
$createdTo = trim((string) ($options['created_to'] ?? ''));
|
||||||
$tenant = trim((string) ($options['tenant'] ?? ''));
|
$tenant = trim((string) ($options['tenant'] ?? ''));
|
||||||
$tenantUuids = array_filter(array_map('trim', explode(',', (string) ($options['tenants'] ?? ''))));
|
$tenantUuids = array_filter(array_map('trim', explode(',', (string) ($options['tenants'] ?? ''))));
|
||||||
$roleIds = self::normalizeIdList($options['roles'] ?? []);
|
$roleIds = RepoQuery::normalizeIdList($options['roles'] ?? []);
|
||||||
$departmentIds = self::normalizeIdList($options['departments'] ?? []);
|
$departmentIds = RepoQuery::normalizeIdList($options['departments'] ?? []);
|
||||||
$emailVerified = $options['email_verified'] ?? null;
|
$emailVerified = $options['email_verified'] ?? null;
|
||||||
$loginStatus = $options['login_status'] ?? null;
|
$loginStatus = $options['login_status'] ?? null;
|
||||||
|
$customFieldFilterSpec = is_array($options['customFieldFilterSpec'] ?? null)
|
||||||
|
? $options['customFieldFilterSpec']
|
||||||
|
: [];
|
||||||
|
$customFieldFilters = is_array($customFieldFilterSpec['filters'] ?? null)
|
||||||
|
? $customFieldFilterSpec['filters']
|
||||||
|
: [];
|
||||||
|
|
||||||
$where = [];
|
$where = [];
|
||||||
$params = [];
|
$params = [];
|
||||||
@@ -34,11 +40,11 @@ class UserRepository
|
|||||||
['aliases' => ['0', 'false', 'inactive'], 'sql' => 'users.active = ?', 'params' => ['0']],
|
['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 >= ?';
|
$where[] = 'users.created >= ?';
|
||||||
$params[] = $createdFrom . ' 00:00:00';
|
$params[] = $createdFrom . ' 00:00:00';
|
||||||
}
|
}
|
||||||
if ($createdTo !== '') {
|
if ($createdTo !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdTo)) {
|
||||||
$where[] = 'users.created <= ?';
|
$where[] = 'users.created <= ?';
|
||||||
$params[] = $createdTo . ' 23:59:59';
|
$params[] = $createdTo . ' 23:59:59';
|
||||||
}
|
}
|
||||||
@@ -69,6 +75,68 @@ class UserRepository
|
|||||||
['aliases' => ['never', 'none', 'no'], 'sql' => 'users.last_login_at is null'],
|
['aliases' => ['never', 'none', 'no'], 'sql' => 'users.last_login_at is null'],
|
||||||
['aliases' => ['ever', 'logged', 'yes'], 'sql' => 'users.last_login_at is not 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'])) {
|
if (!empty($options['tenantUserId'])) {
|
||||||
$tenantUserId = (int) $options['tenantUserId'];
|
$tenantUserId = (int) $options['tenantUserId'];
|
||||||
if ($tenantUserId > 0) {
|
if ($tenantUserId > 0) {
|
||||||
@@ -93,7 +161,7 @@ class UserRepository
|
|||||||
|
|
||||||
private static function buildListQuery(string $whereSql, string $order, string $dir): string
|
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 ' .
|
'pt.description as primary_tenant_label ' .
|
||||||
"from users left join tenants pt on pt.id = users.primary_tenant_id and pt.status = 'active'" .
|
"from users left join tenants pt on pt.id = users.primary_tenant_id and pt.status = 'active'" .
|
||||||
$whereSql .
|
$whereSql .
|
||||||
@@ -157,27 +225,48 @@ class UserRepository
|
|||||||
return $list;
|
return $list;
|
||||||
}
|
}
|
||||||
|
|
||||||
$scopeToActiveTenants = !empty($options['tenantUserId']);
|
$scopeUserId = (int) ($options['tenantUserId'] ?? 0);
|
||||||
|
$scopeToActiveTenants = $scopeUserId > 0;
|
||||||
$tenantLabelJoin = $scopeToActiveTenants
|
$tenantLabelJoin = $scopeToActiveTenants
|
||||||
? "join tenants t on t.id = ut.tenant_id and t.status = 'active' "
|
? "join tenants t on t.id = ut.tenant_id and t.status = 'active' "
|
||||||
: 'join tenants t on t.id = ut.tenant_id ';
|
: 'join tenants t on t.id = ut.tenant_id ';
|
||||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
$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(
|
$labelSql = 'select ut.user_id as user_id, t.id as tenant_id, t.description as description from user_tenants ut ' . $tenantLabelJoin .
|
||||||
'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';
|
||||||
'where ut.user_id in (' . $placeholders . ') order by t.description asc',
|
$labelRows = call_user_func_array(['MintyPHP\\DB', 'select'], array_merge(
|
||||||
...array_map('strval', $ids)
|
[$labelSql],
|
||||||
);
|
array_merge(array_map('strval', $ids), $tenantScopeParams)
|
||||||
$roleLabelRows = DB::select(
|
));
|
||||||
|
$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 ' .
|
'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',
|
'where ur.user_id in (' . $placeholders . ') order by r.description asc',
|
||||||
...array_map('strval', $ids)
|
...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 ' .
|
$departmentScopeSql = '';
|
||||||
'where ud.user_id in (' . $placeholders . ') order by d.description asc',
|
$departmentScopeParams = [];
|
||||||
...array_map('strval', $ids)
|
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 = [];
|
$tenantMapByUser = [];
|
||||||
foreach ($labelRows as $row) {
|
foreach ($labelRows as $row) {
|
||||||
@@ -225,8 +314,8 @@ class UserRepository
|
|||||||
foreach ($tenantMapByUser as $userId => $map) {
|
foreach ($tenantMapByUser as $userId => $map) {
|
||||||
$tenantLabelsByUser[$userId] = array_values($map);
|
$tenantLabelsByUser[$userId] = array_values($map);
|
||||||
}
|
}
|
||||||
$roleLabelsByUser = self::collectLabels($roleLabelRows, 'ur', 'r');
|
$roleLabelsByUser = self::collectLabels($roleRows, 'ur', 'r');
|
||||||
$departmentLabelsByUser = self::collectLabels($departmentLabelRows, 'ud', 'd');
|
$departmentLabelsByUser = self::collectLabels($departmentRows, 'ud', 'd');
|
||||||
|
|
||||||
foreach ($list as &$user) {
|
foreach ($list as &$user) {
|
||||||
$userId = (int) ($user['id'] ?? 0);
|
$userId = (int) ($user['id'] ?? 0);
|
||||||
@@ -242,6 +331,7 @@ class UserRepository
|
|||||||
|
|
||||||
return $list;
|
return $list;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function unwrap(?array $row): ?array
|
private static function unwrap(?array $row): ?array
|
||||||
{
|
{
|
||||||
if (!$row) {
|
if (!$row) {
|
||||||
@@ -277,25 +367,66 @@ class UserRepository
|
|||||||
return $list;
|
return $list;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function list(): array
|
public static function updateLastLogin(int $userId, string $provider = 'local'): void
|
||||||
{
|
|
||||||
$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
|
|
||||||
{
|
{
|
||||||
if ($userId <= 0) {
|
if ($userId <= 0) {
|
||||||
return;
|
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
|
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);
|
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($options);
|
||||||
[$order, $dir] = RepoQuery::sanitizeOrder($options, $allowedOrder);
|
[$order, $dir] = RepoQuery::sanitizeOrder($options, $allowedOrder);
|
||||||
[$whereSql, $params] = self::buildUserFilters($options);
|
[$whereSql, $params] = self::buildUserFilters($options);
|
||||||
@@ -320,7 +451,7 @@ class UserRepository
|
|||||||
public static function find(int $id): ?array
|
public static function find(int $id): ?array
|
||||||
{
|
{
|
||||||
$row = DB::selectOne(
|
$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
|
(string) $id
|
||||||
);
|
);
|
||||||
return self::unwrap($row);
|
return self::unwrap($row);
|
||||||
@@ -329,7 +460,7 @@ class UserRepository
|
|||||||
public static function findByUuid(string $uuid): ?array
|
public static function findByUuid(string $uuid): ?array
|
||||||
{
|
{
|
||||||
$row = DB::selectOne(
|
$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
|
$uuid
|
||||||
);
|
);
|
||||||
return self::unwrap($row);
|
return self::unwrap($row);
|
||||||
@@ -338,7 +469,7 @@ class UserRepository
|
|||||||
public static function findByEmail(string $email): ?array
|
public static function findByEmail(string $email): ?array
|
||||||
{
|
{
|
||||||
$row = DB::selectOne(
|
$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
|
$email
|
||||||
);
|
);
|
||||||
return self::unwrap($row);
|
return self::unwrap($row);
|
||||||
@@ -355,20 +486,12 @@ class UserRepository
|
|||||||
return $name;
|
return $name;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function uuidV4(): string
|
public static function create(array $data): int|false
|
||||||
{
|
|
||||||
$data = random_bytes(16);
|
|
||||||
$data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
|
|
||||||
$data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
|
|
||||||
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function create(array $data)
|
|
||||||
{
|
{
|
||||||
$hash = password_hash($data['password'], PASSWORD_DEFAULT);
|
$hash = password_hash($data['password'], PASSWORD_DEFAULT);
|
||||||
return DB::insert(
|
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(),?,?,?)',
|
'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['first_name'],
|
||||||
$data['last_name'],
|
$data['last_name'],
|
||||||
self::buildDisplayName($data),
|
self::buildDisplayName($data),
|
||||||
@@ -491,6 +614,118 @@ class UserRepository
|
|||||||
return $result !== false;
|
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
|
public static function setLocale(int $id, string $locale): bool
|
||||||
{
|
{
|
||||||
$result = DB::update('update users set locale = ? where id = ?', $locale, (string) $id);
|
$result = DB::update('update users set locale = ? where id = ?', $locale, (string) $id);
|
||||||
@@ -503,6 +738,62 @@ class UserRepository
|
|||||||
return $result !== false;
|
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
|
public static function setPassword(int $id, string $password): bool
|
||||||
{
|
{
|
||||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||||
@@ -534,23 +825,17 @@ class UserRepository
|
|||||||
return $result !== false;
|
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 = [];
|
$ids = [];
|
||||||
foreach ($value as $item) {
|
foreach ($rows as $row) {
|
||||||
if ($item === '' || $item === null) {
|
$data = $row['users'] ?? $row;
|
||||||
continue;
|
$id = (int) ($data['id'] ?? $row['id'] ?? 0);
|
||||||
}
|
if ($id > 0) {
|
||||||
$ids[] = (int) $item;
|
$ids[] = $id;
|
||||||
}
|
|
||||||
$ids = array_values(array_filter(array_unique($ids), static function ($id) {
|
|
||||||
return $id > 0;
|
|
||||||
}));
|
|
||||||
return $ids;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return array_values(array_unique($ids));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
85
lib/Repository/User/UserSavedFilterRepository.php
Normal file
85
lib/Repository/User/UserSavedFilterRepository.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,23 +8,28 @@ use MintyPHP\Repository\Access\PermissionRepository;
|
|||||||
|
|
||||||
class PermissionService
|
class PermissionService
|
||||||
{
|
{
|
||||||
|
private static array $apiPermissionCache = [];
|
||||||
|
|
||||||
public const USERS_CREATE = 'users.create';
|
public const USERS_CREATE = 'users.create';
|
||||||
public const USERS_DELETE = 'users.delete';
|
public const USERS_DELETE = 'users.delete';
|
||||||
public const USERS_VIEW = 'users.view';
|
public const USERS_VIEW = 'users.view';
|
||||||
public const USERS_VIEW_META = 'users.view_meta';
|
public const USERS_VIEW_META = 'users.view_meta';
|
||||||
public const USERS_VIEW_AUDIT = 'users.view_audit';
|
public const USERS_VIEW_AUDIT = 'users.view_audit';
|
||||||
public const USERS_UPDATE = 'users.update';
|
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_SELF_UPDATE = 'users.self_update';
|
||||||
public const USERS_UPDATE_ASSIGNMENTS = 'users.update_assignments';
|
public const USERS_UPDATE_ASSIGNMENTS = 'users.update_assignments';
|
||||||
public const ADDRESS_BOOK_VIEW = 'address_book.view';
|
public const ADDRESS_BOOK_VIEW = 'address_book.view';
|
||||||
public const TENANTS_VIEW = 'tenants.view';
|
public const TENANTS_VIEW = 'tenants.view';
|
||||||
public const TENANTS_CREATE = 'tenants.create';
|
public const TENANTS_CREATE = 'tenants.create';
|
||||||
public const TENANTS_UPDATE = 'tenants.update';
|
public const TENANTS_UPDATE = 'tenants.update';
|
||||||
|
public const TENANTS_SSO_MANAGE = 'tenants.sso_manage';
|
||||||
public const TENANTS_DELETE = 'tenants.delete';
|
public const TENANTS_DELETE = 'tenants.delete';
|
||||||
public const DEPARTMENTS_VIEW = 'departments.view';
|
public const DEPARTMENTS_VIEW = 'departments.view';
|
||||||
public const DEPARTMENTS_CREATE = 'departments.create';
|
public const DEPARTMENTS_CREATE = 'departments.create';
|
||||||
public const DEPARTMENTS_UPDATE = 'departments.update';
|
public const DEPARTMENTS_UPDATE = 'departments.update';
|
||||||
public const DEPARTMENTS_DELETE = 'departments.delete';
|
public const DEPARTMENTS_DELETE = 'departments.delete';
|
||||||
|
public const DEPARTMENTS_IMPORT = 'departments.import';
|
||||||
public const ROLES_VIEW = 'roles.view';
|
public const ROLES_VIEW = 'roles.view';
|
||||||
public const ROLES_CREATE = 'roles.create';
|
public const ROLES_CREATE = 'roles.create';
|
||||||
public const ROLES_UPDATE = 'roles.update';
|
public const ROLES_UPDATE = 'roles.update';
|
||||||
@@ -35,8 +40,23 @@ class PermissionService
|
|||||||
public const PERMISSIONS_DELETE = 'permissions.delete';
|
public const PERMISSIONS_DELETE = 'permissions.delete';
|
||||||
public const SETTINGS_VIEW = 'settings.view';
|
public const SETTINGS_VIEW = 'settings.view';
|
||||||
public const SETTINGS_UPDATE = 'settings.update';
|
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 MAIL_LOG_VIEW = 'mail_log.view';
|
||||||
|
public const API_AUDIT_VIEW = 'api_audit.view';
|
||||||
public const STATS_VIEW = 'stats.view';
|
public const STATS_VIEW = 'stats.view';
|
||||||
|
public const API_TOKENS_MANAGE = 'api_tokens.manage';
|
||||||
|
|
||||||
public static function userHas(int $userId, string $permissionKey): bool
|
public static function userHas(int $userId, string $permissionKey): bool
|
||||||
{
|
{
|
||||||
@@ -53,6 +73,18 @@ class PermissionService
|
|||||||
if ($userId <= 0) {
|
if ($userId <= 0) {
|
||||||
return [];
|
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;
|
$cache = $_SESSION['permissions'] ?? null;
|
||||||
if ($refresh) {
|
if ($refresh) {
|
||||||
$cache = null;
|
$cache = null;
|
||||||
@@ -77,6 +109,11 @@ class PermissionService
|
|||||||
if ($userId <= 0) {
|
if ($userId <= 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (self::isApiRequest()) {
|
||||||
|
return self::$apiPermissionCache[$userId] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
$cache = $_SESSION['permissions'] ?? null;
|
$cache = $_SESSION['permissions'] ?? null;
|
||||||
if (is_array($cache) && (int) ($cache['user_id'] ?? 0) === $userId) {
|
if (is_array($cache) && (int) ($cache['user_id'] ?? 0) === $userId) {
|
||||||
$keys = $cache['keys'] ?? [];
|
$keys = $cache['keys'] ?? [];
|
||||||
@@ -87,12 +124,22 @@ class PermissionService
|
|||||||
|
|
||||||
public static function clearUserCache(int $userId): void
|
public static function clearUserCache(int $userId): void
|
||||||
{
|
{
|
||||||
|
if (self::isApiRequest()) {
|
||||||
|
unset(self::$apiPermissionCache[$userId]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$cache = $_SESSION['permissions'] ?? null;
|
$cache = $_SESSION['permissions'] ?? null;
|
||||||
if (is_array($cache) && (int) ($cache['user_id'] ?? 0) === $userId) {
|
if (is_array($cache) && (int) ($cache['user_id'] ?? 0) === $userId) {
|
||||||
unset($_SESSION['permissions']);
|
unset($_SESSION['permissions']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function isApiRequest(): bool
|
||||||
|
{
|
||||||
|
return defined('MINTY_API_REQUEST');
|
||||||
|
}
|
||||||
|
|
||||||
public static function list(): array
|
public static function list(): array
|
||||||
{
|
{
|
||||||
return PermissionRepository::list();
|
return PermissionRepository::list();
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class RoleService
|
|||||||
|
|
||||||
$createdRole = RoleRepository::find((int) $createdId);
|
$createdRole = RoleRepository::find((int) $createdId);
|
||||||
$uuid = $createdRole['uuid'] ?? null;
|
$uuid = $createdRole['uuid'] ?? null;
|
||||||
if (!empty($input['is_default']) && $createdId) {
|
if (!empty($input['is_default'])) {
|
||||||
SettingService::setDefaultRoleId((int) $createdId);
|
SettingService::setDefaultRoleId((int) $createdId);
|
||||||
}
|
}
|
||||||
return ['ok' => true, 'form' => $form, 'uuid' => $uuid, 'id' => (int) $createdId];
|
return ['ok' => true, 'form' => $form, 'uuid' => $uuid, 'id' => (int) $createdId];
|
||||||
|
|||||||
220
lib/Service/Audit/ApiAuditService.php
Normal file
220
lib/Service/Audit/ApiAuditService.php
Normal 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
202
lib/Service/Audit/ImportAuditService.php
Normal file
202
lib/Service/Audit/ImportAuditService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
251
lib/Service/Audit/UserLifecycleAuditService.php
Normal file
251
lib/Service/Audit/UserLifecycleAuditService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
300
lib/Service/Auth/ApiTokenService.php
Normal file
300
lib/Service/Auth/ApiTokenService.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,15 @@
|
|||||||
namespace MintyPHP\Service\Auth;
|
namespace MintyPHP\Service\Auth;
|
||||||
|
|
||||||
use MintyPHP\Auth;
|
use MintyPHP\Auth;
|
||||||
|
use MintyPHP\Session;
|
||||||
use MintyPHP\Router;
|
use MintyPHP\Router;
|
||||||
use MintyPHP\Support\Flash;
|
use MintyPHP\Support\Flash;
|
||||||
use MintyPHP\Service\User\UserService;
|
use MintyPHP\Service\User\UserService;
|
||||||
|
use MintyPHP\Service\User\UserSavedFilterService;
|
||||||
use MintyPHP\Service\Access\PermissionService;
|
use MintyPHP\Service\Access\PermissionService;
|
||||||
use MintyPHP\Service\Auth\RememberMeService;
|
use MintyPHP\Service\Auth\RememberMeService;
|
||||||
use MintyPHP\Service\Auth\EmailVerificationService;
|
use MintyPHP\Service\Auth\EmailVerificationService;
|
||||||
|
use MintyPHP\Service\Auth\TenantSsoService;
|
||||||
use MintyPHP\Repository\User\UserRepository;
|
use MintyPHP\Repository\User\UserRepository;
|
||||||
|
|
||||||
class AuthService
|
class AuthService
|
||||||
@@ -49,6 +52,14 @@ class AuthService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$userId = (int) ($_SESSION['user']['id'] ?? 0);
|
$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) {
|
if ($userId > 0) {
|
||||||
PermissionService::getUserPermissions($userId, true);
|
PermissionService::getUserPermissions($userId, true);
|
||||||
self::loadTenantDataIntoSession($userId);
|
self::loadTenantDataIntoSession($userId);
|
||||||
@@ -60,12 +71,86 @@ class AuthService
|
|||||||
'flash_key' => 'login_no_active_tenant',
|
'flash_key' => 'login_no_active_tenant',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
UserRepository::updateLastLogin($userId);
|
UserRepository::updateLastLogin($userId, 'local');
|
||||||
}
|
}
|
||||||
|
|
||||||
return ['ok' => true];
|
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(
|
public static function loginAndRedirect(
|
||||||
string $email,
|
string $email,
|
||||||
string $password,
|
string $password,
|
||||||
@@ -130,6 +215,69 @@ class AuthService
|
|||||||
Router::redirect($target);
|
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
|
public static function loadTenantDataIntoSession(int $userId): void
|
||||||
{
|
{
|
||||||
if ($userId <= 0) {
|
if ($userId <= 0) {
|
||||||
@@ -140,11 +288,9 @@ class AuthService
|
|||||||
$availableTenants = UserService::getAvailableTenants($userId);
|
$availableTenants = UserService::getAvailableTenants($userId);
|
||||||
$_SESSION['available_tenants'] = $availableTenants;
|
$_SESSION['available_tenants'] = $availableTenants;
|
||||||
$_SESSION['available_departments_by_tenant'] = UserService::getAvailableDepartmentsByTenant($userId);
|
$_SESSION['available_departments_by_tenant'] = UserService::getAvailableDepartmentsByTenant($userId);
|
||||||
|
$_SESSION['address_book_saved_filters'] = UserSavedFilterService::listForAddressBook($userId);
|
||||||
|
|
||||||
$hasTenantAdminAccess = PermissionService::userHas($userId, PermissionService::TENANTS_UPDATE)
|
if (!$availableTenants) {
|
||||||
|| PermissionService::userHas($userId, PermissionService::SETTINGS_UPDATE);
|
|
||||||
|
|
||||||
if (!$availableTenants && !$hasTenantAdminAccess) {
|
|
||||||
$_SESSION['no_active_tenant'] = true;
|
$_SESSION['no_active_tenant'] = true;
|
||||||
unset($_SESSION['current_tenant']);
|
unset($_SESSION['current_tenant']);
|
||||||
return;
|
return;
|
||||||
@@ -153,7 +299,7 @@ class AuthService
|
|||||||
|
|
||||||
// Load current tenant (with fallback to first available)
|
// Load current tenant (with fallback to first available)
|
||||||
$currentTenant = UserService::getCurrentTenant($userId);
|
$currentTenant = UserService::getCurrentTenant($userId);
|
||||||
if (!$currentTenant && !empty($availableTenants)) {
|
if (!$currentTenant) {
|
||||||
// Fallback: use first available tenant
|
// Fallback: use first available tenant
|
||||||
$currentTenant = $availableTenants[0];
|
$currentTenant = $availableTenants[0];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use MintyPHP\Repository\Auth\EmailVerificationRepository;
|
|||||||
use MintyPHP\Repository\User\UserRepository;
|
use MintyPHP\Repository\User\UserRepository;
|
||||||
use MintyPHP\I18n;
|
use MintyPHP\I18n;
|
||||||
use MintyPHP\Http\Request;
|
use MintyPHP\Http\Request;
|
||||||
|
use MintyPHP\Service\Mail\MailService;
|
||||||
|
|
||||||
class EmailVerificationService
|
class EmailVerificationService
|
||||||
{
|
{
|
||||||
|
|||||||
540
lib/Service/Auth/MicrosoftOidcService.php
Normal file
540
lib/Service/Auth/MicrosoftOidcService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ use MintyPHP\Repository\User\UserRepository;
|
|||||||
use MintyPHP\I18n;
|
use MintyPHP\I18n;
|
||||||
use MintyPHP\Http\Request;
|
use MintyPHP\Http\Request;
|
||||||
use MintyPHP\Service\Auth\RememberMeService;
|
use MintyPHP\Service\Auth\RememberMeService;
|
||||||
|
use MintyPHP\Service\Mail\MailService;
|
||||||
|
use MintyPHP\Service\User\UserService;
|
||||||
|
|
||||||
class PasswordResetService
|
class PasswordResetService
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -79,13 +79,16 @@ class RememberMeService
|
|||||||
if (!empty($user['locale'])) {
|
if (!empty($user['locale'])) {
|
||||||
I18n::$locale = (string) $user['locale'];
|
I18n::$locale = (string) $user['locale'];
|
||||||
}
|
}
|
||||||
$userId = (int) ($user['id'] ?? 0);
|
$userId = (int) $user['id'];
|
||||||
if ($userId > 0) {
|
if ($userId > 0) {
|
||||||
PermissionService::getUserPermissions($userId, true);
|
PermissionService::getUserPermissions($userId, true);
|
||||||
AuthService::loadTenantDataIntoSession($userId);
|
AuthService::loadTenantDataIntoSession($userId);
|
||||||
if (empty($_SESSION['no_active_tenant'])) {
|
if (!empty($_SESSION['no_active_tenant'])) {
|
||||||
UserRepository::updateLastLogin($userId);
|
// Enforce the same tenant policy as interactive login.
|
||||||
|
AuthService::logout();
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
UserRepository::updateLastLogin($userId, 'local');
|
||||||
}
|
}
|
||||||
|
|
||||||
$newToken = bin2hex(random_bytes(32));
|
$newToken = bin2hex(random_bytes(32));
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user