diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index b6ad05d..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.env.example b/.env.example index cd8cbc0..20533be 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,4 @@ APP_NAME=IMVS -APP_SLOGAN=Immobilien Makler Verkaufs Software APP_URL=http://localhost:8080 APP_ENV=dev APP_DEBUG=true @@ -7,7 +6,10 @@ APP_TIMEZONE=Europe/Berlin APP_STORAGE_PATH=./storage APP_LOCALE=de APP_LOCALES=de,en +APP_I18N_DOMAIN=default +APP_CRYPTO_KEY= SESSION_NAME=IMVS +TENANT_SCOPE_STRICT=true DB_HOST=db DB_PORT=3306 @@ -17,3 +19,17 @@ DB_PASS=imvs DB_ROOT_PASSWORD=imvs_root CACHE_SERVERS=memcached:11211 + +FIREWALL_CONCURRENCY=10 +FIREWALL_SPINLOCK_SECONDS=0.15 +FIREWALL_INTERVAL_SECONDS=300 +FIREWALL_CACHE_PREFIX=fw_concurrency_ +FIREWALL_REVERSE_PROXY=false + +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=no-reply@example.com +SMTP_PASS= +SMTP_FROM=no-reply@example.com +SMTP_FROM_NAME=IMVS +SMTP_SECURE=tls diff --git a/.env.prod.example b/.env.prod.example new file mode 100644 index 0000000..1fbdce6 --- /dev/null +++ b/.env.prod.example @@ -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 diff --git a/.gitignore b/.gitignore index 64f7eb7..0636f9c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,21 @@ +# IDE .vscode/ +.idea/ + +# macOS +.DS_Store + +# Composer composer.phar +/vendor/ + +# Secrets / local config config/config.php .env -/vendor/ + +# Runtime & generated /web/debugger/ /data/ /storage/* !/storage/.gitkeep +.phpunit.result.cache diff --git a/README.md b/README.md index 19bd1bc..9c2dcf4 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,234 @@ -

MintyPHP MintyPHP

+# IMVS (MintyPHP) -MintyPHP aims to be a full-stack PHP 7 (or 8) framework that is: +IMVS ist eine mandantenfaehige Admin-Anwendung auf Basis von MintyPHP. +Der Fokus liegt auf klarer Rollen-/Rechtestruktur, Tenant-Scope und einer konsistenten Admin-UI. - - Easy to learn - - Secure by design - - Light-weight +## Funktionsumfang -By design, it does: +- Mandantenverwaltung (inkl. Branding/Farbe/Favicon) +- Abteilungen pro Mandant +- Benutzerverwaltung mit Rollen, Sichtbarkeit und Stammdaten +- Rollen- und Berechtigungsverwaltung +- Adressbuch (tenant-scoped, read-only Profilansicht) +- Globale Suche (Admin + Address Book) +- Mail-Versand (SMTP/PHPMailer) inkl. Mail-Log +- Onboarding-PDF fuer Zugangsdaten (Single inline, Bulk als ZIP) +- Passwort-Reset und Remember-Login-Tokens - - … have one variable scope for all layers. - - … require you to write SQL queries (no ORM). - - … use PHP as a templating language. +## Tech Stack -Mainly to make it easy to learn for PHP developers. +- PHP 8.5 (Runtime im Docker-Container) +- MintyPHP Core +- MariaDB 11 +- Memcached +- Nginx +- Grid.js (Tabellen) +- PHPMailer +- Dompdf (PDF Rendering) +- endroid/qr-code (QR-Code Rendering) -[Download](https://mintyphp.github.io/installation/) / -[Documentation](https://mintyphp.github.io/docs/) +## Schnellstart (Docker) -## External links +1. Umgebungsdatei anlegen -- [MintyPHP v3 is released](https://tqdev.com/2022-mintyphp-v3-is-released) -- [MintyPHP now on packagist!](https://tqdev.com/2018-mindaphp-now-on-packagist) +```bash +cp .env.example .env +``` -## Quickstart (Docker) +2. Container starten -1. Copy `.env.example` to `.env` (defaults are fine for local dev). -2. Build and start the stack: +```bash +docker compose up --build -d +``` - ```bash - docker compose up --build - ``` +3. App oeffnen -3. Open the app at `http://localhost:8080`. -4. Register a user, then visit `/admin` (protected route). -5. phpMyAdmin is available at `http://localhost:8081`. +- App: [http://localhost:8080](http://localhost:8080) +- phpMyAdmin: [http://localhost:8081](http://localhost:8081) -### Notes +4. Container stoppen -- MintyPHP uses Memcached for its firewall cache; the compose stack includes a `memcached` service and the PHP container has the extension enabled. -- `config/config.php` is generated and gitignored by default; adjust `.gitignore` if you want to commit it. +```bash +docker compose down +``` + +## Produktion (Docker) + +Es gibt eine getrennte Produktions-Compose: + +- Dev: `/docker-compose.yml` +- Prod: `/docker-compose.prod.yml` + +Grundschritte: + +1. `.env` auf Produktionswerte setzen (siehe `/.env.prod.example`) +2. Domain und TLS in `/docker/nginx/prod.conf` anpassen +3. Zertifikate unter `/docker/nginx/certs/` bereitstellen +4. Starten: + +```bash +docker compose -f docker-compose.prod.yml up --build -d +``` + +Details stehen in: + +- `/docs/docker-lokal.md` +- `/docs/docker-produktiv.md` +- `/docs/docker-betrieb.md` (Übersicht) + +## Seed-Daten + +Die Initialisierung liegt in `/db/init/init.sql`. + +Standard-Demo-User: + +- E-Mail: `demo@user.com` +- Passwort: `Demo123` +- Tenant: `Cronus` +- Abteilung: `IT` +- Rolle: `Admin` + +## Projektstruktur + +- `/config` Konfiguration (Routen, Settings, Themes, Assets) +- `/db/init` Datenbankschema + Seeds +- `/lib` Backend-Code (`Repository`, `Service`, `Http`, `Support`) +- `/pages` Action-Layer + Views pro Route +- `/templates` Layouts + wiederverwendbare Partials +- `/web` oeffentliche Assets (CSS/JS/Vendor) +- `/i18n` Uebersetzungen +- `/tests` PHPUnit-Tests +- `/docs` Projekt-Dokumentation + +## Architektur-Kurzregeln + +- Keine DB-Zugriffe in `.phtml`-Views. +- SQL bleibt in Repositories. +- Geschaeftslogik bleibt in Services. +- `pages/.../*.php` fungiert als Action-/Controller-Schicht. +- Permissions + Tenant-Scope werden in Actions/Services erzwungen. +- Wiederkehrende UI-Bloecke als Partials zentralisieren. + +## UI-Konventionen + +Edit-Seiten verwenden einheitliche Tab-Struktur: + +- erster Tab: `Master data` +- letzter Tab: `Visibility` +- optional letzter Tab: `Danger zone` + +User-Organisation: + +- Departments werden tenantweise angezeigt (ein Multi-Select je ausgewaehltem Tenant). +- Nach Tenant-Aenderungen im selben Formular werden Department-Optionen nach Speichern/Reload aktualisiert (kein Live-Update). + +Zentrale Partials: + +- `/templates/partials/app-details-titlebar.phtml` +- `/templates/partials/app-visibility-status-field.phtml` +- `/templates/partials/app-danger-zone-delete-field.phtml` +- `/templates/partials/app-details-aside-audit.phtml` +- `/templates/partials/app-details-aside-ids.phtml` + +## Onboarding-PDF (User) + +- Permission: `users.access_pdf` +- Single: `admin/users/access-pdf/{uuid}` (inline im neuen Tab) +- Bulk: `admin/users/access-pdf-bulk` (POST, ZIP mit einer PDF pro User) +- Hard limit: maximal 100 User pro Bulk-Request +- Benoetigte Extensions: `ext-zip`, `ext-dom`, `ext-mbstring` +- Bestehende Installationen: Schema-/Permission-Updates als idempotentes SQL-Update ausfuehren + +## Microsoft SSO (Tenant, Entra ID) + +- Zentraler Einstieg: `login` (optional mit Tenant-Hint `login?tenant={tenant-slug}`) +- OIDC-Endpunkte: `auth/microsoft/start` und `auth/microsoft/callback` +- Tenant-Config liegt in `tenant_auth_microsoft` +- Externe User-Linkung liegt in `user_external_identities` +- Optionaler Profil-Sync pro Tenant: + - `sync_profile_on_login` aktiviert Sync bei jedem Microsoft-Login + - `sync_profile_fields`: `first_name`, `last_name`, `phone`, `mobile`, `avatar` + - Default-Felder bei aktivem Sync ohne Auswahl: `first_name,last_name` + - `phone/mobile/avatar` nutzen Microsoft Graph (`User.Read`) +- Shared App Credentials werden in Settings gepflegt (optional Tenant-Overrides) +- Permission fuer Tenant-SSO-Konfiguration: `tenants.sso_manage` +- Bestandsinstallationen: Schema-/Permission-Updates als idempotentes SQL-Update ausfuehren +- Wichtig: `APP_CRYPTO_KEY` in `.env` setzen, sonst koennen SSO-Secrets nicht gespeichert/verwendet werden + +## Asset- und Frontend-System + +- CSS-Layer-Entrypoint: `/web/css/app-layers.css` +- Vendor-Overrides: `/web/css/vendor-overrides` +- Asset-Gruppen: `/config/assets.php` +- Template-Styles: `renderTemplateStyles('default'|'login')` +- Seiten-Styles per Action: `Buffer::set('style_groups', ...)` +- JS-Bootstrap: + - `/web/js/app-boot.js` (frueh, ohne Module) + - `/web/js/app-init.js` (Standardseiten) + - `/web/js/app-login-init.js` (Loginseiten) + +## Qualitaetschecks + +Alle Befehle aus dem Projekt-Root ausfuehren. + +### PHPUnit + +```bash +docker compose exec php vendor/bin/phpunit --bootstrap vendor/autoload.php +``` + +Gezielt: + +```bash +docker compose exec php vendor/bin/phpunit --bootstrap vendor/autoload.php tests/I18n/TranslationKeysTest.php +docker compose exec php vendor/bin/phpunit --bootstrap vendor/autoload.php tests/Repository/RepoQueryTest.php +``` + +### PHPStan + +```bash +docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress +``` + +### ESLint (Frontend-JS) + +```bash +npx eslint web/js +``` + +## Dokumentation + +- `/docs/index.md` Einstieg und Dokumentationsindex +- `/docs/architektur.md` Request-Flow und Layering +- `/docs/konventionen.md` Coding- und UI-Konventionen +- `/docs/sicherheitsmodell.md` Auth/Permission/Tenant-Scope +- `/docs/entwickler-checkliste.md` konkrete Checkliste fuer Aenderungen +- `/docs/benutzerdefinierte-felder.md` Quick Reference fuer Tenant/User-Zusatzfelder +- `/docs/api.md` API v1 Referenz (inkl. Self-Service Token-Endpunkte) +- `/docs/einstellungen-speicherung.md` Globale Settings: DB als Quelle, `config/settings.php` als Teil-Cache +- `/docs/frontend-javascript.md` Frontend-JS-Struktur +- `/docs/frontend-css.md` Frontend-CSS-Struktur +- `/docs/docker-lokal.md` Docker lokal (Entwicklung) +- `/docs/docker-produktiv.md` Docker produktiv (Serverbetrieb) +- `/docs/docker-betrieb.md` Dev/Prod Docker-Betrieb + +## Troubleshooting + +- Verhalten wirkt stale nach groesseren Refactors: + +```bash +docker compose restart php nginx +``` + +- Uebersetzungs-Keys fehlen: + +```bash +docker compose exec php vendor/bin/phpunit --bootstrap vendor/autoload.php tests/I18n/TranslationKeysTest.php +``` + +- Favicon/Logo aus Storage fehlt: + - Symlink und Tenant-Kontext pruefen (`web/favicon/tenants -> storage/tenants`). + +## Lizenz + +MIT (siehe `/LICENSE.md`) diff --git a/bin/scheduler-run.php b/bin/scheduler-run.php new file mode 100755 index 0000000..db5a406 --- /dev/null +++ b/bin/scheduler-run.php @@ -0,0 +1,39 @@ +#!/usr/bin/env php +getMessage())); + $exitCode = 1; +} finally { + DB::close(); +} +exit($exitCode); diff --git a/composer.json b/composer.json index 8989d23..e3ec0dc 100644 --- a/composer.json +++ b/composer.json @@ -12,13 +12,17 @@ "require": { "php": "^8.5", "mintyphp/core": "*", - "phpmailer/phpmailer": "^7.0" + "phpmailer/phpmailer": "^7.0", + "dompdf/dompdf": "^3.1", + "endroid/qr-code": "^6.0", + "league/commonmark": "^2.8" }, "require-dev": { "mintyphp/tools": "*", "mintyphp/debugger": "*", "phpunit/phpunit": "*", - "phpstan/phpstan": "^1.9" + "phpstan/phpstan": "^1.9", + "icanhazstring/composer-unused": "^0.9.6" }, "autoload-dev": { "psr-4": { diff --git a/composer.lock b/composer.lock index 669090b..95bf95c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,671 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b811586ea9f2ec608a5ce87c611f8f76", + "content-hash": "5abdc17eb52371dc1f353dca0cb0b9ca", "packages": [ + { + "name": "bacon/bacon-qr-code", + "version": "v3.0.3", + "source": { + "type": "git", + "url": "https://github.com/Bacon/BaconQrCode.git", + "reference": "36a1cb2b81493fa5b82e50bf8068bf84d1542563" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/36a1cb2b81493fa5b82e50bf8068bf84d1542563", + "reference": "36a1cb2b81493fa5b82e50bf8068bf84d1542563", + "shasum": "" + }, + "require": { + "dasprid/enum": "^1.0.3", + "ext-iconv": "*", + "php": "^8.1" + }, + "require-dev": { + "phly/keep-a-changelog": "^2.12", + "phpunit/phpunit": "^10.5.11 || ^11.0.4", + "spatie/phpunit-snapshot-assertions": "^5.1.5", + "spatie/pixelmatch-php": "^1.2.0", + "squizlabs/php_codesniffer": "^3.9" + }, + "suggest": { + "ext-imagick": "to generate QR code images" + }, + "type": "library", + "autoload": { + "psr-4": { + "BaconQrCode\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "BaconQrCode is a QR code generator for PHP.", + "homepage": "https://github.com/Bacon/BaconQrCode", + "support": { + "issues": "https://github.com/Bacon/BaconQrCode/issues", + "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.3" + }, + "time": "2025-11-19T17:15:36+00:00" + }, + { + "name": "dasprid/enum", + "version": "1.0.7", + "source": { + "type": "git", + "url": "https://github.com/DASPRiD/Enum.git", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "shasum": "" + }, + "require": { + "php": ">=7.1 <9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "DASPRiD\\Enum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "PHP 7.1 enum implementation", + "keywords": [ + "enum", + "map" + ], + "support": { + "issues": "https://github.com/DASPRiD/Enum/issues", + "source": "https://github.com/DASPRiD/Enum/tree/1.0.7" + }, + "time": "2025-09-16T12:23:56+00:00" + }, + { + "name": "dflydev/dot-access-data", + "version": "v3.0.3", + "source": { + "type": "git", + "url": "https://github.com/dflydev/dflydev-dot-access-data.git", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3", + "scrutinizer/ocular": "1.6.0", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Dflydev\\DotAccessData\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dragonfly Development Inc.", + "email": "info@dflydev.com", + "homepage": "http://dflydev.com" + }, + { + "name": "Beau Simensen", + "email": "beau@dflydev.com", + "homepage": "http://beausimensen.com" + }, + { + "name": "Carlos Frutos", + "email": "carlos@kiwing.it", + "homepage": "https://github.com/cfrutos" + }, + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com" + } + ], + "description": "Given a deep data structure, access data by dot notation.", + "homepage": "https://github.com/dflydev/dflydev-dot-access-data", + "keywords": [ + "access", + "data", + "dot", + "notation" + ], + "support": { + "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", + "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" + }, + "time": "2024-07-08T12:26:09+00:00" + }, + { + "name": "dompdf/dompdf", + "version": "v3.1.4", + "source": { + "type": "git", + "url": "https://github.com/dompdf/dompdf.git", + "reference": "db712c90c5b9868df3600e64e68da62e78a34623" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/db712c90c5b9868df3600e64e68da62e78a34623", + "reference": "db712c90c5b9868df3600e64e68da62e78a34623", + "shasum": "" + }, + "require": { + "dompdf/php-font-lib": "^1.0.0", + "dompdf/php-svg-lib": "^1.0.0", + "ext-dom": "*", + "ext-mbstring": "*", + "masterminds/html5": "^2.0", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "ext-gd": "*", + "ext-json": "*", + "ext-zip": "*", + "mockery/mockery": "^1.3", + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.5", + "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0" + }, + "suggest": { + "ext-gd": "Needed to process images", + "ext-gmagick": "Improves image processing performance", + "ext-imagick": "Improves image processing performance", + "ext-zlib": "Needed for pdf stream compression" + }, + "type": "library", + "autoload": { + "psr-4": { + "Dompdf\\": "src/" + }, + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1" + ], + "authors": [ + { + "name": "The Dompdf Community", + "homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md" + } + ], + "description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter", + "homepage": "https://github.com/dompdf/dompdf", + "support": { + "issues": "https://github.com/dompdf/dompdf/issues", + "source": "https://github.com/dompdf/dompdf/tree/v3.1.4" + }, + "time": "2025-10-29T12:43:30+00:00" + }, + { + "name": "dompdf/php-font-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-font-lib.git", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12" + }, + "type": "library", + "autoload": { + "psr-4": { + "FontLib\\": "src/FontLib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "The FontLib Community", + "homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse, export and make subsets of different types of font files.", + "homepage": "https://github.com/dompdf/php-font-lib", + "support": { + "issues": "https://github.com/dompdf/php-font-lib/issues", + "source": "https://github.com/dompdf/php-font-lib/tree/1.0.2" + }, + "time": "2026-01-20T14:10:26+00:00" + }, + { + "name": "dompdf/php-svg-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-svg-lib.git", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0", + "sabberworm/php-css-parser": "^8.4 || ^9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11" + }, + "type": "library", + "autoload": { + "psr-4": { + "Svg\\": "src/Svg" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "The SvgLib Community", + "homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse and export to PDF SVG files.", + "homepage": "https://github.com/dompdf/php-svg-lib", + "support": { + "issues": "https://github.com/dompdf/php-svg-lib/issues", + "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2" + }, + "time": "2026-01-02T16:01:13+00:00" + }, + { + "name": "endroid/qr-code", + "version": "6.1.3", + "source": { + "type": "git", + "url": "https://github.com/endroid/qr-code.git", + "reference": "5fa534856ed95649d67c0eab0cabc03ab1d8e0e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/endroid/qr-code/zipball/5fa534856ed95649d67c0eab0cabc03ab1d8e0e2", + "reference": "5fa534856ed95649d67c0eab0cabc03ab1d8e0e2", + "shasum": "" + }, + "require": { + "bacon/bacon-qr-code": "^3.0", + "php": "^8.4" + }, + "require-dev": { + "endroid/quality": "dev-main", + "ext-gd": "*", + "khanamiryan/qrcode-detector-decoder": "^2.0.3", + "setasign/fpdf": "^1.8.2" + }, + "suggest": { + "ext-gd": "Enables you to write PNG images", + "khanamiryan/qrcode-detector-decoder": "Enables you to use the image validator", + "roave/security-advisories": "Makes sure package versions with known security issues are not installed", + "setasign/fpdf": "Enables you to use the PDF writer" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.x-dev" + } + }, + "autoload": { + "psr-4": { + "Endroid\\QrCode\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jeroen van den Enden", + "email": "info@endroid.nl" + } + ], + "description": "Endroid QR Code", + "homepage": "https://github.com/endroid/qr-code", + "keywords": [ + "code", + "endroid", + "php", + "qr", + "qrcode" + ], + "support": { + "issues": "https://github.com/endroid/qr-code/issues", + "source": "https://github.com/endroid/qr-code/tree/6.1.3" + }, + "funding": [ + { + "url": "https://github.com/endroid", + "type": "github" + } + ], + "time": "2026-02-05T07:01:58+00:00" + }, + { + "name": "league/commonmark", + "version": "2.8.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/4efa10c1e56488e658d10adf7b7b7dcd19940bfb", + "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/config": "^1.1.1", + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "cebe/markdown": "^1.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", + "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", + "erusev/parsedown": "^1.0", + "ext-json": "*", + "github/gfm": "0.29.0", + "michelf/php-markdown": "^1.4 || ^2.0", + "nyholm/psr7": "^1.5", + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0", + "unleashedtech/php-coding-standard": "^3.1.1", + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" + }, + "suggest": { + "symfony/yaml": "v2.3+ required if using the Front Matter extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.9-dev" + } + }, + "autoload": { + "psr-4": { + "League\\CommonMark\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", + "homepage": "https://commonmark.thephpleague.com", + "keywords": [ + "commonmark", + "flavored", + "gfm", + "github", + "github-flavored", + "markdown", + "md", + "parser" + ], + "support": { + "docs": "https://commonmark.thephpleague.com/", + "forum": "https://github.com/thephpleague/commonmark/discussions", + "issues": "https://github.com/thephpleague/commonmark/issues", + "rss": "https://github.com/thephpleague/commonmark/releases.atom", + "source": "https://github.com/thephpleague/commonmark" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/commonmark", + "type": "tidelift" + } + ], + "time": "2025-11-26T21:48:24+00:00" + }, + { + "name": "league/config", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/config.git", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "shasum": "" + }, + "require": { + "dflydev/dot-access-data": "^3.0.1", + "nette/schema": "^1.2", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.5", + "scrutinizer/ocular": "^1.8.1", + "unleashedtech/php-coding-standard": "^3.1", + "vimeo/psalm": "^4.7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Config\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Define configuration arrays with strict schemas and access values with dot notation", + "homepage": "https://config.thephpleague.com", + "keywords": [ + "array", + "config", + "configuration", + "dot", + "dot-access", + "nested", + "schema" + ], + "support": { + "docs": "https://config.thephpleague.com/", + "issues": "https://github.com/thephpleague/config/issues", + "rss": "https://github.com/thephpleague/config/releases.atom", + "source": "https://github.com/thephpleague/config" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + } + ], + "time": "2022-12-11T20:36:23+00:00" + }, + { + "name": "masterminds/html5", + "version": "2.10.0", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "fcf91eb64359852f00d921887b219479b4f21251" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", + "reference": "fcf91eb64359852f00d921887b219479b4f21251", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" + }, + "time": "2025-07-25T09:04:22+00:00" + }, { "name": "mintyphp/core", "version": "v3.2.0", @@ -51,6 +714,162 @@ }, "time": "2025-02-23T15:53:20+00:00" }, + { + "name": "nette/schema", + "version": "v1.3.4", + "source": { + "type": "git", + "url": "https://github.com/nette/schema.git", + "reference": "086497a2f34b82fede9b5a41cc8e131d087cd8f7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/schema/zipball/086497a2f34b82fede9b5a41cc8e131d087cd8f7", + "reference": "086497a2f34b82fede9b5a41cc8e131d087cd8f7", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0", + "php": "8.1 - 8.5" + }, + "require-dev": { + "nette/tester": "^2.6", + "phpstan/phpstan": "^2.0@stable", + "tracy/tracy": "^2.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "📐 Nette Schema: validating data structures against a given Schema.", + "homepage": "https://nette.org", + "keywords": [ + "config", + "nette" + ], + "support": { + "issues": "https://github.com/nette/schema/issues", + "source": "https://github.com/nette/schema/tree/v1.3.4" + }, + "time": "2026-02-08T02:54:00+00:00" + }, + { + "name": "nette/utils", + "version": "v4.1.3", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/bb3ea637e3d131d72acc033cfc2746ee893349fe", + "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe", + "shasum": "" + }, + "require": { + "php": "8.2 - 8.5" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.5", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.1.3" + }, + "time": "2026-02-13T03:05:33+00:00" + }, { "name": "phpmailer/phpmailer", "version": "v7.0.2", @@ -132,9 +951,787 @@ } ], "time": "2026-01-09T18:02:33+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "sabberworm/php-css-parser", + "version": "v9.1.0", + "source": { + "type": "git", + "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", + "reference": "1b363fdbdc6dd0ca0f4bf98d3a4d7f388133f1fb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/1b363fdbdc6dd0ca0f4bf98d3a4d7f388133f1fb", + "reference": "1b363fdbdc6dd0ca0f4bf98d3a4d7f388133f1fb", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.3" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "1.4.0", + "phpstan/extension-installer": "1.4.3", + "phpstan/phpstan": "1.12.28 || 2.1.25", + "phpstan/phpstan-phpunit": "1.4.2 || 2.0.7", + "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.6", + "phpunit/phpunit": "8.5.46", + "rawr/phpunit-data-provider": "3.3.1", + "rector/rector": "1.2.10 || 2.1.7", + "rector/type-perfect": "1.0.0 || 2.1.0" + }, + "suggest": { + "ext-mbstring": "for parsing UTF-8 CSS" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Sabberworm\\CSS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Raphael Schweikert" + }, + { + "name": "Oliver Klee", + "email": "github@oliverklee.de" + }, + { + "name": "Jake Hotson", + "email": "jake.github@qzdesign.co.uk" + } + ], + "description": "Parser for CSS Files written in PHP", + "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser", + "keywords": [ + "css", + "parser", + "stylesheet" + ], + "support": { + "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.1.0" + }, + "time": "2025-09-14T07:37:21+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-01-02T08:10:11+00:00" + }, + { + "name": "thecodingmachine/safe", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/thecodingmachine/safe.git", + "reference": "2cdd579eeaa2e78e51c7509b50cc9fb89a956236" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/2cdd579eeaa2e78e51c7509b50cc9fb89a956236", + "reference": "2cdd579eeaa2e78e51c7509b50cc9fb89a956236", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10", + "squizlabs/php_codesniffer": "^3.2" + }, + "type": "library", + "autoload": { + "files": [ + "lib/special_cases.php", + "generated/apache.php", + "generated/apcu.php", + "generated/array.php", + "generated/bzip2.php", + "generated/calendar.php", + "generated/classobj.php", + "generated/com.php", + "generated/cubrid.php", + "generated/curl.php", + "generated/datetime.php", + "generated/dir.php", + "generated/eio.php", + "generated/errorfunc.php", + "generated/exec.php", + "generated/fileinfo.php", + "generated/filesystem.php", + "generated/filter.php", + "generated/fpm.php", + "generated/ftp.php", + "generated/funchand.php", + "generated/gettext.php", + "generated/gmp.php", + "generated/gnupg.php", + "generated/hash.php", + "generated/ibase.php", + "generated/ibmDb2.php", + "generated/iconv.php", + "generated/image.php", + "generated/imap.php", + "generated/info.php", + "generated/inotify.php", + "generated/json.php", + "generated/ldap.php", + "generated/libxml.php", + "generated/lzf.php", + "generated/mailparse.php", + "generated/mbstring.php", + "generated/misc.php", + "generated/mysql.php", + "generated/mysqli.php", + "generated/network.php", + "generated/oci8.php", + "generated/opcache.php", + "generated/openssl.php", + "generated/outcontrol.php", + "generated/pcntl.php", + "generated/pcre.php", + "generated/pgsql.php", + "generated/posix.php", + "generated/ps.php", + "generated/pspell.php", + "generated/readline.php", + "generated/rnp.php", + "generated/rpminfo.php", + "generated/rrd.php", + "generated/sem.php", + "generated/session.php", + "generated/shmop.php", + "generated/sockets.php", + "generated/sodium.php", + "generated/solr.php", + "generated/spl.php", + "generated/sqlsrv.php", + "generated/ssdeep.php", + "generated/ssh2.php", + "generated/stream.php", + "generated/strings.php", + "generated/swoole.php", + "generated/uodbc.php", + "generated/uopz.php", + "generated/url.php", + "generated/var.php", + "generated/xdiff.php", + "generated/xml.php", + "generated/xmlrpc.php", + "generated/yaml.php", + "generated/yaz.php", + "generated/zip.php", + "generated/zlib.php" + ], + "classmap": [ + "lib/DateTime.php", + "lib/DateTimeImmutable.php", + "lib/Exceptions/", + "generated/Exceptions/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHP core functions that throw exceptions instead of returning FALSE on error", + "support": { + "issues": "https://github.com/thecodingmachine/safe/issues", + "source": "https://github.com/thecodingmachine/safe/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://github.com/OskarStark", + "type": "github" + }, + { + "url": "https://github.com/shish", + "type": "github" + }, + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2025-05-14T06:15:44+00:00" } ], "packages-dev": [ + { + "name": "composer-unused/contracts", + "version": "0.3.0", + "source": { + "type": "git", + "url": "https://github.com/composer-unused/contracts.git", + "reference": "5ec448d3ee80735dccad6a21a3266c377d0845ae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer-unused/contracts/zipball/5ec448d3ee80735dccad6a21a3266c377d0845ae", + "reference": "5ec448d3ee80735dccad6a21a3266c377d0845ae", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "ComposerUnused\\Contracts\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Frömer", + "email": "composer-unused@icanhazstring.com" + } + ], + "description": "Contract repository for composer-unused", + "support": { + "issues": "https://github.com/composer-unused/contracts/issues", + "source": "https://github.com/composer-unused/contracts/tree/0.3.0" + }, + "funding": [ + { + "url": "https://github.com/icanhazstring", + "type": "github" + } + ], + "time": "2023-03-17T00:41:49+00:00" + }, + { + "name": "composer-unused/symbol-parser", + "version": "0.3.3", + "source": { + "type": "git", + "url": "https://github.com/composer-unused/symbol-parser.git", + "reference": "afa62007cca768bd1ecbc0e8ed347c675c239410" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer-unused/symbol-parser/zipball/afa62007cca768bd1ecbc0e8ed347c675c239410", + "reference": "afa62007cca768bd1ecbc0e8ed347c675c239410", + "shasum": "" + }, + "require": { + "composer-unused/contracts": "^0.3", + "nikic/php-parser": "^5.0", + "php": "^7.4 || ^8.0", + "phpstan/phpdoc-parser": "^1.25 || ^2", + "psr/container": "^1.0 || ^2.0", + "psr/log": "^1.1 || ^2 || ^3", + "symfony/finder": "^5.3 || ^6.0 || ^7.0 || ^8.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.49", + "ext-ds": "*", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^10.5", + "roave/security-advisories": "dev-master", + "squizlabs/php_codesniffer": "^4.0.1", + "symfony/property-access": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/serializer": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "ComposerUnused\\SymbolParser\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Frömer", + "email": "composer-unused@icanhazstring.com" + } + ], + "description": "Toolkit to parse symbols from a composer package", + "homepage": "https://github.com/composer-unused/symbol-parser", + "keywords": [ + "composer", + "parser", + "symbol" + ], + "support": { + "issues": "https://github.com/composer-unused/symbol-parser/issues", + "source": "https://github.com/composer-unused/symbol-parser" + }, + "funding": [ + { + "url": "https://github.com/sponsors/icanhazstring", + "type": "github" + }, + { + "url": "https://paypal.me/icanhazstring", + "type": "other" + } + ], + "time": "2026-01-29T13:38:57+00:00" + }, + { + "name": "composer/pcre", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-11-12T16:29:46+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+00:00" + }, + { + "name": "icanhazstring/composer-unused", + "version": "0.9.6", + "source": { + "type": "git", + "url": "https://github.com/composer-unused/composer-unused.git", + "reference": "c60030af7954a528746dd2180c10b5e0871e84c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer-unused/composer-unused/zipball/c60030af7954a528746dd2180c10b5e0871e84c7", + "reference": "c60030af7954a528746dd2180c10b5e0871e84c7", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.0", + "composer-unused/contracts": "^0.3", + "composer-unused/symbol-parser": "^0.3.1", + "composer/xdebug-handler": "^3.0", + "ext-json": "*", + "nikic/php-parser": "^5.0", + "ondram/ci-detector": "^4.1", + "php": "^8.1", + "phpstan/phpdoc-parser": "^1.25 || ^2", + "psr/container": "^1.0 || ^2.0", + "psr/log": "^1.1 || ^2 || ^3", + "symfony/config": "^6.0 || ^7.0 || ^8.0", + "symfony/console": "^6.0 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^6.0 || ^7.0 || ^8.0", + "symfony/property-access": "^6.0 || ^7.0 || ^8.0", + "symfony/serializer": "^6.0 || ^7.0 || ^8.0", + "webmozart/assert": "^1.10 || ^2.0", + "webmozart/glob": "^4.4" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8", + "codeception/verify": "^3.1", + "dg/bypass-finals": "^1.6", + "ergebnis/composer-normalize": "^2.49", + "ext-ds": "*", + "ext-zend-opcache": "*", + "jangregor/phpstan-prophecy": "^2.1.1", + "mikey179/vfsstream": "^1.6.10", + "php-ds/php-ds": "^1.5", + "phpspec/prophecy-phpunit": "^2.2.0", + "phpstan/extension-installer": "^1.3", + "phpstan/phpstan": "^2.1.37", + "phpstan/phpstan-phpunit": "^2.0.12", + "phpunit/phpunit": "^9.6.34", + "roave/security-advisories": "dev-master", + "squizlabs/php_codesniffer": "^3.13" + }, + "bin": [ + "bin/composer-unused" + ], + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": true + } + }, + "autoload": { + "psr-4": { + "ComposerUnused\\ComposerUnused\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Frömer", + "email": "composer-unused@icanhazstring.com" + } + ], + "description": "Show unused packages by scanning your code", + "homepage": "https://github.com/composer-unused/composer-unused", + "keywords": [ + "composer", + "php-parser", + "static analysis", + "unused" + ], + "support": { + "issues": "https://github.com/composer-unused/composer-unused/issues", + "source": "https://github.com/composer-unused/composer-unused" + }, + "funding": [ + { + "url": "https://github.com/sponsors/icanhazstring", + "type": "github" + }, + { + "url": "https://paypal.me/icanhazstring", + "type": "other" + } + ], + "time": "2026-01-30T05:52:24+00:00" + }, { "name": "mintyphp/debugger", "version": "v3.0.0", @@ -323,6 +1920,84 @@ }, "time": "2024-12-30T11:07:19+00:00" }, + { + "name": "ondram/ci-detector", + "version": "4.2.0", + "source": { + "type": "git", + "url": "https://github.com/OndraM/ci-detector.git", + "reference": "8b0223b5ed235fd377c75fdd1bfcad05c0f168b8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/OndraM/ci-detector/zipball/8b0223b5ed235fd377c75fdd1bfcad05c0f168b8", + "reference": "8b0223b5ed235fd377c75fdd1bfcad05c0f168b8", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.13.2", + "lmc/coding-standard": "^3.0.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.1.0", + "phpstan/phpstan": "^1.2.0", + "phpstan/phpstan-phpunit": "^1.0.0", + "phpunit/phpunit": "^9.6.13" + }, + "type": "library", + "autoload": { + "psr-4": { + "OndraM\\CiDetector\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ondřej Machulda", + "email": "ondrej.machulda@gmail.com" + } + ], + "description": "Detect continuous integration environment and provide unified access to properties of current build", + "keywords": [ + "CircleCI", + "Codeship", + "Wercker", + "adapter", + "appveyor", + "aws", + "aws codebuild", + "azure", + "azure devops", + "azure pipelines", + "bamboo", + "bitbucket", + "buddy", + "ci-info", + "codebuild", + "continuous integration", + "continuousphp", + "devops", + "drone", + "github", + "gitlab", + "interface", + "jenkins", + "pipelines", + "sourcehut", + "teamcity", + "travis" + ], + "support": { + "issues": "https://github.com/OndraM/ci-detector/issues", + "source": "https://github.com/OndraM/ci-detector/tree/4.2.0" + }, + "time": "2024-03-12T13:22:30+00:00" + }, { "name": "phar-io/manifest", "version": "2.0.4", @@ -441,6 +2116,53 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.2", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" + }, + "time": "2026-01-25T14:56:51+00:00" + }, { "name": "phpstan/phpstan", "version": "1.12.19", @@ -918,6 +2640,109 @@ ], "time": "2025-02-21T06:10:40+00:00" }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, { "name": "sebastian/cli-parser", "version": "4.0.0", @@ -1783,6 +3608,1330 @@ ], "time": "2024-10-20T05:08:20+00:00" }, + { + "name": "symfony/config", + "version": "v8.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/config.git", + "reference": "8f45af92f08f82902827a8b6f403aaf49d893539" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/config/zipball/8f45af92f08f82902827a8b6f403aaf49d893539", + "reference": "8f45af92f08f82902827a8b6f403aaf49d893539", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/filesystem": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/service-contracts": "<2.5" + }, + "require-dev": { + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Config\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/config/tree/v8.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-13T13:06:50+00:00" + }, + { + "name": "symfony/console", + "version": "v8.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "ace03c4cf9805080ff40cbeec69fca180c339a3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/ace03c4cf9805080ff40cbeec69fca180c339a3b", + "reference": "ace03c4cf9805080ff40cbeec69fca180c339a3b", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.4|^8.0" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v8.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-13T13:06:50+00:00" + }, + { + "name": "symfony/dependency-injection", + "version": "v8.0.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/dependency-injection.git", + "reference": "40a6c455ade7e3bf25900d6b746d40cfa2573e26" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/40a6c455ade7e3bf25900d6b746d40cfa2573e26", + "reference": "40a6c455ade7e3bf25900d6b746d40cfa2573e26", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^3.6", + "symfony/var-exporter": "^7.4|^8.0" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "symfony/service-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "symfony/config": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DependencyInjection\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows you to standardize and centralize the way objects are constructed in your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dependency-injection/tree/v8.0.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-27T16:18:07+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v8.0.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "d937d400b980523dc9ee946bb69972b5e619058d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/d937d400b980523dc9ee946bb69972b5e619058d", + "reference": "d937d400b980523dc9ee946bb69972b5e619058d", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v8.0.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-12-01T09:13:36+00:00" + }, + { + "name": "symfony/finder", + "version": "v8.0.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "8bd576e97c67d45941365bf824e18dc8538e6eb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/8bd576e97c67d45941365bf824e18dc8538e6eb0", + "reference": "8bd576e97c67d45941365bf824e18dc8538e6eb0", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "symfony/filesystem": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v8.0.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-26T15:08:38+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-27T09:58:17+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "3833d7255cc303546435cb650316bff708a1c75c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-12-23T08:48:59+00:00" + }, + { + "name": "symfony/property-access", + "version": "v8.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-access.git", + "reference": "a35a5ec85b605d0d1a9fd802cb44d87682c746fd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-access/zipball/a35a5ec85b605d0d1a9fd802cb44d87682c746fd", + "reference": "a35a5ec85b605d0d1a9fd802cb44d87682c746fd", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/property-info": "^7.4.4|^8.0.4" + }, + "require-dev": { + "symfony/cache": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyAccess\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides functions to read and write from/to an object or array using a simple string notation", + "homepage": "https://symfony.com", + "keywords": [ + "access", + "array", + "extraction", + "index", + "injection", + "object", + "property", + "property-path", + "reflection" + ], + "support": { + "source": "https://github.com/symfony/property-access/tree/v8.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-05T09:27:50+00:00" + }, + { + "name": "symfony/property-info", + "version": "v8.0.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-info.git", + "reference": "9d987224b54758240e80a062c5e414431bbf84de" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-info/zipball/9d987224b54758240e80a062c5e414431bbf84de", + "reference": "9d987224b54758240e80a062c5e414431bbf84de", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/string": "^7.4|^8.0", + "symfony/type-info": "^7.4.4|^8.0.4" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/type-resolver": "<1.5.1" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "symfony/cache": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts information about PHP class' properties using metadata of popular sources", + "homepage": "https://symfony.com", + "keywords": [ + "doctrine", + "phpdoc", + "property", + "symfony", + "type", + "validator" + ], + "support": { + "source": "https://github.com/symfony/property-info/tree/v8.0.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-27T16:18:07+00:00" + }, + { + "name": "symfony/serializer", + "version": "v8.0.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/serializer.git", + "reference": "867a38a1927d23a503f7248aa182032c6ea42702" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/serializer/zipball/867a38a1927d23a503f7248aa182032c6ea42702", + "reference": "867a38a1927d23a503f7248aa182032c6ea42702", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/property-info": "<7.3" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "seld/jsonlint": "^1.10", + "symfony/cache": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/filesystem": "^7.4|^8.0", + "symfony/form": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/property-info": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/type-info": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Serializer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/serializer/tree/v8.0.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-27T09:06:43+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.6.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-15T11:30:57+00:00" + }, + { + "name": "symfony/string", + "version": "v8.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "758b372d6882506821ed666032e43020c4f57194" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/758b372d6882506821ed666032e43020c4f57194", + "reference": "758b372d6882506821ed666032e43020c4f57194", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v8.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-12T12:37:40+00:00" + }, + { + "name": "symfony/type-info", + "version": "v8.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/type-info.git", + "reference": "106a2d3bbf0d4576b2f70e6ca866fa420956ed0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/type-info/zipball/106a2d3bbf0d4576b2f70e6ca866fa420956ed0d", + "reference": "106a2d3bbf0d4576b2f70e6ca866fa420956ed0d", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/container": "^1.1|^2.0" + }, + "conflict": { + "phpstan/phpdoc-parser": "<1.30" + }, + "require-dev": { + "phpstan/phpdoc-parser": "^1.30|^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\TypeInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Baptiste LEDUC", + "email": "baptiste.leduc@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts PHP types information.", + "homepage": "https://symfony.com", + "keywords": [ + "PHPStan", + "phpdoc", + "symfony", + "type" + ], + "support": { + "source": "https://github.com/symfony/type-info/tree/v8.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-09T12:15:10+00:00" + }, + { + "name": "symfony/var-exporter", + "version": "v8.0.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-exporter.git", + "reference": "7345f46c251f2eb27c7b3ebdb5bb076b3ffcae04" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/7345f46c251f2eb27c7b3ebdb5bb076b3ffcae04", + "reference": "7345f46c251f2eb27c7b3ebdb5bb076b3ffcae04", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "symfony/property-access": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows exporting any serializable PHP data structure to plain PHP code", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "export", + "hydrate", + "instantiate", + "lazy-loading", + "proxy", + "serialize" + ], + "support": { + "source": "https://github.com/symfony/var-exporter/tree/v8.0.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-05T18:53:00+00:00" + }, { "name": "theseer/tokenizer", "version": "1.2.3", @@ -1832,6 +4981,117 @@ } ], "time": "2024-03-03T12:36:25+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.1.5", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "79155f94852fa27e2f73b459f6503f5e87e2c188" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/79155f94852fa27e2f73b459f6503f5e87e2c188", + "reference": "79155f94852fa27e2f73b459f6503f5e87e2c188", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-feature/2-0": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.1.5" + }, + "time": "2026-02-18T14:09:36+00:00" + }, + { + "name": "webmozart/glob", + "version": "4.7.0", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/glob.git", + "reference": "8a2842112d6916e61e0e15e316465b611f3abc17" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/glob/zipball/8a2842112d6916e61e0e15e316465b611f3abc17", + "reference": "8a2842112d6916e61e0e15e316465b611f3abc17", + "shasum": "" + }, + "require": { + "php": "^7.3 || ^8.0.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "symfony/filesystem": "^5.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Glob\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "A PHP implementation of Ant's glob.", + "support": { + "issues": "https://github.com/webmozarts/glob/issues", + "source": "https://github.com/webmozarts/glob/tree/4.7.0" + }, + "time": "2024-03-07T20:33:40+00:00" } ], "aliases": [], diff --git a/config/assets.php b/config/assets.php new file mode 100644 index 0000000..f4dbee3 --- /dev/null +++ b/config/assets.php @@ -0,0 +1,69 @@ + [ + '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'], + ], + ], +]; diff --git a/config/config.php.example b/config/config.php.example index 34828b0..2a1e035 100644 --- a/config/config.php.example +++ b/config/config.php.example @@ -9,52 +9,104 @@ use MintyPHP\I18n; use MintyPHP\Router; use MintyPHP\Session; -define('APP_NAME', getenv('APP_NAME') ?: 'IMVS'); -define('APP_SLOGAN', getenv('APP_SLOGAN') ?: 'Immobilien Makler Verkaufs Software'); -define('APP_TIMEZONE', getenv('APP_TIMEZONE') ?: 'Europe/Berlin'); -define('APP_STORAGE_PATH', getenv('APP_STORAGE_PATH') ?: __DIR__ . '/../storage'); -$tenantScopeEnv = getenv('TENANT_SCOPE_STRICT'); -define('TENANT_SCOPE_STRICT', $tenantScopeEnv === false ? true : filter_var($tenantScopeEnv, FILTER_VALIDATE_BOOLEAN)); +$envString = static function (string $key, string $default): string { + $value = getenv($key); + if ($value === false || $value === '') { + return $default; + } + return (string) $value; +}; + +$envInt = static function (string $key, int $default): int { + $value = getenv($key); + if ($value === false || $value === '') { + return $default; + } + $parsed = filter_var($value, FILTER_VALIDATE_INT); + return $parsed !== false ? (int) $parsed : $default; +}; + +$envFloat = static function (string $key, float $default): float { + $value = getenv($key); + if ($value === false || $value === '') { + return $default; + } + $parsed = filter_var($value, FILTER_VALIDATE_FLOAT); + return $parsed !== false ? (float) $parsed : $default; +}; + +$envBool = static function (string $key, bool $default): bool { + $value = getenv($key); + if ($value === false || $value === '') { + return $default; + } + $parsed = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + return $parsed ?? $default; +}; + +$envList = static function (string $key, array $default): array { + $value = getenv($key); + if ($value === false || $value === '') { + return $default; + } + return array_values(array_filter(array_map('trim', explode(',', (string) $value)))); +}; + +define('APP_NAME', $envString('APP_NAME', 'IMVS')); +define('APP_TIMEZONE', $envString('APP_TIMEZONE', 'Europe/Berlin')); +define('APP_STORAGE_PATH', $envString('APP_STORAGE_PATH', __DIR__ . '/../storage')); +define('APP_CRYPTO_KEY', $envString('APP_CRYPTO_KEY', '')); +define('TENANT_SCOPE_STRICT', $envBool('TENANT_SCOPE_STRICT', true)); if (!defined('APP_LOCALES')) { - $locales = getenv('APP_LOCALES') ?: 'de,en'; - define('APP_LOCALES', array_values(array_filter(array_map('trim', explode(',', $locales))))); + define('APP_LOCALES', $envList('APP_LOCALES', ['de', 'en'])); +} + +if (!defined('APP_ROUTE_DEFINITIONS')) { + $routesFile = __DIR__ . '/routes.php'; + $routeDefinitions = is_file($routesFile) ? include $routesFile : []; + if (!is_array($routeDefinitions)) { + $routeDefinitions = []; + } + define('APP_ROUTE_DEFINITIONS', $routeDefinitions); } if (!defined('APP_PUBLIC_PATHS')) { - define('APP_PUBLIC_PATHS', [ - 'login', - 'register', - 'password/forgot', - 'password/verify', - 'password/reset', - 'lang', - 'imprint', - 'privacy', - ]); + $publicPaths = []; + foreach (APP_ROUTE_DEFINITIONS as $route) { + if (!empty($route['public'])) { + $path = trim((string) ($route['path'] ?? '')); + if ($path !== '') { + $publicPaths[] = $path; + } + } + } + if (!$publicPaths) { + throw new RuntimeException('No public routes configured in config/routes.php'); + } + define('APP_PUBLIC_PATHS', array_values(array_unique($publicPaths))); } date_default_timezone_set(APP_TIMEZONE); -ini_set('date.timezone', APP_TIMEZONE); Router::$baseUrl = '/'; // default: '/' Router::$pageRoot = 'pages/'; // default: 'pages/' Router::$templateRoot = 'templates/'; // default: 'templates/' -Session::$sessionName = getenv('SESSION_NAME') ?: 'MintyPHP'; +Session::$sessionName = $envString('SESSION_NAME', 'MintyPHP'); -Firewall::$concurrency = (int) (getenv('FIREWALL_CONCURRENCY') ?: 10); -Firewall::$spinLockSeconds = (float) (getenv('FIREWALL_SPINLOCK_SECONDS') ?: 0.15); -Firewall::$intervalSeconds = (int) (getenv('FIREWALL_INTERVAL_SECONDS') ?: 300); -Firewall::$cachePrefix = getenv('FIREWALL_CACHE_PREFIX') ?: 'fw_concurrency_'; -Firewall::$reverseProxy = getenv('FIREWALL_REVERSE_PROXY') === 'true'; +Firewall::$concurrency = $envInt('FIREWALL_CONCURRENCY', 10); +Firewall::$spinLockSeconds = $envFloat('FIREWALL_SPINLOCK_SECONDS', 0.15); +Firewall::$intervalSeconds = $envInt('FIREWALL_INTERVAL_SECONDS', 300); +Firewall::$cachePrefix = $envString('FIREWALL_CACHE_PREFIX', 'fw_concurrency_'); +Firewall::$reverseProxy = $envBool('FIREWALL_REVERSE_PROXY', false); -Cache::$servers = getenv('CACHE_SERVERS') ?: '127.0.0.1'; +Cache::$servers = $envString('CACHE_SERVERS', '127.0.0.1'); -DB::$host = getenv('DB_HOST') ?: 'db'; -DB::$username = getenv('DB_USER') ?: 'mintyphp'; -DB::$password = getenv('DB_PASS') ?: 'mintyphp'; -DB::$database = getenv('DB_NAME') ?: 'mintyphp'; -DB::$port = (int) (getenv('DB_PORT') ?: 3306); +DB::$host = $envString('DB_HOST', 'db'); +DB::$username = $envString('DB_USER', 'mintyphp'); +DB::$password = $envString('DB_PASS', 'mintyphp'); +DB::$database = $envString('DB_NAME', 'mintyphp'); +DB::$port = $envInt('DB_PORT', 3306); Auth::$usersTable = 'users'; Auth::$usernameField = 'email'; @@ -62,8 +114,7 @@ Auth::$passwordField = 'password'; Auth::$createdField = 'created'; Auth::$totpSecretField = 'totp_secret'; -$debugEnv = getenv('APP_DEBUG'); -Debugger::$enabled = $debugEnv === false ? true : filter_var($debugEnv, FILTER_VALIDATE_BOOLEAN); +Debugger::$enabled = $envBool('APP_DEBUG', true); -I18n::$domain = getenv('APP_I18N_DOMAIN') ?: 'default'; -I18n::$locale = getenv('APP_LOCALE') ?: 'de'; +I18n::$domain = $envString('APP_I18N_DOMAIN', 'default'); +I18n::$locale = $envString('APP_LOCALE', 'de'); diff --git a/config/router.php b/config/router.php index 99aafd2..eaa0083 100644 --- a/config/router.php +++ b/config/router.php @@ -2,15 +2,18 @@ use MintyPHP\Router; -// Set up redirects +$routesFile = __DIR__ . '/routes.php'; +$routeDefinitions = defined('APP_ROUTE_DEFINITIONS') ? APP_ROUTE_DEFINITIONS : []; +if (!is_array($routeDefinitions) || !$routeDefinitions) { + $routeDefinitions = is_file($routesFile) ? include $routesFile : []; +} -Router::addRoute('login', 'auth/login'); -Router::addRoute('register', 'auth/register'); -Router::addRoute('logout', 'auth/logout'); -Router::addRoute('profile', 'account/profile'); -Router::addRoute('password/forgot', 'auth/forgot'); -Router::addRoute('password/verify', 'auth/verify'); -Router::addRoute('password/reset', 'auth/reset'); -Router::addRoute('verify-email', 'auth/verify-email'); -Router::addRoute('imprint', 'page/imprint'); -Router::addRoute('privacy', 'page/privacy'); +if (is_array($routeDefinitions)) { + foreach ($routeDefinitions as $route) { + $path = trim((string) ($route['path'] ?? '')); + $target = trim((string) ($route['target'] ?? '')); + if ($path !== '' && $target !== '') { + Router::addRoute($path, $target); + } + } +} diff --git a/config/routes.php b/config/routes.php new file mode 100644 index 0000000..a5a3b9d --- /dev/null +++ b/config/routes.php @@ -0,0 +1,14 @@ + '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], +]; diff --git a/config/settings.php b/config/settings.php index a906847..7979eea 100644 --- a/config/settings.php +++ b/config/settings.php @@ -1,9 +1,13 @@ 'IVMS', - 'app_locale' => 'de', - 'app_theme' => 'dark', - 'app_theme_user' => '1', - 'app_registration' => '1', - 'app_primary_color' => '#105433', -); +return [ + 'app_title' => 'Malphite', + 'app_locale' => 'de', + 'app_theme' => 'light', + 'app_theme_user' => '1', + 'app_registration' => '1', + 'app_primary_color' => '#105433', + 'api_token_default_ttl_days' => '90', + 'api_token_max_ttl_days' => '365', + 'api_cors_allowed_origins' => 'http://localhost:8080 +http://127.0.0.1:8080', +]; diff --git a/config/themes.php b/config/themes.php index 2d461b2..1b1a626 100644 --- a/config/themes.php +++ b/config/themes.php @@ -1,5 +1,4 @@ 'Light', 'dark' => 'Dark', diff --git a/db/init/init.sql b/db/init/init.sql index e703f26..d47f073 100644 --- a/db/init/init.sql +++ b/db/init/init.sql @@ -1,5 +1,6 @@ -- Consolidated schema (fresh install) --- Includes all current tables, columns, indexes, and seed data. +-- Generated: 2026-02-19 +-- Includes all current tables, columns, indexes and seed data. CREATE TABLE IF NOT EXISTS `users` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, @@ -21,6 +22,7 @@ CREATE TABLE IF NOT EXISTS `users` ( `hire_date` DATE NULL, `email_verified_at` DATETIME NULL DEFAULT NULL, `last_login_at` DATETIME NULL DEFAULT NULL, + `last_login_provider` VARCHAR(20) NULL DEFAULT NULL, `password` VARCHAR(255) NOT NULL, `locale` VARCHAR(10) DEFAULT NULL, `totp_secret` VARCHAR(255) DEFAULT NULL, @@ -32,6 +34,7 @@ CREATE TABLE IF NOT EXISTS `users` ( `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `modified` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, `active` TINYINT(1) NOT NULL DEFAULT 1, + `authz_version` INT UNSIGNED NOT NULL DEFAULT 1, `active_changed_at` DATETIME NULL DEFAULT NULL, `active_changed_by` INT UNSIGNED DEFAULT NULL, PRIMARY KEY (`id`), @@ -69,6 +72,8 @@ CREATE TABLE IF NOT EXISTS `tenants` ( `privacy_url` VARCHAR(255) NULL, `imprint_url` VARCHAR(255) NULL, `primary_color` VARCHAR(7) NULL, + `default_theme` VARCHAR(32) NULL, + `allow_user_theme` TINYINT(1) NULL, `status` VARCHAR(20) NOT NULL DEFAULT 'active', `status_changed_at` DATETIME NULL DEFAULT NULL, `status_changed_by` INT UNSIGNED DEFAULT NULL, @@ -123,6 +128,7 @@ CREATE TABLE IF NOT EXISTS `departments` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `uuid` CHAR(36) NOT NULL, `description` VARCHAR(255) NOT NULL, + `tenant_id` INT UNSIGNED NOT NULL, `code` VARCHAR(50) DEFAULT NULL, `cost_center` VARCHAR(50) DEFAULT NULL, `active` TINYINT(1) NOT NULL DEFAULT 1, @@ -132,9 +138,11 @@ CREATE TABLE IF NOT EXISTS `departments` ( `modified` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uniq_departments_uuid` (`uuid`), + KEY `idx_departments_tenant` (`tenant_id`), KEY `idx_departments_active` (`active`), KEY `idx_departments_created_by` (`created_by`), KEY `idx_departments_modified_by` (`modified_by`), + CONSTRAINT `fk_departments_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT, CONSTRAINT `fk_departments_created_by` FOREIGN KEY (`created_by`) REFERENCES `users` (`id`) ON DELETE SET NULL, CONSTRAINT `fk_departments_modified_by` FOREIGN KEY (`modified_by`) REFERENCES `users` (`id`) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; @@ -183,6 +191,42 @@ CREATE TABLE IF NOT EXISTS `settings` ( PRIMARY KEY (`key`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +CREATE TABLE IF NOT EXISTS `tenant_auth_microsoft` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `tenant_id` INT UNSIGNED NOT NULL, + `enabled` TINYINT(1) NOT NULL DEFAULT 0, + `enforce_microsoft_login` TINYINT(1) NOT NULL DEFAULT 0, + `sync_profile_on_login` TINYINT(1) NOT NULL DEFAULT 0, + `sync_profile_fields` TEXT NULL, + `entra_tenant_id` VARCHAR(64) NULL, + `allowed_domains` TEXT NULL, + `use_shared_app` TINYINT(1) NOT NULL DEFAULT 1, + `client_id_override` VARCHAR(191) NULL, + `client_secret_override_enc` TEXT NULL, + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `modified` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_tenant_auth_microsoft_tenant` (`tenant_id`), + CONSTRAINT `fk_tenant_auth_microsoft_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `user_external_identities` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` INT UNSIGNED NOT NULL, + `provider` VARCHAR(32) NOT NULL, + `oid` VARCHAR(128) NOT NULL, + `tid` VARCHAR(64) NOT NULL, + `issuer` VARCHAR(255) NOT NULL, + `subject` VARCHAR(255) NOT NULL, + `email_at_link_time` VARCHAR(255) NULL, + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_user_external_provider_tid_oid` (`provider`, `tid`, `oid`), + UNIQUE KEY `uniq_user_external_provider_iss_sub` (`provider`, `issuer`, `subject`), + KEY `idx_user_external_user` (`user_id`), + CONSTRAINT `fk_user_external_identity_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `user_tenants` ( `user_id` INT UNSIGNED NOT NULL, `tenant_id` INT UNSIGNED NOT NULL, @@ -226,17 +270,117 @@ CREATE TABLE IF NOT EXISTS `user_departments` ( CONSTRAINT `fk_user_departments_department` FOREIGN KEY (`department_id`) REFERENCES `departments` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -CREATE TABLE IF NOT EXISTS `tenant_departments` ( +CREATE TABLE IF NOT EXISTS `user_saved_filters` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `uuid` CHAR(36) NOT NULL, + `user_id` INT UNSIGNED NOT NULL, + `context` VARCHAR(64) NOT NULL, + `name` VARCHAR(120) NOT NULL, + `query_json` TEXT NOT NULL, + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `modified` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_user_saved_filters_uuid` (`uuid`), + KEY `idx_user_saved_filters_user_context` (`user_id`, `context`), + KEY `idx_user_saved_filters_user_context_created` (`user_id`, `context`, `created`), + CONSTRAINT `fk_user_saved_filters_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `tenant_custom_field_definitions` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `uuid` CHAR(36) NOT NULL, `tenant_id` INT UNSIGNED NOT NULL, - `department_id` INT UNSIGNED NOT NULL, + `field_key` VARCHAR(120) NOT NULL, + `label` VARCHAR(160) NOT NULL, + `type` VARCHAR(32) NOT NULL, + `is_required` TINYINT(1) NOT NULL DEFAULT 0, + `is_filterable` TINYINT(1) NOT NULL DEFAULT 0, + `active` TINYINT(1) NOT NULL DEFAULT 1, + `sort_order` INT NOT NULL DEFAULT 100, + `created_by` INT UNSIGNED DEFAULT NULL, + `modified_by` INT UNSIGNED DEFAULT NULL, + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `modified` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_tenant_custom_field_uuid` (`uuid`), + UNIQUE KEY `uniq_tenant_custom_field_key` (`tenant_id`, `field_key`), + KEY `idx_tenant_custom_field_tenant_active` (`tenant_id`, `active`, `sort_order`), + KEY `idx_tenant_custom_field_created_by` (`created_by`), + KEY `idx_tenant_custom_field_modified_by` (`modified_by`), + CONSTRAINT `fk_tenant_custom_field_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_tenant_custom_field_created_by` FOREIGN KEY (`created_by`) REFERENCES `users` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_tenant_custom_field_modified_by` FOREIGN KEY (`modified_by`) REFERENCES `users` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `tenant_custom_field_options` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `definition_id` INT UNSIGNED NOT NULL, + `option_key` VARCHAR(120) NOT NULL, + `label` VARCHAR(160) NOT NULL, + `active` TINYINT(1) NOT NULL DEFAULT 1, + `sort_order` INT NOT NULL DEFAULT 100, + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `modified` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_custom_field_option_key` (`definition_id`, `option_key`), + KEY `idx_custom_field_option_definition_active` (`definition_id`, `active`, `sort_order`), + CONSTRAINT `fk_custom_field_option_definition` FOREIGN KEY (`definition_id`) REFERENCES `tenant_custom_field_definitions` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `user_custom_field_values` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` INT UNSIGNED NOT NULL, + `definition_id` INT UNSIGNED NOT NULL, + `value_text` TEXT NULL, + `value_bool` TINYINT(1) NULL, + `value_date` DATE NULL, + `option_id` INT UNSIGNED NULL, + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `modified` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_user_custom_field_definition` (`user_id`, `definition_id`), + KEY `idx_ucfv_definition` (`definition_id`), + KEY `idx_ucfv_option` (`option_id`), + KEY `idx_ucfv_bool` (`definition_id`, `value_bool`), + KEY `idx_ucfv_date` (`definition_id`, `value_date`), + CONSTRAINT `fk_ucfv_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_ucfv_definition` FOREIGN KEY (`definition_id`) REFERENCES `tenant_custom_field_definitions` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_ucfv_option` FOREIGN KEY (`option_id`) REFERENCES `tenant_custom_field_options` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `user_custom_field_value_options` ( + `value_id` INT UNSIGNED NOT NULL, + `option_id` INT UNSIGNED NOT NULL, + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`value_id`, `option_id`), + KEY `idx_ucfvo_option` (`option_id`), + CONSTRAINT `fk_ucfvo_value` FOREIGN KEY (`value_id`) REFERENCES `user_custom_field_values` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_ucfvo_option` FOREIGN KEY (`option_id`) REFERENCES `tenant_custom_field_options` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `user_api_tokens` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `uuid` CHAR(36) NOT NULL, + `user_id` INT UNSIGNED NOT NULL, + `name` VARCHAR(120) NOT NULL DEFAULT '', + `selector` CHAR(24) NOT NULL, + `token_hash` CHAR(64) NOT NULL, + `tenant_id` INT UNSIGNED NULL COMMENT 'Optional: scope token to specific tenant', + `last_used_at` DATETIME NULL DEFAULT NULL, + `last_ip` VARCHAR(45) NULL DEFAULT NULL, + `expires_at` DATETIME NULL DEFAULT NULL, + `revoked_at` DATETIME NULL DEFAULT NULL, + `created_by` INT UNSIGNED NULL, `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), - UNIQUE KEY `uniq_tenant_department` (`tenant_id`, `department_id`), - KEY `idx_tenant_departments_tenant` (`tenant_id`), - KEY `idx_tenant_departments_department` (`department_id`), - CONSTRAINT `fk_tenant_departments_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE CASCADE, - CONSTRAINT `fk_tenant_departments_department` FOREIGN KEY (`department_id`) REFERENCES `departments` (`id`) ON DELETE CASCADE + UNIQUE KEY `uniq_user_api_token_uuid` (`uuid`), + UNIQUE KEY `uniq_user_api_token_selector` (`selector`), + KEY `idx_user_api_token_user` (`user_id`), + KEY `idx_user_api_token_tenant` (`tenant_id`), + KEY `idx_user_api_token_expires` (`expires_at`), + CONSTRAINT `fk_user_api_token_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_user_api_token_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_user_api_token_created_by` FOREIGN KEY (`created_by`) REFERENCES `users` (`id`) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE IF NOT EXISTS `mail_log` ( @@ -254,6 +398,169 @@ CREATE TABLE IF NOT EXISTS `mail_log` ( KEY `idx_mail_log_to_email` (`to_email`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS `api_audit_log` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `request_id` CHAR(36) NOT NULL, + `method` VARCHAR(8) NOT NULL, + `path` VARCHAR(255) NOT NULL, + `query_json` TEXT NULL, + `status_code` SMALLINT UNSIGNED NOT NULL, + `duration_ms` INT UNSIGNED NULL, + `error_code` VARCHAR(100) NULL, + `user_id` INT UNSIGNED NULL, + `tenant_id` INT UNSIGNED NULL, + `api_token_id` INT UNSIGNED NULL, + `token_tenant_id` INT UNSIGNED NULL, + `ip` VARCHAR(45) NULL, + `user_agent` VARCHAR(255) NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_api_audit_request_id_created` (`request_id`, `created_at`), + KEY `idx_api_audit_created` (`created_at`), + KEY `idx_api_audit_status_created` (`status_code`, `created_at`), + KEY `idx_api_audit_user_created` (`user_id`, `created_at`), + KEY `idx_api_audit_tenant_created` (`tenant_id`, `created_at`), + KEY `idx_api_audit_path_created` (`path`, `created_at`), + KEY `idx_api_audit_error_created` (`error_code`, `created_at`), + KEY `idx_api_audit_token_created` (`api_token_id`, `created_at`), + CONSTRAINT `fk_api_audit_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_api_audit_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_api_audit_token` FOREIGN KEY (`api_token_id`) REFERENCES `user_api_tokens` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `import_audit_runs` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `run_uuid` CHAR(36) NOT NULL, + `profile_key` VARCHAR(32) NOT NULL, + `status` VARCHAR(16) NOT NULL, + `source_filename` VARCHAR(255) NULL, + `mapped_targets_csv` TEXT NULL, + `rows_total` INT UNSIGNED NOT NULL DEFAULT 0, + `created_count` INT UNSIGNED NOT NULL DEFAULT 0, + `skipped_count` INT UNSIGNED NOT NULL DEFAULT 0, + `failed_count` INT UNSIGNED NOT NULL DEFAULT 0, + `error_codes_json` TEXT NULL, + `started_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `finished_at` DATETIME NULL DEFAULT NULL, + `duration_ms` INT UNSIGNED NULL, + `user_id` INT UNSIGNED NULL, + `current_tenant_id` INT UNSIGNED NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_import_audit_run_uuid` (`run_uuid`), + KEY `idx_import_audit_started` (`started_at`), + KEY `idx_import_audit_status_started` (`status`, `started_at`), + KEY `idx_import_audit_profile_started` (`profile_key`, `started_at`), + KEY `idx_import_audit_user_started` (`user_id`, `started_at`), + CONSTRAINT `fk_import_audit_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_import_audit_tenant` FOREIGN KEY (`current_tenant_id`) REFERENCES `tenants` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `user_lifecycle_audit_log` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `run_uuid` CHAR(36) NOT NULL, + `action` VARCHAR(16) NOT NULL, + `trigger_type` VARCHAR(16) NOT NULL, + `status` VARCHAR(16) NOT NULL, + `reason_code` VARCHAR(64) NULL, + `policy_deactivate_days` INT UNSIGNED NOT NULL DEFAULT 0, + `policy_delete_days` INT UNSIGNED NOT NULL DEFAULT 0, + `actor_user_id` INT UNSIGNED NULL, + `target_user_id` INT UNSIGNED NULL, + `target_user_uuid` CHAR(36) NULL, + `target_user_email` VARCHAR(255) NULL, + `snapshot_enc` LONGTEXT NULL, + `snapshot_version` SMALLINT UNSIGNED NOT NULL DEFAULT 1, + `restored_at` DATETIME NULL DEFAULT NULL, + `restored_by_user_id` INT UNSIGNED NULL, + `restored_user_id` INT UNSIGNED NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_user_lifecycle_created` (`created_at`), + KEY `idx_user_lifecycle_action_created` (`action`, `created_at`), + KEY `idx_user_lifecycle_status_created` (`status`, `created_at`), + KEY `idx_user_lifecycle_target_uuid_created` (`target_user_uuid`, `created_at`), + KEY `idx_user_lifecycle_run_uuid` (`run_uuid`), + KEY `idx_user_lifecycle_restored` (`restored_at`), + CONSTRAINT `fk_user_lifecycle_actor` FOREIGN KEY (`actor_user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_user_lifecycle_target` FOREIGN KEY (`target_user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_user_lifecycle_restored_by` FOREIGN KEY (`restored_by_user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_user_lifecycle_restored_user` FOREIGN KEY (`restored_user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `scheduled_jobs` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `job_key` VARCHAR(64) NOT NULL, + `label` VARCHAR(120) NOT NULL, + `description` VARCHAR(255) NULL, + `enabled` TINYINT(1) NOT NULL DEFAULT 1, + `timezone` VARCHAR(64) NOT NULL DEFAULT 'Europe/Berlin', + `schedule_type` VARCHAR(16) NOT NULL, + `schedule_interval` SMALLINT UNSIGNED NOT NULL DEFAULT 1, + `schedule_time` CHAR(5) NULL, + `schedule_weekdays_csv` VARCHAR(32) NULL, + `catchup_once` TINYINT(1) NOT NULL DEFAULT 1, + `next_run_at` DATETIME NULL DEFAULT NULL, + `last_run_started_at` DATETIME NULL DEFAULT NULL, + `last_run_finished_at` DATETIME NULL DEFAULT NULL, + `last_run_status` VARCHAR(16) NULL, + `last_error_code` VARCHAR(64) NULL, + `last_error_message` VARCHAR(255) NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_scheduled_jobs_job_key` (`job_key`), + KEY `idx_scheduled_jobs_enabled_next` (`enabled`, `next_run_at`), + KEY `idx_scheduled_jobs_status` (`last_run_status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `scheduled_job_runs` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `run_uuid` CHAR(36) NOT NULL, + `job_id` BIGINT UNSIGNED NOT NULL, + `job_key` VARCHAR(64) NOT NULL, + `trigger_type` VARCHAR(16) NOT NULL, + `status` VARCHAR(16) NOT NULL, + `actor_user_id` INT UNSIGNED NULL, + `started_at` DATETIME NOT NULL, + `finished_at` DATETIME NULL DEFAULT NULL, + `duration_ms` INT UNSIGNED NULL, + `error_code` VARCHAR(64) NULL, + `error_message` VARCHAR(255) NULL, + `result_json` TEXT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sched_runs_run_uuid` (`run_uuid`), + KEY `idx_sched_runs_job_created` (`job_id`, `created_at`), + KEY `idx_sched_runs_status_created` (`status`, `created_at`), + KEY `idx_sched_runs_trigger_created` (`trigger_type`, `created_at`), + CONSTRAINT `fk_sched_runs_job` FOREIGN KEY (`job_id`) REFERENCES `scheduled_jobs` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_sched_runs_actor_user` FOREIGN KEY (`actor_user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `scheduler_runtime_status` ( + `id` TINYINT UNSIGNED NOT NULL, + `last_heartbeat_at` DATETIME NOT NULL, + `last_result` VARCHAR(32) NULL, + `last_error_code` VARCHAR(64) NULL, + `updated_at` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `request_rate_limits` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `scope` VARCHAR(64) NOT NULL, + `subject_hash` CHAR(64) NOT NULL, + `hits` INT UNSIGNED NOT NULL DEFAULT 0, + `window_started_at` DATETIME NOT NULL, + `blocked_until` DATETIME NULL DEFAULT NULL, + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `modified` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_request_rate_scope_subject` (`scope`, `subject_hash`), + KEY `idx_request_rate_scope_blocked` (`scope`, `blocked_until`), + KEY `idx_request_rate_modified` (`modified`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `password_resets` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `user_id` INT UNSIGNED NOT NULL, @@ -302,9 +609,11 @@ INSERT INTO `tenants` (`uuid`, `description`, `created`) SELECT UUID(), 'Cronus', NOW() WHERE NOT EXISTS (SELECT 1 FROM tenants WHERE description = 'Cronus'); -INSERT INTO `departments` (`uuid`, `description`, `code`, `active`, `created`) -SELECT UUID(), 'IT', 'IT', 1, NOW() -WHERE NOT EXISTS (SELECT 1 FROM departments WHERE description = 'IT'); +INSERT INTO `departments` (`uuid`, `description`, `tenant_id`, `code`, `active`, `created`) +SELECT UUID(), 'IT', t.id, 'IT', 1, NOW() +FROM tenants t +WHERE t.description = 'Cronus' + AND NOT EXISTS (SELECT 1 FROM departments WHERE description = 'IT'); INSERT INTO `roles` (`uuid`, `description`, `code`, `active`, `created`) SELECT UUID(), 'Admin', 'ADMIN', 1, NOW() @@ -363,15 +672,6 @@ WHERE u.email = 'demo@user.com' SELECT 1 FROM user_departments ud WHERE ud.user_id = u.id AND ud.department_id = d.id ); -INSERT INTO `tenant_departments` (`tenant_id`, `department_id`, `created`) -SELECT t.id, d.id, NOW() -FROM tenants t -JOIN departments d ON d.description = 'IT' -WHERE t.description = 'Cronus' - AND NOT EXISTS ( - SELECT 1 FROM tenant_departments td WHERE td.tenant_id = t.id AND td.department_id = d.id - ); - INSERT INTO `user_roles` (`user_id`, `role_id`, `created`) SELECT u.id, r.id, NOW() FROM users u @@ -399,6 +699,7 @@ VALUES ('users.view_audit', 'Can view user audit log', 1, 1), ('users.create', 'Can create users', 1, 1), ('users.update', 'Can update users', 1, 1), + ('users.access_pdf', 'Can generate onboarding access PDFs', 1, 1), ('users.delete', 'Can delete users', 1, 1), ('users.self_update', 'Can update own user', 1, 1), ('users.update_assignments', 'Can update user assignments', 1, 1), @@ -406,11 +707,13 @@ VALUES ('tenants.view', 'Can view tenants', 1, 1), ('tenants.create', 'Can create tenants', 1, 1), ('tenants.update', 'Can update tenants', 1, 1), + ('tenants.sso_manage', 'Can manage tenant Microsoft SSO settings', 1, 1), ('tenants.delete', 'Can delete tenants', 1, 1), ('departments.view', 'Can view departments', 1, 1), ('departments.create', 'Can create departments', 1, 1), ('departments.update', 'Can update departments', 1, 1), ('departments.delete', 'Can delete departments', 1, 1), + ('departments.import', 'Can import departments', 1, 1), ('roles.view', 'Can view roles', 1, 1), ('roles.create', 'Can create roles', 1, 1), ('roles.update', 'Can update roles', 1, 1), @@ -421,26 +724,90 @@ VALUES ('permissions.delete', 'Can delete permissions', 1, 1), ('settings.view', 'Can view settings', 1, 1), ('settings.update', 'Can update settings', 1, 1), + ('imports.view', 'Can view imports', 1, 1), + ('imports.audit.view', 'Can view import audit logs', 1, 1), + ('jobs.view', 'Can view scheduled jobs', 1, 1), + ('jobs.manage', 'Can manage scheduled jobs', 1, 1), + ('jobs.run_now', 'Can run scheduled jobs manually', 1, 1), + ('user_lifecycle_audit.view', 'Can view user lifecycle audit logs', 1, 1), + ('users.import', 'Can import users', 1, 1), + ('users.import_assignments', 'Can assign tenant, role and department during user import', 1, 1), + ('users.lifecycle_restore', 'Can restore users from lifecycle audit', 1, 1), + ('custom_fields.manage', 'Can manage tenant custom field definitions', 1, 1), + ('custom_fields.edit_values', 'Can edit user custom field values', 1, 1), + ('api_docs.view', 'Can view API documentation', 1, 1), + ('docs.view', 'Can view developer documentation', 1, 1), ('mail_log.view', 'Can view mail logs', 1, 1), + ('api_audit.view', 'Can view API audit logs', 1, 1), ('stats.view', 'Can view statistics', 1, 1) ON DUPLICATE KEY UPDATE `description` = VALUES(`description`), `active` = VALUES(`active`), `is_system` = VALUES(`is_system`); +INSERT INTO `scheduled_jobs` ( + `job_key`, + `label`, + `description`, + `enabled`, + `timezone`, + `schedule_type`, + `schedule_interval`, + `schedule_time`, + `schedule_weekdays_csv`, + `catchup_once`, + `next_run_at` +) +VALUES ( + 'user_lifecycle_run', + 'User lifecycle run', + 'Runs automatic user deactivate/delete policy', + 1, + 'Europe/Berlin', + 'daily', + 1, + '02:15', + NULL, + 1, + NULL +) +ON DUPLICATE KEY UPDATE + `label` = VALUES(`label`), + `description` = VALUES(`description`); + +INSERT INTO `scheduler_runtime_status` ( + `id`, + `last_heartbeat_at`, + `last_result`, + `last_error_code` +) +VALUES ( + 1, + NOW(), + 'ok', + NULL +) +ON DUPLICATE KEY UPDATE + `id` = `id`; + INSERT INTO `role_permissions` (`role_id`, `permission_id`, `created`) SELECT r.id, p.id, NOW() FROM roles r JOIN permissions p ON p.`key` IN ( - 'users.view', 'users.view_meta', 'users.view_audit', 'users.create', 'users.update', 'users.delete', + 'users.view', 'users.view_meta', 'users.view_audit', 'users.create', 'users.update', 'users.access_pdf', 'users.delete', 'users.self_update', 'users.update_assignments', 'address_book.view', - 'tenants.view', 'tenants.create', 'tenants.update', 'tenants.delete', - 'departments.view', 'departments.create', 'departments.update', 'departments.delete', + 'tenants.view', 'tenants.create', 'tenants.update', 'tenants.sso_manage', 'tenants.delete', + 'departments.view', 'departments.create', 'departments.update', 'departments.delete', 'departments.import', 'roles.view', 'roles.create', 'roles.update', 'roles.delete', 'permissions.view', 'permissions.create', 'permissions.update', 'permissions.delete', 'settings.view', 'settings.update', - 'mail_log.view', + 'imports.view', 'imports.audit.view', 'jobs.view', 'jobs.manage', 'jobs.run_now', + 'user_lifecycle_audit.view', 'users.import', 'users.import_assignments', + 'users.lifecycle_restore', + 'custom_fields.manage', 'custom_fields.edit_values', + 'api_docs.view', 'docs.view', + 'mail_log.view', 'api_audit.view', 'stats.view' ) WHERE r.description IN ('Admin', 'Administrator') OR r.id = 1 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..0a35ae4 --- /dev/null +++ b/docker-compose.prod.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index 7fe0344..a2cfe89 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,22 @@ services: - db - memcached + scheduler: + build: + context: . + dockerfile: docker/php/Dockerfile + env_file: + - .env + environment: + APP_DEBUG: "0" + volumes: + - ./:/var/www + depends_on: + - db + - memcached + command: sh -lc "while true; do php -d display_errors=0 bin/scheduler-run.php || true; sleep 60; done" + restart: unless-stopped + db: image: mariadb:11.4 env_file: diff --git a/docker/.DS_Store b/docker/.DS_Store deleted file mode 100644 index 0473253..0000000 Binary files a/docker/.DS_Store and /dev/null differ diff --git a/docker/nginx/certs/.gitkeep b/docker/nginx/certs/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/docker/nginx/certs/.gitkeep @@ -0,0 +1 @@ + diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf index 40d83a1..6912040 100644 --- a/docker/nginx/default.conf +++ b/docker/nginx/default.conf @@ -2,6 +2,8 @@ server { listen 80; server_name _; + client_max_body_size 10M; + root /var/www/web; index index.php; diff --git a/docker/nginx/prod.conf b/docker/nginx/prod.conf new file mode 100644 index 0000000..c3deb8f --- /dev/null +++ b/docker/nginx/prod.conf @@ -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; + } +} diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile index 8a879c9..0135831 100644 --- a/docker/php/Dockerfile +++ b/docker/php/Dockerfile @@ -4,6 +4,7 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends \ git \ unzip \ + libzip-dev \ libmemcached-dev \ zlib1g-dev \ libssl-dev \ @@ -13,7 +14,7 @@ RUN apt-get update \ && pecl install memcached \ && docker-php-ext-enable memcached \ && docker-php-ext-configure gd --with-jpeg --with-webp \ - && docker-php-ext-install pdo_mysql mysqli gd \ + && docker-php-ext-install pdo_mysql mysqli gd zip \ && rm -rf /var/lib/apt/lists/* COPY --from=composer:2 /usr/bin/composer /usr/bin/composer diff --git a/docker/php/php.ini b/docker/php/php.ini index 7c7ed3f..afed12e 100644 --- a/docker/php/php.ini +++ b/docker/php/php.ini @@ -2,3 +2,5 @@ display_errors=1 display_startup_errors=1 error_reporting=E_ALL memory_limit=256M +upload_max_filesize=10M +post_max_size=12M diff --git a/docker/php/php.prod.ini b/docker/php/php.prod.ini new file mode 100644 index 0000000..ae5b8a7 --- /dev/null +++ b/docker/php/php.prod.ini @@ -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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md deleted file mode 100644 index e6adff2..0000000 --- a/docs/ARCHITECTURE.md +++ /dev/null @@ -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 - - - - - <?php e(Buffer::get('title') ?? appTitle()); ?> - - - - - -
- - -
- - - - -``` - -### 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 - - Neuer Benutzer - -``` - -### 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 - -``` - -### 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 - - -// FALSCH - XSS-Risiko - -``` - -### 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) diff --git a/docs/README-search.md b/docs/README-search.md deleted file mode 100644 index 3db032e..0000000 --- a/docs/README-search.md +++ /dev/null @@ -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. diff --git a/docs/anfragelimits.md b/docs/anfragelimits.md new file mode 100644 index 0000000..6cb0651 --- /dev/null +++ b/docs/anfragelimits.md @@ -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: `|` +- Limit: `300` Hits pro `60` Sekunden +- Block: `120` Sekunden + +2. Kein Bearer (`ip-only`) +- Scope: `api.ip` +- Key: `` +- Limit: `120` Hits pro `60` Sekunden +- Block: `120` Sekunden + +Bei überschreitung: +- Response: `429` +- Body: `{"error":"rate_limit_exceeded"}` +- Header: `Retry-After: ` + +## Login Rate Limiting + +Integration: +- `/pages/auth/login().php` + +Aktuelle Limits: + +1. Schritt `resolve_email` (IP) +- Scope: `login.resolve.ip` +- Key: `` +- Limit: `25` Hits pro `600` Sekunden +- Block: `300` Sekunden + +2. Schritt `login_password` (email + ip) +- Scope: `login.password.email_ip` +- Key: `|` +- 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). diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..b39ec1b --- /dev/null +++ b/docs/api.md @@ -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` diff --git a/docs/architektur.md b/docs/architektur.md new file mode 100644 index 0000000..3bb3ecf --- /dev/null +++ b/docs/architektur.md @@ -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` diff --git a/docs/benutzer-lifecycle-policy.md b/docs/benutzer-lifecycle-policy.md new file mode 100644 index 0000000..08f57a0 --- /dev/null +++ b/docs/benutzer-lifecycle-policy.md @@ -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` diff --git a/docs/benutzerdefinierte-felder.md b/docs/benutzerdefinierte-felder.md new file mode 100644 index 0000000..bb98ed6 --- /dev/null +++ b/docs/benutzerdefinierte-felder.md @@ -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_` + - scalar/select/boolean +- `cfm_` + - multiselect (CSV mit Option-IDs) +- `cfd__from` +- `cfd__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`. diff --git a/docs/conventions.md b/docs/conventions.md deleted file mode 100644 index b164be6..0000000 --- a/docs/conventions.md +++ /dev/null @@ -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`. diff --git a/docs/docker-betrieb.md b/docs/docker-betrieb.md new file mode 100644 index 0000000..a1f1227 --- /dev/null +++ b/docs/docker-betrieb.md @@ -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. diff --git a/docs/docker-lokal.md b/docs/docker-lokal.md new file mode 100644 index 0000000..02f14db --- /dev/null +++ b/docs/docker-lokal.md @@ -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 diff --git a/docs/docker-produktiv.md b/docs/docker-produktiv.md new file mode 100644 index 0000000..8c609d3 --- /dev/null +++ b/docs/docker-produktiv.md @@ -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://...`. diff --git a/docs/dokumentation-erweitern.md b/docs/dokumentation-erweitern.md new file mode 100644 index 0000000..fb8b1a4 --- /dev/null +++ b/docs/dokumentation-erweitern.md @@ -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/.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. diff --git a/docs/einstellungen-speicherung.md b/docs/einstellungen-speicherung.md new file mode 100644 index 0000000..f65d07e --- /dev/null +++ b/docs/einstellungen-speicherung.md @@ -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). diff --git a/docs/entwickler-checkliste.md b/docs/entwickler-checkliste.md new file mode 100644 index 0000000..fe934fd --- /dev/null +++ b/docs/entwickler-checkliste.md @@ -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=` -> 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` diff --git a/docs/erste-aenderung.md b/docs/erste-aenderung.md new file mode 100644 index 0000000..e579dcb --- /dev/null +++ b/docs/erste-aenderung.md @@ -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//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` diff --git a/docs/erste-schritte.md b/docs/erste-schritte.md new file mode 100644 index 0000000..3bae50c --- /dev/null +++ b/docs/erste-schritte.md @@ -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 " \ + 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` diff --git a/docs/fehlerbehebung.md b/docs/fehlerbehebung.md new file mode 100644 index 0000000..716cfd1 --- /dev/null +++ b/docs/fehlerbehebung.md @@ -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 `) +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 diff --git a/docs/frontend-css.md b/docs/frontend-css.md new file mode 100644 index 0000000..8239e58 --- /dev/null +++ b/docs/frontend-css.md @@ -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/.css` | +| Neuer wiederverwendbarer UI-Baustein (mind. auf 2 Seiten) | Neue Komponentendatei anlegen | `/web/css/components/.css` | +| Änderung betrifft globales Layout (Sidebar, Header, Main) | Bestehende Layout-Datei erweitern | `/web/css/layout/.css` | +| Änderung ist nur für eine konkrete Seite relevant | Neue/ bestehende Seiten-Datei nutzen | `/web/css/pages/.css` | +| Externe Library muss übersteuert werden (Grid.js, Swagger UI) | Nur Vendor-Override ändern | `/web/css/vendor-overrides/.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? diff --git a/docs/frontend-javascript.md b/docs/frontend-javascript.md new file mode 100644 index 0000000..fb0a307 --- /dev/null +++ b/docs/frontend-javascript.md @@ -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 `` 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. diff --git a/docs/geplante-aufgaben.md b/docs/geplante-aufgaben.md new file mode 100644 index 0000000..6976e8f --- /dev/null +++ b/docs/geplante-aufgaben.md @@ -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 + '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. diff --git a/docs/globale-suche.md b/docs/globale-suche.md new file mode 100644 index 0000000..1fe0e7a --- /dev/null +++ b/docs/globale-suche.md @@ -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`: + +- `
  • ` 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 diff --git a/docs/importe.md b/docs/importe.md new file mode 100644 index 0000000..d91128b --- /dev/null +++ b/docs/importe.md @@ -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` diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..34db1c1 --- /dev/null +++ b/docs/index.md @@ -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. diff --git a/docs/konfiguration.md b/docs/konfiguration.md new file mode 100644 index 0000000..9c3cb48 --- /dev/null +++ b/docs/konfiguration.md @@ -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 diff --git a/docs/konventionen.md b/docs/konventionen.md new file mode 100644 index 0000000..0cb8f3e --- /dev/null +++ b/docs/konventionen.md @@ -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_` (scalar/select/bool) + - `cfm_` (multiselect als CSV) + - `cfd__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). diff --git a/docs/lokale-entwicklung.md b/docs/lokale-entwicklung.md new file mode 100644 index 0000000..72a4e59 --- /dev/null +++ b/docs/lokale-entwicklung.md @@ -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` diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 0000000..24b2cc1 --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,1534 @@ +openapi: 3.0.3 +info: + title: Malphite API + version: v1 + description: | + OpenAPI specification for the current `/api/v1` endpoints. + Authentication uses Bearer tokens in the format `:`. +servers: + - url: /api/v1 +security: + - BearerAuth: [] +tags: + - name: me + - name: tokens + - name: users + - name: tenants + - name: departments + - name: roles +paths: + /me: + get: + tags: [me] + summary: Get authenticated user profile + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/MeResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '429': + $ref: '#/components/responses/RateLimited' + + /me/tokens: + get: + tags: [tokens] + summary: List current user API tokens + parameters: + - $ref: '#/components/parameters/Limit50' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/TokenListResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/RateLimited' + post: + tags: [tokens] + summary: Create API token for current user + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TokenCreateRequest' + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/TokenCreateResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/ValidationError' + '429': + $ref: '#/components/responses/RateLimited' + + /me/tokens/show/{uuid}: + parameters: + - $ref: '#/components/parameters/UuidPath' + get: + tags: [tokens] + summary: Get current user token by UUID + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/TokenShowResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/RateLimited' + delete: + tags: [tokens] + summary: Revoke current user token by UUID + responses: + '204': + description: No Content + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/ServerError' + '429': + $ref: '#/components/responses/RateLimited' + + /me/tokens/rotate: + post: + tags: [tokens] + summary: Rotate currently used API token + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + expires_at: + type: string + format: date-time + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/TokenCreateResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/ValidationError' + '429': + $ref: '#/components/responses/RateLimited' + + /me/tokens/revoke-all: + post: + tags: [tokens] + summary: Revoke all current user API tokens + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/RevokeAllResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/RateLimited' + + /users: + get: + tags: [users] + summary: List users + parameters: + - $ref: '#/components/parameters/Limit25' + - $ref: '#/components/parameters/Offset' + - $ref: '#/components/parameters/Search' + - $ref: '#/components/parameters/OrderUsers' + - $ref: '#/components/parameters/Dir' + - $ref: '#/components/parameters/ActiveFilter' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/UserListResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/RateLimited' + post: + tags: [users] + summary: Create user + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserCreateRequest' + responses: + '201': + $ref: '#/components/responses/DataObject' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + '429': + $ref: '#/components/responses/RateLimited' + + /users/show/{uuid}: + parameters: + - $ref: '#/components/parameters/UuidPath' + get: + tags: [users] + summary: Get user by UUID + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/UserShowResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/RateLimited' + put: + tags: [users] + summary: Update user by UUID + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserUpdateRequest' + responses: + '200': + $ref: '#/components/responses/DataObject' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '429': + $ref: '#/components/responses/RateLimited' + patch: + tags: [users] + summary: Patch user by UUID + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserUpdateRequest' + responses: + '200': + $ref: '#/components/responses/DataObject' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '429': + $ref: '#/components/responses/RateLimited' + delete: + tags: [users] + summary: Delete user by UUID + responses: + '200': + $ref: '#/components/responses/DataObject' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/RateLimited' + + /tenants: + get: + tags: [tenants] + summary: List tenants + parameters: + - $ref: '#/components/parameters/Limit25' + - $ref: '#/components/parameters/Offset' + - $ref: '#/components/parameters/Search' + - $ref: '#/components/parameters/OrderTenants' + - $ref: '#/components/parameters/Dir' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/TenantListResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/RateLimited' + post: + tags: [tenants] + summary: Create tenant + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TenantWriteRequest' + responses: + '201': + $ref: '#/components/responses/DataObject' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + '429': + $ref: '#/components/responses/RateLimited' + + /tenants/show/{uuid}: + parameters: + - $ref: '#/components/parameters/UuidPath' + get: + tags: [tenants] + summary: Get tenant by UUID + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/TenantShowResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/RateLimited' + put: + tags: [tenants] + summary: Update tenant by UUID + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TenantWriteRequest' + responses: + '200': + $ref: '#/components/responses/DataObject' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '429': + $ref: '#/components/responses/RateLimited' + patch: + tags: [tenants] + summary: Patch tenant by UUID + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TenantWriteRequest' + responses: + '200': + $ref: '#/components/responses/DataObject' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '429': + $ref: '#/components/responses/RateLimited' + delete: + tags: [tenants] + summary: Delete tenant by UUID + responses: + '200': + $ref: '#/components/responses/DataObject' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/RateLimited' + + /departments: + get: + tags: [departments] + summary: List departments + parameters: + - $ref: '#/components/parameters/Limit25' + - $ref: '#/components/parameters/Offset' + - $ref: '#/components/parameters/Search' + - $ref: '#/components/parameters/OrderDepartments' + - $ref: '#/components/parameters/Dir' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentListResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/RateLimited' + post: + tags: [departments] + summary: Create department + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentWriteRequest' + responses: + '201': + $ref: '#/components/responses/DataObject' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + '429': + $ref: '#/components/responses/RateLimited' + + /departments/show/{uuid}: + parameters: + - $ref: '#/components/parameters/UuidPath' + get: + tags: [departments] + summary: Get department by UUID + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentShowResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/RateLimited' + put: + tags: [departments] + summary: Update department by UUID + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentWriteRequest' + responses: + '200': + $ref: '#/components/responses/DataObject' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '429': + $ref: '#/components/responses/RateLimited' + patch: + tags: [departments] + summary: Patch department by UUID + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentWriteRequest' + responses: + '200': + $ref: '#/components/responses/DataObject' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '429': + $ref: '#/components/responses/RateLimited' + delete: + tags: [departments] + summary: Delete department by UUID + responses: + '200': + $ref: '#/components/responses/DataObject' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/RateLimited' + + /roles: + get: + tags: [roles] + summary: List roles + parameters: + - $ref: '#/components/parameters/Limit25' + - $ref: '#/components/parameters/Offset' + - $ref: '#/components/parameters/Search' + - $ref: '#/components/parameters/OrderRoles' + - $ref: '#/components/parameters/Dir' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/RoleListResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/RateLimited' + post: + tags: [roles] + summary: Create role + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RoleWriteRequest' + responses: + '201': + $ref: '#/components/responses/DataObject' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + '429': + $ref: '#/components/responses/RateLimited' + + /roles/show/{uuid}: + parameters: + - $ref: '#/components/parameters/UuidPath' + get: + tags: [roles] + summary: Get role by UUID + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/RoleShowResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/RateLimited' + put: + tags: [roles] + summary: Update role by UUID + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RoleWriteRequest' + responses: + '200': + $ref: '#/components/responses/DataObject' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '429': + $ref: '#/components/responses/RateLimited' + patch: + tags: [roles] + summary: Patch role by UUID + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RoleWriteRequest' + responses: + '200': + $ref: '#/components/responses/DataObject' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '429': + $ref: '#/components/responses/RateLimited' + delete: + tags: [roles] + summary: Delete role by UUID + responses: + '200': + $ref: '#/components/responses/DataObject' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/RateLimited' + +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: selector:token + parameters: + UuidPath: + name: uuid + in: path + required: true + schema: + type: string + format: uuid + Limit25: + name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 25 + Limit50: + name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 50 + Offset: + name: offset + in: query + schema: + type: integer + minimum: 0 + default: 0 + Search: + name: search + in: query + schema: + type: string + Dir: + name: dir + in: query + schema: + type: string + enum: [asc, desc] + default: asc + ActiveFilter: + name: active + in: query + schema: + type: string + description: "Accepted aliases: 1, 0, true, false, active, inactive" + OrderUsers: + name: order + in: query + schema: + type: string + enum: [id, uuid, first_name, last_name, display_name, email, created, modified, active, last_login_at] + default: last_name + OrderTenants: + name: order + in: query + schema: + type: string + enum: [id, uuid, description, created, modified] + default: description + OrderDepartments: + name: order + in: query + schema: + type: string + enum: [id, uuid, description, code, cost_center, active, created, modified] + default: description + OrderRoles: + name: order + in: query + schema: + type: string + enum: [id, uuid, description, code, active, created, modified] + default: description + responses: + Unauthorized: + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: unauthorized + Forbidden: + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + NotFound: + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: not_found + ValidationError: + description: Validation failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: validation_error + errors: + field: + - invalid_value + Conflict: + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + RateLimited: + description: Rate limited + headers: + Retry-After: + schema: + type: integer + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: rate_limit_exceeded + ServerError: + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + DataObject: + description: Generic service response + content: + application/json: + schema: + $ref: '#/components/schemas/GenericDataResponse' + schemas: + ErrorResponse: + type: object + required: [error] + properties: + error: + type: string + errors: + type: object + additionalProperties: + type: array + items: + type: string + GenericDataResponse: + type: object + required: [data] + properties: + data: + type: object + additionalProperties: true + RoleWriteRequest: + type: object + additionalProperties: false + required: [description] + properties: + description: + type: string + minLength: 1 + code: + type: string + active: + type: string + description: "Accepted aliases: 1, 0, true, false, active, inactive" + DepartmentWriteRequest: + type: object + additionalProperties: false + required: [description, tenant_id] + properties: + description: + type: string + minLength: 1 + tenant_id: + type: integer + minimum: 1 + code: + type: string + cost_center: + type: string + active: + type: string + description: "Accepted aliases: 1, 0, true, false, active, inactive" + TenantWriteRequest: + type: object + additionalProperties: false + required: [description, status] + properties: + description: + type: string + minLength: 1 + status: + type: string + enum: [active, inactive] + address: + type: string + postal_code: + type: string + city: + type: string + country: + type: string + region: + type: string + vat_id: + type: string + tax_number: + type: string + phone: + type: string + fax: + type: string + email: + type: string + format: email + support_email: + type: string + format: email + support_phone: + type: string + billing_email: + type: string + format: email + website: + type: string + privacy_url: + type: string + imprint_url: + type: string + primary_color: + type: string + description: "Hex color, e.g. #0f766e" + primary_color_use_default: + type: boolean + default_theme: + type: string + allow_user_theme_mode: + type: string + enum: ['', '0', '1'] + UserCreateRequest: + type: object + additionalProperties: false + required: [first_name, last_name, email, password, password2] + properties: + first_name: + type: string + minLength: 1 + last_name: + type: string + minLength: 1 + email: + type: string + format: email + profile_description: + type: string + job_title: + type: string + phone: + type: string + mobile: + type: string + short_dial: + type: string + address: + type: string + postal_code: + type: string + city: + type: string + country: + type: string + region: + type: string + hire_date: + type: string + format: date + locale: + type: string + theme: + type: string + totp_secret: + type: string + active: + type: boolean + password: + type: string + password2: + type: string + tenant_ids: + type: array + items: + type: integer + minimum: 1 + primary_tenant_id: + type: integer + minimum: 1 + role_ids: + type: array + items: + type: integer + minimum: 1 + department_ids: + type: array + items: + type: integer + minimum: 1 + UserUpdateRequest: + type: object + additionalProperties: false + required: [first_name, last_name, email] + properties: + first_name: + type: string + minLength: 1 + last_name: + type: string + minLength: 1 + email: + type: string + format: email + profile_description: + type: string + job_title: + type: string + phone: + type: string + mobile: + type: string + short_dial: + type: string + address: + type: string + postal_code: + type: string + city: + type: string + country: + type: string + region: + type: string + hire_date: + type: string + format: date + locale: + type: string + theme: + type: string + totp_secret: + type: string + active: + type: boolean + password: + type: string + password2: + type: string + tenant_ids: + type: array + items: + type: integer + minimum: 1 + primary_tenant_id: + type: integer + minimum: 1 + role_ids: + type: array + items: + type: integer + minimum: 1 + department_ids: + type: array + items: + type: integer + minimum: 1 + TenantSummary: + type: object + properties: + uuid: + type: string + format: uuid + description: + type: string + status: + type: string + AssignmentDepartment: + type: object + properties: + uuid: + type: string + format: uuid + description: + type: string + active: + type: boolean + tenant_uuid: + type: string + format: uuid + nullable: true + AssignmentRole: + type: object + properties: + uuid: + type: string + format: uuid + description: + type: string + active: + type: boolean + Assignments: + type: object + properties: + tenants: + type: array + items: + $ref: '#/components/schemas/TenantSummary' + departments: + type: array + items: + $ref: '#/components/schemas/AssignmentDepartment' + roles: + type: array + items: + $ref: '#/components/schemas/AssignmentRole' + UserCustomFieldOption: + type: object + properties: + option_key: + type: string + label: + type: string + UserCustomFieldField: + type: object + properties: + uuid: + type: string + format: uuid + field_key: + type: string + label: + type: string + type: + type: string + is_filterable: + type: boolean + value: + description: Type-dependent value (string, boolean, null, or array of option keys). + options: + type: array + items: + $ref: '#/components/schemas/UserCustomFieldOption' + UserCustomFieldTenantGroup: + type: object + properties: + tenant: + $ref: '#/components/schemas/TenantSummary' + fields: + type: array + items: + $ref: '#/components/schemas/UserCustomFieldField' + MeResponse: + type: object + required: [data] + properties: + data: + type: object + properties: + uuid: + type: string + format: uuid + first_name: + type: string + last_name: + type: string + display_name: + type: string + email: + type: string + job_title: + type: string + phone: + type: string + mobile: + type: string + locale: + type: string + current_tenant: + $ref: '#/components/schemas/TenantSummary' + primary_tenant: + $ref: '#/components/schemas/TenantSummary' + can_view_permissions: + type: boolean + permissions: + type: array + items: + type: string + assignments: + $ref: '#/components/schemas/Assignments' + custom_fields: + type: array + items: + $ref: '#/components/schemas/UserCustomFieldTenantGroup' + TokenItem: + type: object + properties: + uuid: + type: string + format: uuid + name: + type: string + selector_prefix: + type: string + tenant_uuid: + type: string + format: uuid + nullable: true + last_used_at: + type: string + nullable: true + last_ip: + type: string + expires_at: + type: string + nullable: true + revoked_at: + type: string + nullable: true + created: + type: string + nullable: true + is_current_token: + type: boolean + TokenListResponse: + type: object + required: [data] + properties: + data: + type: array + items: + $ref: '#/components/schemas/TokenItem' + limit: + type: integer + TokenShowResponse: + type: object + required: [data] + properties: + data: + $ref: '#/components/schemas/TokenItem' + TokenCreateRequest: + type: object + required: [name] + properties: + name: + type: string + tenant_uuid: + type: string + format: uuid + expires_at: + type: string + format: date-time + TokenCreateResponse: + type: object + required: [data] + properties: + data: + type: object + properties: + token_uuid: + type: string + format: uuid + token: + type: string + selector_prefix: + type: string + tenant_uuid: + type: string + format: uuid + nullable: true + expires_at: + type: string + nullable: true + RevokeAllResponse: + type: object + required: [data] + properties: + data: + type: object + properties: + revoked_count: + type: integer + tenant_uuid: + type: string + format: uuid + nullable: true + UserListItem: + type: object + properties: + uuid: + type: string + format: uuid + first_name: + type: string + last_name: + type: string + display_name: + type: string + email: + type: string + job_title: + type: string + phone: + type: string + mobile: + type: string + active: + type: boolean + created: + type: string + tenants: + type: array + items: + type: string + departments: + type: array + items: + type: string + roles: + type: array + items: + type: string + UserListResponse: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/UserListItem' + total: + type: integer + limit: + type: integer + offset: + type: integer + UserShowResponse: + type: object + properties: + data: + type: object + properties: + uuid: + type: string + format: uuid + first_name: + type: string + last_name: + type: string + display_name: + type: string + email: + type: string + job_title: + type: string + phone: + type: string + mobile: + type: string + short_dial: + type: string + address: + type: string + postal_code: + type: string + city: + type: string + country: + type: string + active: + type: boolean + created: + type: string + modified: + type: string + primary_tenant: + $ref: '#/components/schemas/TenantSummary' + permissions: + type: array + items: + type: string + assignments: + $ref: '#/components/schemas/Assignments' + custom_fields: + type: array + items: + $ref: '#/components/schemas/UserCustomFieldTenantGroup' + TenantListItem: + type: object + properties: + uuid: + type: string + format: uuid + description: + type: string + status: + type: string + city: + type: string + country: + type: string + created: + type: string + TenantListResponse: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/TenantListItem' + total: + type: integer + limit: + type: integer + offset: + type: integer + TenantShowResponse: + type: object + properties: + data: + type: object + properties: + uuid: + type: string + format: uuid + description: + type: string + status: + type: string + address: + type: string + postal_code: + type: string + city: + type: string + country: + type: string + phone: + type: string + email: + type: string + website: + type: string + created: + type: string + modified: + type: string + DepartmentListItem: + type: object + properties: + uuid: + type: string + format: uuid + description: + type: string + code: + type: string + active: + type: boolean + tenant_labels: + type: array + items: + type: string + created: + type: string + DepartmentListResponse: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/DepartmentListItem' + total: + type: integer + limit: + type: integer + offset: + type: integer + DepartmentShowResponse: + type: object + properties: + data: + type: object + properties: + uuid: + type: string + format: uuid + description: + type: string + code: + type: string + active: + type: boolean + tenant_id: + type: integer + nullable: true + created: + type: string + modified: + type: string + RoleListItem: + type: object + properties: + uuid: + type: string + format: uuid + description: + type: string + code: + type: string + active: + type: boolean + created: + type: string + RoleListResponse: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/RoleListItem' + total: + type: integer + limit: + type: integer + offset: + type: integer + RoleShowResponse: + type: object + properties: + data: + type: object + properties: + uuid: + type: string + format: uuid + description: + type: string + code: + type: string + active: + type: boolean + created: + type: string + modified: + type: string diff --git a/docs/sicherheitsmodell.md b/docs/sicherheitsmodell.md new file mode 100644 index 0000000..fe77f5a --- /dev/null +++ b/docs/sicherheitsmodell.md @@ -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=` 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. diff --git a/docs/todo-2fa.md b/docs/todo-2fa.md deleted file mode 100644 index 980a7f7..0000000 --- a/docs/todo-2fa.md +++ /dev/null @@ -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. diff --git a/docs/todo-editorjs-image.md b/docs/todo-editorjs-image.md deleted file mode 100644 index 65daf0b..0000000 --- a/docs/todo-editorjs-image.md +++ /dev/null @@ -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.` -- 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. diff --git a/eslint.config.cjs b/eslint.config.cjs new file mode 100644 index 0000000..8c82eab --- /dev/null +++ b/eslint.config.cjs @@ -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'], + }, + }, +]; diff --git a/i18n/default_de.json b/i18n/default_de.json index 45e79aa..bc7cd29 100644 --- a/i18n/default_de.json +++ b/i18n/default_de.json @@ -1,13 +1,22 @@ { "Login": "Login", + "Login options": "Anmeldeoptionen", "Register": "Registrieren", "Password": "Passwort", + "Or": "Oder", "Forgot password": "Passwort vergessen", "Remember me": "Angemeldet bleiben", "Clear login tokens": "Login-Tokens löschen", "Clear all login tokens for this user?": "Alle Login-Tokens für diesen Benutzer löschen?", "Login tokens cleared": "Login-Tokens gelöscht", "Send access": "Zugang versenden", + "Access PDF": "Zugangs-PDF", + "Download access PDFs": "Zugangs-PDFs herunterladen", + "Generate access PDFs for selected users?": "Zugangs-PDFs für ausgewählte Benutzer erstellen?", + "Too many users selected": "Zu viele Benutzer ausgewählt", + "No valid users selected": "Keine gültigen Benutzer ausgewählt", + "Failed to generate access PDF": "Zugangs-PDF konnte nicht erstellt werden", + "ZIP support missing (ext-zip)": "ZIP-Unterstützung fehlt (ext-zip)", "Send access email to this user?": "Zugangsdaten an diesen Benutzer senden?", "Access email sent": "Zugangsdaten gesendet", "Access email failed": "Zugangsdaten konnten nicht gesendet werden", @@ -37,8 +46,19 @@ "Logout": "Logout", "Account": "Konto", "Please enter your credentials": "Bitte geben Sie Ihre Zugangsdaten ein", + "Enter your email to continue": "E-Mail eingeben und weiter", + "Select your tenant to continue": "Mandant auswählen und weiter", + "Choose how you want to sign in": "Anmeldeart auswählen", + "Continue": "Weiter", + "Use different email": "Andere E-Mail verwenden", + "We could not continue with these login details": "Mit diesen Login-Daten konnte nicht fortgefahren werden", + "Cached": "Cache", + "Global settings are stored in the database. config/settings.php is only a runtime cache for selected app settings.": "Globale Einstellungen werden in der Datenbank gespeichert. config/settings.php ist nur ein Laufzeit-Cache für ausgewählte App-Einstellungen.", + "Too many login attempts. Please wait and try again.": "Zu viele Login-Versuche. Bitte warte kurz und versuche es erneut.", + "No login method is available for this tenant": "Für diesen Mandanten ist keine Login-Methode verfügbar", "Create your account": "Erstelle dein Konto", "First name": "Vorname", + "Name": "Name", "Last name": "Nachname", "Email": "E-Mail", "Password (again)": "Passwort bestätigen", @@ -99,6 +119,7 @@ "perm.users.view": "Benutzer anzeigen", "perm.users.create": "Benutzer erstellen", "perm.users.update": "Benutzer bearbeiten", + "perm.users.access_pdf": "Onboarding-PDFs erstellen", "perm.users.delete": "Benutzer löschen", "perm.users.self_update": "Eigenen Benutzer bearbeiten", "perm.users.update_assignments": "Benutzerzuweisungen bearbeiten", @@ -122,6 +143,11 @@ "perm.settings.view": "Einstellungen anzeigen", "perm.settings.update": "Einstellungen bearbeiten", "perm.mail_log.view": "E-Mail-Protokolle anzeigen", + "perm.jobs.view": "Geplante Aufgaben anzeigen", + "perm.jobs.manage": "Geplante Aufgaben verwalten", + "perm.jobs.run_now": "Geplante Aufgaben manuell ausführen", + "perm.user_lifecycle_audit.view": "Benutzer-Lifecycle-Protokolle anzeigen", + "perm.users.lifecycle_restore": "Benutzer aus Lifecycle-Protokoll wiederherstellen", "Select row": "Zeile auswählen", "Select all": "Alle auswählen", "Activate": "Aktivieren", @@ -226,6 +252,7 @@ "Favicon preview": "Favicon Vorschau", "Primary color": "Primärfarbe", "Primary color is invalid": "Primärfarbe ist ungültig", + "The primary color affects buttons and accent elements across the app.": "Die Primärfarbe beeinflusst Buttons und Akzent-Elemente in der gesamten App.", "Show filters": "Filter anzeigen", "Hide filters": "Filter ausblenden", "Export CSV": "CSV exportieren", @@ -233,15 +260,43 @@ "State": "Status", "Edit": "Bearbeiten", "Status": "Status", + "Visibility": "Sichtbarkeit", + "Inactive entries are hidden in lists and selections.": "Inaktive Einträge werden in Listen und Auswahlen ausgeblendet.", "All": "Alle", "All people": "Alle Personen", "Explorer": "Explorer", "Search": "Suche", "Search...": "Suchen...", + "To start searching, type in the search field.": "Um die Suche zu starten, gib etwas in das Suchfeld ein.", + "Tip: Use {shortcut} to focus search quickly.": "Tipp: Mit {shortcut} fokussierst du die Suche schnell.", "Created from": "Erstellt von", "Created to": "Erstellt bis", "Files": "Dateien", "Settings": "Einstellungen", + "Documentation": "Dokumentation", + "Documentation navigation": "Dokumentationsnavigation", + "On this page": "Auf dieser Seite", + "Getting started": "Erste Schritte", + "Local development": "Lokale Entwicklung", + "First change": "Erster Code-Change", + "Developer checklist": "Entwickler-Checkliste", + "Architecture & rules": "Architektur & Regeln", + "Architecture": "Architektur", + "Security model": "Sicherheitsmodell", + "Conventions": "Konventionen", + "Frontend": "Frontend", + "Features": "Features", + "Settings storage": "Einstellungsspeicher", + "Rate limiting": "Rate-Limiting", + "User lifecycle": "Benutzer-Lebenszyklus", + "Global search": "Globale Suche", + "API reference": "API-Referenz", + "Operations": "Betrieb", + "Troubleshooting": "Fehlerbehebung", + "Configuration": "Konfiguration", + "Automation & integrations": "Automatisierung & Integrationen", + "Monitoring": "Überwachung", + "Logs": "Protokolle", "System": "System", "System permission": "Systemberechtigung", "System permission can not be deleted": "Systemberechtigung kann nicht gelöscht werden", @@ -286,7 +341,11 @@ "No records found": "Keine Einträge gefunden", "An error happened while fetching the data": "Fehler beim Laden der Daten", "Delete this user?": "Diesen Benutzer wirklich löschen?", + "Delete user": "Benutzer löschen", + "This will permanently delete this user.": "Dieser Benutzer wird dauerhaft gelöscht.", "Delete this tenant?": "Diesen Mandanten wirklich löschen?", + "Delete tenant": "Mandant löschen", + "This will permanently delete this tenant.": "Dieser Mandant wird dauerhaft gelöscht.", "Tenant": "Mandant", "Default tenant": "Standard-Mandant", "Default role": "Standard-Rolle", @@ -311,12 +370,24 @@ "setting.app_theme": "Standard-Theme, wenn der Benutzer keine Präferenz hat", "setting.app_theme_user": "Benutzer dürfen ihr eigenes Theme wählen", "setting.app_registration": "Öffentliche Selbstregistrierung erlauben", + "setting.api_token_default_ttl_days": "Standard-Laufzeit für neue API-Tokens in Tagen (Default: 90)", + "setting.api_token_max_ttl_days": "Maximale Laufzeit für API-Tokens in Tagen (Hard Cap)", + "setting.api_cors_allowed_origins": "Erlaubte Browser-Origins für API-CORS (eine Origin pro Zeile)", "setting.app_primary_color": "Überschreibt die Standard-Primärfarbe (Hex wie #2FA4A4)", + "API token default lifetime (days)": "Standardlaufzeit für API-Tokens (Tage)", + "API token maximum lifetime (days)": "Maximale Laufzeit für API-Tokens (Tage)", + "Allowed CORS origins": "Erlaubte CORS-Origins", + "One origin per line, e.g. https://app.example.com": "Eine Origin pro Zeile, z. B. https://app.example.com", "Default theme": "Standard-Theme", "Allow user theme": "Benutzer-Theme erlauben", "Allow registration": "Registrierung erlauben", "General": "Allgemein", "Appearance": "Darstellung", + "API settings": "API-Einstellungen", + "API docs": "API-Doku", + "API documentation": "API-Dokumentation", + "Can view API documentation": "Kann API-Dokumentation anzeigen", + "API tokens and default lifetimes are managed here.": "API-Tokens und Standard-Laufzeiten werden hier verwaltet.", "Use default color": "Standardfarbe verwenden", "Tenants can override this color in their appearance settings.": "Mandanten können diese Farbe in ihren Darstellungseinstellungen überschreiben.", "Using the default color applies the global system appearance.": "Standardfarbe verwenden heißt: Es werden die globalen Systemeinstellungen für die Darstellung genutzt.", @@ -329,7 +400,7 @@ "Call phone": "Telefon anrufen", "Call mobile": "Mobil anrufen", "Choose color": "Farbe wählen", - "Be careful - this resets the tenants color": "Achtung – das setzt die Mandantenfarbe zurück", + "Be careful - this resets the tenants color": "Achtung – das setzt die Primärfarbe des Mandanten zurück", "Code": "Code", "Cost center": "Kostenstelle", "Department code already exists": "Abteilungscode existiert bereits", @@ -345,10 +416,12 @@ "Tenant updated": "Mandant aktualisiert", "Tenant deleted": "Mandant gelöscht", "Tenant not found": "Mandant nicht gefunden", + "Tenant can not be deleted while departments are assigned": "Mandant kann nicht gelöscht werden, solange Abteilungen zugewiesen sind", "Tenant can not be created": "Mandant kann nicht erstellt werden", "Tenant can not be updated": "Mandant kann nicht aktualisiert werden", "Tenant image": "Mandantenbild", "Assigned tenants": "Zugewiesene Mandanten", + "Assigned tenant": "Zugewiesener Mandant", "Primary tenant": "Hauptmandant", "Select primary tenant": "Hauptmandant auswählen", "Please select a primary tenant": "Bitte einen Hauptmandanten auswählen", @@ -357,6 +430,8 @@ "Departments & roles": "Abteilungen und Rollen", "Department assignments cleaned: %d": "Abteilungszuweisungen bereinigt: %d", "Select tenants": "Mandanten auswählen", + "Select tenant": "Mandant auswählen", + "Which tenant do you want to sign in to?": "Mit welchem Mandanten anmelden?", "Assigned roles": "Zugewiesene Rollen", "Select roles": "Rollen auswählen", "Department": "Abteilung", @@ -370,8 +445,14 @@ "Department can not be created": "Abteilung kann nicht erstellt werden", "Department can not be updated": "Abteilung kann nicht aktualisiert werden", "Delete this department?": "Diese Abteilung wirklich löschen?", + "Delete department": "Abteilung löschen", + "This will permanently delete this department.": "Diese Abteilung wird dauerhaft gelöscht.", "Assigned departments": "Zugewiesene Abteilungen", "Select departments": "Abteilungen auswählen", + "Departments for tenant": "Abteilungen für Mandant", + "No departments available for this tenant": "Für diesen Mandanten sind keine Abteilungen verfügbar", + "Assign tenants first to select departments": "Bitte zuerst Mandanten zuweisen, um Abteilungen auszuwählen", + "Department options refresh after save": "Abteilungsoptionen werden nach dem Speichern aktualisiert", "Role": "Rolle", "Roles": "Rollen", "Create role": "Rolle anlegen", @@ -383,6 +464,11 @@ "Role can not be created": "Rolle kann nicht erstellt werden", "Role can not be updated": "Rolle kann nicht aktualisiert werden", "Delete this role?": "Diese Rolle wirklich löschen?", + "Delete role": "Rolle löschen", + "Delete permission": "Berechtigung löschen", + "This will permanently delete this role.": "Diese Rolle wird dauerhaft gelöscht.", + "This will permanently delete this permission.": "Diese Berechtigung wird dauerhaft gelöscht.", + "This action cannot be undone.": "Diese Aktion kann nicht rückgängig gemacht werden.", "Description": "Beschreibung", "Description cannot be empty": "Beschreibung darf nicht leer sein", "Status is invalid": "Status ist ungültig", @@ -431,14 +517,13 @@ "You do not have permission to view this content.": "Du hast keine Berechtigung, diesen Inhalt zu sehen.", "If you believe this is an error, please contact your system administrator.": "Wenn du glaubst, dass dies ein Fehler ist, wende dich bitte an den Systemadministrator.", "Requested URL": "Angeforderte URL", - "Audit": "Audit", "Forward": "Vorwärts", "Status changed": "Status geändert", "Status changed by": "Status geändert von", - "Page": "Seite", "Requested path": "Angeforderter Pfad", "Page not found": "Seite nicht gefunden", "The page you requested does not exist or has moved.": "Die angeforderte Seite existiert nicht oder wurde verschoben.", + "Back to start": "Zur Startseite", "Page updated": "Seite aktualisiert", "Page can not be updated": "Seite kann nicht aktualisiert werden", "Content is invalid": "Inhalt ist ungültig", @@ -480,7 +565,15 @@ "SMTP from address": "Absender-Adresse", "SMTP from name": "Absendername", "Address book": "Adressbuch", - "All people": "Alle Personen", + "Saved filters": "Gespeicherte Filter", + "Save filter": "Filter speichern", + "Enter a name for this filter": "Gib einen Namen für diesen Filter ein", + "Filter name is required": "Filtername ist erforderlich", + "Maximum number of saved filters reached": "Maximale Anzahl gespeicherter Filter erreicht", + "Filter saved": "Filter gespeichert", + "Delete saved filter": "Gespeicherten Filter löschen", + "Filter deleted": "Filter gelöscht", + "Filter save failed": "Filter konnte nicht gespeichert werden", "Mobile": "Mobil", "Short dial": "Kurzwahl", "setting.smtp_host": "SMTP-Serveradresse (z.B. smtp.example.com)", @@ -493,12 +586,34 @@ "High contrast": "Hoher Kontrast", "Toggle contrast": "Kontrast umschalten", "View in address book": "Im Adressbuch ansehen", - "Overview": "Übersicht", "Organization & Quality": "Organisation & Qualität", - "Security": "Sicherheit", - "Communication": "Kommunikation", "Email security": "E-Mail Sicherheit", - "Recipient": "Empfänger", + "API": "API", + "API requests (24h)": "API Requests (24h)", + "Request trend vs previous 24h": "Request-Trend zur vorherigen 24h", + "4xx errors (24h)": "4xx Fehler (24h)", + "5xx errors (24h)": "5xx Fehler (24h)", + "P95 response time (ms)": "P95 Antwortzeit (ms)", + "Tokens expiring in 7 days": "Tokens mit Ablauf in 7 Tagen", + "Top API error codes (24h)": "Top API-Fehlercodes (24h)", + "Slowest API endpoints (avg, 24h)": "Langsamste API-Endpunkte (Durchschnitt, 24h)", + "Import runs (7d)": "Import-Läufe (7 Tage)", + "Run trend vs previous 7d": "Lauftrend vs. vorige 7 Tage", + "Created rows (7d)": "Erstellte Zeilen (7 Tage)", + "Failed rows (7d)": "Fehlgeschlagene Zeilen (7 Tage)", + "Partial or failed runs (7d)": "Teilweise/fehlgeschlagene Läufe (7 Tage)", + "Success rate (7d)": "Erfolgsquote (7 Tage)", + "Average duration (ms, 7d)": "Durchschnittsdauer (ms, 7 Tage)", + "Recent import runs": "Letzte Import-Läufe", + "Imports by profile (7d)": "Importe nach Profil (7 Tage)", + "Top import error codes (7d)": "Häufigste Import-Fehlercodes (7 Tage)", + "Runs": "Läufe", + "Created rows": "Erstellte Zeilen", + "Failed rows": "Fehlgeschlagene Zeilen", + "Success rate": "Erfolgsquote", + "Requests": "Requests", + "Average duration (ms)": "Durchschnittliche Dauer (ms)", + "Maximum duration (ms)": "Maximale Dauer (ms)", "Error": "Fehler", "Active tenants": "Aktive Mandanten", "Inactive tenants": "Inaktive Mandanten", @@ -517,13 +632,16 @@ "Recent logins": "Letzte Anmeldungen", "Never logged in": "Nie angemeldet", "Last login": "Letzte Anmeldung", + "Last login via": "Letzte Anmeldung über", + "Local": "Lokal", + "Microsoft": "Microsoft", + "Unknown": "Unbekannt", "User": "Benutzer", "No entries found": "Keine Einträge gefunden", "Login status": "Anmeldestatus", "Logged in": "Angemeldet", "More filters": "Weitere Filter", - "Email unverified": "E-Mail nicht verifiziert" - , + "Email unverified": "E-Mail nicht verifiziert", "Password resets": "Passwort-Resets", "Requested at": "Angefordert am", "Expires at": "Gültig bis", @@ -538,7 +656,7 @@ "%d active login tokens": "%d aktive Login-Tokens", "%d login tokens expired": "%d Login-Tokens abgelaufen", "Danger zone": "Gefahrenzone", - "This will mark all remember-me tokens as expired. Users will need to sign in again.": "Alle Angemeldet-bleiben Tokens werden als abgelaufen markiert. Benutzer müssen sich erneut anmelden.", + "This will mark all remember-me tokens as expired. Users will need to sign in again.": "Alle Angemeldet-bleiben Tokens werden als abgelaufen markiert. Benutzer müssen sich erneut anmelden sobald die Browser-Session ausgelaufen ist.", "Last used": "Zuletzt verwendet", "Keyboard shortcuts": "Tastenkürzel", "Open search": "Suche öffnen", @@ -556,5 +674,418 @@ "Toggle sidebar": "Sidebar umschalten", "Unverified": "Nicht verifiziert", "Imprint": "Impressum", - "Privacy": "Datenschutz" + "Privacy": "Datenschutz", + "Custom fields": "Zusatzfelder", + "User custom fields": "Benutzerzusatzfelder", + "Affected tenants": "Betroffene Mandanten", + "If a tenant appears here, at least one custom field setup needs review (missing options, inactive fields with values, or unused active fields).": "Wenn ein Mandant hier erscheint, muss mindestens eine Zusatzfeld-Konfiguration geprüft werden (fehlende Optionen, inaktive Felder mit Werten oder ungenutzte aktive Felder).", + "Issue": "Problem", + "Count": "Anzahl", + "Selection fields without options": "Auswahlfelder ohne Optionen", + "Tenants with broken selection fields": "Mandanten mit fehlerhaften Auswahlfeldern", + "Inactive custom fields with values": "Inaktive Zusatzfelder mit Werten", + "Unused active custom fields": "Ungenutzte aktive Zusatzfelder", + "Save tenant first to manage custom fields": "Speichern Sie den Mandanten zuerst, um Zusatzfelder zu verwalten", + "Define custom fields here. They appear per user in the \"Custom fields\" tab and can be maintained there.": "Hier definieren Sie Zusatzfelder. Diese erscheinen pro Benutzer im Reiter \"Zusatzfelder\" und sind dort pflegbar.", + "Add custom field": "Zusatzfeld hinzufügen", + "The label is shown in user custom fields and in address book filters.": "Die Bezeichnung wird bei Benutzer-Zusatzfeldern und in den Adressbuch-Filtern angezeigt.", + "The field type controls input and filter behavior.": "Der Feldtyp steuert Eingabe- und Filterverhalten.", + "Field key": "Feldschlüssel", + "Field type": "Feldtyp", + "Text": "Text", + "Textarea": "Mehrzeiliger Text", + "Select": "Auswahl", + "Multiselect": "Mehrfachauswahl", + "Boolean": "Ja/Nein", + "Date": "Datum", + "Sort order": "Sortierung", + "Filterable": "Filterbar", + "Options (one per line)": "Optionen (eine pro Zeile)", + "Example: manager|Manager": "Beispiel: manager|Manager", + "One option per line. Optional format: key|Label.": "Eine Option pro Zeile. Optionales Format: key|Label.", + "No custom fields configured yet": "Noch keine Zusatzfelder konfiguriert", + "Delete custom field": "Zusatzfeld löschen", + "Custom field saved": "Zusatzfeld gespeichert", + "Custom field deleted": "Zusatzfeld gelöscht", + "Custom field can not be created": "Zusatzfeld kann nicht erstellt werden", + "Custom field can not be updated": "Zusatzfeld kann nicht aktualisiert werden", + "Custom field can not be deleted": "Zusatzfeld kann nicht gelöscht werden", + "Custom field values can not be saved": "Zusatzfeldwerte konnten nicht gespeichert werden", + "Custom field not found": "Zusatzfeld nicht gefunden", + "Label cannot be empty": "Bezeichnung darf nicht leer sein", + "Field key cannot be empty": "Feldschlüssel darf nicht leer sein", + "Field type is invalid": "Feldtyp ist ungültig", + "Field key already exists": "Feldschlüssel existiert bereits", + "Options are required for this field type": "Für diesen Feldtyp sind Optionen erforderlich", + "Custom fields update after save": "Zusatzfelder werden nach Speichern/Neuladen aktualisiert", + "Assign tenants first to edit custom fields": "Bitte zuerst Mandanten zuweisen, um Zusatzfelder zu bearbeiten", + "Custom fields for tenant": "Zusatzfelder für Mandant", + "No custom fields configured for this tenant": "Für diesen Mandanten sind keine Zusatzfelder konfiguriert", + "No custom fields configured for selected tenants": "Für die ausgewählten Mandanten sind keine Zusatzfelder konfiguriert", + "Select options": "Optionen auswählen", + "Please fill required custom field: %s": "Bitte Pflicht-Zusatzfeld ausfüllen: %s", + "Invalid value for %s": "Ungültiger Wert für %s", + "Please select": "Bitte auswählen", + "From": "Von", + "To": "Bis", + "perm.custom_fields.manage": "Mandanten-Zusatzfelder verwalten", + "perm.custom_fields.edit_values": "Benutzer-Zusatzfelder bearbeiten", + "Label": "Bezeichnung", + "Required": "Pflichtfeld", + "Required fields must be filled before saving a user.": "Pflichtfelder müssen vor dem Speichern eines Benutzers ausgefüllt werden.", + "Filterable fields are available in address book filters.": "Filterbare Felder sind im Adressbuch als Filter verfügbar.", + "Inactive fields are hidden in forms and filters.": "Inaktive Felder sind in Formularen und Filtern ausgeblendet.", + "Default theme is invalid": "Standard-Theme ist ungültig", + "User theme policy is invalid": "Benutzer-Theme-Richtlinie ist ungültig", + "Theme change is disabled for this tenant": "Theme-Wechsel ist für diesen Mandanten deaktiviert", + "Inherit global setting": "Globale Einstellung übernehmen", + "Force allow user theme": "Benutzer-Theme erlauben (erzwingen)", + "Force disallow user theme": "Benutzer-Theme verbieten (erzwingen)", + "If set, this tenant uses its own default theme instead of the global setting.": "Wenn gesetzt, verwendet dieser Mandant sein eigenes Standard-Theme statt der globalen Einstellung.", + "Controls whether users in this tenant can choose their own theme.": "Steuert, ob Benutzer in diesem Mandanten ihr eigenes Theme wählen können.", + "Tenants can override color and theme behavior in their appearance settings.": "Mandanten können Farbe und Theme-Verhalten in ihren Darstellungs-Einstellungen überschreiben.", + "User theme policy": "Benutzer-Theme-Richtlinie", + "APP_CRYPTO_KEY is missing or invalid": "APP_CRYPTO_KEY fehlt oder ist ungültig", + "Allowed email domains": "Erlaubte E-Mail-Domains", + "App credentials source": "Quelle der App-Zugangsdaten", + "Authority URL": "Authority-URL", + "Clear override secret": "Override-Secret entfernen", + "Client ID override": "Client-ID-Override", + "Client ID override is required": "Client-ID-Override ist erforderlich", + "Client secret could not be encrypted": "Client-Secret konnte nicht verschlüsselt werden", + "Client secret override": "Client-Secret-Override", + "Client secret override is required": "Client-Secret-Override ist erforderlich", + "Configure Microsoft login per tenant. Local login can stay enabled or be enforced off.": "Konfiguriere Microsoft-Login pro Mandant. Der lokale Login kann aktiv bleiben oder erzwungen deaktiviert werden.", + "Disable password login for this tenant login": "Passwort-Login für diesen Mandanten-Login deaktivieren", + "Enable Microsoft login for this tenant": "Microsoft-Login für diesen Mandanten aktivieren", + "Enforcement": "Erzwingung", + "Entra tenant ID (tid)": "Entra-Tenant-ID (tid)", + "Entra tenant ID is invalid": "Entra-Tenant-ID ist ungültig", + "Login failed": "Login fehlgeschlagen", + "Microsoft SSO": "Microsoft SSO", + "Microsoft login": "Microsoft-Login", + "Microsoft login could not be started": "Microsoft-Login konnte nicht gestartet werden", + "Microsoft login failed": "Microsoft-Login fehlgeschlagen", + "Microsoft login is not allowed for this account": "Microsoft-Login ist für dieses Konto nicht erlaubt", + "Microsoft login is not available for this tenant": "Microsoft-Login ist für diesen Mandanten nicht verfügbar", + "Microsoft login is not fully configured for this tenant.": "Microsoft-Login ist für diesen Mandanten nicht vollständig konfiguriert.", + "Microsoft login was cancelled or failed": "Microsoft-Login wurde abgebrochen oder ist fehlgeschlagen", + "Microsoft-only login requires a complete configuration": "Nur-Microsoft-Login erfordert eine vollständige Konfiguration", + "No access to this tenant": "Kein Zugriff auf diesen Mandanten", + "Shared credentials missing": "Geteilte Zugangsdaten fehlen", + "Optional credential override": "Optionale Credential-Überschreibung", + "Optional restrictions": "Optionale Einschränkungen", + "Optional allow-list, comma or newline separated (example.com).": "Optionale Allow-List, per Komma oder Zeilenumbruch getrennt (example.com).", + "Password login is disabled for this tenant. Please use Microsoft login.": "Passwort-Login ist für diesen Mandanten deaktiviert. Bitte nutze Microsoft-Login.", + "Password login is disabled for all assigned tenants": "Passwort-Login ist für alle zugewiesenen Mandanten deaktiviert.", + "Password login mode": "Passwort-Login-Modus", + "Local password + Microsoft": "Lokales Passwort + Microsoft", + "Credentials source": "Quelle der Zugangsdaten", + "Shared app": "Geteilte App", + "Tenant override": "Mandanten-Override", + "SSO configuration status": "SSO-Konfigurationsstatus", + "Configuration complete": "Konfiguration vollständig", + "Required Microsoft configuration": "Pflichtkonfiguration Microsoft", + "Required for Microsoft login.": "Für Microsoft-Login erforderlich.", + "SSO": "SSO", + "Shared Microsoft app credentials are used by tenants that enable \"Use shared app credentials\".": "Geteilte Microsoft-App-Zugangsdaten werden von Mandanten verwendet, die \"Geteilte App-Zugangsdaten verwenden\" aktivieren.", + "Shared client ID": "Geteilte Client-ID", + "Shared client secret": "Geteiltes Client-Secret", + "Sign in with Microsoft": "Mit Microsoft anmelden", + "Tenant SSO settings could not be saved": "Mandanten-SSO-Einstellungen konnten nicht gespeichert werden", + "Tenant login": "Mandanten-Login", + "Tenant login link is invalid": "Mandanten-Login-Link ist ungültig", + "Tenant login URL": "Mandanten-Login-URL", + "Tenant Microsoft settings": "Mandanten-Microsoft-Einstellungen", + "These settings are required per tenant when Microsoft login is enabled.": "Diese Einstellungen sind pro Mandant erforderlich, sobald Microsoft-Login aktiviert ist.", + "Profile sync on Microsoft login": "Profil-Sync bei Microsoft-Login", + "Profile sync (optional)": "Profil-Sync (optional)", + "Sync fields": "Sync-Felder", + "Sync first name": "Vorname synchronisieren", + "Sync last name": "Nachname synchronisieren", + "Sync phone": "Telefon synchronisieren", + "Sync mobile": "Mobil synchronisieren", + "Sync avatar image": "Avatarbild synchronisieren", + "User profile fields are global and affect all tenants of this user": "Benutzer-Profildaten sind global und wirken auf alle Mandanten dieses Benutzers.", + "Phone/mobile/avatar sync requires Microsoft Graph (User.Read)": "Telefon/Mobil/Avatar-Sync benötigt Microsoft Graph (User.Read).", + "Phone/mobile sync requires Microsoft Graph (User.Read)": "Telefon/Mobil-Sync benötigt Microsoft Graph (User.Read).", + "SSO-enabled tenants": "Mandanten mit aktivem SSO", + "SSO-enforced tenants": "Mandanten mit erzwungenem SSO", + "SSO rollout": "SSO-Rollout", + "Users with Microsoft-only login": "Benutzer mit nur Microsoft-Login", + "Microsoft profile sync gaps": "Microsoft-Profil-Sync-Lücken", + "Tenant SSO overview": "Mandanten-SSO-Übersicht", + "Microsoft SSO policy": "Microsoft-SSO-Richtlinie", + "Sync enabled": "Sync aktiv", + "Local only": "Nur lokal", + "Microsoft only": "Nur Microsoft", + "Mixed (local + Microsoft)": "Gemischt (lokal + Microsoft)", + "Missing values": "Fehlende Werte", + "Use shared app credentials from settings": "Geteilte App-Zugangsdaten aus Einstellungen verwenden", + "Override only if this tenant needs a separate Entra app.": "Override nur nutzen, wenn dieser Mandant eine eigene Entra-App benötigt.", + "Advanced app override": "Erweiterter App-Override", + "Use shared app credentials by default. Configure overrides only when this tenant needs a separate app registration.": "Standardmäßig werden geteilte App-Zugangsdaten verwendet. Overrides nur konfigurieren, wenn dieser Mandant eine eigene App-Registrierung braucht.", + "Use this URL to enter tenant-scoped login (local and/or Microsoft).": "Diese URL für den mandantenspezifischen Login verwenden (lokal und/oder Microsoft).", + "Used only when shared app credentials are disabled.": "Wird nur verwendet, wenn geteilte App-Zugangsdaten deaktiviert sind.", + "Uses tenant-specific Entra tenant ID and policy for this login entry.": "Verwendet die mandantenspezifische Entra-Tenant-ID und Richtlinie für diesen Login-Einstieg.", + "When enabled, users must use Microsoft login on the tenant login URL.": "Wenn aktiv, müssen Benutzer auf der Mandanten-Login-URL Microsoft-Login verwenden.", + "Write-only. Leave empty to keep current secret.": "Nur schreiben. Leer lassen, um das aktuelle Secret beizubehalten.", + "Client secret override is invalid": "Client-Secret-Override ist ungültig", + "Client credentials are missing": "Client-Zugangsdaten fehlen", + "setting.microsoft_shared_client_id": "Einstellungsschlüssel für geteilte Microsoft-Client-ID", + "setting.microsoft_shared_client_secret_enc": "Einstellungsschlüssel für geteiltes Microsoft-Client-Secret (verschlüsselt)", + "setting.microsoft_authority": "Basis-OpenID-Authority-URL für Microsoft-Login", + "API tokens": "API-Tokens", + "Token name": "Token-Name", + "Create API token": "API-Token erstellen", + "Revoke": "Widerrufen", + "Revoke this API token?": "Dieses API-Token widerrufen?", + "Revoked": "Widerrufen", + "Prefix": "Präfix", + "No API tokens": "Keine API-Tokens", + "New API token (copy now — shown only once):": "Neues API-Token (jetzt kopieren — wird nur einmal angezeigt):", + "API token created. Copy it now — it will not be shown again.": "API-Token erstellt. Jetzt kopieren — es wird nicht erneut angezeigt.", + "Could not create API token: %s": "API-Token konnte nicht erstellt werden: %s", + "API token revoked": "API-Token widerrufen", + "%d API tokens revoked": "%d API-Tokens widerrufen", + "%d active API tokens": "%d aktive API-Tokens", + "Revoke all API tokens": "Alle API-Tokens widerrufen", + "Revoke all API tokens?": "Alle API-Tokens widerrufen?", + "All API tokens will be revoked immediately.": "Alle API-Tokens werden sofort widerrufen.", + "API token default lifetime is invalid": "Standardlaufzeit für API-Tokens ist ungültig", + "API token max lifetime is invalid": "Maximale Laufzeit für API-Tokens ist ungültig", + "API audit": "API-Protokolle", + "API audit logs": "API-Protokolle", + "Request ID": "Request-ID", + "Method": "Methode", + "Path": "Pfad", + "Status code": "Statuscode", + "Duration (ms)": "Dauer (ms)", + "Error code": "Fehlercode", + "Token ID": "Token-ID", + "Token tenant": "Token-Mandant", + "Purge API audit logs": "API-Protokolle bereinigen", + "Purge entries older than 90 days?": "Einträge älter als 90 Tage bereinigen?", + "%d API audit entries purged": "%d API-Protokoll-Einträge bereinigt", + "API audit entry not found": "API-Protokoll-Eintrag nicht gefunden", + "View API audit entry": "API-Protokoll-Eintrag anzeigen", + "Request details": "Request-Details", + "Scope": "Geltungsbereich", + "Query": "Query", + "User agent": "User-Agent", + "All methods": "Alle Methoden", + "Tenant ID": "Mandanten-ID", + "User ID": "Benutzer-ID", + "IP": "IP", + "CORS allowed origins are invalid": "Erlaubte CORS-Origins sind ungültig", + "Token name is required": "Token-Name ist erforderlich", + "e.g. CI Pipeline": "z.B. CI-Pipeline", + "Imports": "Importe", + "Upload CSV data, map columns, preview and import.": "CSV hochladen, Spalten zuordnen, Vorschau prüfen und importieren.", + "You do not have permission to run imports.": "Du hast keine Berechtigung für Importe.", + "Entity": "Entität", + "CSV file": "CSV-Datei", + "Max size 10MB, max 20000 rows": "Maximal 10MB, maximal 20000 Zeilen", + "Download sample CSV": "Beispiel-CSV herunterladen", + "Analyze file": "Datei analysieren", + "Map CSV columns": "CSV-Spalten zuordnen", + "Required fields: first_name, last_name, email": "Pflichtfelder: first_name, last_name, email", + "CSV header": "CSV-Header", + "Target field": "Zielfeld", + "Ignore column": "Spalte ignorieren", + "Assignment columns require additional permission.": "Zuweisungsspalten benötigen eine zusätzliche Berechtigung.", + "Run preview": "Vorschau ausführen", + "Preview": "Vorschau", + "Rows total": "Zeilen gesamt", + "Valid rows": "Gültige Zeilen", + "Would create": "Würde erstellen", + "Would skip": "Würde überspringen", + "Would fail": "Würde fehlschlagen", + "Row issues": "Zeilenprobleme", + "Start import": "Import starten", + "Import finished": "Import abgeschlossen", + "Processed": "Verarbeitet", + "Skipped": "Übersprungen", + "Import another file": "Weitere Datei importieren", + "No row errors found": "Keine Zeilenfehler gefunden", + "Row": "Zeile", + "Code": "Code", + "Message": "Nachricht", + "No upload file provided": "Keine Upload-Datei übergeben", + "Upload exceeds the maximum size": "Upload überschreitet die maximale Dateigröße", + "Invalid file type": "Ungültiger Dateityp", + "CSV header is invalid": "CSV-Header ist ungültig", + "CSV file has no data rows": "CSV-Datei enthält keine Datenzeilen", + "CSV row limit exceeded": "CSV-Zeilenlimit überschritten", + "Required mapping is missing": "Erforderliches Mapping fehlt", + "Assignment import permission is required": "Berechtigung für Assignment-Import ist erforderlich", + "Email is invalid": "E-Mail ist ungültig", + "Email appears multiple times in this file": "E-Mail kommt mehrfach in dieser Datei vor", + "Email already exists": "E-Mail existiert bereits", + "Locale is invalid": "Locale ist ungültig", + "Active value is invalid": "Active-Wert ist ungültig", + "Hire date must be YYYY-MM-DD": "Eintrittsdatum muss YYYY-MM-DD sein", + "Tenant is missing, inactive or invalid": "Mandant fehlt, ist inaktiv oder ungültig", + "Role is missing, inactive or invalid": "Rolle fehlt, ist inaktiv oder ungültig", + "Department is missing, inactive or invalid": "Abteilung fehlt, ist inaktiv oder ungültig", + "Department does not belong to tenant": "Abteilung gehört nicht zum Mandanten", + "Tenant assignment is out of your scope": "Mandantenzuweisung liegt außerhalb deines Scopes", + "User could not be created": "Benutzer konnte nicht erstellt werden", + "Unexpected error during import": "Unerwarteter Fehler beim Import", + "Required field is empty: first_name": "Pflichtfeld ist leer: first_name", + "Required field is empty: last_name": "Pflichtfeld ist leer: last_name", + "User assignments can not be updated": "Benutzerzuweisungen können nicht aktualisiert werden", + "How the import works": "So funktioniert der Import", + "1. Upload CSV and click analyze": "1. CSV hochladen und analysieren klicken", + "2. Map your CSV columns to system fields": "2. CSV-Spalten auf Systemfelder mappen", + "3. Review preview counters and row errors": "3. Vorschau, Zähler und Zeilenfehler prüfen", + "4. Start import for valid rows": "4. Import für gültige Zeilen starten", + "Rules": "Regeln", + "Create-only: existing emails are skipped": "Nur erstellen: bestehende E-Mails werden übersprungen", + "Create-only: existing entries are skipped": "Nur erstellen: bestehende Einträge werden übersprungen", + "Here is a CSV template for this import profile.": "Hier ist eine CSV-Vorlage für dieses Importprofil.", + "Reference": "Referenz", + "Required fields": "Pflichtfelder", + "Required field is empty: description": "Pflichtfeld ist leer: description", + "Tenant must be a valid UUID": "Mandant muss eine gültige UUID sein", + "Code already exists": "Code existiert bereits", + "Department already exists for this tenant": "Abteilung für diesen Mandanten existiert bereits", + "Duplicate entry in CSV file": "Doppelter Eintrag in der CSV-Datei", + "Entry could not be created": "Eintrag konnte nicht erstellt werden", + "Import logs": "Import-Protokolle", + "Import audit logs": "Import-Protokolle", + "Can view import audit logs": "Kann Import-Protokolle anzeigen", + "Run UUID": "Run-UUID", + "Mapped fields": "Gemappte Felder", + "Created count": "Erstellt", + "Skipped count": "Übersprungen", + "Failed count": "Fehlgeschlagen", + "Purge import logs": "Import-Protokolle bereinigen", + "%d import audit entries purged": "%d Import-Protokoll-Einträge bereinigt", + "Import audit entry not found": "Import-Protokoll-Eintrag nicht gefunden", + "View import audit entry": "Import-Protokoll-Eintrag anzeigen", + "Import details": "Import-Details", + "Started": "Gestartet", + "Finished": "Beendet", + "File": "Datei", + "Current tenant": "Aktueller Mandant", + "Error code distribution": "Fehlercode-Verteilung", + "All profiles": "Alle Profile", + "Running": "Laufend", + "Success": "Erfolgreich", + "Partial": "Teilweise", + "Import": "Import", + "Counters": "Zähler", + "User lifecycle policy": "Benutzer-Lifecycle-Regel", + "Deactivate users after inactivity (days)": "Benutzer nach Inaktivität deaktivieren (Tage)", + "Delete inactive users after (days)": "Inaktive Benutzer löschen nach (Tagen)", + "0 disables this rule": "0 deaktiviert diese Regel", + "Deletion starts after user was set inactive": "Löschung startet erst nach Inaktivsetzung", + "Privileged admins are excluded from automatic lifecycle actions": "Privilegierte Admins sind von automatischen Lifecycle-Aktionen ausgenommen", + "Run user lifecycle now?": "Benutzer-Lifecycle jetzt ausführen?", + "Run user lifecycle now": "Benutzer-Lifecycle jetzt ausführen", + "setting.user_inactivity_deactivate_days": "Setzt Benutzer nach dieser Anzahl Tagen ohne Login automatisch auf inaktiv", + "setting.user_inactivity_delete_days": "Löscht inaktive Benutzer nach dieser zusätzlichen Anzahl Tagen automatisch", + "User lifecycle already running": "Benutzer-Lifecycle läuft bereits", + "User lifecycle failed": "Benutzer-Lifecycle fehlgeschlagen", + "User lifecycle completed: %d deactivated, %d deleted": "Benutzer-Lifecycle abgeschlossen: %d deaktiviert, %d gelöscht", + "User inactivity deactivation period is invalid": "Zeitraum für automatische Deaktivierung ist ungültig", + "User inactivity deletion period is invalid": "Zeitraum für automatische Löschung ist ungültig", + "Auto-delete is ignored while auto-deactivate is disabled": "Auto-Löschung wird ignoriert, solange Auto-Deaktivierung deaktiviert ist", + "Scheduled jobs": "Geplante Aufgaben", + "Can view scheduled jobs": "Kann geplante Aufgaben anzeigen", + "Can manage scheduled jobs": "Kann geplante Aufgaben verwalten", + "Can run scheduled jobs manually": "Kann geplante Aufgaben manuell ausführen", + "Scheduled job not found": "Geplante Aufgabe nicht gefunden", + "Scheduled job is not registered": "Geplante Aufgabe ist nicht registriert", + "Scheduled job could not be saved": "Geplante Aufgabe konnte nicht gespeichert werden", + "Scheduled job saved": "Geplante Aufgabe gespeichert", + "Edit scheduled job": "Geplante Aufgabe bearbeiten", + "Job": "Aufgabe", + "Job key": "Aufgaben-Key", + "Run history": "Laufhistorie", + "Run now": "Jetzt ausführen", + "Runtime status": "Laufstatus", + "Next run": "Nächster Lauf", + "Last run": "Letzter Lauf", + "Last error": "Letzter Fehler", + "Schedule": "Zeitplan", + "Schedule type": "Zeitplan-Typ", + "Schedule interval": "Zeitplan-Intervall", + "Schedule time": "Zeitplan-Uhrzeit", + "Weekdays": "Wochentage", + "Hourly": "Stündlich", + "Daily": "Täglich", + "Weekly": "Wöchentlich", + "Enabled": "Aktiv", + "Disabled": "Inaktiv", + "Timezone": "Zeitzone", + "Catch up once": "Verpassten Lauf einmal nachholen", + "Scheduler": "Scheduler", + "Every %d hour(s)": "Alle %d Stunde(n)", + "Every %d day(s) at %s": "Alle %d Tag(e) um %s", + "Every %d week(s) on %s at %s": "Alle %d Woche(n) an %s um %s", + "No weekdays": "Keine Wochentage", + "Mon": "Mo", + "Tue": "Di", + "Wed": "Mi", + "Thu": "Do", + "Fri": "Fr", + "Sat": "Sa", + "Sun": "So", + "Schedule type is invalid": "Zeitplan-Typ ist ungültig", + "Schedule time is required": "Zeitplan-Uhrzeit ist erforderlich", + "Timezone is invalid": "Zeitzone ist ungültig", + "Hourly interval must be between 1 and 24": "Stündliches Intervall muss zwischen 1 und 24 liegen", + "Daily interval must be between 1 and 365": "Tägliches Intervall muss zwischen 1 und 365 liegen", + "Weekly interval must be between 1 and 52": "Wöchentliches Intervall muss zwischen 1 und 52 liegen", + "At least one weekday is required for weekly schedule": "Für einen wöchentlichen Zeitplan ist mindestens ein Wochentag erforderlich", + "Scheduled job run completed": "Geplante Aufgabe wurde ausgeführt", + "Scheduled job run skipped": "Geplante Aufgabe wurde übersprungen", + "Scheduled job run failed": "Ausführung der geplanten Aufgabe fehlgeschlagen", + "Scheduler is already running": "Scheduler läuft bereits", + "Purge scheduler logs": "Scheduler-Protokolle bereinigen", + "Purge run logs older than 90 days?": "Laufprotokolle älter als 90 Tage bereinigen?", + "%d scheduler run logs purged": "%d Scheduler-Laufprotokolle bereinigt", + "User lifecycle logs": "Benutzer-Lifecycle-Protokolle", + "View user lifecycle audit entry": "Benutzer-Lifecycle-Eintrag anzeigen", + "Lifecycle audit entry not found": "Benutzer-Lifecycle-Eintrag nicht gefunden", + "Purge user lifecycle logs": "Benutzer-Lifecycle-Protokolle bereinigen", + "Purge entries older than 365 days?": "Einträge älter als 365 Tage bereinigen?", + "%d user lifecycle audit entries purged": "%d Benutzer-Lifecycle-Protokoll-Einträge bereinigt", + "Lifecycle event": "Lifecycle-Ereignis", + "Trigger": "Auslöser", + "Reason code": "Fehlercode", + "Target": "Ziel", + "Target UUID": "Ziel-UUID", + "Target email": "Ziel-E-Mail", + "Policy": "Regel", + "Snapshot summary": "Snapshot-Zusammenfassung", + "Snapshot version": "Snapshot-Version", + "Restore status": "Wiederherstellungsstatus", + "Restored at": "Wiederhergestellt am", + "Restored by": "Wiederhergestellt von", + "Restored user": "Wiederhergestellter Benutzer", + "Restore user": "Benutzer wiederherstellen", + "Restore": "Wiederherstellen", + "Restore this user from lifecycle snapshot?": "Diesen Benutzer aus dem Lifecycle-Snapshot wiederherstellen?", + "User restored": "Benutzer wiederhergestellt", + "User restore failed": "Benutzer-Wiederherstellung fehlgeschlagen", + "Lifecycle event was already restored": "Lifecycle-Ereignis wurde bereits wiederhergestellt", + "Lifecycle snapshot unavailable": "Lifecycle-Snapshot nicht verfügbar", + "Restore not possible: email already exists": "Wiederherstellung nicht möglich: E-Mail existiert bereits", + "Restore not possible: uuid already exists": "Wiederherstellung nicht möglich: UUID existiert bereits", + "All actions": "Alle Aktionen", + "All triggers": "Alle Auslöser", + "Manual": "Manuell", + "Cron": "Cron", + "Enabled jobs": "Aktive Aufgaben", + "Overdue jobs": "Überfällige Aufgaben", + "Scheduler runs (24h)": "Scheduler-Läufe (24h)", + "Failed scheduler runs (24h)": "Fehlgeschlagene Scheduler-Läufe (24h)", + "Cron runner active": "Cron-Runner aktiv", + "Last heartbeat": "Letzter Heartbeat", + "Last runner result": "Letztes Runner-Ergebnis", + "Runner lock not acquired": "Runner-Lock nicht erhalten", + "Locale": "Sprache" } diff --git a/i18n/default_en.json b/i18n/default_en.json index 020ace5..914d4b3 100644 --- a/i18n/default_en.json +++ b/i18n/default_en.json @@ -1,13 +1,22 @@ { "Login": "Login", + "Login options": "Login options", "Register": "Register", "Password": "Password", + "Or": "Or", "Forgot password": "Forgot password", "Remember me": "Remember me", "Clear login tokens": "Clear login tokens", "Clear all login tokens for this user?": "Clear all login tokens for this user?", "Login tokens cleared": "Login tokens cleared", "Send access": "Send access", + "Access PDF": "Access PDF", + "Download access PDFs": "Download access PDFs", + "Generate access PDFs for selected users?": "Generate access PDFs for selected users?", + "Too many users selected": "Too many users selected", + "No valid users selected": "No valid users selected", + "Failed to generate access PDF": "Failed to generate access PDF", + "ZIP support missing (ext-zip)": "ZIP support missing (ext-zip)", "Send access email to this user?": "Send access email to this user?", "Access email sent": "Access email sent", "Access email failed": "Access email failed", @@ -37,8 +46,19 @@ "Logout": "Logout", "Account": "Account", "Please enter your credentials": "Please enter your credentials", + "Enter your email to continue": "Enter your email to continue", + "Select your tenant to continue": "Select your tenant to continue", + "Choose how you want to sign in": "Choose how you want to sign in", + "Continue": "Continue", + "Use different email": "Use different email", + "We could not continue with these login details": "We could not continue with these login details", + "Too many login attempts. Please wait and try again.": "Too many login attempts. Please wait and try again.", + "No login method is available for this tenant": "No login method is available for this tenant", + "Cached": "Cached", + "Global settings are stored in the database. config/settings.php is only a runtime cache for selected app settings.": "Global settings are stored in the database. config/settings.php is only a runtime cache for selected app settings.", "Create your account": "Create your account", "First name": "First name", + "Name": "Name", "Last name": "Last name", "Email": "Email", "Password (again)": "Password confirm", @@ -99,6 +119,7 @@ "perm.users.view": "View users", "perm.users.create": "Create users", "perm.users.update": "Update users", + "perm.users.access_pdf": "Generate onboarding PDFs", "perm.users.delete": "Delete users", "perm.users.self_update": "Update own user", "perm.users.update_assignments": "Update user assignments", @@ -122,6 +143,11 @@ "perm.settings.view": "View settings", "perm.settings.update": "Update settings", "perm.mail_log.view": "View mail logs", + "perm.jobs.view": "View scheduled jobs", + "perm.jobs.manage": "Manage scheduled jobs", + "perm.jobs.run_now": "Run scheduled jobs manually", + "perm.user_lifecycle_audit.view": "View user lifecycle logs", + "perm.users.lifecycle_restore": "Restore users from lifecycle audit", "Select row": "Select row", "Select all": "Select all", "Activate": "Activate", @@ -226,6 +252,7 @@ "Favicon preview": "Favicon preview", "Primary color": "Primary color", "Primary color is invalid": "Primary color is invalid", + "The primary color affects buttons and accent elements across the app.": "The primary color affects buttons and accent elements across the app.", "Show filters": "Show filters", "Hide filters": "Hide filters", "Export CSV": "Export CSV", @@ -233,15 +260,43 @@ "State": "State", "Edit": "Edit", "Status": "Status", + "Visibility": "Visibility", + "Inactive entries are hidden in lists and selections.": "Inactive entries are hidden in lists and selections.", "All": "All", "All people": "All people", "Explorer": "Explorer", "Search": "Search", "Search...": "Search...", + "To start searching, type in the search field.": "To start searching, type in the search field.", + "Tip: Use {shortcut} to focus search quickly.": "Tip: Use {shortcut} to focus search quickly.", "Created from": "Created from", "Created to": "Created to", "Files": "Files", "Settings": "Settings", + "Documentation": "Documentation", + "Documentation navigation": "Documentation navigation", + "On this page": "On this page", + "Getting started": "Getting started", + "Local development": "Local development", + "First change": "First change", + "Developer checklist": "Developer checklist", + "Architecture & rules": "Architecture & rules", + "Architecture": "Architecture", + "Security model": "Security model", + "Conventions": "Conventions", + "Frontend": "Frontend", + "Features": "Features", + "Settings storage": "Settings storage", + "Rate limiting": "Rate limiting", + "User lifecycle": "User lifecycle", + "Global search": "Global search", + "API reference": "API reference", + "Operations": "Operations", + "Troubleshooting": "Troubleshooting", + "Configuration": "Configuration", + "Automation & integrations": "Automation & integrations", + "Monitoring": "Monitoring", + "Logs": "Logs", "System": "System", "System permission": "System permission", "System permission can not be deleted": "System permission can not be deleted", @@ -286,7 +341,11 @@ "No records found": "No records found", "An error happened while fetching the data": "An error happened while fetching the data", "Delete this user?": "Delete this user?", + "Delete user": "Delete user", + "This will permanently delete this user.": "This will permanently delete this user.", "Delete this tenant?": "Delete this tenant?", + "Delete tenant": "Delete tenant", + "This will permanently delete this tenant.": "This will permanently delete this tenant.", "Tenant": "Tenant", "Default tenant": "Default tenant", "Default role": "Default role", @@ -311,12 +370,24 @@ "setting.app_theme": "Default theme used when a user has no theme preference", "setting.app_theme_user": "Allow users to choose their own theme", "setting.app_registration": "Allow public self-registration", + "setting.api_token_default_ttl_days": "Default lifetime for newly created API tokens in days (default: 90)", + "setting.api_token_max_ttl_days": "Maximum lifetime for API tokens in days (hard cap)", + "setting.api_cors_allowed_origins": "Allowed browser origins for API CORS (one origin per line)", "setting.app_primary_color": "Overrides the default primary color (hex like #2FA4A4)", + "API token default lifetime (days)": "API token default lifetime (days)", + "API token maximum lifetime (days)": "API token maximum lifetime (days)", + "Allowed CORS origins": "Allowed CORS origins", + "One origin per line, e.g. https://app.example.com": "One origin per line, e.g. https://app.example.com", "Default theme": "Default theme", "Allow user theme": "Allow user theme", "Allow registration": "Allow registration", "General": "General", "Appearance": "Appearance", + "API settings": "API settings", + "API docs": "API docs", + "API documentation": "API documentation", + "Can view API documentation": "Can view API documentation", + "API tokens and default lifetimes are managed here.": "API tokens and default lifetimes are managed here.", "Use default color": "Use default color", "Tenants can override this color in their appearance settings.": "Tenants can override this color in their appearance settings.", "Using the default color applies the global system appearance.": "Using the default color applies the global system appearance.", @@ -345,10 +416,12 @@ "Tenant updated": "Tenant updated", "Tenant deleted": "Tenant deleted", "Tenant not found": "Tenant not found", + "Tenant can not be deleted while departments are assigned": "Tenant can not be deleted while departments are assigned", "Tenant can not be created": "Tenant can not be created", "Tenant can not be updated": "Tenant can not be updated", "Tenant image": "Tenant image", "Assigned tenants": "Assigned tenants", + "Assigned tenant": "Assigned tenant", "Primary tenant": "Primary tenant", "Select primary tenant": "Select primary tenant", "Please select a primary tenant": "Please select a primary tenant", @@ -357,6 +430,8 @@ "Departments & roles": "Departments & roles", "Department assignments cleaned: %d": "Department assignments cleaned: %d", "Select tenants": "Select tenants", + "Select tenant": "Select tenant", + "Which tenant do you want to sign in to?": "Which tenant do you want to sign in to?", "Assigned roles": "Assigned roles", "Select roles": "Select roles", "Department": "Department", @@ -370,8 +445,14 @@ "Department can not be created": "Department can not be created", "Department can not be updated": "Department can not be updated", "Delete this department?": "Delete this department?", + "Delete department": "Delete department", + "This will permanently delete this department.": "This will permanently delete this department.", "Assigned departments": "Assigned departments", "Select departments": "Select departments", + "Departments for tenant": "Departments for tenant", + "No departments available for this tenant": "No departments available for this tenant", + "Assign tenants first to select departments": "Assign tenants first to select departments", + "Department options refresh after save": "Department options refresh after save", "Role": "Role", "Roles": "Roles", "Create role": "Create role", @@ -383,6 +464,11 @@ "Role can not be created": "Role can not be created", "Role can not be updated": "Role can not be updated", "Delete this role?": "Delete this role?", + "Delete role": "Delete role", + "Delete permission": "Delete permission", + "This will permanently delete this role.": "This will permanently delete this role.", + "This will permanently delete this permission.": "This will permanently delete this permission.", + "This action cannot be undone.": "This action cannot be undone.", "Description": "Description", "Description cannot be empty": "Description cannot be empty", "Status is invalid": "Status is invalid", @@ -431,14 +517,13 @@ "You do not have permission to view this content.": "You do not have permission to view this content.", "If you believe this is an error, please contact your system administrator.": "If you believe this is an error, please contact your system administrator.", "Requested URL": "Requested URL", - "Audit": "Audit", "Forward": "Forward", "Status changed": "Status changed", "Status changed by": "Status changed by", - "Page": "Page", "Requested path": "Requested path", "Page not found": "Page not found", "The page you requested does not exist or has moved.": "The page you requested does not exist or has moved.", + "Back to start": "Back to start", "Page updated": "Page updated", "Page can not be updated": "Page can not be updated", "Content is invalid": "Content is invalid", @@ -480,7 +565,15 @@ "SMTP from address": "SMTP from address", "SMTP from name": "SMTP from name", "Address book": "Address book", - "All people": "All people", + "Saved filters": "Saved filters", + "Save filter": "Save filter", + "Enter a name for this filter": "Enter a name for this filter", + "Filter name is required": "Filter name is required", + "Maximum number of saved filters reached": "Maximum number of saved filters reached", + "Filter saved": "Filter saved", + "Delete saved filter": "Delete saved filter", + "Filter deleted": "Filter deleted", + "Filter save failed": "Filter save failed", "Mobile": "Mobile", "Short dial": "Short dial", "setting.smtp_host": "SMTP server host (e.g. smtp.example.com)", @@ -493,12 +586,34 @@ "High contrast": "High contrast", "Toggle contrast": "Toggle contrast", "View in address book": "View in address book", - "Overview": "Overview", "Organization & Quality": "Organization & Quality", - "Security": "Security", - "Communication": "Communication", "Email security": "Email security", - "Recipient": "Recipient", + "API": "API", + "API requests (24h)": "API requests (24h)", + "Request trend vs previous 24h": "Request trend vs previous 24h", + "4xx errors (24h)": "4xx errors (24h)", + "5xx errors (24h)": "5xx errors (24h)", + "P95 response time (ms)": "P95 response time (ms)", + "Tokens expiring in 7 days": "Tokens expiring in 7 days", + "Top API error codes (24h)": "Top API error codes (24h)", + "Slowest API endpoints (avg, 24h)": "Slowest API endpoints (avg, 24h)", + "Import runs (7d)": "Import runs (7d)", + "Run trend vs previous 7d": "Run trend vs previous 7d", + "Created rows (7d)": "Created rows (7d)", + "Failed rows (7d)": "Failed rows (7d)", + "Partial or failed runs (7d)": "Partial or failed runs (7d)", + "Success rate (7d)": "Success rate (7d)", + "Average duration (ms, 7d)": "Average duration (ms, 7d)", + "Recent import runs": "Recent import runs", + "Imports by profile (7d)": "Imports by profile (7d)", + "Top import error codes (7d)": "Top import error codes (7d)", + "Runs": "Runs", + "Created rows": "Created rows", + "Failed rows": "Failed rows", + "Success rate": "Success rate", + "Requests": "Requests", + "Average duration (ms)": "Average duration (ms)", + "Maximum duration (ms)": "Maximum duration (ms)", "Error": "Error", "Active tenants": "Active tenants", "Inactive tenants": "Inactive tenants", @@ -517,13 +632,16 @@ "Recent logins": "Recent logins", "Never logged in": "Never logged in", "Last login": "Last login", + "Last login via": "Last login via", + "Local": "Local", + "Microsoft": "Microsoft", + "Unknown": "Unknown", "User": "User", "No entries found": "No entries found", "Login status": "Login status", "Logged in": "Logged in", "More filters": "More filters", - "Email unverified": "Email unverified" - , + "Email unverified": "Email unverified", "Password resets": "Password resets", "Requested at": "Requested at", "Expires at": "Expires at", @@ -556,5 +674,418 @@ "Toggle sidebar": "Toggle sidebar", "Unverified": "Unverified", "Imprint": "Imprint", - "Privacy": "Privacy" + "Privacy": "Privacy", + "Custom fields": "Custom fields", + "User custom fields": "User custom fields", + "Affected tenants": "Affected tenants", + "If a tenant appears here, at least one custom field setup needs review (missing options, inactive fields with values, or unused active fields).": "If a tenant appears here, at least one custom field setup needs review (missing options, inactive fields with values, or unused active fields).", + "Issue": "Issue", + "Count": "Count", + "Selection fields without options": "Selection fields without options", + "Tenants with broken selection fields": "Tenants with broken selection fields", + "Inactive custom fields with values": "Inactive custom fields with values", + "Unused active custom fields": "Unused active custom fields", + "Save tenant first to manage custom fields": "Save tenant first to manage custom fields", + "Define custom fields here. They appear per user in the \"Custom fields\" tab and can be maintained there.": "Define custom fields here. They appear per user in the \"Custom fields\" tab and can be maintained there.", + "Add custom field": "Add custom field", + "The label is shown in user custom fields and in address book filters.": "The label is shown in user custom fields and in address book filters.", + "The field type controls input and filter behavior.": "The field type controls input and filter behavior.", + "Field key": "Field key", + "Field type": "Field type", + "Text": "Text", + "Textarea": "Textarea", + "Select": "Select", + "Multiselect": "Multiselect", + "Boolean": "Boolean", + "Date": "Date", + "Sort order": "Sort order", + "Filterable": "Filterable", + "Options (one per line)": "Options (one per line)", + "Example: manager|Manager": "Example: manager|Manager", + "One option per line. Optional format: key|Label.": "One option per line. Optional format: key|Label.", + "No custom fields configured yet": "No custom fields configured yet", + "Delete custom field": "Delete custom field", + "Custom field saved": "Custom field saved", + "Custom field deleted": "Custom field deleted", + "Custom field can not be created": "Custom field can not be created", + "Custom field can not be updated": "Custom field can not be updated", + "Custom field can not be deleted": "Custom field can not be deleted", + "Custom field values can not be saved": "Custom field values can not be saved", + "Custom field not found": "Custom field not found", + "Label cannot be empty": "Label cannot be empty", + "Field key cannot be empty": "Field key cannot be empty", + "Field type is invalid": "Field type is invalid", + "Field key already exists": "Field key already exists", + "Options are required for this field type": "Options are required for this field type", + "Custom fields update after save": "Custom fields update after save", + "Assign tenants first to edit custom fields": "Assign tenants first to edit custom fields", + "Custom fields for tenant": "Custom fields for tenant", + "No custom fields configured for this tenant": "No custom fields configured for this tenant", + "No custom fields configured for selected tenants": "No custom fields configured for selected tenants", + "Select options": "Select options", + "Please fill required custom field: %s": "Please fill required custom field: %s", + "Invalid value for %s": "Invalid value for %s", + "Please select": "Please select", + "From": "From", + "To": "To", + "perm.custom_fields.manage": "Manage tenant custom fields", + "perm.custom_fields.edit_values": "Edit user custom field values", + "Label": "Label", + "Required": "Required", + "Required fields must be filled before saving a user.": "Required fields must be filled before saving a user.", + "Filterable fields are available in address book filters.": "Filterable fields are available in address book filters.", + "Inactive fields are hidden in forms and filters.": "Inactive fields are hidden in forms and filters.", + "Default theme is invalid": "Default theme is invalid", + "User theme policy is invalid": "User theme policy is invalid", + "Theme change is disabled for this tenant": "Theme change is disabled for this tenant", + "Inherit global setting": "Inherit global setting", + "Force allow user theme": "Force allow user theme", + "Force disallow user theme": "Force disallow user theme", + "If set, this tenant uses its own default theme instead of the global setting.": "If set, this tenant uses its own default theme instead of the global setting.", + "Controls whether users in this tenant can choose their own theme.": "Controls whether users in this tenant can choose their own theme.", + "Tenants can override color and theme behavior in their appearance settings.": "Tenants can override color and theme behavior in their appearance settings.", + "User theme policy": "User theme policy", + "APP_CRYPTO_KEY is missing or invalid": "APP_CRYPTO_KEY is missing or invalid", + "Allowed email domains": "Allowed email domains", + "App credentials source": "App credentials source", + "Authority URL": "Authority URL", + "Clear override secret": "Clear override secret", + "Client ID override": "Client ID override", + "Client ID override is required": "Client ID override is required", + "Client secret could not be encrypted": "Client secret could not be encrypted", + "Client secret override": "Client secret override", + "Client secret override is required": "Client secret override is required", + "Configure Microsoft login per tenant. Local login can stay enabled or be enforced off.": "Configure Microsoft login per tenant. Local login can stay enabled or be enforced off.", + "Disable password login for this tenant login": "Disable password login for this tenant login", + "Enable Microsoft login for this tenant": "Enable Microsoft login for this tenant", + "Enforcement": "Enforcement", + "Entra tenant ID (tid)": "Entra tenant ID (tid)", + "Entra tenant ID is invalid": "Entra tenant ID is invalid", + "Login failed": "Login failed", + "Microsoft SSO": "Microsoft SSO", + "Microsoft login": "Microsoft login", + "Microsoft login could not be started": "Microsoft login could not be started", + "Microsoft login failed": "Microsoft login failed", + "Microsoft login is not allowed for this account": "Microsoft login is not allowed for this account", + "Microsoft login is not available for this tenant": "Microsoft login is not available for this tenant", + "Microsoft login is not fully configured for this tenant.": "Microsoft login is not fully configured for this tenant.", + "Microsoft login was cancelled or failed": "Microsoft login was cancelled or failed", + "Microsoft-only login requires a complete configuration": "Microsoft-only login requires a complete configuration", + "No access to this tenant": "No access to this tenant", + "Shared credentials missing": "Shared credentials missing", + "Optional credential override": "Optional credential override", + "Optional restrictions": "Optional restrictions", + "Optional allow-list, comma or newline separated (example.com).": "Optional allow-list, comma or newline separated (example.com).", + "Password login is disabled for this tenant. Please use Microsoft login.": "Password login is disabled for this tenant. Please use Microsoft login.", + "Password login is disabled for all assigned tenants": "Password login is disabled for all assigned tenants", + "Password login mode": "Password login mode", + "Local password + Microsoft": "Local password + Microsoft", + "Credentials source": "Credentials source", + "Shared app": "Shared app", + "Tenant override": "Tenant override", + "SSO configuration status": "SSO configuration status", + "Configuration complete": "Configuration complete", + "Required Microsoft configuration": "Required Microsoft configuration", + "Required for Microsoft login.": "Required for Microsoft login.", + "SSO": "SSO", + "Shared Microsoft app credentials are used by tenants that enable \"Use shared app credentials\".": "Shared Microsoft app credentials are used by tenants that enable \"Use shared app credentials\".", + "Shared client ID": "Shared client ID", + "Shared client secret": "Shared client secret", + "Sign in with Microsoft": "Sign in with Microsoft", + "Tenant SSO settings could not be saved": "Tenant SSO settings could not be saved", + "Tenant login": "Tenant login", + "Tenant login link is invalid": "Tenant login link is invalid", + "Tenant login URL": "Tenant login URL", + "Tenant Microsoft settings": "Tenant Microsoft settings", + "These settings are required per tenant when Microsoft login is enabled.": "These settings are required per tenant when Microsoft login is enabled.", + "Profile sync on Microsoft login": "Profile sync on Microsoft login", + "Profile sync (optional)": "Profile sync (optional)", + "Sync fields": "Sync fields", + "Sync first name": "Sync first name", + "Sync last name": "Sync last name", + "Sync phone": "Sync phone", + "Sync mobile": "Sync mobile", + "Sync avatar image": "Sync avatar image", + "User profile fields are global and affect all tenants of this user": "User profile fields are global and affect all tenants of this user", + "Phone/mobile/avatar sync requires Microsoft Graph (User.Read)": "Phone/mobile/avatar sync requires Microsoft Graph (User.Read)", + "Phone/mobile sync requires Microsoft Graph (User.Read)": "Phone/mobile sync requires Microsoft Graph (User.Read)", + "SSO-enabled tenants": "SSO-enabled tenants", + "SSO-enforced tenants": "SSO-enforced tenants", + "SSO rollout": "SSO rollout", + "Users with Microsoft-only login": "Users with Microsoft-only login", + "Microsoft profile sync gaps": "Microsoft profile sync gaps", + "Tenant SSO overview": "Tenant SSO overview", + "Microsoft SSO policy": "Microsoft SSO policy", + "Sync enabled": "Sync enabled", + "Local only": "Local only", + "Microsoft only": "Microsoft only", + "Mixed (local + Microsoft)": "Mixed (local + Microsoft)", + "Missing values": "Missing values", + "Use shared app credentials from settings": "Use shared app credentials from settings", + "Override only if this tenant needs a separate Entra app.": "Override only if this tenant needs a separate Entra app.", + "Advanced app override": "Advanced app override", + "Use shared app credentials by default. Configure overrides only when this tenant needs a separate app registration.": "Use shared app credentials by default. Configure overrides only when this tenant needs a separate app registration.", + "Use this URL to enter tenant-scoped login (local and/or Microsoft).": "Use this URL to enter tenant-scoped login (local and/or Microsoft).", + "Used only when shared app credentials are disabled.": "Used only when shared app credentials are disabled.", + "Uses tenant-specific Entra tenant ID and policy for this login entry.": "Uses tenant-specific Entra tenant ID and policy for this login entry.", + "When enabled, users must use Microsoft login on the tenant login URL.": "When enabled, users must use Microsoft login on the tenant login URL.", + "Write-only. Leave empty to keep current secret.": "Write-only. Leave empty to keep current secret.", + "Client secret override is invalid": "Client secret override is invalid", + "Client credentials are missing": "Client credentials are missing", + "setting.microsoft_shared_client_id": "Setting key for shared Microsoft client ID", + "setting.microsoft_shared_client_secret_enc": "Setting key for shared Microsoft client secret (encrypted)", + "setting.microsoft_authority": "Base OpenID authority URL for Microsoft login", + "API tokens": "API tokens", + "Token name": "Token name", + "Create API token": "Create API token", + "Revoke": "Revoke", + "Revoke this API token?": "Revoke this API token?", + "Revoked": "Revoked", + "Prefix": "Prefix", + "No API tokens": "No API tokens", + "New API token (copy now — shown only once):": "New API token (copy now — shown only once):", + "API token created. Copy it now — it will not be shown again.": "API token created. Copy it now — it will not be shown again.", + "Could not create API token: %s": "Could not create API token: %s", + "API token revoked": "API token revoked", + "%d API tokens revoked": "%d API tokens revoked", + "%d active API tokens": "%d active API tokens", + "Revoke all API tokens": "Revoke all API tokens", + "Revoke all API tokens?": "Revoke all API tokens?", + "All API tokens will be revoked immediately.": "All API tokens will be revoked immediately.", + "API token default lifetime is invalid": "API token default lifetime is invalid", + "API token max lifetime is invalid": "API token max lifetime is invalid", + "API audit": "API logs", + "API audit logs": "API logs", + "Request ID": "Request ID", + "Method": "Method", + "Path": "Path", + "Status code": "Status code", + "Duration (ms)": "Duration (ms)", + "Error code": "Error code", + "Token ID": "Token ID", + "Token tenant": "Token tenant", + "Purge API audit logs": "Purge API logs", + "Purge entries older than 90 days?": "Purge entries older than 90 days?", + "%d API audit entries purged": "%d API log entries purged", + "API audit entry not found": "API log entry not found", + "View API audit entry": "View API log entry", + "Request details": "Request details", + "Scope": "Scope", + "Query": "Query", + "User agent": "User agent", + "All methods": "All methods", + "Tenant ID": "Tenant ID", + "User ID": "User ID", + "IP": "IP", + "CORS allowed origins are invalid": "CORS allowed origins are invalid", + "Token name is required": "Token name is required", + "e.g. CI Pipeline": "e.g. CI Pipeline", + "Imports": "Imports", + "Upload CSV data, map columns, preview and import.": "Upload CSV data, map columns, preview and import.", + "You do not have permission to run imports.": "You do not have permission to run imports.", + "Entity": "Entity", + "CSV file": "CSV file", + "Max size 10MB, max 20000 rows": "Max size 10MB, max 20000 rows", + "Download sample CSV": "Download sample CSV", + "Analyze file": "Analyze file", + "Map CSV columns": "Map CSV columns", + "Required fields: first_name, last_name, email": "Required fields: first_name, last_name, email", + "CSV header": "CSV header", + "Target field": "Target field", + "Ignore column": "Ignore column", + "Assignment columns require additional permission.": "Assignment columns require additional permission.", + "Run preview": "Run preview", + "Preview": "Preview", + "Rows total": "Rows total", + "Valid rows": "Valid rows", + "Would create": "Would create", + "Would skip": "Would skip", + "Would fail": "Would fail", + "Row issues": "Row issues", + "Start import": "Start import", + "Import finished": "Import finished", + "Processed": "Processed", + "Skipped": "Skipped", + "Import another file": "Import another file", + "No row errors found": "No row errors found", + "Row": "Row", + "Code": "Code", + "Message": "Message", + "No upload file provided": "No upload file provided", + "Upload exceeds the maximum size": "Upload exceeds the maximum size", + "Invalid file type": "Invalid file type", + "CSV header is invalid": "CSV header is invalid", + "CSV file has no data rows": "CSV file has no data rows", + "CSV row limit exceeded": "CSV row limit exceeded", + "Required mapping is missing": "Required mapping is missing", + "Assignment import permission is required": "Assignment import permission is required", + "Email is invalid": "Email is invalid", + "Email appears multiple times in this file": "Email appears multiple times in this file", + "Email already exists": "Email already exists", + "Locale is invalid": "Locale is invalid", + "Active value is invalid": "Active value is invalid", + "Hire date must be YYYY-MM-DD": "Hire date must be YYYY-MM-DD", + "Tenant is missing, inactive or invalid": "Tenant is missing, inactive or invalid", + "Role is missing, inactive or invalid": "Role is missing, inactive or invalid", + "Department is missing, inactive or invalid": "Department is missing, inactive or invalid", + "Department does not belong to tenant": "Department does not belong to tenant", + "Tenant assignment is out of your scope": "Tenant assignment is out of your scope", + "User could not be created": "User could not be created", + "Unexpected error during import": "Unexpected error during import", + "Required field is empty: first_name": "Required field is empty: first_name", + "Required field is empty: last_name": "Required field is empty: last_name", + "User assignments can not be updated": "User assignments can not be updated", + "How the import works": "How the import works", + "1. Upload CSV and click analyze": "1. Upload CSV and click analyze", + "2. Map your CSV columns to system fields": "2. Map your CSV columns to system fields", + "3. Review preview counters and row errors": "3. Review preview counters and row errors", + "4. Start import for valid rows": "4. Start import for valid rows", + "Rules": "Rules", + "Create-only: existing emails are skipped": "Create-only: existing emails are skipped", + "Create-only: existing entries are skipped": "Create-only: existing entries are skipped", + "Here is a CSV template for this import profile.": "Here is a CSV template for this import profile.", + "Reference": "Reference", + "Required fields": "Required fields", + "Required field is empty: description": "Required field is empty: description", + "Tenant must be a valid UUID": "Tenant must be a valid UUID", + "Code already exists": "Code already exists", + "Department already exists for this tenant": "Department already exists for this tenant", + "Duplicate entry in CSV file": "Duplicate entry in CSV file", + "Entry could not be created": "Entry could not be created", + "Import logs": "Import logs", + "Import audit logs": "Import audit logs", + "Can view import audit logs": "Can view import audit logs", + "Run UUID": "Run UUID", + "Mapped fields": "Mapped fields", + "Created count": "Created count", + "Skipped count": "Skipped count", + "Failed count": "Failed count", + "Purge import logs": "Purge import logs", + "%d import audit entries purged": "%d import audit entries purged", + "Import audit entry not found": "Import audit entry not found", + "View import audit entry": "View import audit entry", + "Import details": "Import details", + "Started": "Started", + "Finished": "Finished", + "File": "File", + "Current tenant": "Current tenant", + "Error code distribution": "Error code distribution", + "All profiles": "All profiles", + "Running": "Running", + "Success": "Success", + "Partial": "Partial", + "Import": "Import", + "Counters": "Counters", + "User lifecycle policy": "User lifecycle policy", + "Deactivate users after inactivity (days)": "Deactivate users after inactivity (days)", + "Delete inactive users after (days)": "Delete inactive users after (days)", + "0 disables this rule": "0 disables this rule", + "Deletion starts after user was set inactive": "Deletion starts after user was set inactive", + "Privileged admins are excluded from automatic lifecycle actions": "Privileged admins are excluded from automatic lifecycle actions", + "Run user lifecycle now?": "Run user lifecycle now?", + "Run user lifecycle now": "Run user lifecycle now", + "setting.user_inactivity_deactivate_days": "Automatically set users inactive after this many days without login", + "setting.user_inactivity_delete_days": "Automatically delete inactive users after this many additional days", + "User lifecycle already running": "User lifecycle already running", + "User lifecycle failed": "User lifecycle failed", + "User lifecycle completed: %d deactivated, %d deleted": "User lifecycle completed: %d deactivated, %d deleted", + "User inactivity deactivation period is invalid": "User inactivity deactivation period is invalid", + "User inactivity deletion period is invalid": "User inactivity deletion period is invalid", + "Auto-delete is ignored while auto-deactivate is disabled": "Auto-delete is ignored while auto-deactivate is disabled", + "Scheduled jobs": "Scheduled jobs", + "Can view scheduled jobs": "Can view scheduled jobs", + "Can manage scheduled jobs": "Can manage scheduled jobs", + "Can run scheduled jobs manually": "Can run scheduled jobs manually", + "Scheduled job not found": "Scheduled job not found", + "Scheduled job is not registered": "Scheduled job is not registered", + "Scheduled job could not be saved": "Scheduled job could not be saved", + "Scheduled job saved": "Scheduled job saved", + "Edit scheduled job": "Edit scheduled job", + "Job": "Job", + "Job key": "Job key", + "Run history": "Run history", + "Run now": "Run now", + "Runtime status": "Runtime status", + "Next run": "Next run", + "Last run": "Last run", + "Last error": "Last error", + "Schedule": "Schedule", + "Schedule type": "Schedule type", + "Schedule interval": "Schedule interval", + "Schedule time": "Schedule time", + "Weekdays": "Weekdays", + "Hourly": "Hourly", + "Daily": "Daily", + "Weekly": "Weekly", + "Enabled": "Enabled", + "Disabled": "Disabled", + "Timezone": "Timezone", + "Catch up once": "Catch up once", + "Scheduler": "Scheduler", + "Every %d hour(s)": "Every %d hour(s)", + "Every %d day(s) at %s": "Every %d day(s) at %s", + "Every %d week(s) on %s at %s": "Every %d week(s) on %s at %s", + "No weekdays": "No weekdays", + "Mon": "Mon", + "Tue": "Tue", + "Wed": "Wed", + "Thu": "Thu", + "Fri": "Fri", + "Sat": "Sat", + "Sun": "Sun", + "Schedule type is invalid": "Schedule type is invalid", + "Schedule time is required": "Schedule time is required", + "Timezone is invalid": "Timezone is invalid", + "Hourly interval must be between 1 and 24": "Hourly interval must be between 1 and 24", + "Daily interval must be between 1 and 365": "Daily interval must be between 1 and 365", + "Weekly interval must be between 1 and 52": "Weekly interval must be between 1 and 52", + "At least one weekday is required for weekly schedule": "At least one weekday is required for weekly schedule", + "Scheduled job run completed": "Scheduled job run completed", + "Scheduled job run skipped": "Scheduled job run skipped", + "Scheduled job run failed": "Scheduled job run failed", + "Scheduler is already running": "Scheduler is already running", + "Purge scheduler logs": "Purge scheduler logs", + "Purge run logs older than 90 days?": "Purge run logs older than 90 days?", + "%d scheduler run logs purged": "%d scheduler run logs purged", + "User lifecycle logs": "User lifecycle logs", + "View user lifecycle audit entry": "View user lifecycle audit entry", + "Lifecycle audit entry not found": "Lifecycle audit entry not found", + "Purge user lifecycle logs": "Purge user lifecycle logs", + "Purge entries older than 365 days?": "Purge entries older than 365 days?", + "%d user lifecycle audit entries purged": "%d user lifecycle audit entries purged", + "Lifecycle event": "Lifecycle event", + "Trigger": "Trigger", + "Reason code": "Reason code", + "Target": "Target", + "Target UUID": "Target UUID", + "Target email": "Target email", + "Policy": "Policy", + "Snapshot summary": "Snapshot summary", + "Snapshot version": "Snapshot version", + "Restore status": "Restore status", + "Restored at": "Restored at", + "Restored by": "Restored by", + "Restored user": "Restored user", + "Restore user": "Restore user", + "Restore": "Restore", + "Restore this user from lifecycle snapshot?": "Restore this user from lifecycle snapshot?", + "User restored": "User restored", + "User restore failed": "User restore failed", + "Lifecycle event was already restored": "Lifecycle event was already restored", + "Lifecycle snapshot unavailable": "Lifecycle snapshot unavailable", + "Restore not possible: email already exists": "Restore not possible: email already exists", + "Restore not possible: uuid already exists": "Restore not possible: uuid already exists", + "All actions": "All actions", + "All triggers": "All triggers", + "Manual": "Manual", + "Cron": "Cron", + "Enabled jobs": "Enabled jobs", + "Overdue jobs": "Overdue jobs", + "Scheduler runs (24h)": "Scheduler runs (24h)", + "Failed scheduler runs (24h)": "Failed scheduler runs (24h)", + "Cron runner active": "Cron runner active", + "Last heartbeat": "Last heartbeat", + "Last runner result": "Last runner result", + "Runner lock not acquired": "Runner lock not acquired", + "Locale": "Locale" } diff --git a/lib/.DS_Store b/lib/.DS_Store deleted file mode 100644 index 1453188..0000000 Binary files a/lib/.DS_Store and /dev/null differ diff --git a/lib/Http/AccessControl.php b/lib/Http/AccessControl.php index 26ff5cc..c0349bf 100644 --- a/lib/Http/AccessControl.php +++ b/lib/Http/AccessControl.php @@ -10,18 +10,13 @@ use MintyPHP\Router; */ class AccessControl { - /** Paths that are always public (no auth required) */ - private const ALWAYS_PUBLIC_PATHS = [ - 'login', - 'register', - 'verify-email', - ]; - /** Prefixes that are always public */ private const ALWAYS_PUBLIC_PREFIXES = [ - 'password/', 'branding/', + 'auth/tenant-avatar-file', 'flash/', + 'auth/microsoft/', + 'api/', ]; private array $configuredPublicPaths; @@ -56,11 +51,6 @@ class AccessControl } } - // Check always-public paths - if (in_array($normalizedPath, self::ALWAYS_PUBLIC_PATHS, true)) { - return true; - } - // Check always-public prefixes foreach (self::ALWAYS_PUBLIC_PREFIXES as $prefix) { if (str_starts_with($normalizedPath, $prefix)) { diff --git a/lib/Http/ApiAuth.php b/lib/Http/ApiAuth.php new file mode 100644 index 0000000..2cfb418 --- /dev/null +++ b/lib/Http/ApiAuth.php @@ -0,0 +1,181 @@ + 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'); + } + } +} diff --git a/lib/Http/ApiBootstrap.php b/lib/Http/ApiBootstrap.php new file mode 100644 index 0000000..08aaed1 --- /dev/null +++ b/lib/Http/ApiBootstrap.php @@ -0,0 +1,201 @@ + $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); + } +} diff --git a/lib/Http/LocaleResolver.php b/lib/Http/LocaleResolver.php index cd19924..7b23ba4 100644 --- a/lib/Http/LocaleResolver.php +++ b/lib/Http/LocaleResolver.php @@ -3,7 +3,6 @@ namespace MintyPHP\Http; use MintyPHP\I18n; -use MintyPHP\Router; /** * Handles locale detection and URL manipulation for multi-language support. @@ -18,7 +17,7 @@ class LocaleResolver { $this->defaultLocale = $defaultLocale ?? I18n::$defaultLocale; $this->availableLocales = $availableLocales ?? (defined('APP_LOCALES') ? APP_LOCALES : [$this->defaultLocale]); - $this->basePath = trim(parse_url(Router::getBaseUrl(), PHP_URL_PATH) ?: '/', '/'); + $this->basePath = trim(parse_url(\MintyPHP\Router::getBaseUrl(), PHP_URL_PATH) ?: '/', '/'); } /** @@ -37,7 +36,7 @@ class LocaleResolver $path = parse_url($uri, PHP_URL_PATH) ?: ''; $query = parse_url($uri, PHP_URL_QUERY) ?: ''; - $relativePath = $this->stripBasePath($path); + $relativePath = Request::stripBasePath($path); $segments = $relativePath === '' ? [] : explode('/', $relativePath); $localeCandidate = $segments[0] ?? ''; @@ -120,21 +119,6 @@ class LocaleResolver return $this->defaultLocale; } - private function stripBasePath(string $path): string - { - $relativePath = ltrim($path, '/'); - - if ($this->basePath !== '' && strpos($relativePath, $this->basePath . '/') === 0) { - return substr($relativePath, strlen($this->basePath) + 1); - } - - if ($this->basePath !== '' && $relativePath === $this->basePath) { - return ''; - } - - return $relativePath; - } - private function looksLikeLocale(string $candidate): bool { return preg_match('/^[a-z]{2}(?:-[a-z]{2})?$/i', $candidate) === 1; diff --git a/lib/Http/Request.php b/lib/Http/Request.php index 818a8f7..a909455 100644 --- a/lib/Http/Request.php +++ b/lib/Http/Request.php @@ -7,20 +7,27 @@ use MintyPHP\I18n; class Request { + public static function stripBasePath(string $path): string + { + $base = trim(parse_url(Router::getBaseUrl(), PHP_URL_PATH) ?: '/', '/'); + $path = ltrim($path, '/'); + + if ($base !== '' && strpos($path, $base . '/') === 0) { + return substr($path, strlen($base) + 1); + } + if ($base !== '' && $path === $base) { + return ''; + } + + return $path; + } + public static function path(): string { $uri = $_SERVER['REQUEST_URI'] ?? ''; $path = parse_url($uri, PHP_URL_PATH) ?: ''; - $base = trim(Router::getBaseUrl(), '/'); - $path = ltrim($path, '/'); - if ($base !== '' && strpos($path, $base . '/') === 0) { - $path = substr($path, strlen($base) + 1); - } elseif ($base !== '' && $path === $base) { - $path = ''; - } - - return $path; + return self::stripBasePath($path); } public static function safeReturnTarget(string $returnParam = ''): string @@ -28,15 +35,8 @@ class Request if ($returnParam !== '') { $parts = parse_url($returnParam); if (($parts['scheme'] ?? '') === '' && ($parts['host'] ?? '') === '') { - $path = $parts['path'] ?? ''; + $path = self::stripBasePath($parts['path'] ?? ''); $query = isset($parts['query']) ? '?' . $parts['query'] : ''; - $base = trim(Router::getBaseUrl(), '/'); - $path = ltrim($path, '/'); - if ($base !== '' && strpos($path, $base . '/') === 0) { - $path = substr($path, strlen($base) + 1); - } elseif ($base !== '' && $path === $base) { - $path = ''; - } return $path . $query; } } @@ -47,15 +47,8 @@ class Request $host = $parts['host'] ?? ''; $currentHost = $_SERVER['HTTP_HOST'] ?? ''; if ($host === '' || $host === $currentHost) { - $path = $parts['path'] ?? ''; + $path = self::stripBasePath($parts['path'] ?? ''); $query = isset($parts['query']) ? '?' . $parts['query'] : ''; - $base = trim(Router::getBaseUrl(), '/'); - $path = ltrim($path, '/'); - if ($base !== '' && strpos($path, $base . '/') === 0) { - $path = substr($path, strlen($base) + 1); - } elseif ($base !== '' && $path === $base) { - $path = ''; - } return $path . $query; } } diff --git a/lib/Repository/Access/RolePermissionRepository.php b/lib/Repository/Access/RolePermissionRepository.php index 8b121eb..6a6d06e 100644 --- a/lib/Repository/Access/RolePermissionRepository.php +++ b/lib/Repository/Access/RolePermissionRepository.php @@ -22,6 +22,38 @@ class RolePermissionRepository return array_values(array_unique($ids)); } + public static function countPermissionsByRoleIds(array $roleIds): array + { + $roleIds = array_values(array_unique(array_map('intval', $roleIds))); + $roleIds = array_values(array_filter($roleIds, static fn ($id) => $id > 0)); + if (!$roleIds) { + return []; + } + + $placeholders = implode(',', array_fill(0, count($roleIds), '?')); + $rows = DB::select( + 'select rp.role_id, count(distinct rp.permission_id) as permission_count from role_permissions rp where rp.role_id in (' . + $placeholders . + ') group by rp.role_id', + ...array_map('strval', $roleIds) + ); + + if (!is_array($rows)) { + return []; + } + + $result = []; + foreach ($rows as $row) { + $roleId = self::extractIntField($row, 'role_id'); + if ($roleId <= 0) { + continue; + } + $result[$roleId] = self::extractIntField($row, 'permission_count'); + } + + return $result; + } + public static function replaceForRole(int $roleId, array $permissionIds): bool { DB::delete('delete from role_permissions where role_id = ?', (string) $roleId); @@ -123,27 +155,43 @@ class RolePermissionRepository $rowRp = $row['rp'] ?? ($row['role_permissions'] ?? $row); $permId = $rowRp['permission_id'] ?? ($row['permission_id'] ?? null); - $key = (string) (($rowPerm['key'] ?? $row['key'] ?? '') ?? ''); + $key = (string) ($rowPerm['key'] ?? $row['key'] ?? ''); if ($permId === null || $key === '') { continue; } if (!isset($permMap[$permId])) { - $desc = (string) (($rowPerm['description'] ?? $row['description'] ?? '') ?? ''); + $desc = (string) ($rowPerm['description'] ?? $row['description'] ?? ''); $permMap[$permId] = [ 'key' => $key, 'description' => $desc, 'roles' => [], ]; } - $roleLabel = (string) (($rowRole['description'] ?? $rowRole['role'] ?? $row['role'] ?? '') ?? ''); + $roleLabel = (string) ($rowRole['description'] ?? $rowRole['role'] ?? $row['role'] ?? ''); if ($roleLabel !== '') { $permMap[$permId]['roles'][] = $roleLabel; } } foreach ($permMap as &$item) { - $item['roles'] = array_values(array_unique($item['roles'] ?? [])); + $item['roles'] = array_values(array_unique($item['roles'])); } unset($item); return array_values($permMap); } + + private static function extractIntField(array $row, string $field): int + { + foreach ($row as $value) { + if (!is_array($value)) { + continue; + } + if (array_key_exists($field, $value)) { + return (int) $value[$field]; + } + } + if (array_key_exists($field, $row)) { + return (int) $row[$field]; + } + return 0; + } } diff --git a/lib/Repository/Access/RoleRepository.php b/lib/Repository/Access/RoleRepository.php index ab4be71..337e7bc 100644 --- a/lib/Repository/Access/RoleRepository.php +++ b/lib/Repository/Access/RoleRepository.php @@ -46,6 +46,22 @@ class RoleRepository return self::unwrapList($rows); } + public static function listByIds(array $roleIds): array + { + $roleIds = array_values(array_unique(array_map('intval', $roleIds))); + $roleIds = array_values(array_filter($roleIds, static fn ($id) => $id > 0)); + if (!$roleIds) { + return []; + } + + $placeholders = implode(',', array_fill(0, count($roleIds), '?')); + $rows = DB::select( + 'select id, uuid, description, code, active, created_by, modified_by, created, modified from roles where id in (' . $placeholders . ')', + ...array_map('strval', $roleIds) + ); + return self::unwrapList($rows); + } + public static function listIds(): array { $rows = DB::select('select id from roles'); @@ -143,19 +159,11 @@ class RoleRepository return $count ? ((int) $count > 0) : false; } - private static function uuidV4(): string - { - $data = random_bytes(16); - $data[6] = chr((ord($data[6]) & 0x0f) | 0x40); - $data[8] = chr((ord($data[8]) & 0x3f) | 0x80); - return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); - } - public static function create(array $data) { return DB::insert( 'insert into roles (uuid, description, code, active, created_by, created) values (?,?,?,?,?,NOW())', - $data['uuid'] ?? self::uuidV4(), + $data['uuid'] ?? RepoQuery::uuidV4(), $data['description'], $data['code'] ?? null, $data['active'] ?? 1, diff --git a/lib/Repository/Access/UserRoleRepository.php b/lib/Repository/Access/UserRoleRepository.php index 9e8e035..efc9f19 100644 --- a/lib/Repository/Access/UserRoleRepository.php +++ b/lib/Repository/Access/UserRoleRepository.php @@ -39,4 +39,63 @@ class UserRoleRepository return true; } + + public static function countUsersByRoleIds(array $roleIds): array + { + return self::countByRoleIds($roleIds, false); + } + + public static function countActiveUsersByRoleIds(array $roleIds): array + { + return self::countByRoleIds($roleIds, true); + } + + private static function countByRoleIds(array $roleIds, bool $activeOnly): array + { + $roleIds = array_values(array_unique(array_map('intval', $roleIds))); + $roleIds = array_values(array_filter($roleIds, static fn ($id) => $id > 0)); + if (!$roleIds) { + return []; + } + + $placeholders = implode(',', array_fill(0, count($roleIds), '?')); + $joinUsersSql = $activeOnly ? ' join users u on u.id = ur.user_id and u.active = 1' : ''; + $rows = DB::select( + 'select ur.role_id, count(distinct ur.user_id) as user_count from user_roles ur' . + $joinUsersSql . + ' where ur.role_id in (' . $placeholders . ') group by ur.role_id', + ...array_map('strval', $roleIds) + ); + + if (!is_array($rows)) { + return []; + } + + $result = []; + foreach ($rows as $row) { + $roleId = self::extractIntField($row, 'role_id'); + if ($roleId <= 0) { + continue; + } + $result[$roleId] = self::extractIntField($row, 'user_count'); + } + + return $result; + } + + private static function extractIntField(array $row, string $field): int + { + foreach ($row as $value) { + if (!is_array($value)) { + continue; + } + if (array_key_exists($field, $value)) { + return (int) $value[$field]; + } + } + if (array_key_exists($field, $row)) { + return (int) $row[$field]; + } + return 0; + } } diff --git a/lib/Repository/Audit/ApiAuditLogRepository.php b/lib/Repository/Audit/ApiAuditLogRepository.php new file mode 100644 index 0000000..ba69774 --- /dev/null +++ b/lib/Repository/Audit/ApiAuditLogRepository.php @@ -0,0 +1,223 @@ += 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; + } +} + diff --git a/lib/Repository/Audit/ImportAuditRunRepository.php b/lib/Repository/Audit/ImportAuditRunRepository.php new file mode 100644 index 0000000..fa2a882 --- /dev/null +++ b/lib/Repository/Audit/ImportAuditRunRepository.php @@ -0,0 +1,251 @@ + 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; + } +} diff --git a/lib/Repository/Audit/UserLifecycleAuditRepository.php b/lib/Repository/Audit/UserLifecycleAuditRepository.php new file mode 100644 index 0000000..f50b4ab --- /dev/null +++ b/lib/Repository/Audit/UserLifecycleAuditRepository.php @@ -0,0 +1,297 @@ += ?'; + $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; + } +} + diff --git a/lib/Repository/Auth/ApiTokenRepository.php b/lib/Repository/Auth/ApiTokenRepository.php new file mode 100644 index 0000000..0ac8632 --- /dev/null +++ b/lib/Repository/Auth/ApiTokenRepository.php @@ -0,0 +1,222 @@ + 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; + } + +} diff --git a/lib/Repository/CustomField/TenantCustomFieldDefinitionRepository.php b/lib/Repository/CustomField/TenantCustomFieldDefinitionRepository.php new file mode 100644 index 0000000..515efa0 --- /dev/null +++ b/lib/Repository/CustomField/TenantCustomFieldDefinitionRepository.php @@ -0,0 +1,174 @@ + $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; + } +} diff --git a/lib/Repository/CustomField/TenantCustomFieldOptionRepository.php b/lib/Repository/CustomField/TenantCustomFieldOptionRepository.php new file mode 100644 index 0000000..7dbeb8a --- /dev/null +++ b/lib/Repository/CustomField/TenantCustomFieldOptionRepository.php @@ -0,0 +1,127 @@ + $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; + } +} diff --git a/lib/Repository/CustomField/UserCustomFieldValueOptionRepository.php b/lib/Repository/CustomField/UserCustomFieldValueOptionRepository.php new file mode 100644 index 0000000..a5590ca --- /dev/null +++ b/lib/Repository/CustomField/UserCustomFieldValueOptionRepository.php @@ -0,0 +1,78 @@ + $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; + } +} diff --git a/lib/Repository/CustomField/UserCustomFieldValueRepository.php b/lib/Repository/CustomField/UserCustomFieldValueRepository.php new file mode 100644 index 0000000..1a39ed3 --- /dev/null +++ b/lib/Repository/CustomField/UserCustomFieldValueRepository.php @@ -0,0 +1,120 @@ + $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; + } +} diff --git a/lib/Repository/Org/DepartmentRepository.php b/lib/Repository/Org/DepartmentRepository.php index 8886cfd..91b3264 100644 --- a/lib/Repository/Org/DepartmentRepository.php +++ b/lib/Repository/Org/DepartmentRepository.php @@ -33,7 +33,7 @@ class DepartmentRepository public static function list(): array { $rows = DB::select( - 'select id, uuid, description, code, cost_center, active, created_by, modified_by, created, modified from departments order by id desc' + 'select id, uuid, description, tenant_id, code, cost_center, active, created_by, modified_by, created, modified from departments order by id desc' ); return self::unwrapList($rows); } @@ -54,6 +54,31 @@ class DepartmentRepository return array_values(array_unique($ids)); } + public static function listActiveIdsByTenantIds(array $tenantIds): array + { + $tenantIds = array_values(array_unique(array_map('intval', $tenantIds))); + $tenantIds = array_filter($tenantIds, static fn ($id) => $id > 0); + if (!$tenantIds) { + return []; + } + $placeholders = implode(',', array_fill(0, count($tenantIds), '?')); + $rows = DB::select( + 'select id from departments where active = 1 and tenant_id in (' . $placeholders . ')', + ...array_map('strval', $tenantIds) + ); + if (!is_array($rows)) { + return []; + } + $ids = []; + foreach ($rows as $row) { + $data = $row['departments'] ?? $row; + if (is_array($data) && isset($data['id'])) { + $ids[] = (int) $data['id']; + } + } + return array_values(array_unique($ids)); + } + public static function listByTenantIds(array $tenantIds): array { $tenantIds = array_values(array_unique(array_map('intval', $tenantIds))); @@ -63,17 +88,16 @@ class DepartmentRepository } $placeholders = implode(',', array_fill(0, count($tenantIds), '?')); $rows = DB::select( - 'select departments.id, departments.uuid, departments.description, departments.code, departments.cost_center, departments.active, departments.created_by, departments.modified_by, departments.created, departments.modified ' . - 'from departments departments join tenant_departments td on td.department_id = departments.id ' . - 'where departments.active = 1 and td.tenant_id in (' . $placeholders . ') ' . - 'group by departments.id, departments.uuid, departments.description, departments.code, departments.cost_center, departments.active, departments.created_by, departments.modified_by, departments.created, departments.modified ' . + 'select departments.id, departments.uuid, departments.description, departments.tenant_id, departments.code, departments.cost_center, departments.active, departments.created_by, departments.modified_by, departments.created, departments.modified ' . + "from departments departments join tenants t on t.id = departments.tenant_id and t.status = 'active' " . + 'where departments.active = 1 and departments.tenant_id in (' . $placeholders . ') ' . 'order by departments.description asc', ...array_map('strval', $tenantIds) ); return self::unwrapList($rows); } - public static function listByIds(array $departmentIds): array + public static function listByIds(array $departmentIds, bool $includeInactive = false): array { $departmentIds = array_values(array_unique(array_map('intval', $departmentIds))); $departmentIds = array_filter($departmentIds, static fn ($id) => $id > 0); @@ -81,8 +105,9 @@ class DepartmentRepository return []; } $placeholders = implode(',', array_fill(0, count($departmentIds), '?')); + $activeSql = $includeInactive ? '' : 'active = 1 and '; $rows = DB::select( - 'select id, uuid, description, code, cost_center, active, created_by, modified_by, created, modified from departments where active = 1 and id in (' . $placeholders . ') order by description asc', + 'select id, uuid, description, tenant_id, code, cost_center, active, created_by, modified_by, created, modified from departments where ' . $activeSql . 'id in (' . $placeholders . ') order by description asc', ...array_map('strval', $departmentIds) ); return self::unwrapList($rows); @@ -113,44 +138,25 @@ class DepartmentRepository $where, $params, $tenant, - "exists (select 1 from tenant_departments td join tenants t on t.id = td.tenant_id and t.status = 'active' where td.department_id = departments.id and t.uuid = ?)" + "exists (select 1 from tenants t where t.id = departments.tenant_id and t.status = 'active' and t.uuid = ?)" ); + if (!empty($options['tenantUserId'])) { $tenantUserId = (int) $options['tenantUserId']; if ($tenantUserId > 0) { - if (defined('TENANT_SCOPE_STRICT') && TENANT_SCOPE_STRICT) { - $where[] = 'exists (select 1 from tenant_departments td ' . - "join tenants t on t.id = td.tenant_id and t.status = 'active' " . - 'join user_tenants ut on ut.tenant_id = td.tenant_id ' . - 'where ut.user_id = ? and td.department_id = departments.id)'; - $params[] = (string) $tenantUserId; - } else { - $where[] = '(not exists (select 1 from tenant_departments td where td.department_id = departments.id) ' . - 'or exists (select 1 from tenant_departments td ' . - "join tenants t on t.id = td.tenant_id and t.status = 'active' " . - 'join user_tenants ut on ut.tenant_id = td.tenant_id ' . - 'where ut.user_id = ? and td.department_id = departments.id))'; - $params[] = (string) $tenantUserId; - } + $where[] = 'exists (select 1 from user_tenants ut ' . + "join tenants t on t.id = ut.tenant_id and t.status = 'active' " . + 'where ut.user_id = ? and ut.tenant_id = departments.tenant_id)'; + $params[] = (string) $tenantUserId; } } elseif (array_key_exists('tenantIds', $options)) { $tenantIds = $options['tenantIds']; $tenantIds = is_array($tenantIds) ? array_values(array_unique(array_map('intval', $tenantIds))) : []; if ($tenantIds) { - if (defined('TENANT_SCOPE_STRICT') && TENANT_SCOPE_STRICT) { - $where[] = 'exists (select 1 from tenant_departments td where td.department_id = departments.id and td.tenant_id in (???))'; - $params[] = $tenantIds; - } else { - $where[] = '(not exists (select 1 from tenant_departments td where td.department_id = departments.id) ' . - 'or exists (select 1 from tenant_departments td where td.department_id = departments.id and td.tenant_id in (???)))'; - $params[] = $tenantIds; - } + $where[] = 'departments.tenant_id in (???)'; + $params[] = $tenantIds; } else { - if (defined('TENANT_SCOPE_STRICT') && TENANT_SCOPE_STRICT) { - $where[] = '1=0'; - } else { - $where[] = 'not exists (select 1 from tenant_departments td where td.department_id = departments.id)'; - } + $where[] = '1=0'; } } @@ -158,7 +164,7 @@ class DepartmentRepository $count = DB::selectValue('select count(*) from departments' . $whereSql, ...$params); $total = $count ? (int) $count : 0; - $query = 'select id, uuid, description, code, cost_center, active, created_by, modified_by, created, modified from departments' . + $query = 'select id, uuid, description, tenant_id, code, cost_center, active, created_by, modified_by, created, modified from departments' . $whereSql . sprintf(' order by `%s` %s limit ? offset ?', $order, $dir); @@ -176,48 +182,28 @@ class DepartmentRepository if ($ids) { $placeholders = implode(',', array_fill(0, count($ids), '?')); $labelRows = DB::select( - "select td.department_id as department_id, t.description as description from tenant_departments td join tenants t on t.id = td.tenant_id and t.status = 'active' " . - 'where td.department_id in (' . $placeholders . ') order by t.description asc', + 'select d.id as department_id, t.description as description from departments d join tenants t on t.id = d.tenant_id ' . + 'where d.id in (' . $placeholders . ') order by t.description asc', ...array_map('strval', $ids) ); - $collectLabels = static function (array $rows): array { - $labelsByDepartment = []; - foreach ($rows as $row) { - $departmentId = 0; - $label = ''; - if (isset($row['td']) && is_array($row['td'])) { - $departmentId = (int) ($row['td']['department_id'] ?? 0); + foreach ((array) $labelRows as $row) { + $departmentId = 0; + $label = ''; + foreach ((array) $row as $table) { + if (!is_array($table)) { + continue; } - if (isset($row['t']) && is_array($row['t'])) { - $label = (string) ($row['t']['description'] ?? ''); + if ($departmentId === 0 && isset($table['department_id'])) { + $departmentId = (int) $table['department_id']; } - if ($departmentId === 0 || $label === '') { - foreach ($row as $value) { - if (!is_array($value)) { - continue; - } - if ($departmentId === 0 && isset($value['department_id'])) { - $departmentId = (int) $value['department_id']; - } - if ($label === '' && isset($value['description'])) { - $label = (string) $value['description']; - } - } - } - if ($departmentId === 0 && isset($row['department_id'])) { - $departmentId = (int) $row['department_id']; - } - if ($label === '' && isset($row['description'])) { - $label = (string) $row['description']; - } - if ($departmentId > 0 && $label !== '') { - $labelsByDepartment[$departmentId] ??= []; - $labelsByDepartment[$departmentId][] = $label; + if ($label === '' && isset($table['description'])) { + $label = (string) $table['description']; } } - return $labelsByDepartment; - }; - $tenantLabelsByDepartment = $collectLabels($labelRows); + if ($departmentId > 0 && $label !== '') { + $tenantLabelsByDepartment[$departmentId] = [$label]; + } + } } foreach ($list as &$department) { $departmentId = (int) ($department['id'] ?? 0); @@ -234,7 +220,7 @@ class DepartmentRepository public static function find(int $id): ?array { $row = DB::selectOne( - 'select id, uuid, description, code, cost_center, active, created_by, modified_by, created, modified from departments where id = ? limit 1', + 'select id, uuid, description, tenant_id, code, cost_center, active, created_by, modified_by, created, modified from departments where id = ? limit 1', (string) $id ); return self::unwrap($row); @@ -243,7 +229,7 @@ class DepartmentRepository public static function findByUuid(string $uuid): ?array { $row = DB::selectOne( - 'select id, uuid, description, code, cost_center, active, created_by, modified_by, created, modified from departments where uuid = ? limit 1', + 'select id, uuid, description, tenant_id, code, cost_center, active, created_by, modified_by, created, modified from departments where uuid = ? limit 1', $uuid ); return self::unwrap($row); @@ -263,20 +249,48 @@ class DepartmentRepository return (int) $count > 0; } - private static function uuidV4(): string + public static function existsByTenantAndDescription(int $tenantId, string $description, int $excludeId = 0): bool { - $data = random_bytes(16); - $data[6] = chr((ord($data[6]) & 0x0f) | 0x40); - $data[8] = chr((ord($data[8]) & 0x3f) | 0x80); - return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); + $tenantId = (int) $tenantId; + $description = trim($description); + if ($tenantId <= 0 || $description === '') { + return false; + } + + if ($excludeId > 0) { + $count = DB::selectValue( + 'select count(*) from departments where tenant_id = ? and lower(description) = lower(?) and id != ?', + (string) $tenantId, + $description, + (string) $excludeId + ); + } else { + $count = DB::selectValue( + 'select count(*) from departments where tenant_id = ? and lower(description) = lower(?)', + (string) $tenantId, + $description + ); + } + + return (int) $count > 0; + } + + public static function countByTenantId(int $tenantId): int + { + if ($tenantId <= 0) { + return 0; + } + $count = DB::selectValue('select count(*) from departments where tenant_id = ?', (string) $tenantId); + return $count ? (int) $count : 0; } public static function create(array $data) { return DB::insert( - 'insert into departments (uuid, description, code, cost_center, active, created_by, created) values (?,?,?,?,?,?,NOW())', - $data['uuid'] ?? self::uuidV4(), + 'insert into departments (uuid, description, tenant_id, code, cost_center, active, created_by, created) values (?,?,?,?,?,?,?,NOW())', + $data['uuid'] ?? RepoQuery::uuidV4(), $data['description'], + (string) ($data['tenant_id'] ?? 0), $data['code'] ?? null, $data['cost_center'] ?? null, $data['active'] ?? 1, @@ -288,6 +302,7 @@ class DepartmentRepository { $fields = [ 'description' => $data['description'], + 'tenant_id' => (string) ($data['tenant_id'] ?? 0), 'code' => $data['code'] ?? null, 'cost_center' => $data['cost_center'] ?? null, 'active' => $data['active'] ?? 1, @@ -309,6 +324,19 @@ class DepartmentRepository return $result !== false; } + public static function setTenant(int $id, int $tenantId): bool + { + if ($id <= 0 || $tenantId <= 0) { + return false; + } + $result = DB::update( + 'update departments set tenant_id = ? where id = ?', + (string) $tenantId, + (string) $id + ); + return $result !== false; + } + public static function delete(int $id): bool { $result = DB::delete('delete from departments where id = ?', (string) $id); diff --git a/lib/Repository/Org/UserDepartmentRepository.php b/lib/Repository/Org/UserDepartmentRepository.php index 893f673..01f9ec5 100644 --- a/lib/Repository/Org/UserDepartmentRepository.php +++ b/lib/Repository/Org/UserDepartmentRepository.php @@ -45,10 +45,69 @@ class UserDepartmentRepository $query = 'delete ud from user_departments ud ' . 'where ud.department_id = ? and not exists (' . 'select 1 from user_tenants ut ' . - 'join tenant_departments td on td.tenant_id = ut.tenant_id ' . - 'where ut.user_id = ud.user_id and td.department_id = ud.department_id' . + 'join departments d on d.id = ud.department_id ' . + 'where ut.user_id = ud.user_id and ut.tenant_id = d.tenant_id' . ')'; $result = DB::delete($query, (string) $departmentId); return $result !== false ? (int) $result : 0; } + + public static function countUsersByDepartmentIds(array $departmentIds): array + { + return self::countByDepartmentIds($departmentIds, false); + } + + public static function countActiveUsersByDepartmentIds(array $departmentIds): array + { + return self::countByDepartmentIds($departmentIds, true); + } + + private static function countByDepartmentIds(array $departmentIds, bool $activeOnly): array + { + $departmentIds = array_values(array_unique(array_map('intval', $departmentIds))); + $departmentIds = array_values(array_filter($departmentIds, static fn ($id) => $id > 0)); + if (!$departmentIds) { + return []; + } + + $placeholders = implode(',', array_fill(0, count($departmentIds), '?')); + $joinUsersSql = $activeOnly ? ' join users u on u.id = ud.user_id and u.active = 1' : ''; + $rows = DB::select( + 'select ud.department_id, count(distinct ud.user_id) as user_count from user_departments ud' . + $joinUsersSql . + ' where ud.department_id in (' . $placeholders . ') group by ud.department_id', + ...array_map('strval', $departmentIds) + ); + + if (!is_array($rows)) { + return []; + } + + $result = []; + foreach ($rows as $row) { + $departmentId = self::extractIntField($row, 'department_id'); + if ($departmentId <= 0) { + continue; + } + $result[$departmentId] = self::extractIntField($row, 'user_count'); + } + + return $result; + } + + private static function extractIntField(array $row, string $field): int + { + foreach ($row as $value) { + if (!is_array($value)) { + continue; + } + if (array_key_exists($field, $value)) { + return (int) $value[$field]; + } + } + if (array_key_exists($field, $row)) { + return (int) $row[$field]; + } + return 0; + } } diff --git a/lib/Repository/Scheduler/ScheduledJobRepository.php b/lib/Repository/Scheduler/ScheduledJobRepository.php new file mode 100644 index 0000000..ba18044 --- /dev/null +++ b/lib/Repository/Scheduler/ScheduledJobRepository.php @@ -0,0 +1,252 @@ + $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; + } +} diff --git a/lib/Repository/Scheduler/ScheduledJobRunRepository.php b/lib/Repository/Scheduler/ScheduledJobRunRepository.php new file mode 100644 index 0000000..1346543 --- /dev/null +++ b/lib/Repository/Scheduler/ScheduledJobRunRepository.php @@ -0,0 +1,137 @@ + 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; + } +} diff --git a/lib/Repository/Scheduler/SchedulerRuntimeRepository.php b/lib/Repository/Scheduler/SchedulerRuntimeRepository.php new file mode 100644 index 0000000..c96ecef --- /dev/null +++ b/lib/Repository/Scheduler/SchedulerRuntimeRepository.php @@ -0,0 +1,52 @@ + 0) { + $ids[] = $id; + } + } + $ids = array_values(array_unique($ids)); + sort($ids, SORT_NUMERIC); + return $ids; + } } diff --git a/lib/Repository/Tenant/TenantDepartmentRepository.php b/lib/Repository/Tenant/TenantDepartmentRepository.php deleted file mode 100644 index f384c0d..0000000 --- a/lib/Repository/Tenant/TenantDepartmentRepository.php +++ /dev/null @@ -1,102 +0,0 @@ - $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; - } -} diff --git a/lib/Repository/Tenant/TenantMicrosoftAuthRepository.php b/lib/Repository/Tenant/TenantMicrosoftAuthRepository.php new file mode 100644 index 0000000..f7dd209 --- /dev/null +++ b/lib/Repository/Tenant/TenantMicrosoftAuthRepository.php @@ -0,0 +1,85 @@ + $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; + } + +} diff --git a/lib/Repository/Tenant/TenantRepository.php b/lib/Repository/Tenant/TenantRepository.php index 00eb595..1162d69 100644 --- a/lib/Repository/Tenant/TenantRepository.php +++ b/lib/Repository/Tenant/TenantRepository.php @@ -33,7 +33,7 @@ class TenantRepository public static function list(): array { $rows = DB::select( - 'select id, uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants order by id desc' + 'select id, uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, default_theme, allow_user_theme, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants order by id desc' ); return self::unwrapList($rows); } @@ -54,6 +54,22 @@ class TenantRepository return array_values(array_unique($ids)); } + public static function listByIds(array $tenantIds): array + { + $tenantIds = array_values(array_unique(array_map('intval', $tenantIds))); + $tenantIds = array_values(array_filter($tenantIds, static fn ($id) => $id > 0)); + if (!$tenantIds) { + return []; + } + + $placeholders = implode(',', array_fill(0, count($tenantIds), '?')); + $rows = DB::select( + 'select id, uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, default_theme, allow_user_theme, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants where id in (' . $placeholders . ')', + ...array_map('strval', $tenantIds) + ); + return self::unwrapList($rows); + } + public static function listActiveIdsByIds(array $tenantIds): array { $tenantIds = array_values(array_unique(array_map('intval', $tenantIds))); @@ -85,7 +101,7 @@ class TenantRepository public static function listPaged(array $options): array { $search = trim((string) ($options['search'] ?? '')); - $allowedOrder = ['id', 'uuid', 'description', 'created', 'modified']; + $allowedOrder = ['id', 'uuid', 'description', 'status', 'created', 'modified']; [$limit, $offset] = RepoQuery::sanitizeLimitOffset($options); [$order, $dir] = RepoQuery::sanitizeOrder($options, $allowedOrder); @@ -99,12 +115,23 @@ class TenantRepository $params[] = (string) $tenantUserId; } } + if (array_key_exists('tenantIds', $options)) { + $tenantIds = $options['tenantIds']; + $tenantIds = is_array($tenantIds) ? array_values(array_unique(array_map('intval', $tenantIds))) : []; + $tenantIds = array_values(array_filter($tenantIds, static fn ($id) => $id > 0)); + if ($tenantIds) { + $where[] = 'tenants.id in (???)'; + $params[] = $tenantIds; + } else { + $where[] = '1=0'; + } + } $whereSql = $where ? (' where ' . implode(' and ', $where)) : ''; $count = DB::selectValue('select count(*) from tenants' . $whereSql, ...$params); $total = $count ? (int) $count : 0; - $query = 'select id, uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants' . + $query = 'select id, uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, default_theme, allow_user_theme, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants' . $whereSql . sprintf(' order by `%s` %s limit ? offset ?', $order, $dir); @@ -120,7 +147,7 @@ class TenantRepository public static function find(int $id): ?array { $row = DB::selectOne( - 'select id, uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants where id = ? limit 1', + 'select id, uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, default_theme, allow_user_theme, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants where id = ? limit 1', (string) $id ); return self::unwrap($row); @@ -129,25 +156,17 @@ class TenantRepository public static function findByUuid(string $uuid): ?array { $row = DB::selectOne( - 'select id, uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants where uuid = ? limit 1', + 'select id, uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, default_theme, allow_user_theme, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants where uuid = ? limit 1', $uuid ); return self::unwrap($row); } - private static function uuidV4(): string - { - $data = random_bytes(16); - $data[6] = chr((ord($data[6]) & 0x0f) | 0x40); - $data[8] = chr((ord($data[8]) & 0x3f) | 0x80); - return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); - } - public static function create(array $data) { return DB::insert( - 'insert into tenants (uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, status, status_changed_at, status_changed_by, created_by, created) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW())', - $data['uuid'] ?? self::uuidV4(), + 'insert into tenants (uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, default_theme, allow_user_theme, status, status_changed_at, status_changed_by, created_by, created) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW())', + $data['uuid'] ?? RepoQuery::uuidV4(), $data['description'], $data['address'] ?? null, $data['postal_code'] ?? null, @@ -166,6 +185,8 @@ class TenantRepository $data['privacy_url'] ?? null, $data['imprint_url'] ?? null, $data['primary_color'] ?? null, + $data['default_theme'] ?? null, + $data['allow_user_theme'] ?? null, $data['status'] ?? 'active', $data['status_changed_at'] ?? null, $data['status_changed_by'] ?? null, @@ -194,6 +215,8 @@ class TenantRepository 'privacy_url' => $data['privacy_url'] ?? null, 'imprint_url' => $data['imprint_url'] ?? null, 'primary_color' => $data['primary_color'] ?? null, + 'default_theme' => $data['default_theme'] ?? null, + 'allow_user_theme' => $data['allow_user_theme'] ?? null, 'status' => $data['status'] ?? 'active', ]; if (array_key_exists('modified_by', $data)) { diff --git a/lib/Repository/Tenant/UserTenantRepository.php b/lib/Repository/Tenant/UserTenantRepository.php index dc95e18..84b46eb 100644 --- a/lib/Repository/Tenant/UserTenantRepository.php +++ b/lib/Repository/Tenant/UserTenantRepository.php @@ -39,4 +39,64 @@ class UserTenantRepository return true; } + + public static function countUsersByTenantIds(array $tenantIds): array + { + return self::countByTenantIds($tenantIds, false); + } + + public static function countActiveUsersByTenantIds(array $tenantIds): array + { + return self::countByTenantIds($tenantIds, true); + } + + private static function countByTenantIds(array $tenantIds, bool $activeOnly): array + { + $tenantIds = array_values(array_unique(array_map('intval', $tenantIds))); + $tenantIds = array_values(array_filter($tenantIds, static fn ($id) => $id > 0)); + if (!$tenantIds) { + return []; + } + + $placeholders = implode(',', array_fill(0, count($tenantIds), '?')); + $joinUsersSql = $activeOnly ? ' join users u on u.id = ut.user_id and u.active = 1' : ''; + + $rows = DB::select( + 'select ut.tenant_id, count(distinct ut.user_id) as user_count from user_tenants ut' . + $joinUsersSql . + ' where ut.tenant_id in (' . $placeholders . ') group by ut.tenant_id', + ...array_map('strval', $tenantIds) + ); + + if (!is_array($rows)) { + return []; + } + + $result = []; + foreach ($rows as $row) { + $tenantId = self::extractIntField($row, 'tenant_id'); + if ($tenantId <= 0) { + continue; + } + $result[$tenantId] = self::extractIntField($row, 'user_count'); + } + + return $result; + } + + private static function extractIntField(array $row, string $field): int + { + foreach ($row as $value) { + if (!is_array($value)) { + continue; + } + if (array_key_exists($field, $value)) { + return (int) $value[$field]; + } + } + if (array_key_exists($field, $row)) { + return (int) $row[$field]; + } + return 0; + } } diff --git a/lib/Repository/User/UserExternalIdentityRepository.php b/lib/Repository/User/UserExternalIdentityRepository.php new file mode 100644 index 0000000..5981441 --- /dev/null +++ b/lib/Repository/User/UserExternalIdentityRepository.php @@ -0,0 +1,55 @@ + ['0', 'false', 'inactive'], 'sql' => 'users.active = ?', 'params' => ['0']], ]); - if ($createdFrom !== '') { + if ($createdFrom !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdFrom)) { $where[] = 'users.created >= ?'; $params[] = $createdFrom . ' 00:00:00'; } - if ($createdTo !== '') { + if ($createdTo !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdTo)) { $where[] = 'users.created <= ?'; $params[] = $createdTo . ' 23:59:59'; } @@ -69,6 +75,68 @@ class UserRepository ['aliases' => ['never', 'none', 'no'], 'sql' => 'users.last_login_at is null'], ['aliases' => ['ever', 'logged', 'yes'], 'sql' => 'users.last_login_at is not null'], ]); + if (!empty($customFieldFilters['select']) && is_array($customFieldFilters['select'])) { + foreach ($customFieldFilters['select'] as $definitionId => $optionId) { + $definitionId = (int) $definitionId; + $optionId = (int) $optionId; + if ($definitionId <= 0 || $optionId <= 0) { + continue; + } + $where[] = 'exists (select 1 from user_custom_field_values ucfv ' . + 'where ucfv.user_id = users.id and ucfv.definition_id = ? and ucfv.option_id = ?)'; + $params[] = (string) $definitionId; + $params[] = (string) $optionId; + } + } + if (!empty($customFieldFilters['boolean']) && is_array($customFieldFilters['boolean'])) { + foreach ($customFieldFilters['boolean'] as $definitionId => $boolValue) { + $definitionId = (int) $definitionId; + $boolValue = (int) $boolValue; + if ($definitionId <= 0 || ($boolValue !== 0 && $boolValue !== 1)) { + continue; + } + $where[] = 'exists (select 1 from user_custom_field_values ucfv ' . + 'where ucfv.user_id = users.id and ucfv.definition_id = ? and ucfv.value_bool = ?)'; + $params[] = (string) $definitionId; + $params[] = (string) $boolValue; + } + } + if (!empty($customFieldFilters['multiselect']) && is_array($customFieldFilters['multiselect'])) { + foreach ($customFieldFilters['multiselect'] as $definitionId => $optionIds) { + $definitionId = (int) $definitionId; + $optionIds = RepoQuery::normalizeIdList($optionIds); + if ($definitionId <= 0 || !$optionIds) { + continue; + } + $where[] = 'exists (select 1 from user_custom_field_values ucfv ' . + 'join user_custom_field_value_options ucfvo on ucfvo.value_id = ucfv.id ' . + 'where ucfv.user_id = users.id and ucfv.definition_id = ? and ucfvo.option_id in (???))'; + $params[] = (string) $definitionId; + $params[] = array_map('strval', $optionIds); + } + } + if (!empty($customFieldFilters['date']) && is_array($customFieldFilters['date'])) { + foreach ($customFieldFilters['date'] as $definitionId => $bounds) { + $definitionId = (int) $definitionId; + $from = is_array($bounds) ? trim((string) ($bounds['from'] ?? '')) : ''; + $to = is_array($bounds) ? trim((string) ($bounds['to'] ?? '')) : ''; + if ($definitionId <= 0 || ($from === '' && $to === '')) { + continue; + } + if ($from !== '') { + $where[] = 'exists (select 1 from user_custom_field_values ucfv ' . + 'where ucfv.user_id = users.id and ucfv.definition_id = ? and ucfv.value_date >= ?)'; + $params[] = (string) $definitionId; + $params[] = $from; + } + if ($to !== '') { + $where[] = 'exists (select 1 from user_custom_field_values ucfv ' . + 'where ucfv.user_id = users.id and ucfv.definition_id = ? and ucfv.value_date <= ?)'; + $params[] = (string) $definitionId; + $params[] = $to; + } + } + } if (!empty($options['tenantUserId'])) { $tenantUserId = (int) $options['tenantUserId']; if ($tenantUserId > 0) { @@ -93,7 +161,7 @@ class UserRepository private static function buildListQuery(string $whereSql, string $order, string $dir): string { - return 'select users.id, users.uuid, users.first_name, users.last_name, users.display_name, users.email, users.profile_description, users.job_title, users.phone, users.mobile, users.short_dial, users.address, users.postal_code, users.city, users.country, users.region, users.hire_date, users.theme, users.primary_tenant_id, users.created_by, users.modified_by, users.created, users.modified, users.last_login_at, users.active, ' . + return 'select users.id, users.uuid, users.first_name, users.last_name, users.display_name, users.email, users.profile_description, users.job_title, users.phone, users.mobile, users.short_dial, users.address, users.postal_code, users.city, users.country, users.region, users.hire_date, users.theme, users.primary_tenant_id, users.created_by, users.modified_by, users.created, users.modified, users.last_login_at, users.last_login_provider, users.active, ' . 'pt.description as primary_tenant_label ' . "from users left join tenants pt on pt.id = users.primary_tenant_id and pt.status = 'active'" . $whereSql . @@ -157,27 +225,48 @@ class UserRepository return $list; } - $scopeToActiveTenants = !empty($options['tenantUserId']); + $scopeUserId = (int) ($options['tenantUserId'] ?? 0); + $scopeToActiveTenants = $scopeUserId > 0; $tenantLabelJoin = $scopeToActiveTenants ? "join tenants t on t.id = ut.tenant_id and t.status = 'active' " : 'join tenants t on t.id = ut.tenant_id '; $placeholders = implode(',', array_fill(0, count($ids), '?')); + $tenantScopeSql = ''; + $tenantScopeParams = []; + if ($scopeToActiveTenants) { + $tenantScopeSql = ' and exists (select 1 from user_tenants uts ' . + "join tenants ts on ts.id = uts.tenant_id and ts.status = 'active' " . + 'where uts.user_id = ? and uts.tenant_id = ut.tenant_id)'; + $tenantScopeParams[] = (string) $scopeUserId; + } - $labelRows = DB::select( - 'select ut.user_id as user_id, t.id as tenant_id, t.description as description from user_tenants ut ' . $tenantLabelJoin . - 'where ut.user_id in (' . $placeholders . ') order by t.description asc', - ...array_map('strval', $ids) - ); - $roleLabelRows = DB::select( + $labelSql = 'select ut.user_id as user_id, t.id as tenant_id, t.description as description from user_tenants ut ' . $tenantLabelJoin . + 'where ut.user_id in (' . $placeholders . ')' . $tenantScopeSql . ' order by t.description asc'; + $labelRows = call_user_func_array(['MintyPHP\\DB', 'select'], array_merge( + [$labelSql], + array_merge(array_map('strval', $ids), $tenantScopeParams) + )); + $roleRows = DB::select( 'select ur.user_id as user_id, r.description as description from user_roles ur join roles r on r.id = ur.role_id and r.active = 1 ' . 'where ur.user_id in (' . $placeholders . ') order by r.description asc', ...array_map('strval', $ids) ); - $departmentLabelRows = DB::select( - 'select ud.user_id as user_id, d.description as description from user_departments ud join departments d on d.id = ud.department_id and d.active = 1 ' . - 'where ud.user_id in (' . $placeholders . ') order by d.description asc', - ...array_map('strval', $ids) - ); + + $departmentScopeSql = ''; + $departmentScopeParams = []; + if ($scopeToActiveTenants) { + $departmentScopeSql = ' and exists (select 1 from user_tenants uts ' . + "join tenants ts on ts.id = uts.tenant_id and ts.status = 'active' " . + 'where uts.user_id = ? and uts.tenant_id = d.tenant_id)'; + $departmentScopeParams[] = (string) $scopeUserId; + } + $departmentLabelSql = 'select ud.user_id as user_id, d.description as description from user_departments ud ' . + 'join departments d on d.id = ud.department_id and d.active = 1 ' . + 'where ud.user_id in (' . $placeholders . ')' . $departmentScopeSql . ' order by d.description asc'; + $departmentRows = call_user_func_array(['MintyPHP\\DB', 'select'], array_merge( + [$departmentLabelSql], + array_merge(array_map('strval', $ids), $departmentScopeParams) + )); $tenantMapByUser = []; foreach ($labelRows as $row) { @@ -225,8 +314,8 @@ class UserRepository foreach ($tenantMapByUser as $userId => $map) { $tenantLabelsByUser[$userId] = array_values($map); } - $roleLabelsByUser = self::collectLabels($roleLabelRows, 'ur', 'r'); - $departmentLabelsByUser = self::collectLabels($departmentLabelRows, 'ud', 'd'); + $roleLabelsByUser = self::collectLabels($roleRows, 'ur', 'r'); + $departmentLabelsByUser = self::collectLabels($departmentRows, 'ud', 'd'); foreach ($list as &$user) { $userId = (int) ($user['id'] ?? 0); @@ -242,6 +331,7 @@ class UserRepository return $list; } + private static function unwrap(?array $row): ?array { if (!$row) { @@ -277,25 +367,66 @@ class UserRepository return $list; } - public static function list(): array - { - $rows = DB::select( - 'select id, uuid, first_name, last_name, display_name, email, profile_description, job_title, phone, mobile, short_dial, theme, primary_tenant_id, created_by, modified_by, created, modified, active from users order by id desc' - ); - return self::unwrapList($rows); - } - - public static function updateLastLogin(int $userId): void + public static function updateLastLogin(int $userId, string $provider = 'local'): void { if ($userId <= 0) { return; } - DB::query('update users set last_login_at = UTC_TIMESTAMP() where id = ?', (string) $userId); + $provider = trim(strtolower($provider)); + if (!in_array($provider, ['local', 'microsoft'], true)) { + $provider = 'local'; + } + DB::query('update users set last_login_at = UTC_TIMESTAMP(), last_login_provider = ? where id = ?', $provider, (string) $userId); + } + + public static function findAuthzSnapshot(int $userId): ?array + { + if ($userId <= 0) { + return null; + } + + $row = DB::selectOne( + 'select id, active, authz_version, locale, theme, current_tenant_id from users where id = ? limit 1', + (string) $userId + ); + + return self::unwrap($row); + } + + public static function bumpAuthzVersion(int $userId): bool + { + if ($userId <= 0) { + return false; + } + + $result = DB::update( + 'update users set authz_version = authz_version + 1 where id = ?', + (string) $userId + ); + + return $result !== false; + } + + public static function bumpAuthzVersionByUserIds(array $userIds): int + { + $userIds = array_values(array_unique(array_map('intval', $userIds))); + $userIds = array_values(array_filter($userIds, static fn ($id) => $id > 0)); + if (!$userIds) { + return 0; + } + + $placeholders = implode(',', array_fill(0, count($userIds), '?')); + $result = DB::update( + "update users set authz_version = authz_version + 1 where id in ($placeholders)", + ...array_map('strval', $userIds) + ); + + return $result !== false ? (int) $result : 0; } public static function listPaged(array $options): array { - $allowedOrder = ['id', 'uuid', 'first_name', 'last_name', 'email', 'created', 'modified', 'active', 'last_login_at']; + $allowedOrder = ['id', 'uuid', 'first_name', 'last_name', 'display_name', 'email', 'created', 'modified', 'active', 'last_login_at']; [$limit, $offset] = RepoQuery::sanitizeLimitOffset($options); [$order, $dir] = RepoQuery::sanitizeOrder($options, $allowedOrder); [$whereSql, $params] = self::buildUserFilters($options); @@ -320,7 +451,7 @@ class UserRepository public static function find(int $id): ?array { $row = DB::selectOne( - 'select id, uuid, first_name, last_name, display_name, email, profile_description, job_title, phone, mobile, short_dial, address, postal_code, city, country, region, hire_date, email_verified_at, password, locale, totp_secret, theme, primary_tenant_id, created_by, modified_by, created, modified, active from users where id = ? limit 1', + 'select id, uuid, first_name, last_name, display_name, email, profile_description, job_title, phone, mobile, short_dial, address, postal_code, city, country, region, hire_date, email_verified_at, last_login_at, last_login_provider, password, locale, totp_secret, theme, primary_tenant_id, current_tenant_id, created_by, modified_by, created, modified, active, authz_version from users where id = ? limit 1', (string) $id ); return self::unwrap($row); @@ -329,7 +460,7 @@ class UserRepository public static function findByUuid(string $uuid): ?array { $row = DB::selectOne( - 'select id, uuid, first_name, last_name, display_name, email, profile_description, job_title, phone, mobile, short_dial, address, postal_code, city, country, region, hire_date, email_verified_at, password, locale, totp_secret, theme, primary_tenant_id, created_by, modified_by, created, modified, active, active_changed_at, active_changed_by from users where uuid = ? limit 1', + 'select id, uuid, first_name, last_name, display_name, email, profile_description, job_title, phone, mobile, short_dial, address, postal_code, city, country, region, hire_date, email_verified_at, last_login_at, last_login_provider, password, locale, totp_secret, theme, primary_tenant_id, current_tenant_id, created_by, modified_by, created, modified, active, authz_version, active_changed_at, active_changed_by from users where uuid = ? limit 1', $uuid ); return self::unwrap($row); @@ -338,7 +469,7 @@ class UserRepository public static function findByEmail(string $email): ?array { $row = DB::selectOne( - 'select id, uuid, first_name, last_name, display_name, email, profile_description, job_title, phone, mobile, short_dial, address, postal_code, city, country, region, hire_date, email_verified_at, email_verified_at, password, locale, totp_secret, theme, primary_tenant_id, created_by, modified_by, created, modified, active, active_changed_at, active_changed_by from users where email = ? limit 1', + 'select id, uuid, first_name, last_name, display_name, email, profile_description, job_title, phone, mobile, short_dial, address, postal_code, city, country, region, hire_date, email_verified_at, last_login_at, last_login_provider, password, locale, totp_secret, theme, primary_tenant_id, current_tenant_id, created_by, modified_by, created, modified, active, authz_version, active_changed_at, active_changed_by from users where email = ? limit 1', $email ); return self::unwrap($row); @@ -355,20 +486,12 @@ class UserRepository return $name; } - private static function uuidV4(): string - { - $data = random_bytes(16); - $data[6] = chr((ord($data[6]) & 0x0f) | 0x40); - $data[8] = chr((ord($data[8]) & 0x3f) | 0x80); - return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); - } - - public static function create(array $data) + public static function create(array $data): int|false { $hash = password_hash($data['password'], PASSWORD_DEFAULT); return DB::insert( 'insert into users (uuid, first_name, last_name, display_name, email, profile_description, job_title, phone, mobile, short_dial, address, postal_code, city, country, region, hire_date, password, locale, totp_secret, theme, primary_tenant_id, current_tenant_id, created_by, created, active, active_changed_at, active_changed_by) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW(),?,?,?)', - $data['uuid'] ?? self::uuidV4(), + $data['uuid'] ?? RepoQuery::uuidV4(), $data['first_name'], $data['last_name'], self::buildDisplayName($data), @@ -491,6 +614,118 @@ class UserRepository return $result !== false; } + public static function listPrivilegedUserIdsByPermissionKeys(array $permissionKeys): array + { + $keys = array_values(array_unique(array_filter(array_map( + static fn ($key) => trim((string) $key), + $permissionKeys + )))); + if (!$keys) { + return []; + } + + $placeholders = implode(',', array_fill(0, count($keys), '?')); + $rows = DB::select( + 'select distinct ur.user_id from user_roles ur ' . + 'join roles r on r.id = ur.role_id and r.active = 1 ' . + 'join role_permissions rp on rp.role_id = ur.role_id ' . + 'join permissions p on p.id = rp.permission_id and p.active = 1 ' . + "where p.`key` in ($placeholders)", + ...$keys + ); + if (!is_array($rows)) { + return []; + } + + $ids = []; + foreach ($rows as $row) { + $data = $row['ur'] ?? $row['user_roles'] ?? $row; + $userId = (int) ($data['user_id'] ?? $row['user_id'] ?? 0); + if ($userId > 0) { + $ids[] = $userId; + } + } + return array_values(array_unique($ids)); + } + + public static function listIdsForAutoDeactivate(int $days, array $excludedUserIds, int $limit = 500): array + { + if ($days <= 0 || $limit <= 0) { + return []; + } + + $excluded = array_values(array_unique(array_filter(array_map('intval', $excludedUserIds), static fn ($id) => $id > 0))); + $query = 'select id from users where active = 1 and coalesce(last_login_at, created) <= DATE_SUB(UTC_TIMESTAMP(), INTERVAL ' . + (int) $days . + ' DAY)'; + $params = []; + if ($excluded) { + $placeholders = implode(',', array_fill(0, count($excluded), '?')); + $query .= " and id not in ($placeholders)"; + $params = array_map('strval', $excluded); + } + $query .= ' order by coalesce(last_login_at, created) asc limit ?'; + $params[] = (string) $limit; + $rows = DB::select($query, ...$params); + if (!is_array($rows)) { + return []; + } + return self::extractIdList($rows); + } + + public static function listIdsForAutoDelete(int $days, array $excludedUserIds, int $limit = 500): array + { + if ($days <= 0 || $limit <= 0) { + return []; + } + + $excluded = array_values(array_unique(array_filter(array_map('intval', $excludedUserIds), static fn ($id) => $id > 0))); + $query = 'select id from users where active = 0 and active_changed_at is not null and active_changed_at <= DATE_SUB(UTC_TIMESTAMP(), INTERVAL ' . + (int) $days . + ' DAY)'; + $params = []; + if ($excluded) { + $placeholders = implode(',', array_fill(0, count($excluded), '?')); + $query .= " and id not in ($placeholders)"; + $params = array_map('strval', $excluded); + } + $query .= ' order by active_changed_at asc limit ?'; + $params[] = (string) $limit; + $rows = DB::select($query, ...$params); + if (!is_array($rows)) { + return []; + } + return self::extractIdList($rows); + } + + public static function setInactiveByIds(array $userIds, ?int $changedBy = null): int + { + $ids = array_values(array_unique(array_filter(array_map('intval', $userIds), static fn ($id) => $id > 0))); + if (!$ids) { + return 0; + } + + $placeholders = implode(',', array_fill(0, count($ids), '?')); + $query = "update users set active = 0, modified_by = ?, active_changed_at = UTC_TIMESTAMP(), active_changed_by = ? where active = 1 and id in ($placeholders)"; + $params = array_merge([$changedBy, $changedBy], array_map('strval', $ids)); + $result = DB::update($query, ...$params); + return $result !== false ? (int) $result : 0; + } + + public static function deleteByIds(array $userIds): int + { + $ids = array_values(array_unique(array_filter(array_map('intval', $userIds), static fn ($id) => $id > 0))); + if (!$ids) { + return 0; + } + $placeholders = implode(',', array_fill(0, count($ids), '?')); + $result = DB::delete( + "delete from users where id in ($placeholders)", + ...array_map('strval', $ids) + ); + return $result !== false ? (int) $result : 0; + } + public static function setLocale(int $id, string $locale): bool { $result = DB::update('update users set locale = ? where id = ?', $locale, (string) $id); @@ -503,6 +738,62 @@ class UserRepository return $result !== false; } + public static function updateProfileFieldsFromSso(int $id, array $fields): bool + { + if ($id <= 0) { + return false; + } + + $existing = self::find($id); + if (!$existing) { + return false; + } + + $allowed = ['first_name', 'last_name', 'phone', 'mobile']; + $updates = []; + foreach ($allowed as $field) { + if (!array_key_exists($field, $fields)) { + continue; + } + $value = trim((string) $fields[$field]); + if ($value === '') { + continue; + } + if ((string) ($existing[$field] ?? '') === $value) { + continue; + } + $updates[$field] = $value; + } + + if (!$updates) { + return true; + } + + if (isset($updates['first_name']) || isset($updates['last_name'])) { + $displayData = [ + 'first_name' => $updates['first_name'] ?? (string) ($existing['first_name'] ?? ''), + 'last_name' => $updates['last_name'] ?? (string) ($existing['last_name'] ?? ''), + 'email' => (string) ($existing['email'] ?? ''), + ]; + $updates['display_name'] = self::buildDisplayName($displayData); + } + + $setParts = []; + $params = []; + foreach ($updates as $field => $value) { + $setParts[] = sprintf('`%s` = ?', $field); + $params[] = $value; + } + $params[] = (string) $id; + + $result = DB::update( + 'update users set ' . implode(', ', $setParts) . ' where id = ?', + ...$params + ); + + return $result !== false; + } + public static function setPassword(int $id, string $password): bool { $hash = password_hash($password, PASSWORD_DEFAULT); @@ -534,23 +825,17 @@ class UserRepository return $result !== false; } - private static function normalizeIdList($value): array + private static function extractIdList(array $rows): array { - if (is_string($value)) { - $value = array_filter(array_map('trim', explode(',', $value))); - } elseif (!is_array($value)) { - return []; - } $ids = []; - foreach ($value as $item) { - if ($item === '' || $item === null) { - continue; + foreach ($rows as $row) { + $data = $row['users'] ?? $row; + $id = (int) ($data['id'] ?? $row['id'] ?? 0); + if ($id > 0) { + $ids[] = $id; } - $ids[] = (int) $item; } - $ids = array_values(array_filter(array_unique($ids), static function ($id) { - return $id > 0; - })); - return $ids; + return array_values(array_unique($ids)); } + } diff --git a/lib/Repository/User/UserSavedFilterRepository.php b/lib/Repository/User/UserSavedFilterRepository.php new file mode 100644 index 0000000..977c7db --- /dev/null +++ b/lib/Repository/User/UserSavedFilterRepository.php @@ -0,0 +1,85 @@ + 0; + } +} diff --git a/lib/Service/Access/PermissionService.php b/lib/Service/Access/PermissionService.php index b248d20..e8c4a51 100644 --- a/lib/Service/Access/PermissionService.php +++ b/lib/Service/Access/PermissionService.php @@ -8,23 +8,28 @@ use MintyPHP\Repository\Access\PermissionRepository; class PermissionService { + private static array $apiPermissionCache = []; + public const USERS_CREATE = 'users.create'; public const USERS_DELETE = 'users.delete'; public const USERS_VIEW = 'users.view'; public const USERS_VIEW_META = 'users.view_meta'; public const USERS_VIEW_AUDIT = 'users.view_audit'; public const USERS_UPDATE = 'users.update'; + public const USERS_ACCESS_PDF = 'users.access_pdf'; public const USERS_SELF_UPDATE = 'users.self_update'; public const USERS_UPDATE_ASSIGNMENTS = 'users.update_assignments'; public const ADDRESS_BOOK_VIEW = 'address_book.view'; public const TENANTS_VIEW = 'tenants.view'; public const TENANTS_CREATE = 'tenants.create'; public const TENANTS_UPDATE = 'tenants.update'; + public const TENANTS_SSO_MANAGE = 'tenants.sso_manage'; public const TENANTS_DELETE = 'tenants.delete'; public const DEPARTMENTS_VIEW = 'departments.view'; public const DEPARTMENTS_CREATE = 'departments.create'; public const DEPARTMENTS_UPDATE = 'departments.update'; public const DEPARTMENTS_DELETE = 'departments.delete'; + public const DEPARTMENTS_IMPORT = 'departments.import'; public const ROLES_VIEW = 'roles.view'; public const ROLES_CREATE = 'roles.create'; public const ROLES_UPDATE = 'roles.update'; @@ -35,8 +40,23 @@ class PermissionService public const PERMISSIONS_DELETE = 'permissions.delete'; public const SETTINGS_VIEW = 'settings.view'; public const SETTINGS_UPDATE = 'settings.update'; + public const IMPORTS_VIEW = 'imports.view'; + public const IMPORTS_AUDIT_VIEW = 'imports.audit.view'; + public const JOBS_VIEW = 'jobs.view'; + public const JOBS_MANAGE = 'jobs.manage'; + public const JOBS_RUN_NOW = 'jobs.run_now'; + public const USER_LIFECYCLE_AUDIT_VIEW = 'user_lifecycle_audit.view'; + public const USERS_IMPORT = 'users.import'; + public const USERS_IMPORT_ASSIGNMENTS = 'users.import_assignments'; + public const USERS_LIFECYCLE_RESTORE = 'users.lifecycle_restore'; + public const CUSTOM_FIELDS_MANAGE = 'custom_fields.manage'; + public const CUSTOM_FIELDS_EDIT_VALUES = 'custom_fields.edit_values'; + public const API_DOCS_VIEW = 'api_docs.view'; + public const DOCS_VIEW = 'docs.view'; public const MAIL_LOG_VIEW = 'mail_log.view'; + public const API_AUDIT_VIEW = 'api_audit.view'; public const STATS_VIEW = 'stats.view'; + public const API_TOKENS_MANAGE = 'api_tokens.manage'; public static function userHas(int $userId, string $permissionKey): bool { @@ -53,6 +73,18 @@ class PermissionService if ($userId <= 0) { return []; } + + if (self::isApiRequest()) { + if (!$refresh && isset(self::$apiPermissionCache[$userId])) { + return self::$apiPermissionCache[$userId]; + } + + $roleIds = UserRoleRepository::listRoleIdsByUserId($userId); + $keys = RolePermissionRepository::listPermissionKeysByRoleIds($roleIds); + self::$apiPermissionCache[$userId] = $keys; + return $keys; + } + $cache = $_SESSION['permissions'] ?? null; if ($refresh) { $cache = null; @@ -77,6 +109,11 @@ class PermissionService if ($userId <= 0) { return []; } + + if (self::isApiRequest()) { + return self::$apiPermissionCache[$userId] ?? []; + } + $cache = $_SESSION['permissions'] ?? null; if (is_array($cache) && (int) ($cache['user_id'] ?? 0) === $userId) { $keys = $cache['keys'] ?? []; @@ -87,12 +124,22 @@ class PermissionService public static function clearUserCache(int $userId): void { + if (self::isApiRequest()) { + unset(self::$apiPermissionCache[$userId]); + return; + } + $cache = $_SESSION['permissions'] ?? null; if (is_array($cache) && (int) ($cache['user_id'] ?? 0) === $userId) { unset($_SESSION['permissions']); } } + private static function isApiRequest(): bool + { + return defined('MINTY_API_REQUEST'); + } + public static function list(): array { return PermissionRepository::list(); diff --git a/lib/Service/Access/RoleService.php b/lib/Service/Access/RoleService.php index 02caac6..c4c453a 100644 --- a/lib/Service/Access/RoleService.php +++ b/lib/Service/Access/RoleService.php @@ -54,7 +54,7 @@ class RoleService $createdRole = RoleRepository::find((int) $createdId); $uuid = $createdRole['uuid'] ?? null; - if (!empty($input['is_default']) && $createdId) { + if (!empty($input['is_default'])) { SettingService::setDefaultRoleId((int) $createdId); } return ['ok' => true, 'form' => $form, 'uuid' => $uuid, 'id' => (int) $createdId]; diff --git a/lib/Service/Audit/ApiAuditService.php b/lib/Service/Audit/ApiAuditService.php new file mode 100644 index 0000000..813282d --- /dev/null +++ b/lib/Service/Audit/ApiAuditService.php @@ -0,0 +1,220 @@ + 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'); + } + +} diff --git a/lib/Service/Audit/ImportAuditService.php b/lib/Service/Audit/ImportAuditService.php new file mode 100644 index 0000000..d16aaac --- /dev/null +++ b/lib/Service/Audit/ImportAuditService.php @@ -0,0 +1,202 @@ + + */ + 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 $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; + } +} diff --git a/lib/Service/Audit/UserLifecycleAuditService.php b/lib/Service/Audit/UserLifecycleAuditService.php new file mode 100644 index 0000000..3f196b7 --- /dev/null +++ b/lib/Service/Audit/UserLifecycleAuditService.php @@ -0,0 +1,251 @@ + 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; + } +} + diff --git a/lib/Service/Auth/ApiTokenService.php b/lib/Service/Auth/ApiTokenService.php new file mode 100644 index 0000000..11200d9 --- /dev/null +++ b/lib/Service/Auth/ApiTokenService.php @@ -0,0 +1,300 @@ + 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); + } +} diff --git a/lib/Service/Auth/AuthService.php b/lib/Service/Auth/AuthService.php index 31634d3..45144e1 100644 --- a/lib/Service/Auth/AuthService.php +++ b/lib/Service/Auth/AuthService.php @@ -3,12 +3,15 @@ namespace MintyPHP\Service\Auth; use MintyPHP\Auth; +use MintyPHP\Session; use MintyPHP\Router; use MintyPHP\Support\Flash; use MintyPHP\Service\User\UserService; +use MintyPHP\Service\User\UserSavedFilterService; use MintyPHP\Service\Access\PermissionService; use MintyPHP\Service\Auth\RememberMeService; use MintyPHP\Service\Auth\EmailVerificationService; +use MintyPHP\Service\Auth\TenantSsoService; use MintyPHP\Repository\User\UserRepository; class AuthService @@ -49,6 +52,14 @@ class AuthService } $userId = (int) ($_SESSION['user']['id'] ?? 0); + if ($userId > 0 && !self::isLocalPasswordLoginAllowedForUser($userId)) { + Auth::logout(); + return [ + 'ok' => false, + 'message' => 'Password login is disabled for all assigned tenants', + 'flash_key' => 'login_password_disabled_all_tenants', + ]; + } if ($userId > 0) { PermissionService::getUserPermissions($userId, true); self::loadTenantDataIntoSession($userId); @@ -60,12 +71,86 @@ class AuthService 'flash_key' => 'login_no_active_tenant', ]; } - UserRepository::updateLastLogin($userId); + UserRepository::updateLastLogin($userId, 'local'); } return ['ok' => true]; } + private static function isLocalPasswordLoginAllowedForUser(int $userId): bool + { + if ($userId <= 0) { + return false; + } + + $availableTenants = UserService::getAvailableTenants($userId); + if (!$availableTenants) { + return true; + } + + foreach ($availableTenants as $tenant) { + $tenantId = (int) ($tenant['id'] ?? 0); + if ($tenantId <= 0) { + continue; + } + if (TenantSsoService::isLocalPasswordLoginAllowed($tenantId)) { + return true; + } + } + + return false; + } + + public static function canLoginToTenant(int $userId, int $tenantId): bool + { + if ($userId <= 0 || $tenantId <= 0) { + return false; + } + + foreach (UserService::getAvailableTenants($userId) as $tenant) { + if ((int) ($tenant['id'] ?? 0) === $tenantId) { + return true; + } + } + + return false; + } + + public static function loginUserById(int $userId, ?int $preferredTenantId = null, string $loginProvider = 'local'): array + { + if ($userId <= 0) { + return ['ok' => false, 'error' => 'user_not_found']; + } + + $user = UserRepository::find($userId); + if (!$user) { + return ['ok' => false, 'error' => 'user_not_found']; + } + if (!((int) ($user['active'] ?? 1) === 1)) { + return ['ok' => false, 'error' => 'user_inactive']; + } + + Session::regenerate(); + $_SESSION['user'] = $user; + + if ($preferredTenantId !== null && $preferredTenantId > 0) { + $setTenant = UserService::setCurrentTenant($userId, $preferredTenantId); + if ($setTenant['ok'] ?? false) { + $_SESSION['user']['current_tenant_id'] = $preferredTenantId; + } + } + + PermissionService::getUserPermissions($userId, true); + self::loadTenantDataIntoSession($userId); + if (!empty($_SESSION['no_active_tenant'])) { + Auth::logout(); + return ['ok' => false, 'error' => 'login_no_active_tenant']; + } + + UserRepository::updateLastLogin($userId, $loginProvider); + return ['ok' => true, 'user' => $user]; + } + public static function loginAndRedirect( string $email, string $password, @@ -130,6 +215,69 @@ class AuthService Router::redirect($target); } + public static function refreshSessionAuthState(int $userId): array + { + if ($userId <= 0) { + return [ + 'ok' => false, + 'logout_required' => true, + 'reason' => 'user_missing', + ]; + } + + $snapshot = UserRepository::findAuthzSnapshot($userId); + if (!$snapshot) { + return [ + 'ok' => false, + 'logout_required' => true, + 'reason' => 'user_not_found', + ]; + } + + if ((int) ($snapshot['active'] ?? 0) !== 1) { + return [ + 'ok' => false, + 'logout_required' => true, + 'reason' => 'inactive', + ]; + } + + $sessionVersion = (int) ($_SESSION['user']['authz_version'] ?? 0); + $dbVersion = max(1, (int) ($snapshot['authz_version'] ?? 1)); + $refreshed = false; + + if ($sessionVersion !== $dbVersion) { + $user = UserRepository::find($userId); + if (!$user || (int) ($user['active'] ?? 0) !== 1) { + return [ + 'ok' => false, + 'logout_required' => true, + 'reason' => 'inactive', + ]; + } + + $_SESSION['user'] = $user; + PermissionService::getUserPermissions($userId, true); + self::loadTenantDataIntoSession($userId); + $refreshed = true; + } + + if (!empty($_SESSION['no_active_tenant'])) { + return [ + 'ok' => false, + 'logout_required' => true, + 'reason' => 'no_active_tenant', + 'refreshed' => $refreshed, + ]; + } + + return [ + 'ok' => true, + 'logout_required' => false, + 'refreshed' => $refreshed, + ]; + } + public static function loadTenantDataIntoSession(int $userId): void { if ($userId <= 0) { @@ -140,11 +288,9 @@ class AuthService $availableTenants = UserService::getAvailableTenants($userId); $_SESSION['available_tenants'] = $availableTenants; $_SESSION['available_departments_by_tenant'] = UserService::getAvailableDepartmentsByTenant($userId); + $_SESSION['address_book_saved_filters'] = UserSavedFilterService::listForAddressBook($userId); - $hasTenantAdminAccess = PermissionService::userHas($userId, PermissionService::TENANTS_UPDATE) - || PermissionService::userHas($userId, PermissionService::SETTINGS_UPDATE); - - if (!$availableTenants && !$hasTenantAdminAccess) { + if (!$availableTenants) { $_SESSION['no_active_tenant'] = true; unset($_SESSION['current_tenant']); return; @@ -153,7 +299,7 @@ class AuthService // Load current tenant (with fallback to first available) $currentTenant = UserService::getCurrentTenant($userId); - if (!$currentTenant && !empty($availableTenants)) { + if (!$currentTenant) { // Fallback: use first available tenant $currentTenant = $availableTenants[0]; } diff --git a/lib/Service/Auth/EmailVerificationService.php b/lib/Service/Auth/EmailVerificationService.php index d00a0a2..46d0b59 100644 --- a/lib/Service/Auth/EmailVerificationService.php +++ b/lib/Service/Auth/EmailVerificationService.php @@ -6,6 +6,7 @@ use MintyPHP\Repository\Auth\EmailVerificationRepository; use MintyPHP\Repository\User\UserRepository; use MintyPHP\I18n; use MintyPHP\Http\Request; +use MintyPHP\Service\Mail\MailService; class EmailVerificationService { diff --git a/lib/Service/Auth/MicrosoftOidcService.php b/lib/Service/Auth/MicrosoftOidcService.php new file mode 100644 index 0000000..c1aa583 --- /dev/null +++ b/lib/Service/Auth/MicrosoftOidcService.php @@ -0,0 +1,540 @@ + 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; + } +} diff --git a/lib/Service/Auth/PasswordResetService.php b/lib/Service/Auth/PasswordResetService.php index 83f6d00..7f28bae 100644 --- a/lib/Service/Auth/PasswordResetService.php +++ b/lib/Service/Auth/PasswordResetService.php @@ -7,6 +7,8 @@ use MintyPHP\Repository\User\UserRepository; use MintyPHP\I18n; use MintyPHP\Http\Request; use MintyPHP\Service\Auth\RememberMeService; +use MintyPHP\Service\Mail\MailService; +use MintyPHP\Service\User\UserService; class PasswordResetService { diff --git a/lib/Service/Auth/RememberMeService.php b/lib/Service/Auth/RememberMeService.php index 04b2bb5..797a368 100644 --- a/lib/Service/Auth/RememberMeService.php +++ b/lib/Service/Auth/RememberMeService.php @@ -79,13 +79,16 @@ class RememberMeService if (!empty($user['locale'])) { I18n::$locale = (string) $user['locale']; } - $userId = (int) ($user['id'] ?? 0); + $userId = (int) $user['id']; if ($userId > 0) { PermissionService::getUserPermissions($userId, true); AuthService::loadTenantDataIntoSession($userId); - if (empty($_SESSION['no_active_tenant'])) { - UserRepository::updateLastLogin($userId); + if (!empty($_SESSION['no_active_tenant'])) { + // Enforce the same tenant policy as interactive login. + AuthService::logout(); + return false; } + UserRepository::updateLastLogin($userId, 'local'); } $newToken = bin2hex(random_bytes(32)); diff --git a/lib/Service/Auth/SsoUserLinkService.php b/lib/Service/Auth/SsoUserLinkService.php new file mode 100644 index 0000000..f15154c --- /dev/null +++ b/lib/Service/Auth/SsoUserLinkService.php @@ -0,0 +1,262 @@ + false, 'error' => 'identity_invalid']; + } + + $identity = UserExternalIdentityRepository::findByProviderTidOid( + TenantSsoService::PROVIDER_MICROSOFT, + $tid, + $oid + ); + if (!$identity) { + $identity = UserExternalIdentityRepository::findByProviderIssuerSubject( + TenantSsoService::PROVIDER_MICROSOFT, + $issuer, + $subject + ); + } + + if ($identity) { + $userId = (int) ($identity['user_id'] ?? 0); + $user = UserRepository::find($userId); + if (!$user || empty($user['active'])) { + return ['ok' => false, 'error' => 'user_inactive']; + } + $tenantIds = array_values(array_map('intval', UserTenantRepository::listTenantIdsByUserId($userId))); + if (!in_array($tenantId, $tenantIds, true)) { + return ['ok' => false, 'error' => 'tenant_not_assigned']; + } + self::syncProfileFromMicrosoft($userId, $claims); + return ['ok' => true, 'user_id' => $userId, 'created' => false]; + } + + if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { + return ['ok' => false, 'error' => 'email_missing']; + } + + $user = UserRepository::findByEmail($email); + if ($user) { + $userId = (int) ($user['id'] ?? 0); + if ($userId <= 0 || empty($user['active'])) { + return ['ok' => false, 'error' => 'user_inactive']; + } + + self::ensureTenantAssignment($userId, $tenantId); + self::createIdentityLink($userId, $tid, $oid, $issuer, $subject, $email); + self::syncProfileFromMicrosoft($userId, $claims); + return ['ok' => true, 'user_id' => $userId, 'created' => false]; + } + + $firstName = trim((string) ($claims['given_name'] ?? '')); + $lastName = trim((string) ($claims['family_name'] ?? '')); + $fullName = trim((string) ($claims['name'] ?? '')); + if ($firstName === '' && $lastName === '' && $fullName !== '') { + $parts = preg_split('/\s+/', $fullName) ?: []; + $firstName = (string) ($parts[0] ?? ''); + $lastName = implode(' ', array_slice($parts, 1)); + } + if ($firstName === '') { + $firstName = ucfirst(explode('@', $email, 2)[0]); + } + + $createdId = UserRepository::create([ + 'first_name' => $firstName, + 'last_name' => $lastName, + 'email' => $email, + 'password' => self::randomPassword(), + 'locale' => self::normalizeLocale((string) ($claims['preferred_language'] ?? '')), + 'totp_secret' => '', + 'theme' => self::resolveInitialTheme(SettingService::getAppTheme()), + 'primary_tenant_id' => $tenantId, + 'current_tenant_id' => $tenantId, + 'active' => 1, + 'created_by' => null, + 'active_changed_at' => gmdate('Y-m-d H:i:s'), + 'active_changed_by' => null, + ]); + + if (!$createdId) { + return ['ok' => false, 'error' => 'user_create_failed']; + } + + $userId = (int) $createdId; + UserRepository::setEmailVerified($userId); + UserService::syncTenants($userId, [$tenantId]); + $defaultRoleId = SettingService::getDefaultRoleId(); + if ($defaultRoleId) { + UserService::syncRoles($userId, [$defaultRoleId]); + } + $defaultDepartmentId = SettingService::getDefaultDepartmentId(); + if ($defaultDepartmentId) { + UserService::syncDepartments($userId, [$defaultDepartmentId]); + } + + self::createIdentityLink($userId, $tid, $oid, $issuer, $subject, $email); + self::syncProfileFromMicrosoft($userId, $claims); + return ['ok' => true, 'user_id' => $userId, 'created' => true]; + } + + private static function syncProfileFromMicrosoft(int $userId, array $claims): void + { + if ($userId <= 0 || empty($claims['sync_profile_on_login'])) { + return; + } + + $syncFields = TenantSsoService::normalizeProfileSyncFields($claims['sync_profile_fields'] ?? []); + if (!$syncFields) { + $syncFields = TenantSsoService::defaultProfileSyncFields(); + } + if (!$syncFields) { + return; + } + $syncAvatar = in_array('avatar', $syncFields, true); + + $mappedValues = [ + 'first_name' => trim((string) ($claims['given_name'] ?? '')) !== '' + ? trim((string) ($claims['given_name'] ?? '')) + : trim((string) ($claims['graph_given_name'] ?? '')), + 'last_name' => trim((string) ($claims['family_name'] ?? '')) !== '' + ? trim((string) ($claims['family_name'] ?? '')) + : trim((string) ($claims['graph_family_name'] ?? '')), + 'phone' => trim((string) ($claims['graph_phone'] ?? '')), + 'mobile' => trim((string) ($claims['graph_mobile'] ?? '')), + ]; + + $updates = []; + foreach ($syncFields as $field) { + if ($field === 'avatar') { + continue; + } + $value = trim((string) ($mappedValues[$field] ?? '')); + if ($value === '') { + continue; + } + $updates[$field] = $value; + } + if (!$updates) { + if ($syncAvatar) { + self::syncAvatarFromMicrosoft($userId, $claims); + } + return; + } + + UserRepository::updateProfileFieldsFromSso($userId, $updates); + if ($syncAvatar) { + self::syncAvatarFromMicrosoft($userId, $claims); + } + } + + private static function syncAvatarFromMicrosoft(int $userId, array $claims): void + { + $avatarBase64 = trim((string) ($claims['graph_avatar_data_base64'] ?? '')); + if ($avatarBase64 === '') { + return; + } + + $avatarMime = strtolower(trim((string) ($claims['graph_avatar_mime'] ?? ''))); + if (!in_array($avatarMime, ['image/jpeg', 'image/png', 'image/webp'], true)) { + return; + } + + $binary = base64_decode($avatarBase64, true); + if (!is_string($binary) || $binary === '') { + return; + } + + $user = UserRepository::find($userId); + if (!$user) { + return; + } + $userUuid = (string) ($user['uuid'] ?? ''); + if (!UserAvatarService::isValidUuid($userUuid)) { + return; + } + + // Keep avatar current with Microsoft profile photo on each login. + UserAvatarService::saveBinary($userUuid, $binary, $avatarMime); + } + + private static function ensureTenantAssignment(int $userId, int $tenantId): void + { + $tenantIds = array_values(array_unique(array_map('intval', UserTenantRepository::listTenantIdsByUserId($userId)))); + if (!in_array($tenantId, $tenantIds, true)) { + $tenantIds[] = $tenantId; + UserService::syncTenants($userId, $tenantIds); + } + } + + private static function createIdentityLink( + int $userId, + string $tid, + string $oid, + string $issuer, + string $subject, + string $email + ): void { + try { + UserExternalIdentityRepository::createLink([ + 'user_id' => $userId, + 'provider' => TenantSsoService::PROVIDER_MICROSOFT, + 'oid' => $oid, + 'tid' => $tid, + 'issuer' => $issuer, + 'subject' => $subject, + 'email_at_link_time' => $email, + ]); + } catch (\Throwable $exception) { + // Ignore duplicate-link race conditions. + } + } + + private static function normalizeLocale(string $locale): ?string + { + $locale = strtolower(trim($locale)); + if ($locale === '') { + return null; + } + if (str_contains($locale, '-')) { + $locale = explode('-', $locale, 2)[0]; + } + $allowed = defined('APP_LOCALES') ? APP_LOCALES : []; + if ($allowed && !in_array($locale, $allowed, true)) { + return null; + } + return $locale; + } + + private static function randomPassword(): string + { + return 'sso-' . bin2hex(random_bytes(24)) . '-A1!'; + } + + private static function resolveInitialTheme(?string $theme): string + { + $theme = strtolower(trim((string) ($theme ?? ''))); + $themes = function_exists('appThemes') ? appThemes() : ['light' => 'Light']; + if ($theme !== '' && isset($themes[$theme])) { + return $theme; + } + return isset($themes['light']) ? 'light' : (string) array_key_first($themes); + } +} diff --git a/lib/Service/Auth/TenantSsoService.php b/lib/Service/Auth/TenantSsoService.php new file mode 100644 index 0000000..e6c7d93 --- /dev/null +++ b/lib/Service/Auth/TenantSsoService.php @@ -0,0 +1,485 @@ + 'Sync first name', + 'last_name' => 'Sync last name', + 'phone' => 'Sync phone', + 'mobile' => 'Sync mobile', + 'avatar' => 'Sync avatar image', + ]; + private const DEFAULT_PROFILE_SYNC_FIELDS = ['first_name', 'last_name']; + + public static function findTenantByLoginSlug(string $slug): ?array + { + $slug = trim(strtolower($slug)); + if ($slug === '') { + return null; + } + + if (preg_match('/^[a-f0-9-]{36}$/', $slug)) { + $tenant = TenantRepository::findByUuid($slug); + if ($tenant && (string) ($tenant['status'] ?? 'active') === 'active') { + return $tenant; + } + } + + $matches = []; + foreach (TenantRepository::list() as $tenant) { + if ((string) ($tenant['status'] ?? 'active') !== 'active') { + continue; + } + if (self::slugify((string) ($tenant['description'] ?? '')) === $slug) { + $matches[] = $tenant; + } + } + + if (count($matches) !== 1) { + return null; + } + + return $matches[0]; + } + + public static function tenantLoginSlug(array $tenant): string + { + $slug = self::slugify((string) ($tenant['description'] ?? '')); + if ($slug !== '') { + return $slug; + } + return strtolower((string) ($tenant['uuid'] ?? '')); + } + + public static function getTenantMicrosoftAuth(int $tenantId): array + { + $row = TenantMicrosoftAuthRepository::findByTenantId($tenantId) ?? []; + $syncProfileOnLogin = !empty($row['sync_profile_on_login']); + $syncFields = self::normalizeProfileSyncFields($row['sync_profile_fields'] ?? []); + if ($syncProfileOnLogin && !$syncFields) { + $syncFields = self::defaultProfileSyncFields(); + } + + return [ + 'enabled' => !empty($row['enabled']), + 'enforce_microsoft_login' => !empty($row['enforce_microsoft_login']), + 'sync_profile_on_login' => $syncProfileOnLogin, + 'sync_profile_fields' => self::profileSyncFieldsCsv($syncFields), + 'sync_profile_fields_list' => $syncFields, + 'entra_tenant_id' => strtolower(trim((string) ($row['entra_tenant_id'] ?? ''))), + 'allowed_domains' => self::normalizeDomains((string) ($row['allowed_domains'] ?? '')), + 'use_shared_app' => !array_key_exists('use_shared_app', $row) || !empty($row['use_shared_app']), + 'client_id_override' => trim((string) ($row['client_id_override'] ?? '')), + 'client_secret_override_enc' => trim((string) ($row['client_secret_override_enc'] ?? '')), + ]; + } + + public static function buildMicrosoftUiState(int $tenantId, array $input = []): array + { + $existing = self::getTenantMicrosoftAuth($tenantId); + $state = self::normalizeMicrosoftInputState($input, $existing); + $configState = self::evaluateMicrosoftConfigState($state); + + return [ + 'enabled' => $state['enabled'], + 'config_complete' => !$state['enabled'] || $configState['complete'], + 'config_error_code' => (string) ($configState['error_code'] ?? ''), + 'config_error_label' => self::microsoftConfigErrorLabel((string) ($configState['error_code'] ?? '')), + 'password_mode' => $state['enabled'] && $state['enforce_microsoft_login'] + ? 'microsoft_only' + : 'local_and_microsoft', + 'credential_source' => $state['use_shared_app'] ? 'shared' : 'override', + 'sync_needs_graph' => $state['sync_profile_on_login'] + && self::profileSyncFieldsRequireGraph($state['sync_profile_fields']), + ]; + } + + public static function isLocalPasswordLoginAllowed(int $tenantId): bool + { + $auth = self::getTenantMicrosoftAuth($tenantId); + if (!$auth['enabled']) { + return true; + } + return !$auth['enforce_microsoft_login']; + } + + public static function resolveTenantLoginMethods(int $tenantId): array + { + $tenant = TenantRepository::find($tenantId); + if (!$tenant || (string) ($tenant['status'] ?? 'active') !== 'active') { + return [ + 'local' => false, + 'microsoft' => false, + 'microsoft_reason' => 'tenant_not_found', + ]; + } + + $configResult = self::getEffectiveMicrosoftProviderConfig($tenant); + return [ + 'local' => self::isLocalPasswordLoginAllowed($tenantId), + 'microsoft' => !empty($configResult['ok']), + 'microsoft_reason' => $configResult['ok'] ?? false + ? '' + : (string) ($configResult['error'] ?? 'sso_disabled'), + ]; + } + + public static function getEffectiveMicrosoftProviderConfig(array $tenant): array + { + $tenantId = (int) ($tenant['id'] ?? 0); + if ($tenantId <= 0) { + return ['ok' => false, 'error' => 'tenant_not_found']; + } + + $auth = self::getTenantMicrosoftAuth($tenantId); + if (!$auth['enabled']) { + return ['ok' => false, 'error' => 'sso_disabled']; + } + + $entraTenantId = $auth['entra_tenant_id']; + if (!preg_match('/^[a-f0-9-]{36}$/', $entraTenantId)) { + return ['ok' => false, 'error' => 'tenant_id_invalid']; + } + + $useSharedApp = $auth['use_shared_app']; + $clientId = ''; + $clientSecret = ''; + if ($useSharedApp) { + $clientId = (string) (SettingService::getMicrosoftSharedClientId() ?? ''); + $clientSecret = (string) (SettingService::getMicrosoftSharedClientSecret() ?? ''); + if (trim($clientId) === '' || trim($clientSecret) === '') { + return ['ok' => false, 'error' => 'shared_credentials_missing']; + } + } else { + $clientId = (string) $auth['client_id_override']; + if (trim($clientId) === '') { + return ['ok' => false, 'error' => 'client_id_override_required']; + } + if ($auth['client_secret_override_enc'] === '') { + return ['ok' => false, 'error' => 'client_secret_override_required']; + } + if ($auth['client_secret_override_enc'] !== '') { + try { + $clientSecret = Crypto::decryptString($auth['client_secret_override_enc']); + } catch (\Throwable $exception) { + return ['ok' => false, 'error' => 'secret_invalid']; + } + } + if (trim($clientSecret) === '') { + return ['ok' => false, 'error' => 'client_secret_override_required']; + } + } + + $clientId = trim($clientId); + $clientSecret = trim($clientSecret); + if ($clientId === '' || $clientSecret === '') { + return ['ok' => false, 'error' => 'client_credentials_missing']; + } + + return [ + 'ok' => true, + 'config' => [ + 'provider' => self::PROVIDER_MICROSOFT, + 'tenant_id' => $entraTenantId, + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + 'allowed_domains' => $auth['allowed_domains'], + 'enforce_microsoft_login' => $auth['enforce_microsoft_login'], + 'sync_profile_on_login' => $auth['sync_profile_on_login'], + 'sync_profile_fields' => $auth['sync_profile_fields_list'], + 'sync_profile_needs_graph' => $auth['sync_profile_on_login'] + && self::profileSyncFieldsRequireGraph($auth['sync_profile_fields_list']), + 'authority' => SettingService::getMicrosoftAuthority(), + 'use_shared_app' => $useSharedApp, + ], + ]; + } + + public static function saveTenantMicrosoftAuth(int $tenantId, array $input): array + { + $tenant = TenantRepository::find($tenantId); + if (!$tenant) { + return ['ok' => false, 'errors' => [t('Tenant not found')]]; + } + + $enabled = !empty($input['microsoft_enabled']); + $enforce = !empty($input['enforce_microsoft_login']); + $entraTenantId = strtolower(trim((string) ($input['entra_tenant_id'] ?? ''))); + $allowedDomains = self::normalizeDomains((string) ($input['allowed_domains'] ?? '')); + $syncProfileOnLogin = !empty($input['sync_profile_on_login']); + $syncProfileFields = self::normalizeProfileSyncFields($input['sync_profile_fields'] ?? []); + if ($syncProfileOnLogin && !$syncProfileFields) { + $syncProfileFields = self::defaultProfileSyncFields(); + } + $syncProfileFieldsCsv = self::profileSyncFieldsCsv($syncProfileFields); + $useSharedApp = !empty($input['use_shared_app']); + $clientIdOverride = trim((string) ($input['client_id_override'] ?? '')); + $clientSecretOverride = trim((string) ($input['client_secret_override'] ?? '')); + $clearSecret = !empty($input['clear_client_secret_override']); + + $errors = []; + + $existing = self::getTenantMicrosoftAuth($tenantId); + $clientSecretOverrideEnc = (string) ($existing['client_secret_override_enc'] ?? ''); + if ($clearSecret) { + $clientSecretOverrideEnc = ''; + } elseif ($clientSecretOverride !== '') { + if (!Crypto::isConfigured()) { + $errors[] = t('APP_CRYPTO_KEY is missing or invalid'); + } else { + try { + $clientSecretOverrideEnc = Crypto::encryptString($clientSecretOverride); + } catch (\Throwable $exception) { + $errors[] = t('Client secret could not be encrypted'); + } + } + } + + $stateForValidation = [ + 'enabled' => $enabled, + 'enforce_microsoft_login' => $enforce, + 'sync_profile_on_login' => $syncProfileOnLogin, + 'sync_profile_fields' => $syncProfileFields, + 'entra_tenant_id' => $entraTenantId, + 'use_shared_app' => $useSharedApp, + 'client_id_override' => $clientIdOverride, + 'client_secret_override_enc' => $clientSecretOverrideEnc, + ]; + $configState = self::evaluateMicrosoftConfigState($stateForValidation); + if ($enabled && !($configState['complete'] ?? false)) { + $label = self::microsoftConfigErrorLabel((string) ($configState['error_code'] ?? '')); + if ($label !== '') { + $errors[] = $label; + } + } + if ($enabled && $enforce && !($configState['complete'] ?? false)) { + $errors[] = t('Microsoft-only login requires a complete configuration'); + } + + if ($errors) { + return ['ok' => false, 'errors' => $errors]; + } + + $saved = TenantMicrosoftAuthRepository::upsertByTenantId($tenantId, [ + 'enabled' => $enabled, + 'enforce_microsoft_login' => $enabled ? $enforce : false, + 'sync_profile_on_login' => $enabled ? $syncProfileOnLogin : false, + 'sync_profile_fields' => $syncProfileFieldsCsv !== '' ? $syncProfileFieldsCsv : null, + 'entra_tenant_id' => $enabled ? $entraTenantId : null, + 'allowed_domains' => $allowedDomains === '' ? null : $allowedDomains, + 'use_shared_app' => $useSharedApp, + 'client_id_override' => $useSharedApp ? null : ($clientIdOverride !== '' ? $clientIdOverride : null), + 'client_secret_override_enc' => $useSharedApp ? null : ($clientSecretOverrideEnc !== '' ? $clientSecretOverrideEnc : null), + ]); + + if (!$saved) { + return ['ok' => false, 'errors' => [t('Tenant SSO settings could not be saved')]]; + } + + return ['ok' => true]; + } + + public static function isEmailDomainAllowed(string $email, array $allowedDomains): bool + { + $allowedDomains = array_values(array_filter(array_map( + static fn ($domain): string => strtolower(trim((string) $domain)), + $allowedDomains + ), static fn (string $domain): bool => $domain !== '')); + + if (!$allowedDomains) { + return true; + } + + $parts = explode('@', strtolower(trim($email))); + if (count($parts) !== 2) { + return false; + } + return in_array($parts[1], $allowedDomains, true); + } + + public static function profileSyncFieldOptions(): array + { + return self::PROFILE_SYNC_FIELD_OPTIONS; + } + + public static function defaultProfileSyncFields(): array + { + return self::DEFAULT_PROFILE_SYNC_FIELDS; + } + + public static function normalizeProfileSyncFields($raw): array + { + if (is_string($raw)) { + $raw = preg_split('/[\s,;]+/', $raw) ?: []; + } elseif (!is_array($raw)) { + return []; + } + + $allowed = array_fill_keys(array_keys(self::PROFILE_SYNC_FIELD_OPTIONS), true); + $normalized = []; + foreach ($raw as $item) { + $key = strtolower(trim((string) $item)); + if ($key === '' || !isset($allowed[$key])) { + continue; + } + $normalized[$key] = true; + } + + return array_keys($normalized); + } + + public static function profileSyncFieldsCsv(array $fields): string + { + $fields = self::normalizeProfileSyncFields($fields); + return implode(',', $fields); + } + + public static function profileSyncFieldsRequireGraph(array $fields): bool + { + $fields = self::normalizeProfileSyncFields($fields); + return in_array('phone', $fields, true) + || in_array('mobile', $fields, true) + || in_array('avatar', $fields, true); + } + + private static function normalizeDomains(string $raw): string + { + $parts = preg_split('/[,\n;\r]+/', $raw) ?: []; + $domains = []; + foreach ($parts as $part) { + $domain = strtolower(trim($part)); + $domain = preg_replace('/^@+/', '', $domain); + if ($domain === '') { + continue; + } + if (!preg_match('/^[a-z0-9.-]+\.[a-z]{2,}$/i', $domain)) { + continue; + } + $domains[] = $domain; + } + $domains = array_values(array_unique($domains)); + return implode(',', $domains); + } + + private static function normalizeMicrosoftInputState(array $input, array $existing): array + { + $enabled = array_key_exists('microsoft_enabled', $input) + ? !empty($input['microsoft_enabled']) + : !empty($existing['enabled']); + $enforce = array_key_exists('enforce_microsoft_login', $input) + ? !empty($input['enforce_microsoft_login']) + : !empty($existing['enforce_microsoft_login']); + $syncProfileOnLogin = array_key_exists('sync_profile_on_login', $input) + ? !empty($input['sync_profile_on_login']) + : !empty($existing['sync_profile_on_login']); + $syncProfileFields = array_key_exists('sync_profile_fields', $input) + ? self::normalizeProfileSyncFields($input['sync_profile_fields'] ?? []) + : self::normalizeProfileSyncFields($existing['sync_profile_fields_list'] ?? []); + if ($syncProfileOnLogin && !$syncProfileFields) { + $syncProfileFields = self::defaultProfileSyncFields(); + } + + $entraTenantId = array_key_exists('entra_tenant_id', $input) + ? strtolower(trim((string) ($input['entra_tenant_id'] ?? ''))) + : strtolower(trim((string) ($existing['entra_tenant_id'] ?? ''))); + $useSharedApp = array_key_exists('use_shared_app', $input) + ? !empty($input['use_shared_app']) + : !empty($existing['use_shared_app']); + $clientIdOverride = array_key_exists('client_id_override', $input) + ? trim((string) ($input['client_id_override'] ?? '')) + : trim((string) ($existing['client_id_override'] ?? '')); + + $clientSecretOverrideEnc = trim((string) ($existing['client_secret_override_enc'] ?? '')); + if (array_key_exists('clear_client_secret_override', $input) && !empty($input['clear_client_secret_override'])) { + $clientSecretOverrideEnc = ''; + } elseif (trim((string) ($input['client_secret_override'] ?? '')) !== '') { + // For UI status, a newly typed secret is considered present before persisting. + $clientSecretOverrideEnc = '__pending_secret__'; + } + + return [ + 'enabled' => $enabled, + 'enforce_microsoft_login' => $enforce, + 'sync_profile_on_login' => $syncProfileOnLogin, + 'sync_profile_fields' => $syncProfileFields, + 'entra_tenant_id' => $entraTenantId, + 'use_shared_app' => $useSharedApp, + 'client_id_override' => $clientIdOverride, + 'client_secret_override_enc' => $clientSecretOverrideEnc, + ]; + } + + private static function evaluateMicrosoftConfigState(array $state): array + { + if (empty($state['enabled'])) { + return ['complete' => true, 'error_code' => '']; + } + + $entraTenantId = strtolower(trim((string) ($state['entra_tenant_id'] ?? ''))); + if (!preg_match('/^[a-f0-9-]{36}$/', $entraTenantId)) { + return ['complete' => false, 'error_code' => 'tenant_id_invalid']; + } + + if (!empty($state['use_shared_app'])) { + $sharedClientId = trim((string) (SettingService::getMicrosoftSharedClientId() ?? '')); + $sharedClientSecret = trim((string) (SettingService::getMicrosoftSharedClientSecret() ?? '')); + if ($sharedClientId === '' || $sharedClientSecret === '') { + return ['complete' => false, 'error_code' => 'shared_credentials_missing']; + } + return ['complete' => true, 'error_code' => '']; + } + + $clientIdOverride = trim((string) ($state['client_id_override'] ?? '')); + if ($clientIdOverride === '') { + return ['complete' => false, 'error_code' => 'client_id_override_required']; + } + + $secretEnc = trim((string) ($state['client_secret_override_enc'] ?? '')); + if ($secretEnc === '') { + return ['complete' => false, 'error_code' => 'client_secret_override_required']; + } + if ($secretEnc !== '__pending_secret__') { + try { + $secret = trim((string) Crypto::decryptString($secretEnc)); + } catch (\Throwable $exception) { + return ['complete' => false, 'error_code' => 'secret_invalid']; + } + if ($secret === '') { + return ['complete' => false, 'error_code' => 'client_secret_override_required']; + } + } + + return ['complete' => true, 'error_code' => '']; + } + + private static function microsoftConfigErrorLabel(string $errorCode): string + { + return match ($errorCode) { + 'tenant_id_invalid' => t('Entra tenant ID is invalid'), + 'shared_credentials_missing' => t('Shared credentials missing'), + 'client_id_override_required' => t('Client ID override is required'), + 'client_secret_override_required' => t('Client secret override is required'), + 'secret_invalid' => t('Client secret override is invalid'), + 'client_credentials_missing' => t('Client credentials are missing'), + default => '', + }; + } + + private static function slugify(string $value): string + { + $value = strtolower(trim($value)); + if ($value === '') { + return ''; + } + $value = preg_replace('/[^a-z0-9]+/', '-', $value) ?? ''; + return trim($value, '-'); + } +} diff --git a/lib/Service/Branding/BrandingFaviconService.php b/lib/Service/Branding/BrandingFaviconService.php index f1c1b56..58fd499 100644 --- a/lib/Service/Branding/BrandingFaviconService.php +++ b/lib/Service/Branding/BrandingFaviconService.php @@ -2,8 +2,12 @@ namespace MintyPHP\Service\Branding; +use MintyPHP\Service\Image\ImageUploadTrait; + class BrandingFaviconService { + use ImageUploadTrait; + private const MAX_SIZE = 5242880; // 5 MB private const SIZES = [ 16 => 'favicon-16x16.png', @@ -16,10 +20,7 @@ class BrandingFaviconService public static function storageBase(): string { - if (defined('APP_STORAGE_PATH') && APP_STORAGE_PATH) { - return rtrim(APP_STORAGE_PATH, '/'); - } - return rtrim(dirname(__DIR__, 3) . '/storage', '/'); + return self::imageStorageBase(); } public static function storageDir(): string @@ -75,7 +76,7 @@ class BrandingFaviconService if ($mime !== 'image/png') { return ['ok' => false, 'error' => t('Invalid image file')]; } - if (!self::canResize()) { + if (!self::imageCanResize()) { return ['ok' => false, 'error' => t('Upload failed')]; } @@ -98,7 +99,7 @@ class BrandingFaviconService foreach (self::SIZES as $size => $fileName) { $target = $publicDir . '/' . $fileName; - if (!self::resizeSquare($originalPath, $target, $size)) { + if (!self::imageResizeSquare($originalPath, $target, $size)) { return ['ok' => false, 'error' => t('Upload failed')]; } @chmod($target, 0644); @@ -110,76 +111,7 @@ class BrandingFaviconService public static function detectMime(string $path): string { - if (function_exists('finfo_open')) { - $finfo = finfo_open(FILEINFO_MIME_TYPE); - if ($finfo) { - $mime = finfo_file($finfo, $path); - if (is_string($mime) && $mime !== '') { - return $mime; - } - } - } - return 'application/octet-stream'; - } - - private static function canResize(): bool - { - return function_exists('imagecreatetruecolor') && function_exists('imagecopyresampled'); - } - - private static function loadImage(string $path) - { - if (function_exists('imagecreatefrompng')) { - return imagecreatefrompng($path); - } - return false; - } - - private static function resizeSquare(string $sourcePath, string $targetPath, int $size): bool - { - $src = self::loadImage($sourcePath); - if (!$src) { - return false; - } - $srcWidth = imagesx($src); - $srcHeight = imagesy($src); - if ($srcWidth === 0 || $srcHeight === 0) { - imagedestroy($src); - return false; - } - - $cropSize = min($srcWidth, $srcHeight); - $srcX = (int) round(($srcWidth - $cropSize) / 2); - $srcY = (int) round(($srcHeight - $cropSize) / 2); - - $dst = imagecreatetruecolor($size, $size); - imagealphablending($dst, false); - imagesavealpha($dst, true); - $transparent = imagecolorallocatealpha($dst, 0, 0, 0, 127); - imagefilledrectangle($dst, 0, 0, $size, $size, $transparent); - - imagecopyresampled( - $dst, - $src, - 0, - 0, - $srcX, - $srcY, - $size, - $size, - $cropSize, - $cropSize - ); - - $saved = false; - if (function_exists('imagepng')) { - $saved = imagepng($dst, $targetPath, 6); - } - - imagedestroy($src); - imagedestroy($dst); - - return $saved; + return self::imageDetectMimeSimple($path); } private static function updateManifest(): void diff --git a/lib/Service/Branding/BrandingLogoService.php b/lib/Service/Branding/BrandingLogoService.php index 747c848..b885e8d 100644 --- a/lib/Service/Branding/BrandingLogoService.php +++ b/lib/Service/Branding/BrandingLogoService.php @@ -2,18 +2,19 @@ namespace MintyPHP\Service\Branding; +use MintyPHP\Service\Image\ImageUploadTrait; + class BrandingLogoService { + use ImageUploadTrait; + private const MAX_SIZE = 5242880; // 5 MB private const SIZES = [64, 128, 256]; private const DEFAULT_SIZE = 128; public static function storageBase(): string { - if (defined('APP_STORAGE_PATH') && APP_STORAGE_PATH) { - return rtrim(APP_STORAGE_PATH, '/'); - } - return rtrim(dirname(__DIR__, 3) . '/storage', '/'); + return self::imageStorageBase(); } public static function brandingDir(): string @@ -38,7 +39,7 @@ class BrandingLogoService if ($defaultVariant) { return $defaultVariant; } - $original = self::findOriginalPath($dir); + $original = self::imageFindOriginalPath($dir); return $original ?: null; } @@ -81,12 +82,12 @@ class BrandingLogoService $tmpPath = $file['tmp_name']; $mime = self::detectMime($tmpPath); - $isSvg = self::isSvgUpload($mime, $tmpPath); - $ext = self::extensionForMime($mime, $isSvg); + $isSvg = self::imageIsSvgUpload($mime, $tmpPath); + $ext = self::imageExtensionForMime($mime, $isSvg); if (!$ext) { return ['ok' => false, 'error' => t('Invalid image file')]; } - if ($isSvg && !self::isSafeSvg($tmpPath)) { + if ($isSvg && !self::imageIsSafeSvg($tmpPath)) { return ['ok' => false, 'error' => t('Invalid image file')]; } @@ -103,10 +104,10 @@ class BrandingLogoService @chmod($originalPath, 0644); $variantExt = function_exists('imagewebp') ? 'webp' : 'jpg'; - if (!$isSvg && self::canResize()) { + if (!$isSvg && self::imageCanResize()) { foreach (self::SIZES as $size) { $target = $dir . '/logo-' . $size . '.' . $variantExt; - self::resizeAndFit($originalPath, $target, $size, $size, $variantExt); + self::imageResizeAndFit($originalPath, $target, $size, $size, $variantExt); } } @@ -115,69 +116,7 @@ class BrandingLogoService public static function detectMime(string $path): string { - if (function_exists('finfo_open')) { - $finfo = finfo_open(FILEINFO_MIME_TYPE); - if ($finfo) { - $mime = finfo_file($finfo, $path); - if (is_string($mime) && $mime !== '') { - if (self::isSvgUpload($mime, $path)) { - return 'image/svg+xml'; - } - return $mime; - } - } - } - if (self::isSvgUpload('application/octet-stream', $path)) { - return 'image/svg+xml'; - } - return 'application/octet-stream'; - } - - private static function extensionForMime(string $mime, bool $isSvg = false): ?string - { - if ($isSvg) { - return 'svg'; - } - $map = [ - 'image/jpeg' => 'jpg', - 'image/png' => 'png', - 'image/webp' => 'webp', - ]; - return $map[$mime] ?? null; - } - - private static function isSvgUpload(string $mime, string $path): bool - { - if ($mime === 'image/svg+xml') { - return true; - } - $head = @file_get_contents($path, false, null, 0, 1024); - if (!is_string($head) || $head === '') { - return false; - } - return stripos($head, ' filemtime($a); - }); - return $matches[0] ?? null; - } - - private static function canResize(): bool - { - return function_exists('imagecreatetruecolor') && function_exists('imagecopyresampled'); - } - - private static function createImageResource(string $path, string $mime) - { - if ($mime === 'image/jpeg' && function_exists('imagecreatefromjpeg')) { - return imagecreatefromjpeg($path); - } - if ($mime === 'image/png' && function_exists('imagecreatefrompng')) { - return imagecreatefrompng($path); - } - if ($mime === 'image/webp' && function_exists('imagecreatefromwebp')) { - return imagecreatefromwebp($path); - } - return false; - } - - private static function resizeAndFit(string $sourcePath, string $targetPath, int $width, int $height, string $ext): bool - { - $mime = self::detectMime($sourcePath); - $src = self::createImageResource($sourcePath, $mime); - if (!$src) { - return false; - } - - $srcWidth = imagesx($src); - $srcHeight = imagesy($src); - if ($srcWidth === 0 || $srcHeight === 0) { - imagedestroy($src); - return false; - } - - $scale = min($width / $srcWidth, $height / $srcHeight); - $dstWidth = (int) round($srcWidth * $scale); - $dstHeight = (int) round($srcHeight * $scale); - if ($dstWidth < 1) $dstWidth = 1; - if ($dstHeight < 1) $dstHeight = 1; - - $dst = imagecreatetruecolor($dstWidth, $dstHeight); - if ($ext === 'png' || $ext === 'webp') { - imagealphablending($dst, false); - imagesavealpha($dst, true); - $transparent = imagecolorallocatealpha($dst, 0, 0, 0, 127); - imagefilledrectangle($dst, 0, 0, $dstWidth, $dstHeight, $transparent); - } - - imagecopyresampled( - $dst, - $src, - 0, - 0, - 0, - 0, - $dstWidth, - $dstHeight, - $srcWidth, - $srcHeight - ); - - $saved = false; - if ($ext === 'webp' && function_exists('imagewebp')) { - $saved = imagewebp($dst, $targetPath, 82); - } elseif ($ext === 'png' && function_exists('imagepng')) { - $saved = imagepng($dst, $targetPath, 6); - } else { - $saved = imagejpeg($dst, $targetPath, 85); - } - - imagedestroy($src); - imagedestroy($dst); - - if ($saved) { - @chmod($targetPath, 0644); - } - return $saved; - } } diff --git a/lib/Service/CustomField/TenantCustomFieldService.php b/lib/Service/CustomField/TenantCustomFieldService.php new file mode 100644 index 0000000..08c9095 --- /dev/null +++ b/lib/Service/CustomField/TenantCustomFieldService.php @@ -0,0 +1,320 @@ + (int) ($row['id'] ?? 0), $definitions); + $definitionIds = array_values(array_filter($definitionIds, static fn (int $id): bool => $id > 0)); + $options = TenantCustomFieldOptionRepository::listByDefinitionIds($definitionIds, false); + $optionsByDefinition = []; + foreach ($options as $option) { + $definitionId = (int) ($option['definition_id'] ?? 0); + if ($definitionId <= 0) { + continue; + } + $optionsByDefinition[$definitionId] ??= []; + $optionsByDefinition[$definitionId][] = $option; + } + + foreach ($definitions as &$definition) { + $definitionId = (int) ($definition['id'] ?? 0); + $definition['options'] = $optionsByDefinition[$definitionId] ?? []; + } + unset($definition); + + return $definitions; + } + + public static function createForTenant(int $tenantId, array $input, int $currentUserId): array + { + if ($tenantId <= 0 || !TenantRepository::find($tenantId)) { + return ['ok' => false, 'errors' => [t('Tenant not found')]]; + } + + $form = self::sanitizeForm($input); + $form['tenant_id'] = $tenantId; + $form['field_key'] = self::resolveFieldKey( + $tenantId, + (string) ($form['label'] ?? ''), + null, + (string) ($form['field_key'] ?? '') + ); + $errors = self::validateForm($form, null); + if ($errors) { + return ['ok' => false, 'errors' => $errors, 'form' => $form]; + } + + $definitionId = TenantCustomFieldDefinitionRepository::create([ + 'tenant_id' => $tenantId, + 'field_key' => $form['field_key'], + 'label' => $form['label'], + 'type' => $form['type'], + 'is_required' => $form['is_required'], + 'is_filterable' => $form['is_filterable'], + 'active' => $form['active'], + 'sort_order' => $form['sort_order'] ?? self::nextSortOrder($tenantId), + 'created_by' => $currentUserId > 0 ? $currentUserId : null, + ]); + if (!$definitionId) { + return ['ok' => false, 'errors' => [t('Custom field can not be created')], 'form' => $form]; + } + + if (in_array($form['type'], ['select', 'multiselect'], true)) { + TenantCustomFieldOptionRepository::replaceForDefinition((int) $definitionId, $form['options']); + } + + return ['ok' => true, 'id' => (int) $definitionId, 'form' => $form]; + } + + public static function updateByUuid(string $uuid, array $input, int $currentUserId): array + { + $uuid = trim($uuid); + if ($uuid === '') { + return ['ok' => false, 'errors' => [t('Custom field not found')]]; + } + + $existing = TenantCustomFieldDefinitionRepository::findByUuid($uuid); + if (!$existing) { + return ['ok' => false, 'errors' => [t('Custom field not found')]]; + } + + $form = self::sanitizeForm($input); + $form['tenant_id'] = (int) ($existing['tenant_id'] ?? 0); + $form['field_key'] = self::resolveFieldKey( + (int) ($existing['tenant_id'] ?? 0), + (string) ($form['label'] ?? ''), + (int) ($existing['id'] ?? 0), + (string) ($existing['field_key'] ?? '') + ); + $errors = self::validateForm($form, (int) ($existing['id'] ?? 0)); + if ($errors) { + return ['ok' => false, 'errors' => $errors, 'form' => $form]; + } + + $updated = TenantCustomFieldDefinitionRepository::update((int) $existing['id'], [ + 'field_key' => $form['field_key'], + 'label' => $form['label'], + 'type' => $form['type'], + 'is_required' => $form['is_required'], + 'is_filterable' => $form['is_filterable'], + 'active' => $form['active'], + 'sort_order' => $form['sort_order'] ?? (int) ($existing['sort_order'] ?? 100), + 'modified_by' => $currentUserId > 0 ? $currentUserId : null, + ]); + if (!$updated) { + return ['ok' => false, 'errors' => [t('Custom field can not be updated')], 'form' => $form]; + } + + if (in_array($form['type'], ['select', 'multiselect'], true)) { + TenantCustomFieldOptionRepository::replaceForDefinition((int) $existing['id'], $form['options']); + } else { + TenantCustomFieldOptionRepository::deleteByDefinitionId((int) $existing['id']); + } + + return ['ok' => true, 'form' => $form]; + } + + public static function deleteByUuid(string $uuid): array + { + $uuid = trim($uuid); + if ($uuid === '') { + return ['ok' => false, 'status' => 404, 'error' => 'not_found']; + } + $existing = TenantCustomFieldDefinitionRepository::findByUuid($uuid); + if (!$existing || !isset($existing['id'])) { + return ['ok' => false, 'status' => 404, 'error' => 'not_found']; + } + $deleted = TenantCustomFieldDefinitionRepository::delete((int) $existing['id']); + if (!$deleted) { + return ['ok' => false, 'status' => 500, 'error' => 'delete_failed']; + } + return ['ok' => true, 'definition' => $existing]; + } + + public static function definitionTenantIdByUuid(string $uuid): int + { + $definition = TenantCustomFieldDefinitionRepository::findByUuid(trim($uuid)); + return (int) ($definition['tenant_id'] ?? 0); + } + + private static function sanitizeForm(array $input): array + { + $label = trim((string) ($input['label'] ?? '')); + $fieldKeyInput = trim((string) ($input['field_key'] ?? '')); + $type = strtolower(trim((string) ($input['type'] ?? 'text'))); + $sortOrderRaw = $input['sort_order'] ?? null; + $sortOrder = is_numeric($sortOrderRaw) ? (int) $sortOrderRaw : null; + + return [ + 'label' => $label, + 'field_key' => self::normalizeKey($fieldKeyInput), + 'type' => $type, + // User custom fields are always optional in V1. + 'is_required' => 0, + 'is_filterable' => !empty($input['is_filterable']) ? 1 : 0, + 'active' => array_key_exists('active', $input) ? (int) (!empty($input['active'])) : 1, + 'sort_order' => $sortOrder !== null && $sortOrder > 0 ? $sortOrder : null, + 'options' => self::parseOptionsLines((string) ($input['options_text'] ?? '')), + 'options_text' => (string) ($input['options_text'] ?? ''), + ]; + } + + private static function validateForm(array &$form, ?int $excludeId): array + { + $errors = []; + $tenantId = (int) ($form['tenant_id'] ?? 0); + $type = (string) ($form['type'] ?? ''); + $fieldKey = (string) ($form['field_key'] ?? ''); + + if ($tenantId <= 0) { + $errors[] = t('Tenant not found'); + } + if ($form['label'] === '') { + $errors[] = t('Label cannot be empty'); + } + if ($fieldKey === '') { + $errors[] = t('Field key cannot be empty'); + } + if (!in_array($type, self::ALLOWED_TYPES, true)) { + $errors[] = t('Field type is invalid'); + } + if ($fieldKey !== '') { + $existingByKey = TenantCustomFieldDefinitionRepository::findByTenantIdAndKey($tenantId, $fieldKey); + if ($existingByKey && (int) ($existingByKey['id'] ?? 0) !== (int) ($excludeId ?? 0)) { + $errors[] = t('Field key already exists'); + } + } + + if (in_array($type, ['select', 'multiselect'], true)) { + if (!$form['options']) { + $errors[] = t('Options are required for this field type'); + } + } else { + $form['is_filterable'] = in_array($type, ['boolean', 'date'], true) + ? (int) ($form['is_filterable'] ?? 0) + : 0; + $form['options'] = []; + } + + if (!in_array($type, ['select', 'multiselect', 'boolean', 'date'], true)) { + $form['is_filterable'] = 0; + } + + return $errors; + } + + private static function parseOptionsLines(string $value): array + { + $lines = preg_split('/\r\n|\r|\n/', $value); + if (!is_array($lines)) { + return []; + } + $options = []; + $sortOrder = 10; + foreach ($lines as $line) { + $line = trim((string) $line); + if ($line === '') { + continue; + } + $parts = explode('|', $line, 2); + $label = trim((string) ($parts[1] ?? $parts[0])); + $keyInput = trim((string) ($parts[1] ?? '') !== '' ? $parts[0] : $label); + $optionKey = self::normalizeKey($keyInput); + if ($label === '' || $optionKey === '') { + continue; + } + if (isset($options[$optionKey])) { + continue; + } + $options[$optionKey] = [ + 'option_key' => $optionKey, + 'label' => $label, + 'active' => 1, + 'sort_order' => $sortOrder, + ]; + $sortOrder += 10; + } + return array_values($options); + } + + private static function normalizeKey(string $value): string + { + $value = trim($value); + if ($value === '') { + return ''; + } + if (function_exists('iconv')) { + $ascii = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value); + if (is_string($ascii) && $ascii !== '') { + $value = $ascii; + } + } + $value = strtolower($value); + $value = preg_replace('/[^a-z0-9]+/', '_', $value); + $value = trim((string) $value, '_'); + return $value; + } + + private static function nextSortOrder(int $tenantId): int + { + $max = 0; + foreach (TenantCustomFieldDefinitionRepository::listByTenantId($tenantId, false) as $definition) { + $sortOrder = (int) ($definition['sort_order'] ?? 0); + if ($sortOrder > $max) { + $max = $sortOrder; + } + } + if ($max <= 0) { + return 100; + } + return $max + 10; + } + + private static function resolveFieldKey(int $tenantId, string $label, ?int $excludeId, string $preferred): string + { + $excludeId = (int) ($excludeId ?? 0); + $source = trim($preferred) !== '' ? trim($preferred) : trim($label); + $base = self::normalizeKey($source); + if ($base === '') { + $fallbackSeed = trim($label) !== '' ? trim($label) : $source; + $base = 'field_' . substr(sha1($fallbackSeed), 0, 8); + } + if ($tenantId <= 0) { + return ''; + } + + $candidate = $base; + $counter = 2; + while (true) { + $existing = TenantCustomFieldDefinitionRepository::findByTenantIdAndKey($tenantId, $candidate); + if (!$existing || (int) ($existing['id'] ?? 0) === $excludeId) { + return $candidate; + } + + $candidate = $base . '_' . $counter; + $counter++; + if ($counter > 10000) { + return ''; + } + } + } +} diff --git a/lib/Service/CustomField/UserCustomFieldValueService.php b/lib/Service/CustomField/UserCustomFieldValueService.php new file mode 100644 index 0000000..59402fa --- /dev/null +++ b/lib/Service/CustomField/UserCustomFieldValueService.php @@ -0,0 +1,592 @@ + true, 'errors' => []]; + } + $tenantIds = self::normalizeTenantIds($tenantIds); + if (!$tenantIds) { + return ['ok' => true, 'errors' => []]; + } + $prepared = self::prepareForSync($tenantIds, $post); + if ($prepared['errors']) { + return ['ok' => false, 'errors' => $prepared['errors']]; + } + return ['ok' => true, 'errors' => []]; + } + + public static function buildDefinitionsByTenant(array $tenantIds, bool $includeInactive = false): array + { + $tenantIds = self::normalizeTenantIds($tenantIds); + if (!$tenantIds) { + return []; + } + + $definitions = TenantCustomFieldDefinitionRepository::listByTenantIds($tenantIds, !$includeInactive); + if (!$definitions) { + return []; + } + + $definitionIds = array_values(array_filter(array_map( + static fn (array $definition): int => (int) ($definition['id'] ?? 0), + $definitions + ), static fn (int $id): bool => $id > 0)); + $options = TenantCustomFieldOptionRepository::listByDefinitionIds($definitionIds, !$includeInactive); + $optionsByDefinition = []; + foreach ($options as $option) { + $definitionId = (int) ($option['definition_id'] ?? 0); + if ($definitionId <= 0) { + continue; + } + $optionsByDefinition[$definitionId] ??= []; + $optionsByDefinition[$definitionId][] = $option; + } + + $grouped = []; + foreach ($definitions as $definition) { + $tenantId = (int) ($definition['tenant_id'] ?? 0); + $definitionId = (int) ($definition['id'] ?? 0); + if ($tenantId <= 0 || $definitionId <= 0) { + continue; + } + $definition['options'] = $optionsByDefinition[$definitionId] ?? []; + $grouped[$tenantId] ??= []; + $grouped[$tenantId][] = $definition; + } + + return $grouped; + } + + public static function buildUserValueMap(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 = UserCustomFieldValueRepository::listByUserAndDefinitionIds($userId, $definitionIds); + if (!$rows) { + return []; + } + + $valueIds = array_values(array_filter(array_map( + static fn (array $row): int => (int) ($row['id'] ?? 0), + $rows + ), static fn (int $id): bool => $id > 0)); + $optionIdsByValue = UserCustomFieldValueOptionRepository::listOptionIdsByValueIds($valueIds); + + $map = []; + foreach ($rows as $row) { + $definitionId = (int) ($row['definition_id'] ?? 0); + $valueId = (int) ($row['id'] ?? 0); + if ($definitionId <= 0 || $valueId <= 0) { + continue; + } + $map[$definitionId] = [ + 'id' => $valueId, + 'value_text' => array_key_exists('value_text', $row) ? (string) ($row['value_text'] ?? '') : '', + 'value_bool' => array_key_exists('value_bool', $row) && $row['value_bool'] !== null + ? (int) $row['value_bool'] + : null, + 'value_date' => array_key_exists('value_date', $row) ? (string) ($row['value_date'] ?? '') : '', + 'option_id' => array_key_exists('option_id', $row) && $row['option_id'] !== null + ? (int) $row['option_id'] + : null, + 'option_ids' => $optionIdsByValue[$valueId] ?? [], + ]; + } + + return $map; + } + + /** + * Build API-safe custom field payload grouped by tenant. + * + * @param array> $tenantSummaries Assignment tenant rows + * from UserService::buildAssignmentsForUser(...), each with at least id/uuid/description/status. + * @return array> + */ + public static function buildPublicValuesByTenant(int $userId, array $tenantSummaries, ?int $tenantScopeId = null): array + { + if ($userId <= 0) { + return []; + } + + $tenantScopeId = (int) ($tenantScopeId ?? 0); + $orderedTenants = []; + foreach ($tenantSummaries as $tenantSummary) { + if (!is_array($tenantSummary)) { + continue; + } + $tenantId = (int) ($tenantSummary['id'] ?? 0); + if ($tenantId <= 0) { + continue; + } + if ($tenantScopeId > 0 && $tenantId !== $tenantScopeId) { + continue; + } + $orderedTenants[$tenantId] = [ + 'id' => $tenantId, + 'uuid' => (string) ($tenantSummary['uuid'] ?? ''), + 'description' => (string) ($tenantSummary['description'] ?? ''), + 'status' => (string) ($tenantSummary['status'] ?? ''), + ]; + } + + if (!$orderedTenants) { + return []; + } + + $tenantIds = array_keys($orderedTenants); + $definitionsByTenant = self::buildDefinitionsByTenant($tenantIds, false); + if (!$definitionsByTenant) { + return []; + } + + $definitionIds = []; + foreach ($tenantIds as $tenantId) { + foreach (($definitionsByTenant[$tenantId] ?? []) as $definition) { + $definitionId = (int) ($definition['id'] ?? 0); + if ($definitionId > 0) { + $definitionIds[] = $definitionId; + } + } + } + $definitionIds = array_values(array_unique($definitionIds)); + $valueMap = self::buildUserValueMap($userId, $definitionIds); + + $result = []; + foreach ($tenantIds as $tenantId) { + $definitions = $definitionsByTenant[$tenantId] ?? []; + if (!$definitions) { + continue; + } + + $fields = []; + foreach ($definitions as $definition) { + $definitionId = (int) ($definition['id'] ?? 0); + if ($definitionId <= 0) { + continue; + } + + $type = strtolower(trim((string) ($definition['type'] ?? 'text'))); + $optionsRaw = is_array($definition['options'] ?? null) ? $definition['options'] : []; + $optionsById = []; + $optionsPublic = []; + foreach ($optionsRaw as $option) { + if (!is_array($option)) { + continue; + } + $optionId = (int) ($option['id'] ?? 0); + $optionKey = trim((string) ($option['option_key'] ?? '')); + if ($optionId <= 0 || $optionKey === '') { + continue; + } + $optionsById[$optionId] = $optionKey; + $optionsPublic[] = [ + 'option_key' => $optionKey, + 'label' => (string) ($option['label'] ?? ''), + ]; + } + + $field = [ + 'uuid' => (string) ($definition['uuid'] ?? ''), + 'field_key' => (string) ($definition['field_key'] ?? ''), + 'label' => (string) ($definition['label'] ?? ''), + 'type' => $type, + 'is_filterable' => !empty($definition['is_filterable']), + 'value' => self::mapPublicValueByType($type, $valueMap[$definitionId] ?? null, $optionsById), + ]; + + if (in_array($type, ['select', 'multiselect'], true)) { + $field['options'] = $optionsPublic; + } + + $fields[] = $field; + } + + if (!$fields) { + continue; + } + + $tenant = $orderedTenants[$tenantId]; + $result[] = [ + 'tenant' => [ + 'uuid' => (string) ($tenant['uuid'] ?? ''), + 'description' => (string) ($tenant['description'] ?? ''), + 'status' => (string) ($tenant['status'] ?? ''), + ], + 'fields' => $fields, + ]; + } + + return $result; + } + + public static function syncForUser(int $userId, array $tenantIds, array $post, bool $canEdit): array + { + if ($userId <= 0) { + return ['ok' => false, 'errors' => [t('User not found')]]; + } + + $tenantIds = self::normalizeTenantIds($tenantIds); + // Always clean values that belong to tenants no longer assigned to the user. + if (!UserCustomFieldValueRepository::deleteByUserOutsideTenantIds($userId, $tenantIds)) { + return ['ok' => false, 'errors' => [t('Custom field values can not be saved')]]; + } + if (!$canEdit) { + return ['ok' => true, 'errors' => []]; + } + + if (!$tenantIds) { + return ['ok' => true, 'errors' => []]; + } + + $prepared = self::prepareForSync($tenantIds, $post); + if ($prepared['errors']) { + return ['ok' => false, 'errors' => $prepared['errors']]; + } + if (!$prepared['prepared']) { + return ['ok' => true, 'errors' => []]; + } + foreach ($prepared['prepared'] as $definitionId => $valueData) { + if (!empty($valueData['empty'])) { + if (!UserCustomFieldValueRepository::deleteByUserAndDefinitionIds($userId, [(int) $definitionId])) { + return ['ok' => false, 'errors' => [t('Custom field values can not be saved')]]; + } + continue; + } + + if (!empty($valueData['option_ids'])) { + $valueId = UserCustomFieldValueRepository::upsertScalarValue($userId, (int) $definitionId, [ + 'value_text' => null, + 'value_bool' => null, + 'value_date' => null, + 'option_id' => null, + ]); + if (!$valueId) { + return ['ok' => false, 'errors' => [t('Custom field values can not be saved')]]; + } + if (!UserCustomFieldValueOptionRepository::replaceForValueId((int) $valueId, $valueData['option_ids'])) { + return ['ok' => false, 'errors' => [t('Custom field values can not be saved')]]; + } + continue; + } + + $valueId = UserCustomFieldValueRepository::upsertScalarValue($userId, (int) $definitionId, [ + 'value_text' => $valueData['value_text'], + 'value_bool' => $valueData['value_bool'], + 'value_date' => $valueData['value_date'], + 'option_id' => $valueData['option_id'], + ]); + if (!$valueId) { + return ['ok' => false, 'errors' => [t('Custom field values can not be saved')]]; + } + if (!UserCustomFieldValueOptionRepository::replaceForValueId((int) $valueId, [])) { + return ['ok' => false, 'errors' => [t('Custom field values can not be saved')]]; + } + } + + return ['ok' => true, 'errors' => []]; + } + + private static function prepareForSync(array $tenantIds, array $post): array + { + $definitionsByTenant = self::buildDefinitionsByTenant($tenantIds, false); + $definitions = []; + foreach ($definitionsByTenant as $tenantDefinitionList) { + foreach ($tenantDefinitionList as $definition) { + $definitionId = (int) ($definition['id'] ?? 0); + if ($definitionId > 0) { + $definitions[$definitionId] = $definition; + } + } + } + if (!$definitions) { + return ['prepared' => [], 'errors' => []]; + } + + $rawValues = is_array($post['custom_field_values'] ?? null) ? $post['custom_field_values'] : []; + $rawMultiValues = is_array($post['custom_field_values_multi'] ?? null) ? $post['custom_field_values_multi'] : []; + + $errors = []; + $prepared = []; + foreach ($definitions as $definitionId => $definition) { + $type = strtolower((string) ($definition['type'] ?? '')); + $label = (string) ($definition['label'] ?? ('#' . $definitionId)); + $options = is_array($definition['options'] ?? null) ? $definition['options'] : []; + $activeOptionIds = array_values(array_filter(array_map( + static fn (array $option): int => !empty($option['active']) ? (int) ($option['id'] ?? 0) : 0, + $options + ), static fn (int $id): bool => $id > 0)); + $activeOptionMap = $activeOptionIds ? array_fill_keys($activeOptionIds, true) : []; + + $rawScalar = $rawValues[(string) $definitionId] ?? null; + $rawMulti = $rawMultiValues[(string) $definitionId] ?? []; + + $valueData = [ + 'value_text' => null, + 'value_bool' => null, + 'value_date' => null, + 'option_id' => null, + 'option_ids' => [], + 'empty' => true, + ]; + + if (in_array($type, ['text', 'textarea'], true)) { + $text = trim((string) $rawScalar); + if ($text !== '') { + $valueData['value_text'] = $text; + $valueData['empty'] = false; + } + } elseif ($type === 'boolean') { + $bool = self::normalizeBool($rawScalar); + if ($bool === null && trim((string) $rawScalar) !== '') { + $errors[] = sprintf(t('Invalid value for %s'), $label); + continue; + } + if ($bool !== null) { + $valueData['value_bool'] = $bool; + $valueData['empty'] = false; + } + } elseif ($type === 'date') { + $date = self::normalizeDate($rawScalar); + if ($date === null && trim((string) $rawScalar) !== '') { + $errors[] = sprintf(t('Invalid value for %s'), $label); + continue; + } + if ($date !== null) { + $valueData['value_date'] = $date; + $valueData['empty'] = false; + } + } elseif ($type === 'select') { + $optionId = (int) $rawScalar; + if ($optionId > 0 && !isset($activeOptionMap[$optionId])) { + $errors[] = sprintf(t('Invalid value for %s'), $label); + continue; + } + if ($optionId > 0) { + $valueData['option_id'] = $optionId; + $valueData['empty'] = false; + } + } elseif ($type === 'multiselect') { + $optionIds = RepoQuery::normalizeIdList($rawMulti); + $invalid = array_values(array_filter($optionIds, static fn (int $id): bool => !isset($activeOptionMap[$id]))); + if ($invalid) { + $errors[] = sprintf(t('Invalid value for %s'), $label); + continue; + } + if ($optionIds) { + $valueData['option_ids'] = $optionIds; + $valueData['empty'] = false; + } + } + + $prepared[$definitionId] = $valueData; + } + + return ['prepared' => $prepared, 'errors' => $errors]; + } + + public static function extractAddressBookFilterSpec(array $query, int $currentUserId): array + { + $tenantIds = TenantScopeService::getUserTenantIds($currentUserId); + $definitions = TenantCustomFieldDefinitionRepository::listFilterableByTenantIds($tenantIds); + if (!$definitions) { + return ['definitions' => [], 'filters' => [], 'query' => []]; + } + + $definitionMapByUuid = []; + $definitionIds = []; + foreach ($definitions as $definition) { + $uuid = strtolower(trim((string) ($definition['uuid'] ?? ''))); + $definitionId = (int) ($definition['id'] ?? 0); + if ($uuid === '' || $definitionId <= 0) { + continue; + } + $definitionMapByUuid[$uuid] = $definition; + $definitionIds[] = $definitionId; + } + $definitionIds = array_values(array_unique($definitionIds)); + $options = TenantCustomFieldOptionRepository::listByDefinitionIds($definitionIds, true); + $optionMapByDefinition = []; + foreach ($options as $option) { + $definitionId = (int) ($option['definition_id'] ?? 0); + $optionId = (int) ($option['id'] ?? 0); + if ($definitionId <= 0 || $optionId <= 0) { + continue; + } + $optionMapByDefinition[$definitionId] ??= []; + $optionMapByDefinition[$definitionId][$optionId] = true; + } + + $filters = [ + 'select' => [], + 'boolean' => [], + 'multiselect' => [], + 'date' => [], + ]; + $normalizedQuery = []; + foreach ($query as $rawKey => $rawValue) { + $key = strtolower(trim((string) $rawKey)); + if ($key === '') { + continue; + } + + if (preg_match('/^cf_([a-f0-9-]{36})$/', $key, $match)) { + $uuid = $match[1]; + $definition = $definitionMapByUuid[$uuid] ?? null; + if (!$definition) { + continue; + } + $definitionId = (int) ($definition['id'] ?? 0); + $type = strtolower((string) ($definition['type'] ?? '')); + if ($type === 'select') { + $optionId = (int) $rawValue; + if ($optionId > 0 && !empty($optionMapByDefinition[$definitionId][$optionId])) { + $filters['select'][$definitionId] = $optionId; + $normalizedQuery[$key] = (string) $optionId; + } + } elseif ($type === 'boolean') { + $bool = self::normalizeBool($rawValue); + if ($bool !== null) { + $filters['boolean'][$definitionId] = $bool; + $normalizedQuery[$key] = (string) $bool; + } + } + continue; + } + + if (preg_match('/^cfm_([a-f0-9-]{36})$/', $key, $match)) { + $uuid = $match[1]; + $definition = $definitionMapByUuid[$uuid] ?? null; + if (!$definition || strtolower((string) ($definition['type'] ?? '')) !== 'multiselect') { + continue; + } + $definitionId = (int) ($definition['id'] ?? 0); + $optionIds = RepoQuery::normalizeIdList($rawValue); + if (!$optionIds) { + continue; + } + $allowedMap = $optionMapByDefinition[$definitionId] ?? []; + $optionIds = array_values(array_filter($optionIds, static fn (int $id): bool => isset($allowedMap[$id]))); + if (!$optionIds) { + continue; + } + $filters['multiselect'][$definitionId] = $optionIds; + $normalizedQuery[$key] = implode(',', $optionIds); + continue; + } + + if (preg_match('/^cfd_([a-f0-9-]{36})_(from|to)$/', $key, $match)) { + $uuid = $match[1]; + $bound = $match[2]; + $definition = $definitionMapByUuid[$uuid] ?? null; + if (!$definition || strtolower((string) ($definition['type'] ?? '')) !== 'date') { + continue; + } + $date = self::normalizeDate($rawValue); + if ($date === null) { + continue; + } + $definitionId = (int) ($definition['id'] ?? 0); + $filters['date'][$definitionId] ??= ['from' => null, 'to' => null]; + $filters['date'][$definitionId][$bound] = $date; + $normalizedQuery[$key] = $date; + } + } + + return [ + 'definitions' => array_values($definitionMapByUuid), + 'filters' => $filters, + 'query' => $normalizedQuery, + ]; + } + + private static function normalizeTenantIds(array $tenantIds): array + { + $tenantIds = array_values(array_unique(array_map('intval', $tenantIds))); + return array_values(array_filter($tenantIds, static fn ($id) => $id > 0)); + } + + private static function normalizeBool($value): ?int + { + if (is_bool($value)) { + return $value ? 1 : 0; + } + $value = strtolower(trim((string) $value)); + if ($value === '') { + return null; + } + if (in_array($value, ['1', 'true', 'yes', 'on'], true)) { + return 1; + } + if (in_array($value, ['0', 'false', 'no', 'off'], true)) { + return 0; + } + return null; + } + + private static function normalizeDate($value): ?string + { + $value = trim((string) $value); + if ($value === '') { + return null; + } + $dt = \DateTimeImmutable::createFromFormat('Y-m-d', $value); + if (!$dt || $dt->format('Y-m-d') !== $value) { + return null; + } + return $value; + } + + private static function mapPublicValueByType(string $type, ?array $valueData, array $optionsById) + { + $hasValue = is_array($valueData); + if ($type === 'multiselect') { + if (!$hasValue) { + return []; + } + $optionIds = is_array($valueData['option_ids'] ?? null) ? $valueData['option_ids'] : []; + $keys = []; + foreach ($optionIds as $optionIdRaw) { + $optionId = (int) $optionIdRaw; + if ($optionId > 0 && isset($optionsById[$optionId])) { + $keys[] = (string) $optionsById[$optionId]; + } + } + return array_values(array_unique($keys)); + } + + if (!$hasValue) { + return null; + } + + return match ($type) { + 'boolean' => array_key_exists('value_bool', $valueData) && $valueData['value_bool'] !== null + ? ((int) $valueData['value_bool'] === 1) + : null, + 'date' => (($date = trim((string) ($valueData['value_date'] ?? ''))) !== '' ? $date : null), + 'select' => (($optionId = (int) ($valueData['option_id'] ?? 0)) > 0 && isset($optionsById[$optionId])) + ? (string) $optionsById[$optionId] + : null, + default => (($text = (string) ($valueData['value_text'] ?? '')) !== '' ? $text : null), + }; + } +} diff --git a/lib/Service/Docs/DocsCatalogService.php b/lib/Service/Docs/DocsCatalogService.php new file mode 100644 index 0000000..f60a52f --- /dev/null +++ b/lib/Service/Docs/DocsCatalogService.php @@ -0,0 +1,573 @@ +>|null */ + private static ?array $docGroupsCache = null; + + /** @var array|null */ + private static ?array $allDocsCache = null; + + private static function docsDir(): ?string + { + $docsDir = realpath(dirname(__DIR__, 3) . '/docs'); + if ($docsDir === false || !is_dir($docsDir)) { + return null; + } + + return $docsDir; + } + + private static function humanizeGroupLabel(string $groupHeading): string + { + $knownGroups = [ + 'Start hier (neue Entwickler)' => t('Getting started'), + 'Architektur und Regeln' => t('Architecture & rules'), + 'Frontend' => t('Frontend'), + 'Fach- und Feature-Dokumentation' => t('Features'), + 'API Referenz' => t('API reference'), + 'Betriebswissen' => t('Operations'), + ]; + + return $knownGroups[$groupHeading] ?? t($groupHeading); + } + + private static function normalizePlainText(string $value): string + { + $decoded = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $normalized = preg_replace('/\s+/u', ' ', $decoded); + return trim((string) $normalized); + } + + private static function titleFromMarkdownFile(string $docPath, string $docSlug): string + { + $content = file_get_contents($docPath); + if (!is_string($content) || trim($content) === '') { + return str_replace('-', ' ', $docSlug); + } + + if (preg_match('/^#\s+(.+)$/m', $content, $match)) { + $title = self::normalizePlainText(strip_tags((string) $match[1])); + if ($title !== '') { + return $title; + } + } + + return str_replace('-', ' ', $docSlug); + } + + /** + * @param array> $groups + * @return array> + */ + private static function ensureUniqueLabels(array $groups): array + { + $labelCounts = []; + foreach ($groups as $items) { + foreach ($items as $label) { + $key = mb_strtolower(trim($label)); + if ($key === '') { + continue; + } + $labelCounts[$key] = ($labelCounts[$key] ?? 0) + 1; + } + } + + foreach ($groups as $groupLabel => $items) { + foreach ($items as $docSlug => $label) { + $key = mb_strtolower(trim($label)); + if (($labelCounts[$key] ?? 0) > 1) { + $groups[$groupLabel][$docSlug] = $label . ' (' . $docSlug . ')'; + } + } + } + + return $groups; + } + + /** + * @return array> + */ + private static function parseDocGroupsFromIndex(string $indexFile, string $docsDir): array + { + if (!is_readable($indexFile)) { + return []; + } + + $indexContent = file_get_contents($indexFile); + if (!is_string($indexContent) || trim($indexContent) === '') { + return []; + } + + $groups = []; + $currentGroup = null; + $lines = preg_split('/\R/', $indexContent) ?: []; + foreach ($lines as $line) { + $trimmedLine = trim($line); + + if (preg_match('/^##\s+(.+)$/', $trimmedLine, $headingMatch)) { + $groupHeading = trim($headingMatch[1]); + $currentGroup = $groupHeading !== '' ? self::humanizeGroupLabel($groupHeading) : ''; + if ($currentGroup !== '' && !isset($groups[$currentGroup])) { + $groups[$currentGroup] = []; + } + continue; + } + + if ($currentGroup === null) { + continue; + } + + if (!preg_match('~^(?:\d+\.|-)\s+`?/docs/([a-z0-9-]+)\.md`?$~i', $trimmedLine, $docMatch)) { + continue; + } + + $docSlug = strtolower((string) $docMatch[1]); + $docPath = $docsDir . DIRECTORY_SEPARATOR . $docSlug . '.md'; + if (!is_readable($docPath)) { + continue; + } + + $groups[$currentGroup][$docSlug] = self::titleFromMarkdownFile($docPath, $docSlug); + } + + $groups = array_filter( + $groups, + static fn (array $items): bool => $items !== [] + ); + + return self::ensureUniqueLabels($groups); + } + + /** + * @return array> + */ + public static function docGroups(): array + { + if (self::$docGroupsCache !== null) { + return self::$docGroupsCache; + } + + $docsDir = self::docsDir(); + if ($docsDir === null) { + self::$docGroupsCache = []; + return self::$docGroupsCache; + } + + self::$docGroupsCache = self::parseDocGroupsFromIndex($docsDir . DIRECTORY_SEPARATOR . 'index.md', $docsDir); + return self::$docGroupsCache; + } + + /** + * @return array + */ + public static function allDocs(): array + { + if (self::$allDocsCache !== null) { + return self::$allDocsCache; + } + + $allDocs = []; + foreach (self::docGroups() as $items) { + foreach ($items as $docSlug => $label) { + $allDocs[$docSlug] = $label; + } + } + + self::$allDocsCache = $allDocs; + return self::$allDocsCache; + } + + public static function defaultSlug(): string + { + return array_key_first(self::allDocs()) ?: 'erste-schritte'; + } + + public static function docPathBySlug(string $slug): ?string + { + $slug = trim(strtolower($slug)); + if ($slug === '') { + return null; + } + + $allDocs = self::allDocs(); + if (!array_key_exists($slug, $allDocs)) { + return null; + } + + $docsDir = self::docsDir(); + if ($docsDir === null) { + return null; + } + + $path = $docsDir . DIRECTORY_SEPARATOR . $slug . '.md'; + if (!is_readable($path)) { + return null; + } + + return $path; + } + + public static function readMarkdown(string $slug): ?string + { + $path = self::docPathBySlug($slug); + if ($path === null) { + return null; + } + + $rawMarkdown = file_get_contents($path); + if (!is_string($rawMarkdown)) { + return null; + } + + return $rawMarkdown; + } + + private static function markdownEnvironment(): Environment + { + $environment = new Environment([ + 'heading_permalink' => [ + 'html_class' => 'docs-heading-anchor', + 'id_prefix' => '', + 'fragment_prefix' => '', + 'title' => '', + 'symbol' => '', + 'insert' => 'before', + ], + ]); + $environment->addExtension(new CommonMarkCoreExtension()); + $environment->addExtension(new GithubFlavoredMarkdownExtension()); + $environment->addExtension(new TaskListExtension()); + $environment->addExtension(new HeadingPermalinkExtension()); + + return $environment; + } + + public static function renderMarkdown(string $rawMarkdown): string + { + $converter = new MarkdownConverter(self::markdownEnvironment()); + return (string) $converter->convert($rawMarkdown); + } + + private static function slugifyHeading(string $heading): string + { + $slug = strtolower((string) preg_replace('/[^a-z0-9]+/i', '-', $heading)); + return trim($slug, '-'); + } + + /** + * @param array $seenAnchors + */ + private static function nextUniqueAnchor(string $baseAnchor, array &$seenAnchors): string + { + $anchor = $baseAnchor !== '' ? $baseAnchor : 'section'; + if (!isset($seenAnchors[$anchor])) { + $seenAnchors[$anchor] = 1; + return $anchor; + } + + $seenAnchors[$anchor]++; + return $anchor . '-' . $seenAnchors[$anchor]; + } + + /** + * @param int[] $levels + * @return array{html:string, headings:array} + */ + public static function prepareRenderedDocument(string $rawMarkdown, array $levels = [1, 2, 3]): array + { + $renderedHtml = self::renderMarkdown($rawMarkdown); + $sourceHtml = $renderedHtml; + + preg_match_all('/]*)>(.*?)<\/h\1>/is', $sourceHtml, $headingBlocks, PREG_SET_ORDER | PREG_OFFSET_CAPTURE); + if ($headingBlocks === []) { + return [ + 'html' => $renderedHtml, + 'headings' => [], + ]; + } + + $seenAnchors = []; + $hasMissingId = false; + $parsedHeadings = []; + + foreach ($headingBlocks as $headingBlock) { + $level = (int) ($headingBlock[1][0] ?? 0); + if (!in_array($level, [1, 2, 3], true)) { + continue; + } + + $attrs = (string) ($headingBlock[2][0] ?? ''); + $html = (string) ($headingBlock[3][0] ?? ''); + $text = self::normalizePlainText(strip_tags($html)); + if ($text === '') { + continue; + } + + $id = ''; + if (preg_match('/\bid=(["\'])([^"\']+)\1/i', $attrs, $idMatch)) { + $id = trim((string) ($idMatch[2] ?? '')); + } + + if ($id === '') { + $id = self::nextUniqueAnchor(self::slugifyHeading($text), $seenAnchors); + $hasMissingId = true; + } else { + $id = self::nextUniqueAnchor($id, $seenAnchors); + } + + $fullHtml = (string) ($headingBlock[0][0] ?? ''); + $start = (int) ($headingBlock[0][1] ?? 0); + $end = $start + strlen($fullHtml); + + $parsedHeadings[] = [ + 'level' => $level, + 'attrs' => $attrs, + 'html' => $html, + 'text' => $text, + 'anchor' => $id, + 'start' => $start, + 'end' => $end, + ]; + } + + if ($parsedHeadings === []) { + return [ + 'html' => $renderedHtml, + 'headings' => [], + ]; + } + + if ($hasMissingId) { + $headingIndex = 0; + $renderedHtml = preg_replace_callback( + '/]*)>(.*?)<\/h\1>/is', + static function (array $match) use (&$headingIndex, $parsedHeadings): string { + if (!isset($parsedHeadings[$headingIndex])) { + return $match[0]; + } + + $heading = $parsedHeadings[$headingIndex++]; + $level = (string) ($match[1] ?? '2'); + $attrs = (string) ($match[2] ?? ''); + $html = (string) ($match[3] ?? ''); + + if (preg_match('/\bid=(["\'])([^"\']+)\1/i', $attrs)) { + return "{$html}"; + } + + return "{$html}"; + }, + $renderedHtml + ) ?? $renderedHtml; + } + + $headings = []; + $countHeadings = count($parsedHeadings); + foreach ($parsedHeadings as $index => $heading) { + $nextStart = $index + 1 < $countHeadings ? (int) $parsedHeadings[$index + 1]['start'] : strlen($sourceHtml); + $bodyLength = max(0, $nextStart - (int) $heading['end']); + $bodyHtml = substr($sourceHtml, (int) $heading['end'], $bodyLength); + $bodyText = self::normalizePlainText(strip_tags($bodyHtml)); + + $level = (int) $heading['level']; + if (!in_array($level, $levels, true)) { + continue; + } + + $headings[] = [ + 'level' => $level, + 'text' => (string) $heading['text'], + 'anchor' => (string) $heading['anchor'], + 'body' => $bodyText, + ]; + } + + return [ + 'html' => $renderedHtml, + 'headings' => $headings, + ]; + } + + private static function titleScore(string $needle, string $title): int + { + $title = mb_strtolower(trim($title)); + if ($title === '' || $needle === '') { + return 0; + } + + if ($title === $needle) { + return 1000; + } + if (str_starts_with($title, $needle)) { + return 900; + } + if (str_contains($title, $needle)) { + return 800; + } + + return 0; + } + + private static function sectionScore(string $needle, string $section, string $body): int + { + $section = mb_strtolower(trim($section)); + $body = mb_strtolower(trim($body)); + if ($needle === '') { + return 0; + } + + $score = 0; + if ($section !== '') { + if ($section === $needle) { + $score = max($score, 700); + } elseif (str_starts_with($section, $needle)) { + $score = max($score, 650); + } elseif (str_contains($section, $needle)) { + $score = max($score, 600); + } + } + if ($body !== '' && str_contains($body, $needle)) { + $score = max($score, 500); + } + + return $score; + } + + private static function buildSnippet(string $body, string $needle): string + { + $body = self::normalizePlainText($body); + if ($body === '') { + return ''; + } + + $maxLength = 160; + $needlePos = mb_stripos($body, $needle); + if ($needlePos === false) { + $snippet = mb_substr($body, 0, $maxLength); + if (mb_strlen($body) > $maxLength) { + $snippet .= '...'; + } + + return $snippet; + } + + $start = max(0, $needlePos - 40); + $snippet = mb_substr($body, $start, $maxLength); + if ($start > 0) { + $snippet = '... ' . ltrim($snippet); + } + if ($start + $maxLength < mb_strlen($body)) { + $snippet = rtrim($snippet) . ' ...'; + } + + return $snippet; + } + + /** + * @return array + */ + public static function search(string $query): array + { + $needle = mb_strtolower(trim($query)); + if ($needle === '') { + return []; + } + + $resultsByKey = []; + foreach (self::allDocs() as $slug => $title) { + $rawMarkdown = self::readMarkdown($slug); + if ($rawMarkdown === null) { + continue; + } + + $prepared = self::prepareRenderedDocument($rawMarkdown, [1, 2, 3]); + $headings = $prepared['headings']; + if ($headings === []) { + $bodyText = self::normalizePlainText(strip_tags($prepared['html'])); + $score = max( + self::titleScore($needle, $title), + self::sectionScore($needle, '', $bodyText) + ); + if ($score <= 0) { + continue; + } + + $key = $slug . '#'; + $resultsByKey[$key] = [ + 'slug' => $slug, + 'title' => $title, + 'section' => '', + 'anchor' => '', + 'snippet' => self::buildSnippet($bodyText, $needle), + 'score' => $score, + ]; + continue; + } + + $docScore = self::titleScore($needle, $title); + if ($docScore > 0) { + $firstHeading = $headings[0]; + $docKey = $slug . '#' . (string) ($firstHeading['anchor'] ?? ''); + $resultsByKey[$docKey] = [ + 'slug' => $slug, + 'title' => $title, + 'section' => '', + 'anchor' => (string) ($firstHeading['anchor'] ?? ''), + 'snippet' => self::buildSnippet((string) ($firstHeading['body'] ?? ''), $needle), + 'score' => $docScore, + ]; + } + + foreach ($headings as $heading) { + $sectionText = (string) ($heading['text'] ?? ''); + $anchor = (string) ($heading['anchor'] ?? ''); + $body = (string) ($heading['body'] ?? ''); + $score = self::sectionScore($needle, $sectionText, $body); + if ($score <= 0) { + continue; + } + + $key = $slug . '#' . $anchor; + $candidate = [ + 'slug' => $slug, + 'title' => $title, + 'section' => $sectionText, + 'anchor' => $anchor, + 'snippet' => self::buildSnippet($body, $needle), + 'score' => $score, + ]; + + if (!isset($resultsByKey[$key]) || (int) $resultsByKey[$key]['score'] < $score) { + $resultsByKey[$key] = $candidate; + } + } + } + + $results = array_values($resultsByKey); + usort($results, static function (array $a, array $b): int { + $scoreCmp = ((int) $b['score']) <=> ((int) $a['score']); + if ($scoreCmp !== 0) { + return $scoreCmp; + } + + $titleCmp = strcmp((string) $a['title'], (string) $b['title']); + if ($titleCmp !== 0) { + return $titleCmp; + } + + return strcmp((string) $a['section'], (string) $b['section']); + }); + + return $results; + } +} diff --git a/lib/Service/Image/ImageUploadTrait.php b/lib/Service/Image/ImageUploadTrait.php new file mode 100644 index 0000000..d9476d0 --- /dev/null +++ b/lib/Service/Image/ImageUploadTrait.php @@ -0,0 +1,238 @@ + 'jpg', + 'image/png' => 'png', + 'image/webp' => 'webp', + ]; + return $map[$mime] ?? null; + } + + protected static function imageIsSvgUpload(string $mime, string $path): bool + { + if ($mime === 'image/svg+xml') { + return true; + } + $head = @file_get_contents($path, false, null, 0, 1024); + if (!is_string($head) || $head === '') { + return false; + } + return stripos($head, ' filemtime($a); + }); + return $matches[0] ?? null; + } + + protected static function imageCreateResource(string $path, string $mime) + { + if ($mime === 'image/jpeg' && function_exists('imagecreatefromjpeg')) { + return imagecreatefromjpeg($path); + } + if ($mime === 'image/png' && function_exists('imagecreatefrompng')) { + return imagecreatefrompng($path); + } + if ($mime === 'image/webp' && function_exists('imagecreatefromwebp')) { + return imagecreatefromwebp($path); + } + return false; + } + + protected static function imageResizeAndFit(string $sourcePath, string $targetPath, int $width, int $height, string $ext): bool + { + $mime = self::detectMime($sourcePath); + $src = self::imageCreateResource($sourcePath, $mime); + if (!$src) { + return false; + } + + $srcWidth = imagesx($src); + $srcHeight = imagesy($src); + + $scale = min($width / $srcWidth, $height / $srcHeight); + $dstWidth = (int) round($srcWidth * $scale); + $dstHeight = (int) round($srcHeight * $scale); + if ($dstWidth < 1) $dstWidth = 1; + if ($dstHeight < 1) $dstHeight = 1; + + $dst = imagecreatetruecolor($dstWidth, $dstHeight); + if ($ext === 'png' || $ext === 'webp') { + imagealphablending($dst, false); + imagesavealpha($dst, true); + $transparent = imagecolorallocatealpha($dst, 0, 0, 0, 127); + imagefilledrectangle($dst, 0, 0, $dstWidth, $dstHeight, $transparent); + } + + imagecopyresampled( + $dst, + $src, + 0, + 0, + 0, + 0, + $dstWidth, + $dstHeight, + $srcWidth, + $srcHeight + ); + + $saved = false; + if ($ext === 'webp' && function_exists('imagewebp')) { + $saved = imagewebp($dst, $targetPath, 82); + } elseif ($ext === 'png' && function_exists('imagepng')) { + $saved = imagepng($dst, $targetPath, 6); + } else { + $saved = imagejpeg($dst, $targetPath, 85); + } + + imagedestroy($src); + imagedestroy($dst); + + if ($saved) { + @chmod($targetPath, 0644); + } + return $saved; + } + + protected static function imageResizeSquare(string $sourcePath, string $targetPath, int $size): bool + { + $src = self::imageLoadPng($sourcePath); + if (!$src) { + return false; + } + $srcWidth = imagesx($src); + $srcHeight = imagesy($src); + + $cropSize = min($srcWidth, $srcHeight); + $srcX = (int) round(($srcWidth - $cropSize) / 2); + $srcY = (int) round(($srcHeight - $cropSize) / 2); + + $dst = imagecreatetruecolor($size, $size); + imagealphablending($dst, false); + imagesavealpha($dst, true); + $transparent = imagecolorallocatealpha($dst, 0, 0, 0, 127); + imagefilledrectangle($dst, 0, 0, $size, $size, $transparent); + + imagecopyresampled( + $dst, + $src, + 0, + 0, + $srcX, + $srcY, + $size, + $size, + $cropSize, + $cropSize + ); + + $saved = false; + if (function_exists('imagepng')) { + $saved = imagepng($dst, $targetPath, 6); + } + + imagedestroy($src); + imagedestroy($dst); + + return $saved; + } + + protected static function imageLoadPng(string $path) + { + if (function_exists('imagecreatefrompng')) { + return imagecreatefrompng($path); + } + return false; + } +} diff --git a/lib/Service/Import/CsvReaderService.php b/lib/Service/Import/CsvReaderService.php new file mode 100644 index 0000000..a230bf2 --- /dev/null +++ b/lib/Service/Import/CsvReaderService.php @@ -0,0 +1,253 @@ +, rows_total?:int, code?:string} + */ + public static function analyze(string $path, int $maxRows): array + { + if (!is_file($path) || !is_readable($path)) { + return ['ok' => false, 'code' => 'invalid_file_type']; + } + + $delimiter = self::detectDelimiter($path); + $headerMeta = self::readHeader($path, $delimiter); + if (!$headerMeta['ok']) { + $code = isset($headerMeta['code']) ? (string) $headerMeta['code'] : 'invalid_csv_header'; + return ['ok' => false, 'code' => $code]; + } + + $headers = $headerMeta['headers'] ?? []; + $headerLine = (int) ($headerMeta['header_line'] ?? 1); + if (!$headers) { + return ['ok' => false, 'code' => 'invalid_csv_header']; + } + + $rowsTotal = self::countRows($path, $delimiter, $headers, $headerLine); + if ($rowsTotal <= 0) { + return ['ok' => false, 'code' => 'empty_csv']; + } + if ($rowsTotal > $maxRows) { + return ['ok' => false, 'code' => 'max_rows_exceeded']; + } + + return [ + 'ok' => true, + 'delimiter' => $delimiter, + 'headers' => $headers, + 'rows_total' => $rowsTotal, + ]; + } + + /** + * @param array $headers + */ + public static function streamRows(string $path, string $delimiter, array $headers, callable $callback): void + { + $headerMeta = self::readHeader($path, $delimiter); + if (!$headerMeta['ok']) { + return; + } + $headerLine = (int) ($headerMeta['header_line'] ?? 1); + + $handle = @fopen($path, 'rb'); + if (!$handle) { + return; + } + + $lineNumber = 0; + $rowIndex = 0; + while (($row = fgetcsv($handle, 0, $delimiter, '"', '\\')) !== false) { + $lineNumber++; + if ($lineNumber <= $headerLine) { + continue; + } + + $row = self::normalizeRowColumns($row, count($headers)); + if (self::isEmptyRow($row)) { + continue; + } + + $rowIndex++; + $assoc = []; + foreach ($headers as $index => $header) { + $assoc[$header] = self::cleanCell($row[$index] ?? ''); + } + $callback($assoc, $lineNumber, $rowIndex); + } + + fclose($handle); + } + + private static function detectDelimiter(string $path): string + { + $sample = ''; + $handle = @fopen($path, 'rb'); + if ($handle) { + for ($i = 0; $i < 5; $i++) { + $line = fgets($handle); + if ($line === false) { + break; + } + $sample .= $line; + } + fclose($handle); + } + + $bestDelimiter = ','; + $bestScore = -1; + foreach (self::DELIMITERS as $delimiter) { + $score = substr_count($sample, $delimiter); + if ($score > $bestScore) { + $bestScore = $score; + $bestDelimiter = $delimiter; + } + } + + return $bestDelimiter; + } + + /** + * @return array{ok:bool, headers?:array, header_line?:int, code?:string} + */ + private static function readHeader(string $path, string $delimiter): array + { + $handle = @fopen($path, 'rb'); + if (!$handle) { + return ['ok' => false, 'code' => 'invalid_csv_header']; + } + + $lineNumber = 0; + while (($row = fgetcsv($handle, 0, $delimiter, '"', '\\')) !== false) { + $lineNumber++; + if (!is_array($row)) { + continue; + } + $row = self::normalizeRowColumns($row, count($row)); + if (self::isEmptyRow($row)) { + continue; + } + + $headers = []; + foreach ($row as $index => $cell) { + $value = self::cleanCell((string) $cell); + if ($index === 0) { + $value = self::stripBom($value); + } + $headers[] = trim($value); + } + + fclose($handle); + + if (!$headers) { + return ['ok' => false, 'code' => 'invalid_csv_header']; + } + + foreach ($headers as $headerValue) { + if ($headerValue === '') { + return ['ok' => false, 'code' => 'invalid_csv_header']; + } + } + + $normalizedKeys = array_map([self::class, 'lower'], $headers); + if (count(array_unique($normalizedKeys)) !== count($normalizedKeys)) { + return ['ok' => false, 'code' => 'invalid_csv_header']; + } + + return ['ok' => true, 'headers' => $headers, 'header_line' => $lineNumber]; + } + + fclose($handle); + return ['ok' => false, 'code' => 'empty_csv']; + } + + /** + * @param array $row + * @return array + */ + private static function normalizeRowColumns(array $row, int $size): array + { + $normalized = []; + foreach ($row as $cell) { + $normalized[] = self::cleanCell((string) $cell); + } + + $current = count($normalized); + if ($current < $size) { + $normalized = array_pad($normalized, $size, ''); + } elseif ($current > $size) { + $normalized = array_slice($normalized, 0, $size); + } + + return $normalized; + } + + /** + * @param array $headers + */ + private static function countRows(string $path, string $delimiter, array $headers, int $headerLine): int + { + $count = 0; + $handle = @fopen($path, 'rb'); + if (!$handle) { + return 0; + } + + $lineNumber = 0; + while (($row = fgetcsv($handle, 0, $delimiter, '"', '\\')) !== false) { + $lineNumber++; + if ($lineNumber <= $headerLine) { + continue; + } + if (!is_array($row)) { + continue; + } + $row = self::normalizeRowColumns($row, count($headers)); + if (self::isEmptyRow($row)) { + continue; + } + $count++; + } + + fclose($handle); + return $count; + } + + private static function isEmptyRow(array $row): bool + { + foreach ($row as $cell) { + if (trim((string) $cell) !== '') { + return false; + } + } + return true; + } + + private static function stripBom(string $value): string + { + if (str_starts_with($value, "\xEF\xBB\xBF")) { + return substr($value, 3); + } + return $value; + } + + private static function cleanCell(string $value): string + { + $value = str_replace("\0", '', $value); + $value = self::stripBom($value); + return trim($value); + } + + private static function lower(string $value): string + { + if (function_exists('mb_strtolower')) { + return mb_strtolower($value, 'UTF-8'); + } + return strtolower($value); + } +} diff --git a/lib/Service/Import/ImportService.php b/lib/Service/Import/ImportService.php new file mode 100644 index 0000000..bf06868 --- /dev/null +++ b/lib/Service/Import/ImportService.php @@ -0,0 +1,642 @@ + PermissionService::USERS_IMPORT, + 'departments' => PermissionService::DEPARTMENTS_IMPORT, + ]; + + /** + * @return array + */ + public static function profiles(): array + { + static $profiles = null; + if (is_array($profiles)) { + return $profiles; + } + + $users = new UserImportProfile(); + $departments = new DepartmentImportProfile(); + $profiles = [ + $users->key() => $users, + $departments->key() => $departments, + ]; + + return $profiles; + } + + public static function profile(string $type): ?ImportProfileInterface + { + $type = trim($type); + if ($type === '') { + return null; + } + $profiles = self::profiles(); + return $profiles[$type] ?? null; + } + + /** + * @return array + */ + public static function listProfileOptions(): array + { + $options = []; + foreach (self::profiles() as $profile) { + $options[] = [ + 'key' => $profile->key(), + 'label' => $profile->label(), + ]; + } + return $options; + } + + public static function requiredPermissionForType(string $type): string + { + $type = trim($type); + if ($type === '') { + return ''; + } + return self::PROFILE_PERMISSION_MAP[$type] ?? ''; + } + + /** + * @param array $upload + * @return array + */ + public static function analyzeUpload(array $upload, string $type, int $currentUserId): array + { + $profile = self::profile($type); + if (!$profile) { + return ['ok' => false, 'code' => 'invalid_file_type']; + } + + $stored = ImportTempFileService::storeUploadedFile($upload); + if (!$stored['ok']) { + $code = isset($stored['code']) ? (string) $stored['code'] : 'upload_missing'; + return ['ok' => false, 'code' => $code]; + } + + $path = (string) ($stored['path'] ?? ''); + $analysis = CsvReaderService::analyze($path, self::MAX_ROWS); + if (!$analysis['ok']) { + ImportTempFileService::delete($path); + $code = isset($analysis['code']) ? (string) $analysis['code'] : 'invalid_csv_header'; + return ['ok' => false, 'code' => $code]; + } + + $headers = $analysis['headers'] ?? []; + $delimiter = (string) ($analysis['delimiter'] ?? ','); + $rowsTotal = (int) ($analysis['rows_total'] ?? 0); + + $token = self::newToken(); + self::setState($token, [ + 'token' => $token, + 'type' => $profile->key(), + 'path' => $path, + 'source_filename' => trim((string) ($upload['name'] ?? '')), + 'delimiter' => $delimiter, + 'headers' => $headers, + 'rows_total' => $rowsTotal, + 'user_id' => $currentUserId, + 'created_at' => time(), + ]); + + return [ + 'ok' => true, + 'token' => $token, + 'type' => $profile->key(), + 'headers' => $headers, + 'rows_total' => $rowsTotal, + 'delimiter' => $delimiter, + 'allowed_targets' => $profile->allowedTargets(), + 'required_targets' => $profile->requiredTargets(), + 'auto_mapping' => self::buildAutoMapping($headers, $profile), + ]; + } + + /** + * @param array $mappingInput + * @return array + */ + public static function preview(string $token, array $mappingInput, int $currentUserId): array + { + $state = self::state($token); + if (!$state) { + return ['ok' => false, 'code' => 'invalid_file_type']; + } + if ((int) ($state['user_id'] ?? 0) !== $currentUserId) { + return ['ok' => false, 'code' => 'invalid_file_type']; + } + + $profile = self::profile((string) ($state['type'] ?? '')); + if (!$profile) { + return ['ok' => false, 'code' => 'invalid_file_type']; + } + + $mappingResult = self::normalizeMapping($mappingInput, (array) ($state['headers'] ?? []), $profile); + if (!($mappingResult['ok'] ?? false)) { + return ['ok' => false, 'code' => (string) ($mappingResult['code'] ?? 'mapping_required_missing')]; + } + + $mappedTargets = $mappingResult['mapped_targets'] ?? []; + if ( + $profile->requiresAssignmentPermission() + && self::usesAssignmentTargets($mappedTargets) + && !self::canImportAssignments($currentUserId) + ) { + return ['ok' => false, 'code' => 'assignment_permission_required']; + } + + return self::process($state, $mappingResult['mapping'], $profile, $currentUserId, false); + } + + /** + * @param array $mappingInput + * @return array + */ + public static function commit(string $token, array $mappingInput, int $currentUserId): array + { + $state = self::state($token); + if (!$state) { + return ['ok' => false, 'code' => 'invalid_file_type']; + } + if ((int) ($state['user_id'] ?? 0) !== $currentUserId) { + return ['ok' => false, 'code' => 'invalid_file_type']; + } + + $profile = self::profile((string) ($state['type'] ?? '')); + if (!$profile) { + return ['ok' => false, 'code' => 'invalid_file_type']; + } + + $mappingResult = self::normalizeMapping($mappingInput, (array) ($state['headers'] ?? []), $profile); + if (!($mappingResult['ok'] ?? false)) { + return ['ok' => false, 'code' => (string) ($mappingResult['code'] ?? 'mapping_required_missing')]; + } + + $mappedTargets = $mappingResult['mapped_targets'] ?? []; + if ( + $profile->requiresAssignmentPermission() + && self::usesAssignmentTargets($mappedTargets) + && !self::canImportAssignments($currentUserId) + ) { + return ['ok' => false, 'code' => 'assignment_permission_required']; + } + + $auditRunId = ImportAuditService::startRun( + (string) ($state['type'] ?? $profile->key()), + $mappingResult['mapped_targets'] ?? [], + isset($state['source_filename']) ? (string) $state['source_filename'] : null, + $currentUserId, + isset($_SESSION['current_tenant']['id']) ? (int) $_SESSION['current_tenant']['id'] : null + ); + + $result = null; + $forcedAuditStatus = null; + try { + $result = self::process($state, $mappingResult['mapping'], $profile, $currentUserId, true); + } catch (\Throwable $exception) { + $forcedAuditStatus = 'failed'; + $result = ['ok' => false, 'code' => 'unexpected_error', 'processed' => 0, 'created' => 0, 'skipped' => 0, 'failed' => 1]; + } finally { + ImportAuditService::finishRun($auditRunId, is_array($result) ? $result : [], $forcedAuditStatus); + } + + ImportTempFileService::delete((string) ($state['path'] ?? '')); + self::clearState($token); + + return is_array($result) ? $result : ['ok' => false, 'code' => 'unexpected_error']; + } + + /** + * @return array|null + */ + public static function state(string $token): ?array + { + $token = trim($token); + if ($token === '') { + return null; + } + + $all = $_SESSION[self::SESSION_KEY] ?? []; + if (!is_array($all)) { + return null; + } + + $state = $all[$token] ?? null; + if (!is_array($state)) { + return null; + } + + $createdAt = (int) ($state['created_at'] ?? 0); + if ($createdAt > 0 && (time() - $createdAt) > 7200) { + ImportTempFileService::delete((string) ($state['path'] ?? '')); + self::clearState($token); + return null; + } + + return $state; + } + + /** + * @param array $state + * @param array $mapping + * @return array + */ + private static function process( + array $state, + array $mapping, + ImportProfileInterface $profile, + int $currentUserId, + bool $commit + ): array { + $path = (string) ($state['path'] ?? ''); + $delimiter = (string) ($state['delimiter'] ?? ','); + $headers = $state['headers'] ?? []; + if (!is_array($headers) || !$headers) { + return ['ok' => false, 'code' => 'invalid_csv_header']; + } + + $context = self::buildContext($currentUserId); + $seenRowKeys = []; + $processed = 0; + $created = 0; + $skipped = 0; + $failed = 0; + $wouldCreate = 0; + $errors = []; + $errorCounts = []; + + CsvReaderService::streamRows( + $path, + $delimiter, + array_values($headers), + function (array $rowAssoc, int $lineNumber) use ( + $profile, + $mapping, + $context, + $commit, + &$seenRowKeys, + &$processed, + &$created, + &$skipped, + &$failed, + &$wouldCreate, + &$errors, + &$errorCounts + ): void { + $processed++; + $mapped = []; + $identifier = ''; + try { + $mapped = self::mapRow($rowAssoc, $mapping); + $validation = $profile->validateMappedRow($mapped, $context); + if (!$validation['ok']) { + $failed++; + foreach ($validation['errors'] as $error) { + self::incrementErrorCount($errorCounts, (string) ($error['code'] ?? 'unexpected_error')); + self::pushError( + $errors, + $lineNumber, + $profile->rowIdentifier($mapped), + (string) $error['code'], + isset($error['message']) ? (string) $error['message'] : null + ); + } + return; + } + + $normalized = $validation['normalized']; + $identifier = $profile->rowIdentifier($normalized); + $duplicateKey = $profile->inFileDuplicateKey($normalized); + if ($duplicateKey !== null && $duplicateKey !== '') { + if (isset($seenRowKeys[$duplicateKey])) { + $skipped++; + self::incrementErrorCount($errorCounts, 'duplicate_in_file'); + self::pushError($errors, $lineNumber, $identifier, 'duplicate_in_file', null); + return; + } + $seenRowKeys[$duplicateKey] = true; + } + + $result = $commit ? $profile->commitRow($normalized, $context) : $profile->dryRunRow($normalized, $context); + $status = (string) $result['status']; + $code = isset($result['code']) ? (string) $result['code'] : ''; + $message = isset($result['message']) ? (string) $result['message'] : null; + + if ($status === 'created') { + $created++; + return; + } + if ($status === 'would_create') { + $wouldCreate++; + return; + } + if ($status === 'skipped') { + $skipped++; + $errorCode = $code !== '' ? $code : 'email_exists'; + self::incrementErrorCount($errorCounts, $errorCode); + self::pushError($errors, $lineNumber, $identifier, $errorCode, $message); + return; + } + + $failed++; + $errorCode = $code !== '' ? $code : 'unexpected_error'; + self::incrementErrorCount($errorCounts, $errorCode); + self::pushError($errors, $lineNumber, $identifier, $errorCode, $message); + } catch (\Throwable $exception) { + $failed++; + self::incrementErrorCount($errorCounts, 'unexpected_error'); + if ($identifier === '' && $mapped) { + try { + $identifier = $profile->rowIdentifier($mapped); + } catch (\Throwable $innerException) { + $identifier = ''; + } + } + self::pushError($errors, $lineNumber, $identifier, 'unexpected_error', null); + } + } + ); + + if ($commit) { + return [ + 'ok' => true, + 'processed' => $processed, + 'created' => $created, + 'skipped' => $skipped, + 'failed' => $failed, + 'error_counts' => $errorCounts, + 'errors' => $errors, + ]; + } + + return [ + 'ok' => true, + 'rows_total' => $processed, + 'valid_rows' => $wouldCreate + $skipped, + 'would_create' => $wouldCreate, + 'would_skip' => $skipped, + 'would_fail' => $failed, + 'errors' => $errors, + ]; + } + + /** + * @param array $headers + * @return array + */ + private static function normalizeMapping(array $mappingInput, array $headers, ImportProfileInterface $profile): array + { + $mappingRaw = $mappingInput['mapping'] ?? $mappingInput; + if (!is_array($mappingRaw)) { + $mappingRaw = []; + } + + $allowedTargets = array_keys($profile->allowedTargets()); + $allowedMap = array_fill_keys($allowedTargets, true); + $requiredTargets = $profile->requiredTargets(); + $requiredMap = array_fill_keys($requiredTargets, true); + $mapping = []; + $mappedTargets = []; + + foreach ($headers as $header) { + $target = trim((string) ($mappingRaw[$header] ?? '')); + if ($target === '' || !isset($allowedMap[$target])) { + continue; + } + if (isset($mappedTargets[$target])) { + continue; + } + $mapping[$header] = $target; + $mappedTargets[$target] = true; + } + + foreach ($requiredMap as $target => $_) { + if (!isset($mappedTargets[$target])) { + return ['ok' => false, 'code' => 'mapping_required_missing']; + } + } + + return [ + 'ok' => true, + 'mapping' => $mapping, + 'mapped_targets' => array_keys($mappedTargets), + ]; + } + + /** + * @param array $mapping + * @param array $rowAssoc + * @return array + */ + private static function mapRow(array $rowAssoc, array $mapping): array + { + $mapped = []; + foreach ($mapping as $header => $target) { + if (isset($mapped[$target])) { + continue; + } + $mapped[$target] = trim((string) ($rowAssoc[$header] ?? '')); + } + return $mapped; + } + + /** + * @param array $headers + * @return array + */ + private static function buildAutoMapping(array $headers, ImportProfileInterface $profile): array + { + $mapping = []; + $aliases = $profile->autoMapAliases(); + $normalizedAliasMap = []; + + foreach ($aliases as $target => $targetAliases) { + $normalizedAliasMap[self::normalizeHeader($target)] = $target; + foreach ($targetAliases as $alias) { + $normalizedAliasMap[self::normalizeHeader($alias)] = $target; + } + } + + $assignedTargets = []; + foreach ($headers as $header) { + $normalized = self::normalizeHeader($header); + if ($normalized === '') { + continue; + } + $target = $normalizedAliasMap[$normalized] ?? ''; + if ($target === '' || isset($assignedTargets[$target])) { + continue; + } + $mapping[$header] = $target; + $assignedTargets[$target] = true; + } + + return $mapping; + } + + /** + * @param array $mappedTargets + */ + private static function usesAssignmentTargets(array $mappedTargets): bool + { + foreach ($mappedTargets as $target) { + if (in_array($target, ['tenant', 'role', 'department'], true)) { + return true; + } + } + return false; + } + + private static function canImportAssignments(int $userId): bool + { + return PermissionService::userHas($userId, PermissionService::USERS_IMPORT_ASSIGNMENTS); + } + + /** + * @return array + */ + private static function buildContext(int $currentUserId): array + { + $isGlobalAdmin = TenantScopeService::hasGlobalAccess($currentUserId); + return [ + 'current_user_id' => $currentUserId, + 'is_global_admin' => $isGlobalAdmin, + 'allowed_tenant_ids' => $isGlobalAdmin ? [] : TenantScopeService::getUserTenantIds($currentUserId), + 'default_tenant_id' => SettingService::getDefaultTenantId(), + 'default_role_id' => SettingService::getDefaultRoleId(), + 'default_department_id' => SettingService::getDefaultDepartmentId(), + 'can_import_assignments' => self::canImportAssignments($currentUserId), + ]; + } + + private static function newToken(): string + { + try { + return bin2hex(random_bytes(24)); + } catch (\Throwable $exception) { + return str_replace('.', '', uniqid('import_', true)); + } + } + + /** + * @param array $state + */ + private static function setState(string $token, array $state): void + { + if (!isset($_SESSION[self::SESSION_KEY]) || !is_array($_SESSION[self::SESSION_KEY])) { + $_SESSION[self::SESSION_KEY] = []; + } + $_SESSION[self::SESSION_KEY][$token] = $state; + } + + private static function clearState(string $token): void + { + if (!isset($_SESSION[self::SESSION_KEY]) || !is_array($_SESSION[self::SESSION_KEY])) { + return; + } + unset($_SESSION[self::SESSION_KEY][$token]); + } + + private static function normalizeHeader(string $value): string + { + $value = trim($value); + if ($value === '') { + return ''; + } + + $value = self::lower($value); + $value = str_replace(['-', '.', ' '], '_', $value); + $value = preg_replace('/[^a-z0-9_]/', '', $value) ?? ''; + $value = preg_replace('/_+/', '_', $value) ?? ''; + return trim($value, '_'); + } + + /** + * @param array $errors + */ + private static function pushError(array &$errors, int $lineNumber, string $identifier, string $code, ?string $message): void + { + if (count($errors) >= self::MAX_ERROR_ROWS) { + return; + } + $errors[] = [ + 'row_number' => $lineNumber, + 'identifier' => $identifier, + 'code' => $code, + 'message' => $message !== null && $message !== '' ? $message : self::defaultMessageForCode($code), + ]; + } + + /** + * @param array $errorCounts + */ + private static function incrementErrorCount(array &$errorCounts, string $code): void + { + $code = trim($code); + if ($code === '') { + return; + } + $errorCounts[$code] = (int) ($errorCounts[$code] ?? 0) + 1; + } + + private static function defaultMessageForCode(string $code): string + { + $map = [ + 'upload_missing' => t('No upload file provided'), + 'upload_too_large' => t('Upload exceeds the maximum size'), + 'invalid_file_type' => t('Invalid file type'), + 'invalid_csv_header' => t('CSV header is invalid'), + 'empty_csv' => t('CSV file has no data rows'), + 'max_rows_exceeded' => t('CSV row limit exceeded'), + 'mapping_required_missing' => t('Required mapping is missing'), + 'assignment_permission_required' => t('Assignment import permission is required'), + 'invalid_email' => t('Email is invalid'), + 'duplicate_in_file' => t('Duplicate entry in CSV file'), + 'email_exists' => t('Email already exists'), + 'invalid_locale' => t('Locale is invalid'), + 'invalid_active' => t('Active value is invalid'), + 'invalid_hire_date' => t('Hire date must be YYYY-MM-DD'), + 'invalid_tenant_uuid' => t('Tenant must be a valid UUID'), + 'tenant_not_found' => t('Tenant is missing, inactive or invalid'), + 'role_not_found' => t('Role is missing, inactive or invalid'), + 'department_not_found' => t('Department is missing, inactive or invalid'), + 'department_tenant_mismatch' => t('Department does not belong to tenant'), + 'assignment_out_of_scope' => t('Tenant assignment is out of your scope'), + 'code_exists' => t('Code already exists'), + 'department_exists' => t('Department already exists for this tenant'), + 'create_failed' => t('Entry could not be created'), + 'unexpected_error' => t('Unexpected error during import'), + ]; + return $map[$code] ?? t('Unexpected error during import'); + } + + public static function messageForCode(string $code): string + { + return self::defaultMessageForCode(trim($code)); + } + + private static function lower(string $value): string + { + if (function_exists('mb_strtolower')) { + return mb_strtolower($value, 'UTF-8'); + } + return strtolower($value); + } +} diff --git a/lib/Service/Import/ImportTempFileService.php b/lib/Service/Import/ImportTempFileService.php new file mode 100644 index 0000000..91164bd --- /dev/null +++ b/lib/Service/Import/ImportTempFileService.php @@ -0,0 +1,135 @@ + $upload + * @return array{ok:bool, path?:string, code?:string} + */ + public static function storeUploadedFile(array $upload): array + { + $tmpName = (string) ($upload['tmp_name'] ?? ''); + $name = (string) ($upload['name'] ?? ''); + $size = (int) ($upload['size'] ?? 0); + $error = (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE); + + if ($error === UPLOAD_ERR_NO_FILE || $tmpName === '') { + return ['ok' => false, 'code' => 'upload_missing']; + } + if ($error !== UPLOAD_ERR_OK) { + return ['ok' => false, 'code' => 'invalid_file_type']; + } + if ($size <= 0) { + return ['ok' => false, 'code' => 'empty_csv']; + } + if ($size > self::MAX_UPLOAD_BYTES) { + return ['ok' => false, 'code' => 'upload_too_large']; + } + if (!self::isAllowedExtension($name)) { + return ['ok' => false, 'code' => 'invalid_file_type']; + } + + $dir = self::tmpDir(); + if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) { + return ['ok' => false, 'code' => 'unexpected_error']; + } + + self::cleanupExpired(); + + try { + $target = $dir . '/import_' . bin2hex(random_bytes(16)) . '.csv'; + } catch (\Throwable $exception) { + $target = $dir . '/import_' . str_replace('.', '', uniqid('', true)) . '.csv'; + } + $moved = false; + if (is_uploaded_file($tmpName)) { + $moved = @move_uploaded_file($tmpName, $target); + } elseif (is_file($tmpName) && is_readable($tmpName)) { + $moved = @rename($tmpName, $target); + if (!$moved) { + $moved = @copy($tmpName, $target); + } + } + + if (!$moved) { + return ['ok' => false, 'code' => 'invalid_file_type']; + } + + @chmod($target, 0640); + return ['ok' => true, 'path' => $target]; + } + + public static function delete(string $path): void + { + $path = trim($path); + if ($path === '') { + return; + } + if (!self::isInTmpDir($path)) { + return; + } + if (is_file($path)) { + @unlink($path); + } + } + + public static function cleanupExpired(): void + { + $dir = self::tmpDir(); + if (!is_dir($dir)) { + return; + } + + $files = glob($dir . '/*'); + if (!is_array($files)) { + return; + } + + $threshold = time() - self::MAX_FILE_AGE_SECONDS; + foreach ($files as $file) { + if (!is_file($file)) { + continue; + } + $modifiedAt = @filemtime($file); + if ($modifiedAt !== false && $modifiedAt < $threshold) { + @unlink($file); + } + } + } + + public static function storageBase(): string + { + if (defined('APP_STORAGE_PATH') && APP_STORAGE_PATH) { + return rtrim((string) APP_STORAGE_PATH, '/'); + } + return rtrim(dirname(__DIR__, 3) . '/storage', '/'); + } + + public static function tmpDir(): string + { + return self::storageBase() . self::TMP_SUBDIR; + } + + private static function isAllowedExtension(string $name): bool + { + $extension = strtolower(pathinfo($name, PATHINFO_EXTENSION)); + return in_array($extension, ['csv', 'txt'], true); + } + + private static function isInTmpDir(string $path): bool + { + $tmpDir = self::tmpDir(); + $realTmpDir = realpath($tmpDir); + $realPath = realpath($path); + if ($realTmpDir === false || $realPath === false) { + return false; + } + return str_starts_with($realPath, $realTmpDir . DIRECTORY_SEPARATOR); + } +} diff --git a/lib/Service/Import/Profile/DepartmentImportProfile.php b/lib/Service/Import/Profile/DepartmentImportProfile.php new file mode 100644 index 0000000..3a7f3ae --- /dev/null +++ b/lib/Service/Import/Profile/DepartmentImportProfile.php @@ -0,0 +1,234 @@ + t('Description'), + 'tenant' => t('Tenant'), + 'code' => t('Code'), + 'cost_center' => t('Cost center'), + 'active' => t('Active'), + ]; + } + + public function requiredTargets(): array + { + return ['description']; + } + + public function autoMapAliases(): array + { + return [ + 'description' => ['description', 'name', 'department', 'abteilung'], + 'tenant' => ['tenant', 'tenant_uuid', 'mandant', 'mandant_uuid'], + 'code' => ['code', 'department_code', 'abteilungscode'], + 'cost_center' => ['cost_center', 'kostenstelle'], + 'active' => ['active', 'status', 'aktiv'], + ]; + } + + public function supportsAssignments(): bool + { + return true; + } + + public function requiresAssignmentPermission(): bool + { + return false; + } + + public function validateMappedRow(array $mapped, array $context): array + { + $errors = []; + $normalized = []; + + $normalized['description'] = trim((string) ($mapped['description'] ?? '')); + if ($normalized['description'] === '') { + $errors[] = ['code' => 'mapping_required_missing', 'message' => t('Required field is empty: description')]; + } + + $normalized['code'] = trim((string) ($mapped['code'] ?? '')); + $normalized['cost_center'] = trim((string) ($mapped['cost_center'] ?? '')); + + $activeRaw = trim((string) ($mapped['active'] ?? '')); + $active = $this->parseActive($activeRaw); + if ($activeRaw !== '' && $active === null) { + $errors[] = ['code' => 'invalid_active']; + } + $normalized['active'] = $active ?? 1; + + $tenantRaw = trim((string) ($mapped['tenant'] ?? '')); + $tenant = null; + if ($tenantRaw !== '') { + if (!$this->isUuid($tenantRaw)) { + $errors[] = ['code' => 'invalid_tenant_uuid']; + } else { + $tenant = TenantRepository::findByUuid($tenantRaw); + if (!$tenant || (string) ($tenant['status'] ?? '') !== 'active') { + $errors[] = ['code' => 'tenant_not_found']; + } + } + } + + if (!$tenant) { + $defaultTenantId = (int) ($context['default_tenant_id'] ?? 0); + if ($defaultTenantId > 0) { + $defaultTenant = TenantRepository::find($defaultTenantId); + if ($defaultTenant && (string) ($defaultTenant['status'] ?? '') === 'active') { + $tenant = $defaultTenant; + } + } + } + + $tenantId = (int) ($tenant['id'] ?? 0); + if ($tenantId <= 0) { + $errors[] = ['code' => 'tenant_not_found']; + } + + if ($tenantId > 0 && empty($context['is_global_admin'])) { + $allowedTenantIds = $context['allowed_tenant_ids'] ?? []; + if (!in_array($tenantId, $allowedTenantIds, true)) { + $errors[] = ['code' => 'assignment_out_of_scope']; + } + } + + $normalized['tenant_id'] = $tenantId; + + return [ + 'ok' => !$errors, + 'normalized' => $normalized, + 'errors' => $errors, + ]; + } + + public function dryRunRow(array $normalized, array $context): array + { + if ($this->codeExists($normalized)) { + return ['status' => 'skipped', 'code' => 'code_exists']; + } + if ($this->departmentExists($normalized)) { + return ['status' => 'skipped', 'code' => 'department_exists']; + } + return ['status' => 'would_create']; + } + + public function commitRow(array $normalized, array $context): array + { + if ($this->codeExists($normalized)) { + return ['status' => 'skipped', 'code' => 'code_exists']; + } + if ($this->departmentExists($normalized)) { + return ['status' => 'skipped', 'code' => 'department_exists']; + } + + $result = DepartmentService::createFromAdmin([ + 'description' => (string) ($normalized['description'] ?? ''), + 'tenant_id' => (int) ($normalized['tenant_id'] ?? 0), + 'code' => (string) ($normalized['code'] ?? ''), + 'cost_center' => (string) ($normalized['cost_center'] ?? ''), + 'active' => (int) ($normalized['active'] ?? 1), + ], (int) ($context['current_user_id'] ?? 0)); + + if (!($result['ok'] ?? false)) { + $errors = $result['errors'] ?? []; + $message = is_array($errors) && isset($errors[0]) ? (string) $errors[0] : t('Department can not be created'); + return ['status' => 'failed', 'code' => 'create_failed', 'message' => $message]; + } + + return [ + 'status' => 'created', + 'uuid' => (string) ($result['uuid'] ?? ''), + ]; + } + + public function inFileDuplicateKey(array $normalized): ?string + { + $code = $this->lower(trim((string) ($normalized['code'] ?? ''))); + if ($code !== '') { + return 'code:' . $code; + } + + $tenantId = (int) ($normalized['tenant_id'] ?? 0); + $description = $this->lower(trim((string) ($normalized['description'] ?? ''))); + if ($tenantId <= 0 || $description === '') { + return null; + } + + return 'tenant:' . $tenantId . '|description:' . $description; + } + + public function rowIdentifier(array $normalized): string + { + $code = trim((string) ($normalized['code'] ?? '')); + if ($code !== '') { + return $code; + } + return trim((string) ($normalized['description'] ?? '')); + } + + private function codeExists(array $normalized): bool + { + $code = trim((string) ($normalized['code'] ?? '')); + if ($code === '') { + return false; + } + return DepartmentRepository::existsByCode($code); + } + + private function departmentExists(array $normalized): bool + { + $tenantId = (int) ($normalized['tenant_id'] ?? 0); + $description = trim((string) ($normalized['description'] ?? '')); + if ($tenantId <= 0 || $description === '') { + return false; + } + return DepartmentRepository::existsByTenantAndDescription($tenantId, $description); + } + + private function parseActive(string $value): ?int + { + $value = $this->lower(trim($value)); + if ($value === '') { + return null; + } + + if (in_array($value, ['1', 'true', 'yes', 'active', 'ja', 'aktiv'], true)) { + return 1; + } + if (in_array($value, ['0', 'false', 'no', 'inactive', 'nein', 'inaktiv'], true)) { + return 0; + } + return null; + } + + private function isUuid(string $value): bool + { + return (bool) preg_match('/^[a-f0-9-]{36}$/i', $value); + } + + private function lower(string $value): string + { + if (function_exists('mb_strtolower')) { + return mb_strtolower($value, 'UTF-8'); + } + return strtolower($value); + } +} diff --git a/lib/Service/Import/Profile/ImportProfileInterface.php b/lib/Service/Import/Profile/ImportProfileInterface.php new file mode 100644 index 0000000..272994f --- /dev/null +++ b/lib/Service/Import/Profile/ImportProfileInterface.php @@ -0,0 +1,63 @@ + target => label + */ + public function allowedTargets(): array; + + /** + * @return array + */ + public function requiredTargets(): array; + + /** + * @return array> target => aliases + */ + public function autoMapAliases(): array; + + public function supportsAssignments(): bool; + + public function requiresAssignmentPermission(): bool; + + /** + * @param array $mapped + * @param array $context + * @return array{ok:bool, normalized:array, errors:array} + */ + public function validateMappedRow(array $mapped, array $context): array; + + /** + * @param array $normalized + * @param array $context + * @return array{status:string, code?:string, message?:string} + */ + public function dryRunRow(array $normalized, array $context): array; + + /** + * @param array $normalized + * @param array $context + * @return array{status:string, code?:string, message?:string, uuid?:string} + */ + public function commitRow(array $normalized, array $context): array; + + /** + * Returns a deterministic key for in-file duplicate detection. + * Return null when duplicate detection is not required for this row. + * + * @param array $normalized + */ + public function inFileDuplicateKey(array $normalized): ?string; + + /** + * @param array $normalized + */ + public function rowIdentifier(array $normalized): string; +} diff --git a/lib/Service/Import/Profile/UserImportProfile.php b/lib/Service/Import/Profile/UserImportProfile.php new file mode 100644 index 0000000..a72812c --- /dev/null +++ b/lib/Service/Import/Profile/UserImportProfile.php @@ -0,0 +1,440 @@ +|null + */ + private ?array $allowedLocales = null; + + public function key(): string + { + return 'users'; + } + + public function label(): string + { + return t('Users'); + } + + public function allowedTargets(): array + { + return [ + 'first_name' => t('First name'), + 'last_name' => t('Last name'), + 'email' => t('Email'), + 'job_title' => t('Job title'), + 'profile_description' => t('Profile description'), + 'phone' => t('Phone'), + 'mobile' => t('Mobile'), + 'short_dial' => t('Short dial'), + 'address' => t('Address'), + 'postal_code' => t('Postal code'), + 'city' => t('City'), + 'region' => t('Region'), + 'country' => t('Country'), + 'hire_date' => t('Hire date'), + 'locale' => t('Language'), + 'active' => t('Active'), + 'tenant' => t('Tenant'), + 'role' => t('Role'), + 'department' => t('Department'), + ]; + } + + public function requiredTargets(): array + { + return ['first_name', 'last_name', 'email']; + } + + public function autoMapAliases(): array + { + return [ + 'first_name' => ['first_name', 'firstname', 'vorname', 'given_name'], + 'last_name' => ['last_name', 'lastname', 'nachname', 'surname', 'family_name'], + 'email' => ['email', 'mail', 'e_mail', 'e-mail'], + 'job_title' => ['job_title', 'job', 'title', 'position'], + 'profile_description' => ['profile_description', 'description', 'about', 'bio'], + 'phone' => ['phone', 'telefon', 'business_phone'], + 'mobile' => ['mobile', 'cell', 'mobile_phone', 'handy'], + 'short_dial' => ['short_dial', 'extension', 'ext'], + 'address' => ['address', 'street', 'strasse', 'anschrift'], + 'postal_code' => ['postal_code', 'zip', 'plz'], + 'city' => ['city', 'ort'], + 'region' => ['region', 'state', 'bundesland'], + 'country' => ['country', 'land'], + 'hire_date' => ['hire_date', 'entry_date', 'start_date', 'eintrittsdatum'], + 'locale' => ['locale', 'language', 'lang', 'sprache'], + 'active' => ['active', 'status', 'enabled', 'aktiv'], + 'tenant' => ['tenant', 'tenant_uuid', 'tenant_id', 'mandant', 'mandant_uuid', 'mandant_id'], + 'role' => ['role', 'role_uuid', 'role_id', 'rolle', 'rolle_uuid', 'rolle_id'], + 'department' => ['department', 'department_uuid', 'department_id', 'abteilung', 'abteilung_uuid', 'abteilung_id'], + ]; + } + + public function supportsAssignments(): bool + { + return true; + } + + public function requiresAssignmentPermission(): bool + { + return true; + } + + public function validateMappedRow(array $mapped, array $context): array + { + $errors = []; + $normalized = []; + + $normalized['first_name'] = trim((string) ($mapped['first_name'] ?? '')); + $normalized['last_name'] = trim((string) ($mapped['last_name'] ?? '')); + $normalized['email'] = $this->lower(trim((string) ($mapped['email'] ?? ''))); + + if ($normalized['first_name'] === '') { + $errors[] = ['code' => 'mapping_required_missing', 'message' => t('Required field is empty: first_name')]; + } + if ($normalized['last_name'] === '') { + $errors[] = ['code' => 'mapping_required_missing', 'message' => t('Required field is empty: last_name')]; + } + if ($normalized['email'] === '' || !filter_var($normalized['email'], FILTER_VALIDATE_EMAIL)) { + $errors[] = ['code' => 'invalid_email']; + } + + foreach ([ + 'job_title', + 'profile_description', + 'phone', + 'mobile', + 'short_dial', + 'address', + 'postal_code', + 'city', + 'region', + 'country', + ] as $field) { + $normalized[$field] = trim((string) ($mapped[$field] ?? '')); + } + + $normalized['hire_date'] = trim((string) ($mapped['hire_date'] ?? '')); + if ($normalized['hire_date'] !== '' && !$this->isValidDate($normalized['hire_date'])) { + $errors[] = ['code' => 'invalid_hire_date']; + } + + $normalized['locale'] = $this->lower(trim((string) ($mapped['locale'] ?? ''))); + if ($normalized['locale'] !== '' && !in_array($normalized['locale'], $this->allowedLocales(), true)) { + $errors[] = ['code' => 'invalid_locale']; + } + + $activeRaw = trim((string) ($mapped['active'] ?? '')); + $active = $this->parseActive($activeRaw); + if ($activeRaw !== '' && $active === null) { + $errors[] = ['code' => 'invalid_active']; + } + $normalized['active'] = $active ?? 1; + + $assignmentResult = $this->resolveAssignments($mapped, $context); + if (!$assignmentResult['ok']) { + $errors = array_merge($errors, $assignmentResult['errors'] ?? []); + } else { + $normalized['tenant_id'] = (int) ($assignmentResult['tenant_id'] ?? 0); + $normalized['role_id'] = (int) ($assignmentResult['role_id'] ?? 0); + $normalized['department_id'] = (int) ($assignmentResult['department_id'] ?? 0); + } + + return [ + 'ok' => !$errors, + 'normalized' => $normalized, + 'errors' => $errors, + ]; + } + + public function dryRunRow(array $normalized, array $context): array + { + $existing = UserRepository::findByEmail((string) ($normalized['email'] ?? '')); + if ($existing) { + return ['status' => 'skipped', 'code' => 'email_exists']; + } + return ['status' => 'would_create']; + } + + public function commitRow(array $normalized, array $context): array + { + $existing = UserRepository::findByEmail((string) ($normalized['email'] ?? '')); + if ($existing) { + return ['status' => 'skipped', 'code' => 'email_exists']; + } + + $password = $this->generatePassword(); + $input = [ + 'first_name' => (string) ($normalized['first_name'] ?? ''), + 'last_name' => (string) ($normalized['last_name'] ?? ''), + 'email' => (string) ($normalized['email'] ?? ''), + 'job_title' => (string) ($normalized['job_title'] ?? ''), + 'profile_description' => (string) ($normalized['profile_description'] ?? ''), + 'phone' => (string) ($normalized['phone'] ?? ''), + 'mobile' => (string) ($normalized['mobile'] ?? ''), + 'short_dial' => (string) ($normalized['short_dial'] ?? ''), + 'address' => (string) ($normalized['address'] ?? ''), + 'postal_code' => (string) ($normalized['postal_code'] ?? ''), + 'city' => (string) ($normalized['city'] ?? ''), + 'region' => (string) ($normalized['region'] ?? ''), + 'country' => (string) ($normalized['country'] ?? ''), + 'hire_date' => (string) ($normalized['hire_date'] ?? ''), + 'locale' => (string) ($normalized['locale'] ?? ''), + 'password' => $password, + 'password2' => $password, + 'tenant_ids' => [(int) ($normalized['tenant_id'] ?? 0)], + 'role_ids' => [(int) ($normalized['role_id'] ?? 0)], + 'department_ids' => [(int) ($normalized['department_id'] ?? 0)], + 'primary_tenant_id' => (int) ($normalized['tenant_id'] ?? 0), + ]; + + if ((int) ($normalized['active'] ?? 1) === 1) { + $input['active'] = 1; + } else { + // UserService::createFromAdmin expects a present-but-null value for inactive state. + $input['active'] = null; + } + + $result = UserService::createFromAdmin($input, (int) ($context['current_user_id'] ?? 0)); + if (!($result['ok'] ?? false)) { + $errors = $result['errors'] ?? []; + $message = is_array($errors) && isset($errors[0]) ? (string) $errors[0] : t('User can not be registered'); + return ['status' => 'failed', 'code' => 'create_failed', 'message' => $message]; + } + + return [ + 'status' => 'created', + 'uuid' => (string) ($result['uuid'] ?? ''), + ]; + } + + public function inFileDuplicateKey(array $normalized): ?string + { + $email = $this->lower(trim((string) ($normalized['email'] ?? ''))); + return $email !== '' ? 'email:' . $email : null; + } + + public function rowIdentifier(array $normalized): string + { + return trim((string) ($normalized['email'] ?? '')); + } + + /** + * @param array $mapped + * @param array $context + * @return array{ok:bool, tenant_id?:int, role_id?:int, department_id?:int, errors?:array} + */ + private function resolveAssignments(array $mapped, array $context): array + { + $errors = []; + + $tenantRaw = trim((string) ($mapped['tenant'] ?? '')); + $roleRaw = trim((string) ($mapped['role'] ?? '')); + $departmentRaw = trim((string) ($mapped['department'] ?? '')); + $hasAssignmentInput = $tenantRaw !== '' || $roleRaw !== '' || $departmentRaw !== ''; + + if ($hasAssignmentInput && empty($context['can_import_assignments'])) { + return ['ok' => false, 'errors' => [['code' => 'assignment_permission_required']]]; + } + + $tenantId = 0; + $roleId = 0; + $departmentId = 0; + $departmentTenantId = 0; + + if ($tenantRaw !== '') { + $tenant = $this->resolveTenant($tenantRaw); + if (!$tenant) { + $errors[] = ['code' => 'tenant_not_found']; + } else { + $tenantId = (int) ($tenant['id'] ?? 0); + } + } + + if ($roleRaw !== '') { + $role = $this->resolveRole($roleRaw); + if (!$role) { + $errors[] = ['code' => 'role_not_found']; + } else { + $roleId = (int) ($role['id'] ?? 0); + } + } + + if ($departmentRaw !== '') { + $department = $this->resolveDepartment($departmentRaw); + if (!$department) { + $errors[] = ['code' => 'department_not_found']; + } else { + $departmentId = (int) ($department['id'] ?? 0); + $departmentTenantId = (int) ($department['tenant_id'] ?? 0); + if ($tenantId > 0 && $departmentTenantId > 0 && $tenantId !== $departmentTenantId) { + $errors[] = ['code' => 'department_tenant_mismatch']; + } elseif ($tenantId <= 0 && $departmentTenantId > 0) { + $tenantId = $departmentTenantId; + } + } + } + + if ($tenantId <= 0) { + $tenantId = (int) ($context['default_tenant_id'] ?? 0); + if ($tenantId <= 0 || !$this->resolveTenant((string) $tenantId)) { + $errors[] = ['code' => 'tenant_not_found']; + } + } + if ($roleId <= 0) { + $roleId = (int) ($context['default_role_id'] ?? 0); + if ($roleId <= 0 || !$this->resolveRole((string) $roleId)) { + $errors[] = ['code' => 'role_not_found']; + } + } + if ($departmentId <= 0) { + $departmentId = (int) ($context['default_department_id'] ?? 0); + if ($departmentId <= 0) { + $errors[] = ['code' => 'department_not_found']; + } else { + $department = $this->resolveDepartment((string) $departmentId); + if (!$department) { + $errors[] = ['code' => 'department_not_found']; + } else { + $departmentTenantId = (int) ($department['tenant_id'] ?? 0); + } + } + } + + if ($tenantId > 0 && $departmentTenantId > 0 && $tenantId !== $departmentTenantId) { + $errors[] = ['code' => 'department_tenant_mismatch']; + } + + if ($tenantId > 0 && empty($context['is_global_admin'])) { + $allowedTenantIds = $context['allowed_tenant_ids'] ?? []; + if (!in_array($tenantId, $allowedTenantIds, true)) { + $errors[] = ['code' => 'assignment_out_of_scope']; + } + } + + return [ + 'ok' => !$errors, + 'tenant_id' => $tenantId, + 'role_id' => $roleId, + 'department_id' => $departmentId, + 'errors' => $errors, + ]; + } + + private function resolveTenant(string $value): ?array + { + $tenant = null; + if ($this->isUuid($value)) { + $tenant = TenantRepository::findByUuid($value); + } elseif (ctype_digit($value)) { + $tenant = TenantRepository::find((int) $value); + } + + if (!$tenant) { + return null; + } + return (($tenant['status'] ?? '') === 'active') ? $tenant : null; + } + + private function resolveRole(string $value): ?array + { + $role = null; + if ($this->isUuid($value)) { + $role = RoleRepository::findByUuid($value); + } elseif (ctype_digit($value)) { + $role = RoleRepository::find((int) $value); + } + + if (!$role) { + return null; + } + return ((int) ($role['active'] ?? 0) === 1) ? $role : null; + } + + private function resolveDepartment(string $value): ?array + { + $department = null; + if ($this->isUuid($value)) { + $department = DepartmentRepository::findByUuid($value); + } elseif (ctype_digit($value)) { + $department = DepartmentRepository::find((int) $value); + } + + if (!$department) { + return null; + } + return ((int) ($department['active'] ?? 0) === 1) ? $department : null; + } + + private function isValidDate(string $value): bool + { + $dt = \DateTimeImmutable::createFromFormat('Y-m-d', $value); + return $dt && $dt->format('Y-m-d') === $value; + } + + private function parseActive(string $value): ?int + { + $value = $this->lower(trim($value)); + if ($value === '') { + return null; + } + + if (in_array($value, ['1', 'true', 'yes', 'active', 'ja', 'aktiv'], true)) { + return 1; + } + if (in_array($value, ['0', 'false', 'no', 'inactive', 'nein', 'inaktiv'], true)) { + return 0; + } + return null; + } + + /** + * @return array + */ + private function allowedLocales(): array + { + if ($this->allowedLocales !== null) { + return $this->allowedLocales; + } + + $locales = defined('APP_LOCALES') && is_array(APP_LOCALES) ? APP_LOCALES : []; + $locales = array_values(array_filter(array_map(function ($locale) { + return $this->lower(trim((string) $locale)); + }, $locales), static fn ($locale) => $locale !== '')); + + $this->allowedLocales = $locales ?: ['de', 'en']; + return $this->allowedLocales; + } + + private function generatePassword(): string + { + try { + return 'imp_' . bin2hex(random_bytes(24)) . 'A!'; + } catch (\Throwable $exception) { + return 'imp_' . str_replace('.', '', uniqid('', true)) . 'A!'; + } + } + + private function isUuid(string $value): bool + { + return (bool) preg_match('/^[a-f0-9-]{36}$/i', $value); + } + + private function lower(string $value): string + { + if (function_exists('mb_strtolower')) { + return mb_strtolower($value, 'UTF-8'); + } + return strtolower($value); + } +} diff --git a/lib/Service/Org/DepartmentService.php b/lib/Service/Org/DepartmentService.php index 00b0491..a306aa0 100644 --- a/lib/Service/Org/DepartmentService.php +++ b/lib/Service/Org/DepartmentService.php @@ -3,7 +3,6 @@ namespace MintyPHP\Service\Org; use MintyPHP\Repository\Org\DepartmentRepository; -use MintyPHP\Repository\Tenant\TenantDepartmentRepository; use MintyPHP\Repository\Org\UserDepartmentRepository; use MintyPHP\Service\Settings\SettingService; use MintyPHP\Service\Tenant\TenantScopeService; @@ -31,6 +30,39 @@ class DepartmentService return DepartmentRepository::listByTenantIds($tenantIds); } + public static function groupActiveByTenantIds(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 []; + } + + $departments = self::listByTenantIds($tenantIds); + if (!$departments) { + return []; + } + + $grouped = []; + foreach ($departments as $department) { + $tenantId = (int) ($department['tenant_id'] ?? 0); + if ($tenantId <= 0) { + continue; + } + $grouped[$tenantId] ??= []; + $grouped[$tenantId][] = $department; + } + + foreach ($grouped as &$items) { + usort($items, static function (array $a, array $b): int { + return strcmp((string) ($a['description'] ?? ''), (string) ($b['description'] ?? '')); + }); + } + unset($items); + + return $grouped; + } + public static function listByIds(array $departmentIds): array { return DepartmentRepository::listByIds($departmentIds); @@ -79,6 +111,7 @@ class DepartmentService $createdId = DepartmentRepository::create([ 'description' => $form['description'], + 'tenant_id' => $form['tenant_id'], 'code' => $form['code'] ?: null, 'cost_center' => $form['cost_center'] ?: null, 'active' => $form['active'], @@ -91,7 +124,7 @@ class DepartmentService $createdDepartment = DepartmentRepository::find((int) $createdId); $uuid = $createdDepartment['uuid'] ?? null; - if (!empty($input['is_default']) && $createdId) { + if (!empty($input['is_default'])) { SettingService::setDefaultDepartmentId((int) $createdId); } return ['ok' => true, 'form' => $form, 'warnings' => $warnings, 'uuid' => $uuid, 'id' => (int) $createdId]; @@ -109,6 +142,7 @@ class DepartmentService $updated = DepartmentRepository::update($departmentId, [ 'description' => $form['description'], + 'tenant_id' => $form['tenant_id'], 'code' => $form['code'] ?: null, 'cost_center' => $form['cost_center'] ?: null, 'active' => $form['active'], @@ -122,12 +156,26 @@ class DepartmentService return ['ok' => true, 'form' => $form, 'warnings' => $warnings]; } - public static function syncTenants(int $departmentId, array $tenantIds): int + public static function syncTenant(int $departmentId, int $tenantId): int { - $updated = TenantDepartmentRepository::replaceForDepartment($departmentId, $tenantIds); + $updated = DepartmentRepository::setTenant($departmentId, $tenantId); if (!$updated) { return -1; } + return self::cleanupUserAssignments($departmentId); + } + + public static function syncTenants(int $departmentId, array $tenantIds): int + { + $tenantId = (int) ($tenantIds[0] ?? 0); + if ($tenantId <= 0) { + return -1; + } + return self::syncTenant($departmentId, $tenantId); + } + + public static function cleanupUserAssignments(int $departmentId): int + { return UserDepartmentRepository::removeInvalidForDepartment($departmentId); } @@ -155,6 +203,7 @@ class DepartmentService { return [ 'description' => trim((string) ($input['description'] ?? '')), + 'tenant_id' => (int) ($input['tenant_id'] ?? 0), 'code' => trim((string) ($input['code'] ?? '')), 'cost_center' => trim((string) ($input['cost_center'] ?? '')), 'active' => self::normalizeActive($input['active'] ?? 1), @@ -167,6 +216,9 @@ class DepartmentService if ($form['description'] === '') { $errors[] = t('Description cannot be empty'); } + if ((int) ($form['tenant_id'] ?? 0) <= 0) { + $errors[] = t('Please select at least one tenant'); + } return $errors; } diff --git a/lib/Service/Scheduler/Handler/ScheduledJobHandlerInterface.php b/lib/Service/Scheduler/Handler/ScheduledJobHandlerInterface.php new file mode 100644 index 0000000..5c774ee --- /dev/null +++ b/lib/Service/Scheduler/Handler/ScheduledJobHandlerInterface.php @@ -0,0 +1,46 @@ + 'User lifecycle run', + 'description' => 'Runs automatic user deactivate/delete policy', + 'default_enabled' => 1, + 'default_timezone' => defined('APP_TIMEZONE') ? (string) APP_TIMEZONE : 'UTC', + 'default_schedule_type' => 'daily', + 'default_schedule_interval' => 1, + 'default_schedule_time' => '02:15', + 'default_schedule_weekdays_csv' => null, + 'default_catchup_once' => 1, + 'allowed_schedule_types' => ['hourly', 'daily', 'weekly'], + ]; + } + + public static function execute(?int $actorUserId): array + { + $result = UserLifecycleService::run($actorUserId !== null && $actorUserId > 0 ? $actorUserId : null); + + 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' => self::formatResult($result), + ]; + } + + return [ + 'status' => 'success', + 'error_code' => null, + 'error_message' => null, + 'result' => self::formatResult($result), + ]; + } + + private static function formatResult(array $result): array + { + return [ + 'run_uuid' => (string) ($result['run_uuid'] ?? ''), + 'deactivated_count' => (int) ($result['deactivated_count'] ?? 0), + 'deleted_count' => (int) ($result['deleted_count'] ?? 0), + 'skipped_count' => (int) ($result['skipped_count'] ?? 0), + 'duration_ms' => (int) ($result['duration_ms'] ?? 0), + ]; + } +} diff --git a/lib/Service/Scheduler/ScheduleCalculator.php b/lib/Service/Scheduler/ScheduleCalculator.php new file mode 100644 index 0000000..9b17cea --- /dev/null +++ b/lib/Service/Scheduler/ScheduleCalculator.php @@ -0,0 +1,173 @@ + 23 || $minute < 0 || $minute > 59) { + return null; + } + return sprintf('%02d:%02d', $hour, $minute); + } + + public static function normalizeWeekdaysCsv(mixed $value): ?string + { + $list = self::parseWeekdays($value); + if (!$list) { + return null; + } + return implode(',', $list); + } + + /** + * @return array + */ + public static function parseWeekdays(mixed $value): array + { + $raw = []; + if (is_array($value)) { + $raw = $value; + } else { + $raw = explode(',', (string) $value); + } + + $weekdays = []; + foreach ($raw as $item) { + $day = (int) trim((string) $item); + if ($day >= 1 && $day <= 7) { + $weekdays[$day] = true; + } + } + + $list = array_keys($weekdays); + sort($list, SORT_NUMERIC); + return $list; + } + + /** + * Calculates the next UTC execution time for a scheduled job based on its schedule + * configuration and a reference point in time. + * + * Expected keys in $job: + * - schedule_type (string) 'hourly'|'daily'|'weekly' + * - schedule_interval (int) 1..24 for hourly, 1..365 for daily, 1..52 for weekly + * - timezone (string) IANA timezone name (e.g. 'Europe/Berlin') + * - schedule_time (string) 'HH:MM', required for daily and weekly + * - schedule_weekdays_csv (string) CSV of ISO weekday numbers 1 (Mon)..7 (Sun), required for weekly + * + * All schedule calculations are performed in the job's local timezone to correctly + * handle DST transitions. The returned value is always UTC. + * + * Returns null if the schedule is misconfigured (e.g. missing schedule_time for daily/weekly) + * or if no valid next run could be found within the search window. + */ + public static function calculateNextRunUtc(array $job, ?\DateTimeImmutable $referenceUtc = null): ?\DateTimeImmutable + { + $referenceUtc = $referenceUtc ?? new \DateTimeImmutable('now', new \DateTimeZone('UTC')); + $type = self::normalizeScheduleType((string) ($job['schedule_type'] ?? 'daily')); + $interval = self::normalizeInterval($type, (int) ($job['schedule_interval'] ?? 1)); + $timezone = new \DateTimeZone(self::normalizeTimezone((string) ($job['timezone'] ?? ''))); + $localReference = $referenceUtc->setTimezone($timezone); + + if ($type === 'hourly') { + // Snap to the top of the current hour, then advance by the interval. + $candidate = $localReference + ->setTime((int) $localReference->format('H'), 0, 0) + ->modify('+' . $interval . ' hour'); + return $candidate->setTimezone(new \DateTimeZone('UTC')); + } + + $time = self::normalizeTime((string) ($job['schedule_time'] ?? '')); + if ($time === null) { + return null; + } + [$hours, $minutes] = array_map('intval', explode(':', $time)); + + if ($type === 'daily') { + $candidate = $localReference->setTime($hours, $minutes, 0); + if ($candidate <= $localReference) { + $candidate = $candidate->modify('+' . $interval . ' day'); + } + return $candidate->setTimezone(new \DateTimeZone('UTC')); + } + + // Weekly: iterate forward day by day until we find a matching weekday in the correct + // week interval. The search window is 1110 days (~3 years), which comfortably covers + // the maximum weekly interval of 52 weeks × 7 days/week = 364 days, with margin for + // weekday alignment and DST edge cases. + $weekdays = self::parseWeekdays((string) ($job['schedule_weekdays_csv'] ?? '')); + if (!$weekdays) { + $weekdays = [1]; + } + + $startOfDay = $localReference->setTime(0, 0, 0); + + // 1970-01-05 is a Monday in any timezone offset that does not cross the date line, + // used as a stable epoch to count week indices for "every N weeks" interval logic. + $epochMonday = new \DateTimeImmutable('1970-01-05 00:00:00', $timezone); + for ($i = 0; $i < 370 * 3; $i++) { + $day = $startOfDay->modify('+' . $i . ' day'); + $isoDay = (int) $day->format('N'); + if (!in_array($isoDay, $weekdays, true)) { + continue; + } + + $daysFromEpoch = (int) $epochMonday->diff($day)->format('%r%a'); + $weekIndex = (int) floor($daysFromEpoch / 7); + if ($interval > 1 && ($weekIndex % $interval) !== 0) { + continue; + } + + $candidate = $day->setTime($hours, $minutes, 0); + if ($candidate <= $localReference) { + continue; + } + return $candidate->setTimezone(new \DateTimeZone('UTC')); + } + + return null; + } +} diff --git a/lib/Service/Scheduler/ScheduledJobRegistry.php b/lib/Service/Scheduler/ScheduledJobRegistry.php new file mode 100644 index 0000000..a2812bd --- /dev/null +++ b/lib/Service/Scheduler/ScheduledJobRegistry.php @@ -0,0 +1,79 @@ + handler class name (must implement ScheduledJobHandlerInterface). + * + * To register a new job: + * 1. Create lib/Service/Scheduler/Handler/YourJobHandler.php implementing the interface. + * 2. Add a public const for the job key above. + * 3. Add one line here: self::YOUR_JOB_KEY => YourJobHandler::class, + * + * No other file needs to be changed. + * + * @return array> + */ + private static function handlers(): array + { + return [ + self::USER_LIFECYCLE_RUN => UserLifecycleJobHandler::class, + ]; + } + + /** + * Returns all job definitions keyed by job_key. + * Consumed by ScheduledJobService::ensureSystemJobs() to sync registry with the database. + */ + public static function definitions(): array + { + $result = []; + foreach (self::handlers() as $jobKey => $handlerClass) { + $result[$jobKey] = $handlerClass::definition(); + } + return $result; + } + + /** + * Returns the definition for a single job key, or null if not registered. + */ + public static function get(string $jobKey): ?array + { + $jobKey = trim($jobKey); + if ($jobKey === '') { + return null; + } + $handlers = self::handlers(); + return isset($handlers[$jobKey]) ? $handlers[$jobKey]::definition() : null; + } + + /** + * Dispatches execution to the registered handler for the given job key and returns + * a normalized result envelope. Unknown job keys return status 'failed' with + * error_code 'job_not_supported' rather than throwing an exception. + * + * @param string $jobKey Registered job identifier (see class constants). + * @param int|null $actorUserId Null for scheduler-triggered runs; user ID for manual runs. + * @return array{status:string,error_code:?string,error_message:?string,result:array} + */ + public static function execute(string $jobKey, ?int $actorUserId): array + { + $handlers = self::handlers(); + if (!isset($handlers[$jobKey])) { + return [ + 'status' => 'failed', + 'error_code' => 'job_not_supported', + 'error_message' => null, + 'result' => [], + ]; + } + return $handlers[$jobKey]::execute($actorUserId); + } +} diff --git a/lib/Service/Scheduler/ScheduledJobService.php b/lib/Service/Scheduler/ScheduledJobService.php new file mode 100644 index 0000000..ddb09ed --- /dev/null +++ b/lib/Service/Scheduler/ScheduledJobService.php @@ -0,0 +1,271 @@ + $definition) { + $existing = ScheduledJobRepository::findByKey($jobKey); + $job = self::buildDefaultJob($definition, $jobKey, $nowUtc); + + if (!$existing) { + ScheduledJobRepository::create($job); + continue; + } + + $normalized = self::normalizeStoredJob($existing, $definition); + if (!self::jobsDiffer($existing, $normalized)) { + continue; + } + ScheduledJobRepository::updateJobMeta((int) $existing['id'], $normalized); + } + } + + public static function listPaged(array $filters): array + { + self::ensureSystemJobs(); + return ScheduledJobRepository::listPaged($filters); + } + + public static function find(int $id): ?array + { + self::ensureSystemJobs(); + return ScheduledJobRepository::find($id); + } + + public static function updateFromAdmin(int $id, array $input): array + { + $job = self::find($id); + if (!$job) { + return ['ok' => false, 'errors' => [t('Scheduled job not found')]]; + } + + $definition = ScheduledJobRegistry::get((string) ($job['job_key'] ?? '')); + if (!$definition) { + return ['ok' => false, 'errors' => [t('Scheduled job is not registered')]]; + } + + $form = [ + 'enabled' => isset($input['enabled']) ? 1 : 0, + 'timezone' => ScheduleCalculator::normalizeTimezone((string) ($input['timezone'] ?? ($job['timezone'] ?? ''))), + 'schedule_type' => ScheduleCalculator::normalizeScheduleType((string) ($input['schedule_type'] ?? ($job['schedule_type'] ?? 'daily'))), + 'schedule_interval' => (int) ($input['schedule_interval'] ?? ($job['schedule_interval'] ?? 1)), + 'schedule_time' => ScheduleCalculator::normalizeTime((string) ($input['schedule_time'] ?? ($job['schedule_time'] ?? ''))), + 'schedule_weekdays_csv' => ScheduleCalculator::normalizeWeekdaysCsv( + array_key_exists('schedule_weekdays', $input) + ? $input['schedule_weekdays'] + : ($job['schedule_weekdays_csv'] ?? '') + ), + 'catchup_once' => isset($input['catchup_once']) ? 1 : 0, + ]; + + $errors = self::validateForm($form, $definition); + if ($errors) { + return ['ok' => false, 'errors' => $errors, 'form' => $form, 'job' => $job]; + } + + $nextRunAt = null; + if ((int) $form['enabled'] === 1) { + $nextRun = ScheduleCalculator::calculateNextRunUtc($form, new \DateTimeImmutable('now', new \DateTimeZone('UTC'))); + $nextRunAt = $nextRun ? $nextRun->format('Y-m-d H:i:s') : null; + } + + $updated = ScheduledJobRepository::updateJobMeta((int) $job['id'], [ + 'label' => (string) ($definition['label'] ?? (string) ($job['label'] ?? '')), + 'description' => (string) ($definition['description'] ?? (string) ($job['description'] ?? '')), + 'enabled' => (int) $form['enabled'], + 'timezone' => (string) $form['timezone'], + 'schedule_type' => (string) $form['schedule_type'], + 'schedule_interval' => (int) $form['schedule_interval'], + 'schedule_time' => $form['schedule_time'], + 'schedule_weekdays_csv' => $form['schedule_weekdays_csv'], + 'catchup_once' => (int) $form['catchup_once'], + 'next_run_at' => $nextRunAt, + ]); + + if (!$updated) { + return ['ok' => false, 'errors' => [t('Scheduled job could not be saved')], 'form' => $form, 'job' => $job]; + } + + $fresh = self::find((int) $job['id']); + return ['ok' => true, 'job' => $fresh, 'form' => $form]; + } + + public static function listRunsByJobId(int $jobId, array $filters): array + { + return ScheduledJobRunRepository::listPagedByJobId($jobId, $filters); + } + + public static function purgeRunsExpired(): int + { + return ScheduledJobRunRepository::purgeOlderThanDays(self::RUN_RETENTION_DAYS); + } + + public static function scheduleSummary(array $job): string + { + $type = ScheduleCalculator::normalizeScheduleType((string) ($job['schedule_type'] ?? 'daily')); + $interval = (int) ($job['schedule_interval'] ?? 1); + $time = (string) ($job['schedule_time'] ?? ''); + + if ($type === 'hourly') { + return sprintf(t('Every %d hour(s)'), max(1, $interval)); + } + if ($type === 'daily') { + return sprintf(t('Every %d day(s) at %s'), max(1, $interval), $time !== '' ? $time : '--:--'); + } + + $weekdays = ScheduleCalculator::parseWeekdays((string) ($job['schedule_weekdays_csv'] ?? '')); + $labels = []; + foreach ($weekdays as $day) { + $labels[] = self::weekdayLabel($day); + } + $weekdayText = $labels ? implode(', ', $labels) : t('No weekdays'); + return sprintf( + t('Every %d week(s) on %s at %s'), + max(1, $interval), + $weekdayText, + $time !== '' ? $time : '--:--' + ); + } + + public static function weekdaysFromCsv(?string $csv): array + { + return ScheduleCalculator::parseWeekdays((string) ($csv ?? '')); + } + + private static function validateForm(array $form, array $definition): array + { + $errors = []; + $allowedTypes = $definition['allowed_schedule_types'] ?? ['hourly', 'daily', 'weekly']; + $type = (string) ($form['schedule_type'] ?? 'daily'); + if (!in_array($type, $allowedTypes, true)) { + $errors[] = t('Schedule type is invalid'); + return $errors; + } + + $interval = (int) ($form['schedule_interval'] ?? 0); + if ($type === 'hourly' && ($interval < 1 || $interval > 24)) { + $errors[] = t('Hourly interval must be between 1 and 24'); + } elseif ($type === 'daily' && ($interval < 1 || $interval > 365)) { + $errors[] = t('Daily interval must be between 1 and 365'); + } elseif ($type === 'weekly' && ($interval < 1 || $interval > 52)) { + $errors[] = t('Weekly interval must be between 1 and 52'); + } + + $timezone = trim((string) ($form['timezone'] ?? '')); + try { + new \DateTimeZone($timezone); + } catch (\Throwable $exception) { + $errors[] = t('Timezone is invalid'); + } + + if (in_array($type, ['daily', 'weekly'], true) && $form['schedule_time'] === null) { + $errors[] = t('Schedule time is required'); + } + if ($type === 'weekly') { + $weekdays = ScheduleCalculator::parseWeekdays((string) ($form['schedule_weekdays_csv'] ?? '')); + if (!$weekdays) { + $errors[] = t('At least one weekday is required for weekly schedule'); + } + } + + return $errors; + } + + private static function buildDefaultJob(array $definition, string $jobKey, \DateTimeImmutable $nowUtc): array + { + $type = (string) ($definition['default_schedule_type'] ?? 'daily'); + $job = [ + 'job_key' => $jobKey, + 'label' => (string) ($definition['label'] ?? $jobKey), + 'description' => (string) ($definition['description'] ?? ''), + 'enabled' => (int) ($definition['default_enabled'] ?? 1), + 'timezone' => ScheduleCalculator::normalizeTimezone((string) ($definition['default_timezone'] ?? '')), + 'schedule_type' => ScheduleCalculator::normalizeScheduleType($type), + 'schedule_interval' => ScheduleCalculator::normalizeInterval($type, (int) ($definition['default_schedule_interval'] ?? 1)), + 'schedule_time' => ScheduleCalculator::normalizeTime((string) ($definition['default_schedule_time'] ?? '')), + 'schedule_weekdays_csv' => ScheduleCalculator::normalizeWeekdaysCsv($definition['default_schedule_weekdays_csv'] ?? null), + 'catchup_once' => (int) ($definition['default_catchup_once'] ?? 1), + 'next_run_at' => null, + ]; + if ((int) $job['enabled'] === 1) { + $nextRun = ScheduleCalculator::calculateNextRunUtc($job, $nowUtc); + $job['next_run_at'] = $nextRun ? $nextRun->format('Y-m-d H:i:s') : null; + } + return $job; + } + + private static function normalizeStoredJob(array $existing, array $definition): array + { + $type = ScheduleCalculator::normalizeScheduleType((string) ($existing['schedule_type'] ?? ($definition['default_schedule_type'] ?? 'daily'))); + $normalized = [ + 'label' => (string) ($definition['label'] ?? (string) ($existing['label'] ?? '')), + 'description' => (string) ($definition['description'] ?? (string) ($existing['description'] ?? '')), + 'enabled' => (int) ($existing['enabled'] ?? ($definition['default_enabled'] ?? 1)), + 'timezone' => ScheduleCalculator::normalizeTimezone((string) ($existing['timezone'] ?? ($definition['default_timezone'] ?? ''))), + 'schedule_type' => $type, + 'schedule_interval' => ScheduleCalculator::normalizeInterval($type, (int) ($existing['schedule_interval'] ?? ($definition['default_schedule_interval'] ?? 1))), + 'schedule_time' => ScheduleCalculator::normalizeTime((string) ($existing['schedule_time'] ?? ($definition['default_schedule_time'] ?? ''))), + 'schedule_weekdays_csv' => ScheduleCalculator::normalizeWeekdaysCsv( + (string) ($existing['schedule_weekdays_csv'] ?? ($definition['default_schedule_weekdays_csv'] ?? '')) + ), + 'catchup_once' => (int) ($existing['catchup_once'] ?? ($definition['default_catchup_once'] ?? 1)), + 'next_run_at' => (string) ($existing['next_run_at'] ?? ''), + ]; + if ($normalized['next_run_at'] === '' && (int) $normalized['enabled'] === 1) { + $nextRun = ScheduleCalculator::calculateNextRunUtc($normalized, new \DateTimeImmutable('now', new \DateTimeZone('UTC'))); + $normalized['next_run_at'] = $nextRun ? $nextRun->format('Y-m-d H:i:s') : null; + } else { + $normalized['next_run_at'] = $normalized['next_run_at'] !== '' ? $normalized['next_run_at'] : null; + } + return $normalized; + } + + private static function jobsDiffer(array $existing, array $normalized): bool + { + $keys = [ + 'label', + 'description', + 'enabled', + 'timezone', + 'schedule_type', + 'schedule_interval', + 'schedule_time', + 'schedule_weekdays_csv', + 'catchup_once', + 'next_run_at', + ]; + foreach ($keys as $key) { + $a = (string) ($existing[$key] ?? ''); + $b = (string) ($normalized[$key] ?? ''); + if ($a !== $b) { + return true; + } + } + return false; + } + + private static function weekdayLabel(int $weekday): string + { + return match ($weekday) { + 1 => t('Mon'), + 2 => t('Tue'), + 3 => t('Wed'), + 4 => t('Thu'), + 5 => t('Fri'), + 6 => t('Sat'), + 7 => t('Sun'), + default => '-', + }; + } +} diff --git a/lib/Service/Scheduler/SchedulerRunService.php b/lib/Service/Scheduler/SchedulerRunService.php new file mode 100644 index 0000000..2621908 --- /dev/null +++ b/lib/Service/Scheduler/SchedulerRunService.php @@ -0,0 +1,296 @@ + true, + 'error' => null, + 'duration_ms' => 0, + 'processed' => 0, + 'success' => 0, + 'failed' => 0, + 'skipped' => 0, + ]; + + if (!self::acquireRunnerLock()) { + $result['ok'] = false; + $result['error'] = 'lock_not_acquired'; + $result['duration_ms'] = self::durationMs($startedAt); + self::updateRuntimeHeartbeat('lock_not_acquired', 'lock_not_acquired'); + return $result; + } + + try { + $nowUtc = gmdate('Y-m-d H:i:s'); + $dueJobs = ScheduledJobRepository::listDueJobs($nowUtc, $limit); + foreach ($dueJobs as $job) { + $run = self::runOne($job, 'scheduler', null, false); + $result['processed']++; + $status = (string) ($run['status'] ?? 'failed'); + if ($status === 'success') { + $result['success']++; + } elseif ($status === 'skipped') { + $result['skipped']++; + } else { + $result['failed']++; + } + } + } catch (\Throwable $exception) { + $result['ok'] = false; + $result['error'] = 'unexpected_error'; + } finally { + self::releaseRunnerLock(); + $result['duration_ms'] = self::durationMs($startedAt); + if ($result['ok']) { + self::updateRuntimeHeartbeat('ok', null); + } else { + $errorCode = self::nullableString($result['error'] ?? null) ?? 'unexpected_error'; + self::updateRuntimeHeartbeat('unexpected_error', $errorCode); + } + } + + return $result; + } + + public static function runJobNow(int $jobId, int $actorUserId): array + { + ScheduledJobService::ensureSystemJobs(); + if ($jobId <= 0 || $actorUserId <= 0) { + return ['ok' => false, 'error' => 'invalid_request']; + } + if (!self::acquireRunnerLock()) { + return ['ok' => false, 'error' => 'lock_not_acquired']; + } + + try { + $job = ScheduledJobRepository::find($jobId); + if (!$job) { + return ['ok' => false, 'error' => 'job_not_found']; + } + $run = self::runOne($job, 'manual', $actorUserId, true); + return [ + 'ok' => in_array((string) ($run['status'] ?? ''), ['success', 'skipped'], true), + 'status' => (string) ($run['status'] ?? 'failed'), + 'error' => (string) ($run['error_code'] ?? ''), + ]; + } finally { + self::releaseRunnerLock(); + } + } + + private static function runOne(array $job, string $triggerType, ?int $actorUserId, bool $force): array + { + $jobId = (int) ($job['id'] ?? 0); + $nowUtc = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); + $startedAtUtc = $nowUtc->format('Y-m-d H:i:s'); + + $triggerType = in_array($triggerType, ['scheduler', 'manual'], true) ? $triggerType : 'scheduler'; + if ($jobId <= 0) { + return ['status' => 'failed', 'error_code' => 'job_not_found', 'error_message' => null]; + } + + if (!$force && ((int) ($job['enabled'] ?? 0) !== 1 || empty($job['next_run_at']))) { + return ['status' => 'skipped', 'error_code' => 'job_disabled', 'error_message' => null]; + } + + $markedRunning = ScheduledJobRepository::markRunning($jobId, $startedAtUtc); + if (!$markedRunning) { + self::insertRunLog([ + 'job_id' => $jobId, + 'job_key' => (string) ($job['job_key'] ?? ''), + 'trigger_type' => $triggerType, + 'status' => 'skipped', + 'actor_user_id' => $actorUserId, + 'started_at' => $startedAtUtc, + 'finished_at' => $startedAtUtc, + 'duration_ms' => 0, + 'error_code' => 'job_already_running', + 'error_message' => null, + 'result_json' => null, + ]); + return ['status' => 'skipped', 'error_code' => 'job_already_running', 'error_message' => null]; + } + + $startedMs = microtime(true); + $status = 'failed'; + $errorCode = null; + $errorMessage = null; + $resultPayload = []; + + try { + $definition = ScheduledJobRegistry::get((string) ($job['job_key'] ?? '')); + if (!$definition) { + $status = 'failed'; + $errorCode = 'job_not_registered'; + } else { + $execution = ScheduledJobRegistry::execute((string) $job['job_key'], $actorUserId); + $status = in_array((string) $execution['status'], ['success', 'failed', 'skipped'], true) + ? (string) $execution['status'] + : 'failed'; + $errorCode = self::nullableString($execution['error_code']); + $errorMessage = self::nullableString($execution['error_message']); + $resultPayload = $execution['result']; + } + } catch (\Throwable $exception) { + $status = 'failed'; + $errorCode = 'unexpected_error'; + $errorMessage = substr($exception->getMessage(), 0, 255) ?: null; + } + + $finishedAt = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); + $finishedAtUtc = $finishedAt->format('Y-m-d H:i:s'); + $durationMs = max(0, (int) round((microtime(true) - $startedMs) * 1000)); + + $nextRunUtc = null; + if ((int) ($job['enabled'] ?? 0) === 1) { + if ($triggerType === 'scheduler' && (int) ($job['catchup_once'] ?? 1) === 0 && !empty($job['next_run_at'])) { + // catchup_once = 0: advance next_run_at strictly from the originally scheduled + // time, skipping any windows that already passed. This prevents a backlog storm + // after downtime – missed slots are dropped, only the next future slot is kept. + // The guard cap (500) prevents an infinite loop if calculateNextRunUtc has a bug + // and keeps returning a time that is still in the past. + $reference = self::parseUtc((string) $job['next_run_at']) ?? $finishedAt; + $next = ScheduleCalculator::calculateNextRunUtc($job, $reference); + $guard = 0; + while ($next && $next <= $finishedAt && $guard < 500) { + $next = ScheduleCalculator::calculateNextRunUtc($job, $next); + $guard++; + } + $nextRunUtc = $next ? $next->format('Y-m-d H:i:s') : null; + } else { + // catchup_once = 1 (default): calculate next_run_at from the actual finish time. + // If a run was delayed or a slot was missed, exactly one catch-up run is scheduled + // starting from now, then normal scheduling resumes. + $next = ScheduleCalculator::calculateNextRunUtc($job, $finishedAt); + $nextRunUtc = $next ? $next->format('Y-m-d H:i:s') : null; + } + } + + ScheduledJobRepository::finishRun( + $jobId, + $status, + $startedAtUtc, + $finishedAtUtc, + $nextRunUtc, + $errorCode, + $errorMessage + ); + + self::insertRunLog([ + 'job_id' => $jobId, + 'job_key' => (string) ($job['job_key'] ?? ''), + 'trigger_type' => $triggerType, + 'status' => $status, + 'actor_user_id' => $actorUserId, + 'started_at' => $startedAtUtc, + 'finished_at' => $finishedAtUtc, + 'duration_ms' => $durationMs, + 'error_code' => $errorCode, + 'error_message' => $errorMessage, + 'result_json' => self::encodeResult($resultPayload), + ]); + + return ['status' => $status, 'error_code' => $errorCode, 'error_message' => $errorMessage]; + } + + private static function insertRunLog(array $data): void + { + try { + ScheduledJobRunRepository::create([ + 'run_uuid' => RepoQuery::uuidV4(), + 'job_id' => (int) ($data['job_id'] ?? 0), + 'job_key' => (string) ($data['job_key'] ?? ''), + 'trigger_type' => (string) ($data['trigger_type'] ?? 'scheduler'), + 'status' => (string) ($data['status'] ?? 'failed'), + 'actor_user_id' => ($data['actor_user_id'] ?? null) !== null ? (int) $data['actor_user_id'] : null, + 'started_at' => (string) ($data['started_at'] ?? ''), + 'finished_at' => $data['finished_at'] ?? null, + 'duration_ms' => ($data['duration_ms'] ?? null) !== null ? (int) $data['duration_ms'] : null, + 'error_code' => $data['error_code'] ?? null, + 'error_message' => $data['error_message'] ?? null, + 'result_json' => $data['result_json'] ?? null, + ]); + } catch (\Throwable $exception) { + // fail-open + } + } + + private static function encodeResult(array $result): ?string + { + if (!$result) { + return null; + } + $encoded = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + return is_string($encoded) ? $encoded : null; + } + + private static function parseUtc(string $value): ?\DateTimeImmutable + { + $value = trim($value); + if ($value === '') { + return null; + } + try { + return new \DateTimeImmutable($value, new \DateTimeZone('UTC')); + } catch (\Throwable $exception) { + return null; + } + } + + private static function nullableString(mixed $value): ?string + { + $value = trim((string) $value); + return $value !== '' ? $value : null; + } + + private static function acquireRunnerLock(): bool + { + $got = DB::selectValue('select GET_LOCK(?, 0) as got_lock', self::RUNNER_LOCK_NAME); + return (int) $got === 1; + } + + private static function updateRuntimeHeartbeat(string $status, ?string $errorCode): void + { + try { + SchedulerRuntimeRepository::touchHeartbeat($status, $errorCode); + } catch (\Throwable $exception) { + // fail-open + } + } + + private static function releaseRunnerLock(): void + { + try { + DB::selectValue('select RELEASE_LOCK(?) as released_lock', self::RUNNER_LOCK_NAME); + } catch (\Throwable $exception) { + // no-op + } + } + + private static function durationMs(float $startedAt): int + { + return (int) max(0, round((microtime(true) - $startedAt) * 1000)); + } +} diff --git a/lib/Service/Security/RateLimiterService.php b/lib/Service/Security/RateLimiterService.php new file mode 100644 index 0000000..f828565 --- /dev/null +++ b/lib/Service/Security/RateLimiterService.php @@ -0,0 +1,224 @@ + true, 'retry_after' => 0]; + } + + try { + $row = RateLimitRepository::findByScopeAndHash($normalized['scope'], $normalized['subject_hash']); + if (!is_array($row)) { + return ['allowed' => true, 'retry_after' => 0]; + } + + $blockedUntilTs = self::parseTimestamp((string) ($row['blocked_until'] ?? '')); + if ($blockedUntilTs === null) { + return ['allowed' => true, 'retry_after' => 0]; + } + + $nowTs = time(); + if ($blockedUntilTs <= $nowTs) { + RateLimitRepository::updateStateById( + (int) ($row['id'] ?? 0), + 0, + gmdate('Y-m-d H:i:s', $nowTs), + null + ); + return ['allowed' => true, 'retry_after' => 0]; + } + + return [ + 'allowed' => false, + 'retry_after' => max(1, $blockedUntilTs - $nowTs), + ]; + } catch (\Throwable $exception) { + // Fail-open: never lock users out because of rate limit storage issues. + return ['allowed' => true, 'retry_after' => 0]; + } + } + + public static function reset(string $scope, string $subject): void + { + $normalized = self::normalize($scope, $subject); + if ($normalized === null) { + return; + } + + try { + RateLimitRepository::deleteByScopeAndHash($normalized['scope'], $normalized['subject_hash']); + } catch (\Throwable $exception) { + // Ignore reset failures. + } + } + + private static function apply( + string $scope, + string $subject, + int $maxHits, + int $windowSeconds, + int $blockSeconds, + bool $increment + ): array { + $normalized = self::normalize($scope, $subject); + if ($normalized === null) { + return ['allowed' => true, 'retry_after' => 0]; + } + + $maxHits = max(1, $maxHits); + $windowSeconds = max(1, $windowSeconds); + $blockSeconds = max(1, $blockSeconds); + + try { + $nowTs = time(); + $nowSql = gmdate('Y-m-d H:i:s', $nowTs); + + for ($attempt = 0; $attempt < 2; $attempt++) { + $row = RateLimitRepository::findByScopeAndHash($normalized['scope'], $normalized['subject_hash']); + + if (!is_array($row)) { + if (!$increment) { + return ['allowed' => true, 'retry_after' => 0]; + } + + $hits = 1; + $blockedUntilTs = null; + if ($hits > $maxHits) { + $blockedUntilTs = $nowTs + $blockSeconds; + } + + $created = RateLimitRepository::create( + $normalized['scope'], + $normalized['subject_hash'], + $hits, + $nowSql, + $blockedUntilTs !== null ? gmdate('Y-m-d H:i:s', $blockedUntilTs) : null + ); + + if ($created) { + if ($blockedUntilTs !== null) { + return [ + 'allowed' => false, + 'retry_after' => max(1, $blockedUntilTs - $nowTs), + ]; + } + return ['allowed' => true, 'retry_after' => 0]; + } + + continue; + } + + $id = (int) ($row['id'] ?? 0); + if ($id <= 0) { + return ['allowed' => true, 'retry_after' => 0]; + } + + $blockedUntilTs = self::parseTimestamp((string) ($row['blocked_until'] ?? '')); + if ($blockedUntilTs !== null && $blockedUntilTs > $nowTs) { + return [ + 'allowed' => false, + 'retry_after' => max(1, $blockedUntilTs - $nowTs), + ]; + } + + $windowStartedTs = self::parseTimestamp((string) ($row['window_started_at'] ?? '')); + if ($windowStartedTs === null || ($windowStartedTs + $windowSeconds) <= $nowTs) { + $windowStartedTs = $nowTs; + $hits = 0; + } else { + $hits = max(0, (int) ($row['hits'] ?? 0)); + } + + if ($increment) { + $hits++; + } + + $newBlockedUntilTs = null; + if ($hits > $maxHits) { + $newBlockedUntilTs = $nowTs + $blockSeconds; + } + + RateLimitRepository::updateStateById( + $id, + $hits, + gmdate('Y-m-d H:i:s', $windowStartedTs), + $newBlockedUntilTs !== null ? gmdate('Y-m-d H:i:s', $newBlockedUntilTs) : null + ); + + if ($newBlockedUntilTs !== null) { + return [ + 'allowed' => false, + 'retry_after' => max(1, $newBlockedUntilTs - $nowTs), + ]; + } + + return ['allowed' => true, 'retry_after' => 0]; + } + + return ['allowed' => true, 'retry_after' => 0]; + } catch (\Throwable $exception) { + // Fail-open on storage failures. + return ['allowed' => true, 'retry_after' => 0]; + } + } + + private static function normalize(string $scope, string $subject): ?array + { + $scope = strtolower(trim($scope)); + $subject = trim($subject); + + if ($scope === '' || $subject === '') { + return null; + } + + if (strlen($scope) > 64) { + $scope = substr($scope, 0, 64); + } + + return [ + 'scope' => $scope, + 'subject_hash' => hash(self::HASH_ALGO, $subject), + ]; + } + + private static function parseTimestamp(string $value): ?int + { + $value = trim($value); + if ($value === '') { + return null; + } + + $date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $value, new DateTimeZone('UTC')); + if ($date instanceof DateTimeImmutable) { + return $date->getTimestamp(); + } + + $timestamp = strtotime($value . ' UTC'); + if ($timestamp === false || $timestamp <= 0) { + return null; + } + + return $timestamp; + } +} diff --git a/lib/Service/Settings/SettingCacheService.php b/lib/Service/Settings/SettingCacheService.php new file mode 100644 index 0000000..6b33b2b --- /dev/null +++ b/lib/Service/Settings/SettingCacheService.php @@ -0,0 +1,108 @@ + $value) { + $settingKey = trim((string) $key); + if ($settingKey === '') { + continue; + } + $current[$settingKey] = $value === null ? null : (string) $value; + } + return self::write($current); + } + + public static function write(array $settings): bool + { + $file = self::filePath(); + $dir = dirname($file); + if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) { + return false; + } + + $tmp = tempnam($dir, 'settings_'); + if ($tmp === false) { + return false; + } + + $payload = " $item) { + $lines[] = $childIndent + . var_export($key, true) + . ' => ' + . self::exportValue($item, $depth + 1) + . ','; + } + + return "[\n" . implode("\n", $lines) . "\n" . $indent . ']'; + } +} diff --git a/lib/Service/Settings/SettingService.php b/lib/Service/Settings/SettingService.php index 10c78f6..c310947 100644 --- a/lib/Service/Settings/SettingService.php +++ b/lib/Service/Settings/SettingService.php @@ -6,6 +6,7 @@ use MintyPHP\Repository\Settings\SettingRepository; use MintyPHP\Repository\Tenant\TenantRepository; use MintyPHP\Repository\Access\RoleRepository; use MintyPHP\Repository\Org\DepartmentRepository; +use MintyPHP\Support\Crypto; class SettingService { @@ -18,6 +19,12 @@ class SettingService public const APP_THEME_USER_KEY = 'app_theme_user'; public const APP_REGISTRATION_KEY = 'app_registration'; public const APP_PRIMARY_COLOR_KEY = 'app_primary_color'; + public const API_TOKEN_DEFAULT_TTL_DAYS_KEY = 'api_token_default_ttl_days'; + public const API_TOKEN_MAX_TTL_DAYS_KEY = 'api_token_max_ttl_days'; + public const API_CORS_ALLOWED_ORIGINS_KEY = 'api_cors_allowed_origins'; + public const MICROSOFT_SHARED_CLIENT_ID_KEY = 'microsoft_shared_client_id'; + public const MICROSOFT_SHARED_CLIENT_SECRET_ENC_KEY = 'microsoft_shared_client_secret_enc'; + public const MICROSOFT_AUTHORITY_KEY = 'microsoft_authority'; public const SMTP_HOST_KEY = 'smtp_host'; public const SMTP_PORT_KEY = 'smtp_port'; public const SMTP_USER_KEY = 'smtp_user'; @@ -25,6 +32,16 @@ class SettingService public const SMTP_SECURE_KEY = 'smtp_secure'; public const SMTP_FROM_KEY = 'smtp_from'; public const SMTP_FROM_NAME_KEY = 'smtp_from_name'; + public const USER_INACTIVITY_DEACTIVATE_DAYS_KEY = 'user_inactivity_deactivate_days'; + public const USER_INACTIVITY_DELETE_DAYS_KEY = 'user_inactivity_delete_days'; + private const API_TOKEN_DEFAULT_TTL_DAYS_FALLBACK = 90; + private const API_TOKEN_MAX_TTL_DAYS_FALLBACK = 365; + private const API_TOKEN_TTL_DAYS_MIN = 1; + private const API_TOKEN_TTL_DAYS_HARD_MAX = 3650; + private const USER_INACTIVITY_DEACTIVATE_DAYS_FALLBACK = 180; + private const USER_INACTIVITY_DELETE_DAYS_FALLBACK = 365; + private const USER_INACTIVITY_DAYS_MIN = 0; + private const USER_INACTIVITY_DAYS_MAX = 3650; public static function get(string $key): ?array { @@ -60,7 +77,7 @@ class SettingService { $value = $tenantId && $tenantId > 0 ? (string) $tenantId : null; if ($value !== null) { - $exists = TenantRepository::find($tenantId ?? 0); + $exists = TenantRepository::find($tenantId); if (!$exists) { return false; } @@ -79,7 +96,7 @@ class SettingService { $value = $roleId && $roleId > 0 ? (string) $roleId : null; if ($value !== null) { - $exists = RoleRepository::find($roleId ?? 0); + $exists = RoleRepository::find($roleId); if (!$exists) { return false; } @@ -98,7 +115,7 @@ class SettingService { $value = $departmentId && $departmentId > 0 ? (string) $departmentId : null; if ($value !== null) { - $exists = DepartmentRepository::find($departmentId ?? 0); + $exists = DepartmentRepository::find($departmentId); if (!$exists) { return false; } @@ -184,23 +201,7 @@ class SettingService private static function allowedThemes(): array { - $file = dirname(__DIR__, 3) . '/config/themes.php'; - if (!is_file($file)) { - return ['light', 'dark']; - } - $themes = include $file; - if (!is_array($themes)) { - return ['light', 'dark']; - } - $keys = []; - foreach ($themes as $key => $label) { - $key = strtolower(trim((string) $key)); - $label = trim((string) $label); - if ($key !== '' && $label !== '') { - $keys[] = $key; - } - } - return $keys ?: ['light', 'dark']; + return array_keys(ThemeConfigService::all()); } public static function isRegistrationEnabled(): bool @@ -219,6 +220,289 @@ class SettingService return SettingRepository::set(self::APP_REGISTRATION_KEY, $value, $desc); } + public static function getApiTokenDefaultTtlDays(): int + { + $maxDays = self::getApiTokenMaxTtlDays(); + $value = self::getInt(self::API_TOKEN_DEFAULT_TTL_DAYS_KEY); + if ($value !== null && $value >= self::API_TOKEN_TTL_DAYS_MIN && $value <= self::API_TOKEN_TTL_DAYS_HARD_MAX) { + return min($value, $maxDays); + } + return min(self::API_TOKEN_DEFAULT_TTL_DAYS_FALLBACK, $maxDays); + } + + public static function setApiTokenDefaultTtlDays(?int $days, ?string $description = null): bool + { + $value = $days ?? self::API_TOKEN_DEFAULT_TTL_DAYS_FALLBACK; + if ($value < self::API_TOKEN_TTL_DAYS_MIN || $value > self::API_TOKEN_TTL_DAYS_HARD_MAX) { + return false; + } + if ($value > self::getApiTokenMaxTtlDays()) { + return false; + } + $desc = $description ?? 'setting.api_token_default_ttl_days'; + return SettingRepository::set(self::API_TOKEN_DEFAULT_TTL_DAYS_KEY, (string) $value, $desc); + } + + public static function getApiTokenMaxTtlDays(): int + { + $value = self::getInt(self::API_TOKEN_MAX_TTL_DAYS_KEY); + if ($value !== null && $value >= self::API_TOKEN_TTL_DAYS_MIN && $value <= self::API_TOKEN_TTL_DAYS_HARD_MAX) { + return $value; + } + return self::API_TOKEN_MAX_TTL_DAYS_FALLBACK; + } + + public static function setApiTokenMaxTtlDays(?int $days, ?string $description = null): bool + { + $value = $days ?? self::API_TOKEN_MAX_TTL_DAYS_FALLBACK; + if ($value < self::API_TOKEN_TTL_DAYS_MIN || $value > self::API_TOKEN_TTL_DAYS_HARD_MAX) { + return false; + } + + $currentDefault = self::getInt(self::API_TOKEN_DEFAULT_TTL_DAYS_KEY); + if ($currentDefault !== null && $currentDefault > $value) { + $defaultUpdated = SettingRepository::set( + self::API_TOKEN_DEFAULT_TTL_DAYS_KEY, + (string) $value, + 'setting.api_token_default_ttl_days' + ); + if (!$defaultUpdated) { + return false; + } + } + + $desc = $description ?? 'setting.api_token_max_ttl_days'; + return SettingRepository::set(self::API_TOKEN_MAX_TTL_DAYS_KEY, (string) $value, $desc); + } + + public static function getUserInactivityDeactivateDays(): int + { + $value = self::getInt(self::USER_INACTIVITY_DEACTIVATE_DAYS_KEY); + if ($value !== null && self::isValidInactivityDays($value)) { + return $value; + } + return self::USER_INACTIVITY_DEACTIVATE_DAYS_FALLBACK; + } + + public static function setUserInactivityDeactivateDays(?int $days, ?string $description = null): bool + { + $value = $days ?? self::USER_INACTIVITY_DEACTIVATE_DAYS_FALLBACK; + if (!self::isValidInactivityDays($value)) { + return false; + } + if ($value === 0) { + $deleteUpdated = SettingRepository::set( + self::USER_INACTIVITY_DELETE_DAYS_KEY, + '0', + 'setting.user_inactivity_delete_days' + ); + if (!$deleteUpdated) { + return false; + } + } + $desc = $description ?? 'setting.user_inactivity_deactivate_days'; + return SettingRepository::set(self::USER_INACTIVITY_DEACTIVATE_DAYS_KEY, (string) $value, $desc); + } + + public static function getUserInactivityDeleteDays(): int + { + $deactivateDays = self::getUserInactivityDeactivateDays(); + if ($deactivateDays <= 0) { + return 0; + } + $value = self::getInt(self::USER_INACTIVITY_DELETE_DAYS_KEY); + if ($value !== null && self::isValidInactivityDays($value)) { + return $value; + } + return self::USER_INACTIVITY_DELETE_DAYS_FALLBACK; + } + + public static function setUserInactivityDeleteDays(?int $days, ?string $description = null): bool + { + $value = $days ?? self::USER_INACTIVITY_DELETE_DAYS_FALLBACK; + if (!self::isValidInactivityDays($value)) { + return false; + } + if ($value > 0 && self::getUserInactivityDeactivateDays() <= 0) { + return false; + } + $desc = $description ?? 'setting.user_inactivity_delete_days'; + return SettingRepository::set(self::USER_INACTIVITY_DELETE_DAYS_KEY, (string) $value, $desc); + } + + public static function getApiCorsAllowedOrigins(): array + { + $stored = SettingRepository::getValue(self::API_CORS_ALLOWED_ORIGINS_KEY); + if ($stored === null || trim($stored) === '') { + return []; + } + return self::parseCorsOrigins($stored, false) ?? []; + } + + public static function getApiCorsAllowedOriginsText(): string + { + $origins = self::getApiCorsAllowedOrigins(); + return implode("\n", $origins); + } + + public static function setApiCorsAllowedOrigins(?string $rawOrigins, ?string $description = null): bool + { + $value = (string) ($rawOrigins ?? ''); + if (trim($value) === '') { + return SettingRepository::set( + self::API_CORS_ALLOWED_ORIGINS_KEY, + null, + $description ?? 'setting.api_cors_allowed_origins' + ); + } + + $origins = self::parseCorsOrigins($value, true); + if ($origins === null) { + return false; + } + + return SettingRepository::set( + self::API_CORS_ALLOWED_ORIGINS_KEY, + implode("\n", $origins), + $description ?? 'setting.api_cors_allowed_origins' + ); + } + + private static function parseCorsOrigins(string $rawOrigins, bool $strict): ?array + { + $parts = preg_split('/[\r\n,]+/', $rawOrigins) ?: []; + $normalizedOrigins = []; + foreach ($parts as $part) { + $origin = self::normalizeCorsOrigin($part); + if ($origin === null) { + if ($strict && trim((string) $part) !== '') { + return null; + } + continue; + } + $normalizedOrigins[$origin] = true; + } + return array_keys($normalizedOrigins); + } + + private static function normalizeCorsOrigin(string $origin): ?string + { + $origin = trim($origin); + if ($origin === '') { + return null; + } + + // Browser Origin header never contains a trailing slash. + $origin = rtrim($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; + } + + public static function getMicrosoftSharedClientId(): ?string + { + $value = SettingRepository::getValue(self::MICROSOFT_SHARED_CLIENT_ID_KEY); + $value = $value !== null ? trim((string) $value) : ''; + return $value !== '' ? $value : null; + } + + public static function setMicrosoftSharedClientId(?string $clientId, ?string $description = null): bool + { + $value = $clientId !== null ? trim((string) $clientId) : ''; + if ($value === '') { + $value = null; + } + $desc = $description ?? 'setting.microsoft_shared_client_id'; + return SettingRepository::set(self::MICROSOFT_SHARED_CLIENT_ID_KEY, $value, $desc); + } + + public static function getMicrosoftSharedClientSecretEncrypted(): ?string + { + $value = SettingRepository::getValue(self::MICROSOFT_SHARED_CLIENT_SECRET_ENC_KEY); + $value = $value !== null ? trim((string) $value) : ''; + return $value !== '' ? $value : null; + } + + public static function getMicrosoftSharedClientSecret(): ?string + { + $encrypted = self::getMicrosoftSharedClientSecretEncrypted(); + if ($encrypted === null) { + return null; + } + try { + $decrypted = Crypto::decryptString($encrypted); + } catch (\Throwable $exception) { + return null; + } + $decrypted = trim($decrypted); + return $decrypted !== '' ? $decrypted : null; + } + + public static function setMicrosoftSharedClientSecret(?string $secret, ?string $description = null): bool + { + $value = $secret !== null ? trim((string) $secret) : ''; + if ($value === '') { + return SettingRepository::set( + self::MICROSOFT_SHARED_CLIENT_SECRET_ENC_KEY, + null, + $description ?? 'setting.microsoft_shared_client_secret_enc' + ); + } + $encrypted = Crypto::encryptString($value); + $desc = $description ?? 'setting.microsoft_shared_client_secret_enc'; + return SettingRepository::set(self::MICROSOFT_SHARED_CLIENT_SECRET_ENC_KEY, $encrypted, $desc); + } + + public static function getMicrosoftAuthority(): string + { + $value = SettingRepository::getValue(self::MICROSOFT_AUTHORITY_KEY); + $value = trim((string) ($value ?? '')); + if ($value === '') { + return 'https://login.microsoftonline.com/common/v2.0'; + } + return rtrim($value, '/'); + } + + public static function setMicrosoftAuthority(?string $authority, ?string $description = null): bool + { + $value = trim((string) ($authority ?? '')); + if ($value === '') { + $value = 'https://login.microsoftonline.com/common/v2.0'; + } + if (!preg_match('#^https://#i', $value)) { + return false; + } + $desc = $description ?? 'setting.microsoft_authority'; + return SettingRepository::set(self::MICROSOFT_AUTHORITY_KEY, rtrim($value, '/'), $desc); + } + public static function getAppPrimaryColor(): ?string { $value = SettingRepository::getValue(self::APP_PRIMARY_COLOR_KEY); @@ -375,4 +659,9 @@ class SettingService $desc = $description ?? 'setting.smtp_from_name'; return SettingRepository::set(self::SMTP_FROM_NAME_KEY, $value, $desc); } + + private static function isValidInactivityDays(int $days): bool + { + return $days >= self::USER_INACTIVITY_DAYS_MIN && $days <= self::USER_INACTIVITY_DAYS_MAX; + } } diff --git a/lib/Service/Settings/ThemeConfigService.php b/lib/Service/Settings/ThemeConfigService.php new file mode 100644 index 0000000..562c9ef --- /dev/null +++ b/lib/Service/Settings/ThemeConfigService.php @@ -0,0 +1,45 @@ + 'Light', + 'dark' => 'Dark', + ]; + + private static ?array $themes = null; + + public static function all(): array + { + if (self::$themes !== null) { + return self::$themes; + } + + $file = dirname(__DIR__, 3) . '/config/themes.php'; + if (!is_file($file)) { + self::$themes = self::DEFAULT_THEMES; + return self::$themes; + } + + $loaded = include $file; + if (!is_array($loaded)) { + self::$themes = self::DEFAULT_THEMES; + return self::$themes; + } + + $clean = []; + foreach ($loaded as $key => $label) { + $themeKey = strtolower(trim((string) $key)); + $themeLabel = trim((string) $label); + if ($themeKey === '' || $themeLabel === '') { + continue; + } + $clean[$themeKey] = $themeLabel; + } + + self::$themes = $clean ?: self::DEFAULT_THEMES; + return self::$themes; + } +} diff --git a/lib/Service/Tenant/TenantAvatarService.php b/lib/Service/Tenant/TenantAvatarService.php index 3204c95..0247fd9 100644 --- a/lib/Service/Tenant/TenantAvatarService.php +++ b/lib/Service/Tenant/TenantAvatarService.php @@ -2,23 +2,24 @@ namespace MintyPHP\Service\Tenant; +use MintyPHP\Service\Image\ImageUploadTrait; + class TenantAvatarService { + use ImageUploadTrait; + private const MAX_SIZE = 5242880; // 5 MB private const SIZES = [64, 128, 256]; private const DEFAULT_SIZE = 128; public static function isValidUuid(string $uuid): bool { - return (bool) preg_match('/^[a-f0-9-]{36}$/i', $uuid); + return self::imageIsValidUuid($uuid); } public static function storageBase(): string { - if (defined('APP_STORAGE_PATH') && APP_STORAGE_PATH) { - return rtrim(APP_STORAGE_PATH, '/'); - } - return rtrim(dirname(__DIR__, 3) . '/storage', '/'); + return self::imageStorageBase(); } public static function tenantDir(string $uuid): string @@ -47,7 +48,7 @@ class TenantAvatarService if ($defaultVariant) { return $defaultVariant; } - $original = self::findOriginalPath($dir); + $original = self::imageFindOriginalPath($dir); if ($original) { return $original; } @@ -101,12 +102,12 @@ class TenantAvatarService $tmpPath = $file['tmp_name']; $mime = self::detectMime($tmpPath); - $isSvg = self::isSvgUpload($mime, $tmpPath); - $ext = self::extensionForMime($mime, $isSvg); + $isSvg = self::imageIsSvgUpload($mime, $tmpPath); + $ext = self::imageExtensionForMime($mime, $isSvg); if (!$ext) { return ['ok' => false, 'error' => t('Invalid image file')]; } - if ($isSvg && !self::isSafeSvg($tmpPath)) { + if ($isSvg && !self::imageIsSafeSvg($tmpPath)) { return ['ok' => false, 'error' => t('Invalid image file')]; } @@ -123,10 +124,10 @@ class TenantAvatarService @chmod($originalPath, 0644); $variantExt = function_exists('imagewebp') ? 'webp' : 'jpg'; - if (!$isSvg && self::canResize()) { + if (!$isSvg && self::imageCanResize()) { foreach (self::SIZES as $size) { $target = $dir . '/avatar-' . $size . '.' . $variantExt; - self::resizeAndFit($originalPath, $target, $size, $size, $variantExt); + self::imageResizeAndFit($originalPath, $target, $size, $size, $variantExt); } } @@ -135,69 +136,7 @@ class TenantAvatarService public static function detectMime(string $path): string { - if (function_exists('finfo_open')) { - $finfo = finfo_open(FILEINFO_MIME_TYPE); - if ($finfo) { - $mime = finfo_file($finfo, $path); - if (is_string($mime) && $mime !== '') { - if (self::isSvgUpload($mime, $path)) { - return 'image/svg+xml'; - } - return $mime; - } - } - } - if (self::isSvgUpload('application/octet-stream', $path)) { - return 'image/svg+xml'; - } - return 'application/octet-stream'; - } - - private static function extensionForMime(string $mime, bool $isSvg = false): ?string - { - if ($isSvg) { - return 'svg'; - } - $map = [ - 'image/jpeg' => 'jpg', - 'image/png' => 'png', - 'image/webp' => 'webp', - ]; - return $map[$mime] ?? null; - } - - private static function isSvgUpload(string $mime, string $path): bool - { - if ($mime === 'image/svg+xml') { - return true; - } - $head = @file_get_contents($path, false, null, 0, 1024); - if (!is_string($head) || $head === '') { - return false; - } - return stripos($head, ' filemtime($a); - }); - return $matches[0] ?? null; - } - - private static function canResize(): bool - { - return function_exists('imagecreatetruecolor') && function_exists('imagecopyresampled'); - } - - private static function createImageResource(string $path, string $mime) - { - if ($mime === 'image/jpeg' && function_exists('imagecreatefromjpeg')) { - return imagecreatefromjpeg($path); - } - if ($mime === 'image/png' && function_exists('imagecreatefrompng')) { - return imagecreatefrompng($path); - } - if ($mime === 'image/webp' && function_exists('imagecreatefromwebp')) { - return imagecreatefromwebp($path); - } - return false; - } - - private static function resizeAndFit(string $sourcePath, string $targetPath, int $width, int $height, string $ext): bool - { - $mime = self::detectMime($sourcePath); - $src = self::createImageResource($sourcePath, $mime); - if (!$src) { - return false; - } - - $srcWidth = imagesx($src); - $srcHeight = imagesy($src); - if ($srcWidth === 0 || $srcHeight === 0) { - imagedestroy($src); - return false; - } - - $scale = min($width / $srcWidth, $height / $srcHeight); - $dstWidth = (int) round($srcWidth * $scale); - $dstHeight = (int) round($srcHeight * $scale); - if ($dstWidth < 1) $dstWidth = 1; - if ($dstHeight < 1) $dstHeight = 1; - - $dst = imagecreatetruecolor($dstWidth, $dstHeight); - if ($ext === 'png' || $ext === 'webp') { - imagealphablending($dst, false); - imagesavealpha($dst, true); - $transparent = imagecolorallocatealpha($dst, 0, 0, 0, 127); - imagefilledrectangle($dst, 0, 0, $dstWidth, $dstHeight, $transparent); - } - - imagecopyresampled( - $dst, - $src, - 0, - 0, - 0, - 0, - $dstWidth, - $dstHeight, - $srcWidth, - $srcHeight - ); - - $saved = false; - if ($ext === 'webp' && function_exists('imagewebp')) { - $saved = imagewebp($dst, $targetPath, 82); - } elseif ($ext === 'png' && function_exists('imagepng')) { - $saved = imagepng($dst, $targetPath, 6); - } else { - $saved = imagejpeg($dst, $targetPath, 85); - } - - imagedestroy($src); - imagedestroy($dst); - - if ($saved) { - @chmod($targetPath, 0644); - } - return $saved; - } - private static function avatarDirs(string $uuid): array { $dirs = [self::tenantDir($uuid)]; diff --git a/lib/Service/Tenant/TenantFaviconService.php b/lib/Service/Tenant/TenantFaviconService.php index 3571928..6a8e875 100644 --- a/lib/Service/Tenant/TenantFaviconService.php +++ b/lib/Service/Tenant/TenantFaviconService.php @@ -2,8 +2,12 @@ namespace MintyPHP\Service\Tenant; +use MintyPHP\Service\Image\ImageUploadTrait; + class TenantFaviconService { + use ImageUploadTrait; + private const MAX_SIZE = 5242880; // 5 MB private const SIZES = [ 16 => 'favicon-16x16.png', @@ -16,15 +20,12 @@ class TenantFaviconService public static function isValidUuid(string $uuid): bool { - return (bool) preg_match('/^[a-f0-9-]{36}$/i', $uuid); + return self::imageIsValidUuid($uuid); } public static function storageBase(): string { - if (defined('APP_STORAGE_PATH') && APP_STORAGE_PATH) { - return rtrim(APP_STORAGE_PATH, '/'); - } - return rtrim(dirname(__DIR__, 3) . '/storage', '/'); + return self::imageStorageBase(); } public static function storageDir(string $uuid): string @@ -91,7 +92,7 @@ class TenantFaviconService if ($mime !== 'image/png') { return ['ok' => false, 'error' => t('Invalid image file')]; } - if (!self::canResize()) { + if (!self::imageCanResize()) { return ['ok' => false, 'error' => t('Upload failed')]; } @@ -114,7 +115,7 @@ class TenantFaviconService foreach (self::SIZES as $size => $fileName) { $target = $publicDir . '/' . $fileName; - if (!self::resizeSquare($originalPath, $target, $size)) { + if (!self::imageResizeSquare($originalPath, $target, $size)) { return ['ok' => false, 'error' => t('Upload failed')]; } @chmod($target, 0644); @@ -125,76 +126,7 @@ class TenantFaviconService public static function detectMime(string $path): string { - if (function_exists('finfo_open')) { - $finfo = finfo_open(FILEINFO_MIME_TYPE); - if ($finfo) { - $mime = finfo_file($finfo, $path); - if (is_string($mime) && $mime !== '') { - return $mime; - } - } - } - return 'application/octet-stream'; - } - - private static function canResize(): bool - { - return function_exists('imagecreatetruecolor') && function_exists('imagecopyresampled'); - } - - private static function loadImage(string $path) - { - if (function_exists('imagecreatefrompng')) { - return imagecreatefrompng($path); - } - return false; - } - - private static function resizeSquare(string $sourcePath, string $targetPath, int $size): bool - { - $src = self::loadImage($sourcePath); - if (!$src) { - return false; - } - $srcWidth = imagesx($src); - $srcHeight = imagesy($src); - if ($srcWidth === 0 || $srcHeight === 0) { - imagedestroy($src); - return false; - } - - $cropSize = min($srcWidth, $srcHeight); - $srcX = (int) round(($srcWidth - $cropSize) / 2); - $srcY = (int) round(($srcHeight - $cropSize) / 2); - - $dst = imagecreatetruecolor($size, $size); - imagealphablending($dst, false); - imagesavealpha($dst, true); - $transparent = imagecolorallocatealpha($dst, 0, 0, 0, 127); - imagefilledrectangle($dst, 0, 0, $size, $size, $transparent); - - imagecopyresampled( - $dst, - $src, - 0, - 0, - $srcX, - $srcY, - $size, - $size, - $cropSize, - $cropSize - ); - - $saved = false; - if (function_exists('imagepng')) { - $saved = imagepng($dst, $targetPath, 6); - } - - imagedestroy($src); - imagedestroy($dst); - - return $saved; + return self::imageDetectMimeSimple($path); } private static function storageDirs(string $uuid): array diff --git a/lib/Service/Tenant/TenantScopeService.php b/lib/Service/Tenant/TenantScopeService.php index 3593d78..15875a3 100644 --- a/lib/Service/Tenant/TenantScopeService.php +++ b/lib/Service/Tenant/TenantScopeService.php @@ -2,7 +2,7 @@ namespace MintyPHP\Service\Tenant; -use MintyPHP\Repository\Tenant\TenantDepartmentRepository; +use MintyPHP\Repository\Org\DepartmentRepository; use MintyPHP\Repository\Tenant\UserTenantRepository; use MintyPHP\Repository\Tenant\TenantRepository; use MintyPHP\Service\Access\PermissionService; @@ -83,6 +83,25 @@ class TenantScopeService return self::canBypass($userId); } + public static function resourceBelongsToTenant(string $resource, int $resourceId, int $tenantId): bool + { + if ($resourceId <= 0 || $tenantId <= 0) { + return false; + } + + $resourceTenantIds = self::getResourceTenantIds($resource, $resourceId); + if (!$resourceTenantIds) { + return false; + } + + $activeResourceTenantIds = self::filterActiveTenantIds($resourceTenantIds); + if (!$activeResourceTenantIds) { + return false; + } + + return in_array($tenantId, $activeResourceTenantIds, true); + } + private static function getResourceTenantIds(string $resource, int $resourceId): array { $resource = strtolower(trim($resource)); @@ -93,7 +112,9 @@ class TenantScopeService return array_values(array_unique(array_map('intval', UserTenantRepository::listTenantIdsByUserId($resourceId)))); } if ($resource === 'departments') { - return array_values(array_unique(array_map('intval', TenantDepartmentRepository::listTenantIdsByDepartmentId($resourceId)))); + $department = DepartmentRepository::find($resourceId); + $tenantId = (int) ($department['tenant_id'] ?? 0); + return $tenantId > 0 ? [$tenantId] : []; } return []; } diff --git a/lib/Service/Tenant/TenantService.php b/lib/Service/Tenant/TenantService.php index bfcea31..a4469e6 100644 --- a/lib/Service/Tenant/TenantService.php +++ b/lib/Service/Tenant/TenantService.php @@ -2,6 +2,7 @@ namespace MintyPHP\Service\Tenant; +use MintyPHP\Repository\Org\DepartmentRepository; use MintyPHP\Repository\Tenant\TenantRepository; use MintyPHP\Service\Settings\SettingService; @@ -59,6 +60,8 @@ class TenantService 'primary_color' => $form['primary_color_use_default'] ? null : ($form['primary_color'] !== '' ? $form['primary_color'] : null), + 'default_theme' => $form['default_theme'] !== '' ? $form['default_theme'] : null, + 'allow_user_theme' => $form['allow_user_theme_mode'] === '' ? null : (int) $form['allow_user_theme_mode'], 'status' => $form['status'], 'status_changed_at' => $statusChangedAt, 'status_changed_by' => $currentUserId > 0 ? $currentUserId : null, @@ -71,7 +74,7 @@ class TenantService $createdTenant = TenantRepository::find((int) $createdId); $uuid = $createdTenant['uuid'] ?? null; - if (!empty($input['is_default']) && $createdId) { + if (!empty($input['is_default'])) { SettingService::setDefaultTenantId((int) $createdId); } return ['ok' => true, 'form' => $form, 'uuid' => $uuid, 'id' => (int) $createdId]; @@ -114,6 +117,8 @@ class TenantService 'primary_color' => $form['primary_color_use_default'] ? null : ($form['primary_color'] !== '' ? $form['primary_color'] : null), + 'default_theme' => $form['default_theme'] !== '' ? $form['default_theme'] : null, + 'allow_user_theme' => $form['allow_user_theme_mode'] === '' ? null : (int) $form['allow_user_theme_mode'], 'status' => $form['status'], 'modified_by' => $currentUserId > 0 ? $currentUserId : null, ]; @@ -143,7 +148,12 @@ class TenantService return ['ok' => false, 'status' => 404, 'error' => 'not_found']; } - $deleted = TenantRepository::delete((int) $tenant['id']); + $tenantId = (int) $tenant['id']; + if (DepartmentRepository::countByTenantId($tenantId) > 0) { + return ['ok' => false, 'status' => 409, 'error' => 'tenant_has_departments']; + } + + $deleted = TenantRepository::delete($tenantId); if (!$deleted) { return ['ok' => false, 'status' => 500, 'error' => 'delete_failed']; } @@ -173,6 +183,8 @@ class TenantService 'imprint_url' => trim((string) ($input['imprint_url'] ?? '')), 'primary_color' => trim((string) ($input['primary_color'] ?? '')), 'primary_color_use_default' => !empty($input['primary_color_use_default']), + 'default_theme' => strtolower(trim((string) ($input['default_theme'] ?? ''))), + 'allow_user_theme_mode' => trim((string) ($input['allow_user_theme_mode'] ?? '')), 'status' => trim((string) ($input['status'] ?? '')), ]; } @@ -193,6 +205,13 @@ class TenantService ) { $errors[] = t('Primary color is invalid'); } + $themes = function_exists('appThemes') ? appThemes() : []; + if ($form['default_theme'] !== '' && !isset($themes[$form['default_theme']])) { + $errors[] = t('Default theme is invalid'); + } + if (!in_array($form['allow_user_theme_mode'], ['', '0', '1'], true)) { + $errors[] = t('User theme policy is invalid'); + } return $errors; } diff --git a/lib/Service/Ui/HotkeyService.php b/lib/Service/Ui/HotkeyService.php new file mode 100644 index 0000000..429057e --- /dev/null +++ b/lib/Service/Ui/HotkeyService.php @@ -0,0 +1,65 @@ + + */ + public static function list(): array + { + return [ + [ + 'action_key' => 'Open search', + 'mac' => 'Cmd + K', + 'win' => 'Ctrl + K', + ], + [ + 'action_key' => 'Toggle sidebar', + 'mac' => 'Cmd + B', + 'win' => 'Ctrl + B', + ], + [ + 'action_key' => 'Switch sidebar section', + 'mac' => 'Cmd + 1 ... 0', + 'win' => 'Ctrl + 1 ... 0', + ], + [ + 'action_key' => 'Back', + 'mac' => 'Alt + Left', + 'win' => 'Alt + Left', + ], + [ + 'action_key' => 'Forward', + 'mac' => 'Alt + Right', + 'win' => 'Alt + Right', + ], + ]; + } + + /** + * Keywords that should trigger hotkey search results. + * + * @return string[] + */ + public static function searchKeywords(): array + { + return [ + 'shortcut', + 'shortcuts', + 'hotkey', + 'hotkeys', + 'keyboard', + 'key', + 'ctrl', + 'cmd', + 'command', + 'tastenkurzel', + 'tastenkuerzel', + 'taste', + ]; + } +} diff --git a/lib/Service/User/UserAccessPdfService.php b/lib/Service/User/UserAccessPdfService.php new file mode 100644 index 0000000..66bded4 --- /dev/null +++ b/lib/Service/User/UserAccessPdfService.php @@ -0,0 +1,289 @@ +loadHtml($html, 'UTF-8'); + $dompdf->setPaper('A4'); + $dompdf->render(); + $dompdf->addInfo('Title', (string) $vars['pdf_title']); + $dompdf->addInfo('Subject', (string) $vars['pdf_title']); + return $dompdf->output(); + } catch (Throwable $e) { + return ''; + } + } + + // Creates one PDF per user and packs them into a temp ZIP file. + // Caller is responsible for deleting the returned ZIP path after download. + public static function renderUsersAccessZip(array $users): array + { + if (!class_exists(ZipArchive::class) || !$users) { + return ['path' => '', 'filename' => '', 'count' => 0]; + } + + $zipPath = tempnam(sys_get_temp_dir(), 'access_pdfs_'); + if ($zipPath === false) { + return ['path' => '', 'filename' => '', 'count' => 0]; + } + + $zip = new ZipArchive(); + if ($zip->open($zipPath, ZipArchive::OVERWRITE | ZipArchive::CREATE) !== true) { + @unlink($zipPath); + return ['path' => '', 'filename' => '', 'count' => 0]; + } + + $usedNames = []; + $count = 0; + foreach ($users as $user) { + if (!is_array($user)) { + continue; + } + $pdf = self::renderUserAccessPdf($user); + if ($pdf === '') { + continue; + } + $filename = self::buildPdfEntryName($user, $usedNames, $count + 1); + if (!$zip->addFromString($filename, $pdf)) { + continue; + } + $count++; + } + + $closed = $zip->close(); + if (!$closed || $count === 0) { + @unlink($zipPath); + return ['path' => '', 'filename' => '', 'count' => 0]; + } + + return [ + 'path' => $zipPath, + 'filename' => 'user-invitations-' . gmdate('Ymd-His') . '.zip', + 'count' => $count, + ]; + } + + private static function configurePdfOptions(Options $options): void + { + // Keep PDF rendering deterministic and avoid remote/network execution. + if (method_exists($options, 'setIsRemoteEnabled')) { + $options->setIsRemoteEnabled(false); + } else { + $options->set('isRemoteEnabled', false); + } + if (method_exists($options, 'setIsPhpEnabled')) { + $options->setIsPhpEnabled(false); + } else { + $options->set('isPhpEnabled', false); + } + if (method_exists($options, 'setDefaultFont')) { + $options->setDefaultFont('DejaVu Sans'); + } + } + + private static function renderTemplate(string $template, array $vars, string $locale): string + { + $base = dirname(__DIR__, 3) . '/templates'; + $pdfBase = $base . '/pdfs'; + $emailBase = $base . '/emails'; + + $htmlPath = $pdfBase . '/' . $locale . '/' . $template . '.html'; + if (!is_file($htmlPath)) { + $htmlPath = $pdfBase . '/' . $template . '.html'; + } + if (!is_file($htmlPath)) { + return ''; + } + + // Priority: locale-specific PDF partials -> locale email partials -> generic email partials. + $htmlHeaderPath = $pdfBase . '/' . $locale . '/partials/header.html'; + $htmlFooterPath = $pdfBase . '/' . $locale . '/partials/footer.html'; + if (!is_file($htmlHeaderPath)) { + $htmlHeaderPath = $emailBase . '/' . $locale . '/partials/header.html'; + } + if (!is_file($htmlFooterPath)) { + $htmlFooterPath = $emailBase . '/' . $locale . '/partials/footer.html'; + } + if (!is_file($htmlHeaderPath)) { + $htmlHeaderPath = $emailBase . '/partials/header.html'; + } + if (!is_file($htmlFooterPath)) { + $htmlFooterPath = $emailBase . '/partials/footer.html'; + } + + $html = (string) file_get_contents($htmlPath); + if (!isset($vars['email_header'])) { + $vars['email_header'] = is_file($htmlHeaderPath) ? (string) file_get_contents($htmlHeaderPath) : ''; + } + if (!isset($vars['email_footer'])) { + $vars['email_footer'] = is_file($htmlFooterPath) ? (string) file_get_contents($htmlFooterPath) : ''; + } + + foreach ($vars as $key => $value) { + $html = str_replace('{{' . $key . '}}', (string) $value, $html); + } + // Second pass resolves placeholders that may appear inside injected header/footer snippets. + foreach ($vars as $key => $value) { + $html = str_replace('{{' . $key . '}}', (string) $value, $html); + } + + return $html; + } + + private static function resolveLogoDataUri(): string + { + $path = ''; + $mime = ''; + + // Prefer tenant/app branding logo first. + if (class_exists(BrandingLogoService::class) && BrandingLogoService::hasLogo()) { + $logoPath = BrandingLogoService::findLogoPath(128); + if ($logoPath && is_file($logoPath)) { + $path = $logoPath; + $mime = BrandingLogoService::detectMime($logoPath); + } + } + + // Fallback to static brand logo from web assets. + if ($path === '') { + $fallbackPath = dirname(__DIR__, 3) . '/web/brand/logo.svg'; + if (is_file($fallbackPath)) { + $path = $fallbackPath; + $mime = 'image/svg+xml'; + } + } + + if ($path === '' || !is_file($path)) { + return self::transparentPixelDataUri(); + } + + $content = @file_get_contents($path); + if ($content === false || $content === '') { + return self::transparentPixelDataUri(); + } + + if ($mime === '') { + $mime = 'image/png'; + } + return 'data:' . $mime . ';base64,' . base64_encode($content); + } + + private static function buildLoginQrDataUri(string $loginUrl): string + { + $loginUrl = trim($loginUrl); + if ($loginUrl === '') { + return self::transparentPixelDataUri(); + } + if (!class_exists(QrCode::class) || !class_exists(PngWriter::class)) { + return self::transparentPixelDataUri(); + } + + try { + $qrCode = new QrCode(data: $loginUrl, size: 180, margin: 8); + + $writer = new PngWriter(); + $result = $writer->write($qrCode); + $png = (string) $result->getString(); + if ($png === '') { + return self::transparentPixelDataUri(); + } + return 'data:image/png;base64,' . base64_encode($png); + } catch (Throwable $e) { + return self::transparentPixelDataUri(); + } + } + + private static function transparentPixelDataUri(): string + { + // Smallest safe image placeholder to keep tags valid in PDF templates. + return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4////fwAJ+wP9KobjigAAAABJRU5ErkJggg=='; + } + + private static function buildPdfEntryName(array $user, array &$usedNames, int $fallbackIndex): string + { + $parts = [ + (string) ($user['first_name'] ?? ''), + (string) ($user['last_name'] ?? ''), + ]; + if (trim(implode('', $parts)) === '' && !empty($user['email'])) { + $parts[] = (string) preg_replace('/@.*/', '', (string) $user['email']); + } + $base = self::slugify(implode('-', array_filter(array_map('trim', $parts)))); + if ($base === '') { + $base = 'user-' . $fallbackIndex; + } + + $uuid = trim((string) ($user['uuid'] ?? '')); + $suffix = ''; + if ($uuid !== '') { + $suffix = substr(preg_replace('/[^a-z0-9]/i', '', $uuid), 0, 8); + } + $name = $base; + if ($suffix !== '') { + $name .= '-' . strtolower($suffix); + } + $name .= '.pdf'; + + $candidate = $name; + $counter = 2; + // Ensure stable unique filenames inside the ZIP archive. + while (isset($usedNames[$candidate])) { + $candidate = substr($name, 0, -4) . '-' . $counter . '.pdf'; + $counter++; + } + $usedNames[$candidate] = true; + return $candidate; + } + + private static function slugify(string $value): string + { + $value = strtolower(trim($value)); + if ($value === '') { + return ''; + } + $value = preg_replace('/[^a-z0-9]+/', '-', $value); + $value = trim((string) $value, '-'); + return $value; + } + + private static function buildPdfTitle(string $locale): string + { + if (str_starts_with(strtolower($locale), 'de')) { + return 'Zugangsdaten PDF'; + } + return 'Access Details PDF'; + } +} diff --git a/lib/Service/User/UserAccessTemplateService.php b/lib/Service/User/UserAccessTemplateService.php new file mode 100644 index 0000000..83bf09a --- /dev/null +++ b/lib/Service/User/UserAccessTemplateService.php @@ -0,0 +1,97 @@ + $locale, + 'subject' => $subject, + 'vars' => [ + 'app_name' => appTitle(), + 'app_logo_url' => appLogoUrlAbsolute(128), + 'imprint_url' => appUrl(Request::withLocale('imprint', $locale)), + 'privacy_url' => appUrl(Request::withLocale('privacy', $locale)), + 'greeting' => $greeting, + 'username' => (string) ($user['email'] ?? ''), + 'login_url' => $loginUrl, + 'reset_url' => $resetUrl, + ], + ]; + } + + public static function resolveLocale(array $user): string + { + // Keep user locale within configured APP_LOCALES; otherwise fallback to app default. + $locale = strtolower(trim((string) ($user['locale'] ?? ''))); + if ($locale === '') { + $locale = I18n::$locale ?? I18n::$defaultLocale; + } + $available = defined('APP_LOCALES') && is_array(APP_LOCALES) ? APP_LOCALES : []; + if ($available && !in_array($locale, $available, true)) { + $locale = I18n::$defaultLocale; + } + return $locale; + } + + private static function resolveTenantLoginUrl(array $user, string $locale): string + { + $tenantIds = []; + $primaryTenantId = (int) ($user['primary_tenant_id'] ?? 0); + $currentTenantId = (int) ($user['current_tenant_id'] ?? 0); + if ($primaryTenantId > 0) { + $tenantIds[] = $primaryTenantId; + } + if ($currentTenantId > 0 && $currentTenantId !== $primaryTenantId) { + $tenantIds[] = $currentTenantId; + } + + $userId = (int) ($user['id'] ?? 0); + if (!$tenantIds && $userId > 0) { + $tenantIds = array_values(array_map('intval', UserTenantRepository::listTenantIdsByUserId($userId))); + } + + foreach ($tenantIds as $tenantId) { + if ($tenantId <= 0) { + continue; + } + $tenant = TenantRepository::find($tenantId); + if (!$tenant || (string) ($tenant['status'] ?? 'active') !== 'active') { + continue; + } + $tenantSlug = TenantSsoService::tenantLoginSlug($tenant); + if ($tenantSlug !== '') { + return appUrl(Request::withLocale('login?tenant=' . rawurlencode($tenantSlug), $locale)); + } + } + + return appUrl(Request::withLocale('login', $locale)); + } +} diff --git a/lib/Service/User/UserAvatarService.php b/lib/Service/User/UserAvatarService.php index d69462d..06e96b5 100644 --- a/lib/Service/User/UserAvatarService.php +++ b/lib/Service/User/UserAvatarService.php @@ -2,23 +2,24 @@ namespace MintyPHP\Service\User; +use MintyPHP\Service\Image\ImageUploadTrait; + class UserAvatarService { + use ImageUploadTrait; + private const MAX_SIZE = 5242880; // 5 MB private const SIZES = [64, 128, 256]; private const DEFAULT_SIZE = 128; public static function isValidUuid(string $uuid): bool { - return (bool) preg_match('/^[a-f0-9-]{36}$/i', $uuid); + return self::imageIsValidUuid($uuid); } public static function storageBase(): string { - if (defined('APP_STORAGE_PATH') && APP_STORAGE_PATH) { - return rtrim(APP_STORAGE_PATH, '/'); - } - return rtrim(dirname(__DIR__, 3) . '/storage', '/'); + return self::imageStorageBase(); } public static function userDir(string $uuid): string @@ -46,7 +47,7 @@ class UserAvatarService if ($defaultVariant) { return $defaultVariant; } - $original = self::findOriginalPath($dir); + $original = self::imageFindOriginalPath($dir); return $original ?: null; } @@ -95,12 +96,12 @@ class UserAvatarService $tmpPath = $file['tmp_name']; $mime = self::detectMime($tmpPath); - $isSvg = self::isSvgUpload($mime, $tmpPath); - $ext = self::extensionForMime($mime, $isSvg); + $isSvg = self::imageIsSvgUpload($mime, $tmpPath); + $ext = self::imageExtensionForMime($mime, $isSvg); if (!$ext) { return ['ok' => false, 'error' => t('Invalid image file')]; } - if ($isSvg && !self::isSafeSvg($tmpPath)) { + if ($isSvg && !self::imageIsSafeSvg($tmpPath)) { return ['ok' => false, 'error' => t('Invalid image file')]; } @@ -117,81 +118,83 @@ class UserAvatarService @chmod($originalPath, 0644); $variantExt = function_exists('imagewebp') ? 'webp' : 'jpg'; - if (!$isSvg && self::canResize()) { + if (!$isSvg && self::imageCanResize()) { foreach (self::SIZES as $size) { $target = $dir . '/avatar-' . $size . '.' . $variantExt; - self::resizeAndFit($originalPath, $target, $size, $size, $variantExt); + self::imageResizeAndFit($originalPath, $target, $size, $size, $variantExt); } } return ['ok' => true, 'path' => $originalPath, 'mime' => $mime]; } - public static function detectMime(string $path): string + public static function saveBinary(string $uuid, string $contents, string $mime = ''): array { - if (function_exists('finfo_open')) { - $finfo = finfo_open(FILEINFO_MIME_TYPE); - if ($finfo) { - $mime = finfo_file($finfo, $path); - if (is_string($mime) && $mime !== '') { - if (self::isSvgUpload($mime, $path)) { - return 'image/svg+xml'; - } - return $mime; - } + if (!self::isValidUuid($uuid)) { + return ['ok' => false, 'error' => t('User not found')]; + } + + $size = strlen($contents); + if ($size <= 0) { + return ['ok' => false, 'error' => t('Invalid image file')]; + } + if ($size > self::MAX_SIZE) { + return ['ok' => false, 'error' => t('File is too large')]; + } + + $tmpPath = tempnam(sys_get_temp_dir(), 'user-avatar-'); + if ($tmpPath === false) { + return ['ok' => false, 'error' => t('Upload failed')]; + } + if (@file_put_contents($tmpPath, $contents) === false) { + @unlink($tmpPath); + return ['ok' => false, 'error' => t('Upload failed')]; + } + + $detectedMime = self::detectMime($tmpPath); + if ($detectedMime === 'application/octet-stream') { + $headerMime = strtolower(trim(explode(';', $mime, 2)[0])); + if (in_array($headerMime, ['image/jpeg', 'image/png', 'image/webp'], true)) { + $detectedMime = $headerMime; } } - if (self::isSvgUpload('application/octet-stream', $path)) { - return 'image/svg+xml'; + $ext = self::imageExtensionForMime($detectedMime, false); + if (!$ext) { + @unlink($tmpPath); + return ['ok' => false, 'error' => t('Invalid image file')]; } - return 'application/octet-stream'; + + $dir = self::userDir($uuid); + if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) { + @unlink($tmpPath); + return ['ok' => false, 'error' => t('Upload failed')]; + } + + self::delete($uuid); + $originalPath = $dir . '/original.' . $ext; + if (!@rename($tmpPath, $originalPath)) { + if (!@copy($tmpPath, $originalPath)) { + @unlink($tmpPath); + return ['ok' => false, 'error' => t('Upload failed')]; + } + @unlink($tmpPath); + } + @chmod($originalPath, 0644); + + $variantExt = function_exists('imagewebp') ? 'webp' : 'jpg'; + if (self::imageCanResize()) { + foreach (self::SIZES as $sizeVariant) { + $target = $dir . '/avatar-' . $sizeVariant . '.' . $variantExt; + self::imageResizeAndFit($originalPath, $target, $sizeVariant, $sizeVariant, $variantExt); + } + } + + return ['ok' => true, 'path' => $originalPath, 'mime' => $detectedMime]; } - private static function extensionForMime(string $mime, bool $isSvg = false): ?string + public static function detectMime(string $path): string { - if ($isSvg) { - return 'svg'; - } - $map = [ - 'image/jpeg' => 'jpg', - 'image/png' => 'png', - 'image/webp' => 'webp', - ]; - return $map[$mime] ?? null; - } - - private static function isSvgUpload(string $mime, string $path): bool - { - if ($mime === 'image/svg+xml') { - return true; - } - $head = @file_get_contents($path, false, null, 0, 1024); - if (!is_string($head) || $head === '') { - return false; - } - return stripos($head, ' filemtime($a); - }); - return $matches[0] ?? null; - } - - private static function canResize(): bool - { - return function_exists('imagecreatetruecolor') && function_exists('imagecopyresampled'); - } - - private static function createImageResource(string $path, string $mime) - { - if ($mime === 'image/jpeg' && function_exists('imagecreatefromjpeg')) { - return imagecreatefromjpeg($path); - } - if ($mime === 'image/png' && function_exists('imagecreatefrompng')) { - return imagecreatefrompng($path); - } - if ($mime === 'image/webp' && function_exists('imagecreatefromwebp')) { - return imagecreatefromwebp($path); - } - return false; - } - - private static function resizeAndFit(string $sourcePath, string $targetPath, int $width, int $height, string $ext): bool - { - $mime = self::detectMime($sourcePath); - $src = self::createImageResource($sourcePath, $mime); - if (!$src) { - return false; - } - - $srcWidth = imagesx($src); - $srcHeight = imagesy($src); - if ($srcWidth === 0 || $srcHeight === 0) { - imagedestroy($src); - return false; - } - - $scale = min($width / $srcWidth, $height / $srcHeight); - $dstWidth = (int) round($srcWidth * $scale); - $dstHeight = (int) round($srcHeight * $scale); - if ($dstWidth < 1) $dstWidth = 1; - if ($dstHeight < 1) $dstHeight = 1; - - $dst = imagecreatetruecolor($dstWidth, $dstHeight); - if ($ext === 'png' || $ext === 'webp') { - imagealphablending($dst, false); - imagesavealpha($dst, true); - $transparent = imagecolorallocatealpha($dst, 0, 0, 0, 127); - imagefilledrectangle($dst, 0, 0, $dstWidth, $dstHeight, $transparent); - } - - imagecopyresampled( - $dst, - $src, - 0, - 0, - 0, - 0, - $dstWidth, - $dstHeight, - $srcWidth, - $srcHeight - ); - - $saved = false; - if ($ext === 'webp' && function_exists('imagewebp')) { - $saved = imagewebp($dst, $targetPath, 82); - } elseif ($ext === 'png' && function_exists('imagepng')) { - $saved = imagepng($dst, $targetPath, 6); - } else { - $saved = imagejpeg($dst, $targetPath, 85); - } - - imagedestroy($src); - imagedestroy($dst); - - if ($saved) { - @chmod($targetPath, 0644); - } - return $saved; - } } diff --git a/lib/Service/User/UserLifecycleRestoreService.php b/lib/Service/User/UserLifecycleRestoreService.php new file mode 100644 index 0000000..b59e57d --- /dev/null +++ b/lib/Service/User/UserLifecycleRestoreService.php @@ -0,0 +1,156 @@ + false, 'error' => 'invalid_request']; + } + + $db = DB::handle(); + $db->begin_transaction(); + try { + $event = UserLifecycleAuditService::findDeleteEventForRestore($auditId, true); + if (!$event) { + $db->rollback(); + return ['ok' => false, 'error' => 'audit_event_not_found']; + } + if (!empty($event['restored_at'])) { + $db->rollback(); + return ['ok' => false, 'error' => 'audit_event_already_restored']; + } + + $snapshot = UserLifecycleAuditService::decryptSnapshot($event); + if (!$snapshot) { + $db->rollback(); + return ['ok' => false, 'error' => 'snapshot_unavailable']; + } + + $uuid = trim((string) ($snapshot['uuid'] ?? '')); + $email = trim((string) ($snapshot['email'] ?? '')); + if ($uuid === '' || $email === '') { + $db->rollback(); + return ['ok' => false, 'error' => 'snapshot_invalid']; + } + + if (UserRepository::findByUuid($uuid)) { + $db->rollback(); + return ['ok' => false, 'error' => 'restore_uuid_exists']; + } + if (UserRepository::findByEmail($email)) { + $db->rollback(); + return ['ok' => false, 'error' => 'restore_email_exists']; + } + + $createdId = UserRepository::create([ + 'uuid' => $uuid, + 'first_name' => trim((string) ($snapshot['first_name'] ?? '')), + 'last_name' => trim((string) ($snapshot['last_name'] ?? '')), + 'email' => $email, + 'profile_description' => self::nullableText($snapshot['profile_description'] ?? null), + 'job_title' => self::nullableText($snapshot['job_title'] ?? null), + 'phone' => self::nullableText($snapshot['phone'] ?? null), + 'mobile' => self::nullableText($snapshot['mobile'] ?? null), + 'short_dial' => self::nullableText($snapshot['short_dial'] ?? null), + 'address' => self::nullableText($snapshot['address'] ?? null), + 'postal_code' => self::nullableText($snapshot['postal_code'] ?? null), + 'city' => self::nullableText($snapshot['city'] ?? null), + 'country' => self::nullableText($snapshot['country'] ?? null), + 'region' => self::nullableText($snapshot['region'] ?? null), + 'hire_date' => self::nullableDate($snapshot['hire_date'] ?? null), + 'password' => self::randomPassword(), + 'locale' => self::nullableText($snapshot['locale'] ?? null), + 'totp_secret' => '', + 'theme' => self::normalizeTheme($snapshot['theme'] ?? null), + 'primary_tenant_id' => null, + 'current_tenant_id' => null, + 'created_by' => $actorUserId, + 'active' => 0, + 'active_changed_at' => gmdate('Y-m-d H:i:s'), + 'active_changed_by' => $actorUserId, + ]); + if (!$createdId) { + $db->rollback(); + return ['ok' => false, 'error' => 'restore_create_failed']; + } + $restoredUserId = (int) $createdId; + + $marked = UserLifecycleAuditService::markDeleteEventRestored($auditId, $actorUserId, $restoredUserId); + if (!$marked) { + $db->rollback(); + return ['ok' => false, 'error' => 'restore_mark_failed']; + } + + UserLifecycleAuditService::logRestore( + RepoQuery::uuidV4(), + 'manual', + [ + 'deactivate_days' => (int) ($event['policy_deactivate_days'] ?? 0), + 'delete_days' => (int) ($event['policy_delete_days'] ?? 0), + ], + $actorUserId, + [ + 'id' => $restoredUserId, + 'uuid' => $uuid, + 'email' => $email, + ], + 'success', + null + ); + + $db->commit(); + return [ + 'ok' => true, + 'restored_user_id' => $restoredUserId, + 'restored_user_uuid' => $uuid, + 'audit_id' => $auditId, + ]; + } catch (\Throwable $exception) { + if ($db->errno === 0) { + $db->rollback(); + } else { + $db->rollback(); + } + return ['ok' => false, 'error' => 'restore_unexpected_error']; + } + } + + private static function nullableText(mixed $value): ?string + { + $value = trim((string) $value); + return $value !== '' ? $value : null; + } + + private static function nullableDate(mixed $value): ?string + { + $value = trim((string) $value); + if ($value === '') { + return null; + } + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) { + return null; + } + return $value; + } + + private static function randomPassword(): string + { + return bin2hex(random_bytes(24)); + } + + private static function normalizeTheme(mixed $value): string + { + $theme = strtolower(trim((string) $value)); + $themes = appThemes(); + return isset($themes[$theme]) ? $theme : appDefaultTheme(); + } +} + diff --git a/lib/Service/User/UserLifecycleService.php b/lib/Service/User/UserLifecycleService.php new file mode 100644 index 0000000..3fdc746 --- /dev/null +++ b/lib/Service/User/UserLifecycleService.php @@ -0,0 +1,179 @@ + 0 ? 'manual' : 'cron'; + $deactivateDays = SettingService::getUserInactivityDeactivateDays(); + $deleteDays = SettingService::getUserInactivityDeleteDays(); + if ($deactivateDays <= 0) { + $deleteDays = 0; + } + + $privilegedUserIds = UserRepository::listPrivilegedUserIdsByPermissionKeys([ + PermissionService::SETTINGS_UPDATE, + PermissionService::TENANTS_UPDATE, + ]); + + $result = [ + 'ok' => true, + 'locked' => false, + 'error' => null, + 'run_uuid' => $runUuid, + 'deactivated_count' => 0, + 'deleted_count' => 0, + 'skipped_count' => 0, + 'skipped_privileged_count' => count($privilegedUserIds), + 'policy' => [ + 'deactivate_days' => $deactivateDays, + 'delete_days' => $deleteDays, + ], + 'duration_ms' => 0, + ]; + + if (!self::acquireLock()) { + $result['ok'] = false; + $result['locked'] = true; + $result['error'] = 'lock_not_acquired'; + $result['duration_ms'] = self::durationMs($startedAt); + return $result; + } + + try { + if ($deactivateDays > 0) { + while (true) { + $ids = UserRepository::listIdsForAutoDeactivate($deactivateDays, $privilegedUserIds, self::BATCH_SIZE); + if (!$ids) { + break; + } + + $usersById = []; + foreach ($ids as $id) { + $user = UserRepository::find((int) $id); + if ($user) { + $usersById[(int) $id] = $user; + } + } + $updated = UserRepository::setInactiveByIds( + $ids, + $actorUserId && $actorUserId > 0 ? $actorUserId : null + ); + if ($updated <= 0) { + $result['ok'] = false; + $result['error'] = 'deactivate_failed'; + break; + } + $result['deactivated_count'] += $updated; + UserRepository::bumpAuthzVersionByUserIds($ids); + foreach ($ids as $id) { + if (!isset($usersById[(int) $id])) { + continue; + } + UserLifecycleAuditService::logDeactivate( + $runUuid, + $triggerType, + $result['policy'], + $actorUserId, + $usersById[(int) $id], + 'success', + null + ); + } + if (count($ids) < self::BATCH_SIZE) { + break; + } + } + } + + if ($deleteDays > 0) { + while (true) { + $ids = UserRepository::listIdsForAutoDelete($deleteDays, $privilegedUserIds, self::BATCH_SIZE); + if (!$ids) { + break; + } + + foreach ($ids as $id) { + $user = UserRepository::find((int) $id); + if (!$user) { + continue; + } + + $auditId = UserLifecycleAuditService::logDeleteWithSnapshot( + $runUuid, + $triggerType, + $result['policy'], + $actorUserId, + $user + ); + if (!$auditId) { + $result['skipped_count']++; + UserLifecycleAuditService::logDeleteFailure( + $runUuid, + $triggerType, + $result['policy'], + $actorUserId, + $user, + 'audit_snapshot_failed' + ); + continue; + } + + $deleted = UserRepository::deleteByIds([(int) $id]); + if ($deleted <= 0) { + $result['skipped_count']++; + UserLifecycleAuditService::updateStatus((int) $auditId, 'failed', 'delete_failed'); + continue; + } + $result['deleted_count'] += $deleted; + } + if (count($ids) < self::BATCH_SIZE) { + break; + } + } + } + } catch (\Throwable $exception) { + $result['ok'] = false; + $result['error'] = 'unexpected_error'; + } finally { + self::releaseLock(); + $result['duration_ms'] = self::durationMs($startedAt); + } + + return $result; + } + + private static function acquireLock(): bool + { + $gotLock = DB::selectValue('select GET_LOCK(?, 0) as got_lock', self::LOCK_NAME); + return (int) $gotLock === 1; + } + + private static function releaseLock(): void + { + try { + DB::selectValue('select RELEASE_LOCK(?) as released_lock', self::LOCK_NAME); + } catch (\Throwable $exception) { + // no-op + } + } + + private static function durationMs(float $startedAt): int + { + return (int) max(0, round((microtime(true) - $startedAt) * 1000)); + } +} diff --git a/lib/Service/User/UserSavedFilterService.php b/lib/Service/User/UserSavedFilterService.php new file mode 100644 index 0000000..e6aad11 --- /dev/null +++ b/lib/Service/User/UserSavedFilterService.php @@ -0,0 +1,224 @@ + (int) ($row['id'] ?? 0), + 'uuid' => $uuid, + 'name' => $name, + 'query' => $query, + 'created' => (string) ($row['created'] ?? ''), + ]; + } + return $list; + } + + public static function saveByContext(int $userId, string $context, string $name, array $rawQuery): array + { + $name = self::truncate(trim($name), self::NAME_MAX_LENGTH); + if ($userId <= 0 || $context === '') { + return ['ok' => false, 'error' => 'invalid_user_or_context']; + } + if ($name === '') { + return ['ok' => false, 'error' => 'name_required']; + } + $count = UserSavedFilterRepository::countByUserAndContext($userId, $context); + if ($count >= self::MAX_PER_USER_CONTEXT) { + return ['ok' => false, 'error' => 'max_reached']; + } + $query = self::normalizeQuery($rawQuery); + $queryJson = json_encode($query, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if (!is_string($queryJson)) { + $queryJson = '{}'; + } + $id = UserSavedFilterRepository::create([ + 'user_id' => $userId, + 'context' => $context, + 'name' => $name, + 'query_json' => $queryJson, + ]); + if (!$id) { + return ['ok' => false, 'error' => 'create_failed']; + } + return ['ok' => true, 'id' => (int) $id, 'name' => $name, 'query' => $query]; + } + + public static function deleteByContext(int $userId, string $context, string $uuid): bool + { + if ($userId <= 0 || $context === '' || trim($uuid) === '') { + return false; + } + return UserSavedFilterRepository::deleteByUuidForUserAndContext($uuid, $userId, $context); + } + + private static function decodeQuery(string $json): array + { + $decoded = json_decode($json, true); + return is_array($decoded) ? $decoded : []; + } + + private static function normalizeQuery(array $rawQuery): array + { + $search = self::truncate(trim((string) ($rawQuery['search'] ?? '')), self::SEARCH_MAX_LENGTH); + $tenants = self::normalizeUuidList($rawQuery['tenants'] ?? []); + $departments = RepoQuery::normalizeIdList($rawQuery['departments'] ?? []); + $roles = RepoQuery::normalizeIdList($rawQuery['roles'] ?? []); + $custom = self::normalizeCustomFieldQuery($rawQuery); + + $query = []; + if ($search !== '') { + $query['search'] = $search; + } + if ($tenants) { + $query['tenants'] = $tenants; + } + if ($departments) { + $query['departments'] = $departments; + } + if ($roles) { + $query['roles'] = $roles; + } + foreach ($custom as $key => $value) { + $query[$key] = $value; + } + + return $query; + } + + private static function normalizeCustomFieldQuery(array $rawQuery): array + { + $normalized = []; + foreach ($rawQuery as $rawKey => $rawValue) { + $key = strtolower(trim((string) $rawKey)); + if ($key === '') { + continue; + } + if (preg_match('/^cf_[a-f0-9-]{36}$/', $key)) { + $value = trim((string) $rawValue); + if ($value !== '' && preg_match('/^[a-z0-9_-]{1,64}$/i', $value)) { + $normalized[$key] = $value; + } + continue; + } + if (preg_match('/^cfm_[a-f0-9-]{36}$/', $key)) { + $ids = RepoQuery::normalizeIdList($rawValue); + if ($ids) { + $normalized[$key] = $ids; + } + continue; + } + if (preg_match('/^cfd_[a-f0-9-]{36}_(from|to)$/', $key)) { + $value = trim((string) $rawValue); + $dt = \DateTimeImmutable::createFromFormat('Y-m-d', $value); + if ($dt && $dt->format('Y-m-d') === $value) { + $normalized[$key] = $value; + } + } + } + ksort($normalized, SORT_STRING); + return $normalized; + } + + private static function normalizeUuidList($value): array + { + $items = self::normalizeStringList($value); + $list = []; + foreach ($items as $item) { + $item = strtolower($item); + if (preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/', $item)) { + $list[] = $item; + } + } + $list = array_values(array_unique($list)); + sort($list, SORT_STRING); + return $list; + } + + private static function normalizeStringList($value): array + { + $raw = is_array($value) ? $value : [$value]; + $flat = []; + $collect = static function ($item) use (&$collect, &$flat): void { + if (is_array($item)) { + foreach ($item as $nested) { + $collect($nested); + } + return; + } + $text = trim((string) $item); + if ($text === '') { + return; + } + if (str_contains($text, ',')) { + foreach (explode(',', $text) as $part) { + $part = trim($part); + if ($part !== '') { + $flat[] = $part; + } + } + return; + } + $flat[] = $text; + }; + foreach ($raw as $item) { + $collect($item); + } + return array_values(array_unique($flat)); + } + + private static function truncate(string $value, int $maxLength): string + { + if ($maxLength <= 0 || $value === '') { + return $value; + } + if (function_exists('mb_strlen') && function_exists('mb_substr')) { + if (mb_strlen($value) > $maxLength) { + return mb_substr($value, 0, $maxLength); + } + return $value; + } + if (strlen($value) > $maxLength) { + return substr($value, 0, $maxLength); + } + return $value; + } +} diff --git a/lib/Service/User/UserService.php b/lib/Service/User/UserService.php index 0fda9cd..1e51fbd 100644 --- a/lib/Service/User/UserService.php +++ b/lib/Service/User/UserService.php @@ -10,17 +10,13 @@ use MintyPHP\Repository\User\UserRepository; use MintyPHP\Repository\Org\UserDepartmentRepository; use MintyPHP\Repository\Access\UserRoleRepository; use MintyPHP\Repository\Tenant\UserTenantRepository; -use MintyPHP\Repository\Tenant\TenantDepartmentRepository; use MintyPHP\Service\Access\PermissionService; +use MintyPHP\Service\Settings\SettingService; use MintyPHP\Service\Tenant\TenantScopeService; class UserService { private const PASSWORD_MIN_LENGTH = 12; - public static function list(): array - { - return UserRepository::list(); - } public static function listPaged(array $options): array { @@ -48,6 +44,146 @@ class UserService return UserRepository::findByEmail($email); } + public static function buildAssignmentsForUser(int $userId): array + { + if ($userId <= 0) { + return [ + 'tenants' => [], + 'departments' => [], + 'roles' => [], + ]; + } + + $tenantIds = UserTenantRepository::listTenantIdsByUserId($userId); + $departmentIds = UserDepartmentRepository::listDepartmentIdsByUserId($userId); + $roleIds = UserRoleRepository::listRoleIdsByUserId($userId); + + $tenantsById = []; + foreach (TenantRepository::listByIds($tenantIds) as $tenant) { + $tenantId = (int) ($tenant['id'] ?? 0); + if ($tenantId <= 0) { + continue; + } + $tenantsById[$tenantId] = [ + 'id' => $tenantId, + 'uuid' => (string) ($tenant['uuid'] ?? ''), + 'description' => (string) ($tenant['description'] ?? ''), + 'status' => (string) ($tenant['status'] ?? ''), + ]; + } + + $departmentsById = []; + foreach (DepartmentRepository::listByIds($departmentIds, true) as $department) { + $departmentId = (int) ($department['id'] ?? 0); + if ($departmentId <= 0) { + continue; + } + $departmentTenantId = (int) ($department['tenant_id'] ?? 0); + $departmentsById[$departmentId] = [ + 'id' => $departmentId, + 'uuid' => (string) ($department['uuid'] ?? ''), + 'description' => (string) ($department['description'] ?? ''), + 'active' => (bool) ($department['active'] ?? 0), + 'tenant_id' => $departmentTenantId, + 'tenant_uuid' => (string) (($tenantsById[$departmentTenantId]['uuid'] ?? '')), + ]; + } + + $rolesById = []; + foreach (RoleRepository::listByIds($roleIds) as $role) { + $roleId = (int) ($role['id'] ?? 0); + if ($roleId <= 0) { + continue; + } + $rolesById[$roleId] = [ + 'id' => $roleId, + 'uuid' => (string) ($role['uuid'] ?? ''), + 'description' => (string) ($role['description'] ?? ''), + 'active' => (bool) ($role['active'] ?? 0), + ]; + } + + $tenants = []; + foreach ($tenantIds as $tenantId) { + if (isset($tenantsById[$tenantId])) { + $tenants[] = $tenantsById[$tenantId]; + } + } + + $departments = []; + foreach ($departmentIds as $departmentId) { + if (isset($departmentsById[$departmentId])) { + $departments[] = $departmentsById[$departmentId]; + } + } + + $roles = []; + foreach ($roleIds as $roleId) { + if (isset($rolesById[$roleId])) { + $roles[] = $rolesById[$roleId]; + } + } + + return [ + 'tenants' => $tenants, + 'departments' => $departments, + 'roles' => $roles, + ]; + } + + public static function findTenantSummaryInAssignments(array $assignments, int $tenantId): ?array + { + if ($tenantId <= 0) { + return null; + } + + foreach (($assignments['tenants'] ?? []) as $tenant) { + $currentTenantId = (int) ($tenant['id'] ?? 0); + if ($currentTenantId !== $tenantId) { + continue; + } + + return [ + 'uuid' => (string) ($tenant['uuid'] ?? ''), + 'description' => (string) ($tenant['description'] ?? ''), + 'status' => (string) ($tenant['status'] ?? ''), + ]; + } + + return null; + } + + public static function mapAssignmentsToPublic(array $assignments): array + { + return [ + 'tenants' => array_values(array_map( + static fn (array $tenant): array => [ + 'uuid' => (string) ($tenant['uuid'] ?? ''), + 'description' => (string) ($tenant['description'] ?? ''), + 'status' => (string) ($tenant['status'] ?? ''), + ], + $assignments['tenants'] ?? [] + )), + 'departments' => array_values(array_map( + static fn (array $department): array => [ + 'uuid' => (string) ($department['uuid'] ?? ''), + 'description' => (string) ($department['description'] ?? ''), + 'active' => (bool) ($department['active'] ?? 0), + 'tenant_uuid' => (string) ($department['tenant_uuid'] ?? ''), + ], + $assignments['departments'] ?? [] + )), + 'roles' => array_values(array_map( + static fn (array $role): array => [ + 'uuid' => (string) ($role['uuid'] ?? ''), + 'description' => (string) ($role['description'] ?? ''), + 'active' => (bool) ($role['active'] ?? 0), + ], + $assignments['roles'] ?? [] + )), + ]; + } + public static function setLocale(int $userId, string $locale): bool { return UserRepository::setLocale($userId, $locale); @@ -227,11 +363,17 @@ class UserService { $form = self::sanitizeBase($input); $form['totp_secret'] = trim((string) ($input['totp_secret'] ?? '')); - $form['theme'] = self::normalizeTheme($input['theme'] ?? null); $form['locale'] = self::normalizeLocale($input['locale'] ?? null); + $themeProvided = array_key_exists('theme', $input); $primaryTenantId = (int) ($input['primary_tenant_id'] ?? 0); $tenantIdsProvided = array_key_exists('tenant_ids', $input); $primaryProvided = array_key_exists('primary_tenant_id', $input); + $existing = UserRepository::find($userId) ?? []; + if ($themeProvided) { + $form['theme'] = self::normalizeTheme($input['theme'] ?? null); + } else { + $form['theme'] = self::normalizeTheme($existing['theme'] ?? null); + } if ($tenantIdsProvided) { $tenantIds = $input['tenant_ids'] ?? []; if (!is_array($tenantIds)) { @@ -244,7 +386,6 @@ class UserService if (array_key_exists('active', $input)) { $form['active'] = isset($input['active']) ? 1 : 0; } else { - $existing = UserRepository::find($userId); $form['active'] = (int) ($existing['active'] ?? 1); } $password = (string) ($input['password'] ?? ''); @@ -287,17 +428,20 @@ class UserService 'region' => $form['region'] !== '' ? $form['region'] : null, 'hire_date' => $form['hire_date'] !== '' ? $form['hire_date'] : null, 'totp_secret' => $form['totp_secret'], - 'theme' => $form['theme'], 'locale' => $form['locale'], 'active' => $form['active'], 'password' => $password, 'modified_by' => $currentUserId > 0 ? $currentUserId : null, ]; + if ($themeProvided) { + $updateData['theme'] = $form['theme']; + } if ($tenantIdsProvided || $primaryProvided) { $updateData['primary_tenant_id'] = $form['primary_tenant_id'] ?? null; } - if ((int) ($existing['active'] ?? 1) !== (int) $form['active']) { + $activeChanged = (int) ($existing['active'] ?? 1) !== (int) $form['active']; + if ($activeChanged) { $updateData['active_changed_at'] = gmdate('Y-m-d H:i:s'); $updateData['active_changed_by'] = $currentUserId > 0 ? $currentUserId : null; } @@ -308,6 +452,10 @@ class UserService return ['ok' => false, 'errors' => [t('User can not be updated')], 'form' => $form]; } + if ($activeChanged) { + self::bumpAuthzVersion($userId); + } + return ['ok' => true, 'form' => $form]; } @@ -338,6 +486,8 @@ class UserService return ['ok' => false, 'status' => 500, 'error' => 'update_failed']; } + self::bumpAuthzVersion($userId); + return ['ok' => true, 'user' => $user]; } @@ -360,6 +510,18 @@ class UserService return ['ok' => false, 'error' => 'update_failed']; } + $userIds = []; + foreach ($uuids as $uuid) { + $user = UserRepository::findByUuid((string) $uuid); + $userId = (int) ($user['id'] ?? 0); + if ($userId > 0) { + $userIds[] = $userId; + } + } + if ($userIds) { + UserRepository::bumpAuthzVersionByUserIds($userIds); + } + return ['ok' => true, 'count' => count($uuids)]; } @@ -437,13 +599,17 @@ class UserService return ['ok' => true]; } - public static function syncTenants(int $userId, array $tenantIds): bool + public static function syncTenants(int $userId, array $tenantIds, bool $bumpAuthz = true): bool { $ids = self::normalizeTenantIds($tenantIds); - return UserTenantRepository::replaceForUser($userId, $ids); + $result = UserTenantRepository::replaceForUser($userId, $ids); + if ($result && $bumpAuthz) { + self::bumpAuthzVersion($userId); + } + return $result; } - public static function syncRoles(int $userId, array $roleIds): bool + public static function syncRoles(int $userId, array $roleIds, bool $bumpAuthz = true): bool { $ids = array_values(array_unique(array_map('intval', $roleIds))); $ids = array_filter($ids, static fn ($id) => $id > 0); @@ -454,18 +620,28 @@ class UserService } $result = UserRoleRepository::replaceForUser($userId, $ids); PermissionService::clearUserCache($userId); + if ($result && $bumpAuthz) { + self::bumpAuthzVersion($userId); + } return $result; } - public static function syncDepartments(int $userId, array $departmentIds): bool + public static function syncDepartments(int $userId, array $departmentIds, bool $bumpAuthz = true): bool { $ids = array_values(array_unique(array_map('intval', $departmentIds))); $ids = array_filter($ids, static fn ($id) => $id > 0); - $userTenantIds = TenantScopeService::getUserTenantIds($userId); + // Important: use the user's assigned tenants directly (no permission bypass), + // otherwise privileged users (e.g. admins) would incorrectly allow departments + // from tenants that were just removed from their assignments. + $userTenantIds = array_values(array_unique(array_map( + 'intval', + UserTenantRepository::listTenantIdsByUserId($userId) + ))); + $userTenantIds = array_values(array_filter($userTenantIds, static fn ($id) => $id > 0)); if (!$userTenantIds) { $ids = []; } else { - $allowedIds = TenantDepartmentRepository::listDepartmentIdsByTenantIds($userTenantIds); + $allowedIds = DepartmentRepository::listActiveIdsByTenantIds($userTenantIds); if ($allowedIds) { $allowedMap = array_fill_keys($allowedIds, true); $ids = array_values(array_filter($ids, static fn ($id) => isset($allowedMap[$id]))); @@ -473,7 +649,19 @@ class UserService $ids = []; } } - return UserDepartmentRepository::replaceForUser($userId, $ids); + $result = UserDepartmentRepository::replaceForUser($userId, $ids); + if ($result && $bumpAuthz) { + self::bumpAuthzVersion($userId); + } + return $result; + } + + public static function bumpAuthzVersion(int $userId): void + { + if ($userId <= 0) { + return; + } + UserRepository::bumpAuthzVersion($userId); } private static function sanitizeBase(array $input): array @@ -498,10 +686,37 @@ class UserService public static function normalizeIdInput($value): array { - if (!is_array($value)) { - $value = [$value]; + $raw = is_array($value) ? $value : [$value]; + $flat = []; + + $collect = static function ($item) use (&$collect, &$flat): void { + if (is_array($item)) { + foreach ($item as $nested) { + $collect($nested); + } + return; + } + $text = trim((string) $item); + if ($text === '') { + return; + } + if (str_contains($text, ',')) { + foreach (explode(',', $text) as $part) { + $part = trim($part); + if ($part !== '') { + $flat[] = $part; + } + } + return; + } + $flat[] = $text; + }; + + foreach ($raw as $item) { + $collect($item); } - $ids = array_values(array_unique(array_map('intval', $value))); + + $ids = array_values(array_unique(array_map('intval', $flat))); return array_values(array_filter($ids, static fn ($id) => $id > 0)); } @@ -577,7 +792,7 @@ class UserService return [$primaryTenantId, []]; } - private static function validatePassword( + public static function validatePassword( string $password, string $password2, bool $required, @@ -726,6 +941,53 @@ class UserService return $tenants; } + /** + * Get only explicitly assigned active tenants for a user. + * This intentionally ignores global tenant-scope bypass permissions. + */ + public static function getAssignedActiveTenants(int $userId): array + { + if ($userId <= 0) { + return []; + } + + $tenantIds = array_values(array_unique(array_map( + 'intval', + UserTenantRepository::listTenantIdsByUserId($userId) + ))); + $tenantIds = array_values(array_filter($tenantIds, static fn ($id) => $id > 0)); + if (!$tenantIds) { + return []; + } + + $activeTenantIds = TenantRepository::listActiveIdsByIds($tenantIds); + if (!$activeTenantIds) { + return []; + } + + $tenantsById = []; + foreach (TenantRepository::listByIds($activeTenantIds) as $tenant) { + $tenantId = (int) ($tenant['id'] ?? 0); + if ($tenantId <= 0) { + continue; + } + $tenantsById[$tenantId] = $tenant; + } + + $tenants = []; + foreach ($tenantIds as $tenantId) { + if (isset($tenantsById[$tenantId])) { + $tenants[] = $tenantsById[$tenantId]; + } + } + + usort($tenants, static function ($a, $b) { + return strcmp((string) ($a['description'] ?? ''), (string) ($b['description'] ?? '')); + }); + + return $tenants; + } + /** * Get available departments grouped by tenant for a user. * Returns an array of groups: ['tenant' => [...], 'departments' => [...]] @@ -743,8 +1005,7 @@ class UserService if ($tenantId <= 0) { continue; } - $departmentIds = TenantDepartmentRepository::listDepartmentIdsByTenantIds([$tenantId]); - $departments = $departmentIds ? DepartmentRepository::listByIds($departmentIds) : []; + $departments = DepartmentRepository::listByTenantIds([$tenantId]); usort($departments, static function ($a, $b) { return strcmp((string) ($a['description'] ?? ''), (string) ($b['description'] ?? '')); }); diff --git a/lib/Support/Crypto.php b/lib/Support/Crypto.php new file mode 100644 index 0000000..2781efa --- /dev/null +++ b/lib/Support/Crypto.php @@ -0,0 +1,125 @@ + 1, + 'iv' => self::base64UrlEncode($iv), + 'tag' => self::base64UrlEncode($tag), + 'ct' => self::base64UrlEncode($ciphertext), + ]); + + if (!is_string($payload)) { + throw new \RuntimeException('Encryption payload encoding failed'); + } + + return base64_encode($payload); + } + + public static function decryptString(string $encoded): string + { + $key = self::getKey(); + if ($key === null) { + throw new \RuntimeException('Missing APP_CRYPTO_KEY'); + } + + $json = base64_decode($encoded, true); + if (!is_string($json) || $json === '') { + throw new \RuntimeException('Encrypted payload is invalid'); + } + + $payload = json_decode($json, true); + if (!is_array($payload)) { + throw new \RuntimeException('Encrypted payload cannot be decoded'); + } + + $iv = self::base64UrlDecode((string) ($payload['iv'] ?? '')); + $tag = self::base64UrlDecode((string) ($payload['tag'] ?? '')); + $ciphertext = self::base64UrlDecode((string) ($payload['ct'] ?? '')); + if ($iv === '' || strlen($iv) !== 12 || $tag === '' || strlen($tag) !== 16 || $ciphertext === '') { + throw new \RuntimeException('Encrypted payload is malformed'); + } + + $plaintext = openssl_decrypt( + $ciphertext, + self::CIPHER, + $key, + OPENSSL_RAW_DATA, + $iv, + $tag + ); + + if (!is_string($plaintext)) { + throw new \RuntimeException('Decryption failed'); + } + + return $plaintext; + } + + private static function getKey(): ?string + { + $raw = defined('APP_CRYPTO_KEY') ? trim((string) APP_CRYPTO_KEY) : ''; + if ($raw === '') { + return null; + } + + $hex = preg_match('/^[0-9a-f]{64}$/i', $raw) ? hex2bin($raw) : false; + if (is_string($hex) && strlen($hex) === 32) { + return $hex; + } + + $base64 = base64_decode($raw, true); + if (is_string($base64) && strlen($base64) === 32) { + return $base64; + } + + return null; + } + + 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 : ''; + } +} diff --git a/lib/Support/Guard.php b/lib/Support/Guard.php index c7d6d3c..eb9a55e 100644 --- a/lib/Support/Guard.php +++ b/lib/Support/Guard.php @@ -86,7 +86,7 @@ class Guard echo json_encode(['error' => 'forbidden']); exit; } - Router::redirect('error/forbidden'); + Router::redirect('error/forbidden?url=' . urlencode(Request::pathWithQuery())); } } } diff --git a/lib/Support/Hello.php b/lib/Support/Hello.php deleted file mode 100644 index c8b2f79..0000000 --- a/lib/Support/Hello.php +++ /dev/null @@ -1,13 +0,0 @@ - 0) { + $label = trim($label . ' — ' . $statusCode); + } + if ($label === '' && $requestId !== '') { + $label = $requestId; + } + $url = lurl('admin/api-audit/view/' . ($data['id'] ?? '')) . '?search=' . urlencode($query); + } elseif ($key === 'pages') { + $label = (string) ($data['slug'] ?? ''); + $url = lurl('page/' . $label) . '?search=' . urlencode($query); + } else { + $label = (string) ($data['description'] ?? ''); + $url = lurl('admin/' . $key . '/edit/' . ($data['uuid'] ?? '')) . '?search=' . urlencode($query); + } + + if ($label === '') { + return null; + } + + return ['label' => $label, 'url' => $url]; + } + + public static function mapResultItem(string $key, string $label, array $data): ?array + { + $title = ''; + $description = ''; + $url = ''; + $image = ''; + + if ($key === 'users') { + $title = trim(($data['first_name'] ?? '') . ' ' . ($data['last_name'] ?? '')); + $description = (string) ($data['email'] ?? ''); + $url = lurl('admin/users/edit/' . ($data['uuid'] ?? '')); + } elseif ($key === 'address-book') { + $title = trim(($data['first_name'] ?? '') . ' ' . ($data['last_name'] ?? '')); + $description = (string) ($data['email'] ?? ''); + $uuid = (string) ($data['uuid'] ?? ''); + $url = lurl('address-book/view/' . $uuid); + if ($uuid !== '' && UserAvatarService::hasAvatar($uuid)) { + $image = lurl('admin/users/avatar-file') . '?uuid=' . urlencode($uuid) . '&size=64'; + } + } elseif ($key === 'permissions') { + $title = (string) ($data['key'] ?? ''); + $description = (string) ($data['description'] ?? ''); + $url = lurl('admin/permissions/edit/' . ($data['id'] ?? '')); + } elseif ($key === 'scheduled-jobs') { + $jobLabel = trim((string) ($data['label'] ?? '')); + $jobKey = trim((string) ($data['job_key'] ?? '')); + $title = $jobLabel !== '' ? $jobLabel : $jobKey; + $status = trim((string) ($data['last_run_status'] ?? '')); + $description = $status !== '' ? strtoupper($status) : ($jobKey !== '' ? $jobKey : ''); + $url = lurl('admin/scheduled-jobs/edit/' . ($data['id'] ?? '')); + } elseif ($key === 'pages') { + $title = (string) ($data['slug'] ?? ''); + $description = t('Page'); + $url = lurl('page/' . $title); + } elseif ($key === 'mail-log') { + $title = trim((string) ($data['subject'] ?? '')); + if ($title === '') { + $title = trim((string) ($data['to_email'] ?? '')); + } + $status = trim((string) ($data['status'] ?? '')); + $toEmail = trim((string) ($data['to_email'] ?? '')); + $description = trim(($status !== '' ? strtoupper($status) : '') . ($toEmail !== '' ? ' — ' . $toEmail : '')); + $url = lurl('admin/mail-log/view/' . ($data['id'] ?? '')); + } elseif ($key === 'api-audit') { + $method = strtoupper(trim((string) ($data['method'] ?? ''))); + $path = trim((string) ($data['path'] ?? '')); + $statusCode = (int) ($data['status_code'] ?? 0); + $requestId = trim((string) ($data['request_id'] ?? '')); + $title = trim($method . ' ' . $path); + if ($title === '') { + $title = $requestId; + } + $description = trim(($statusCode > 0 ? (string) $statusCode : '') . ($requestId !== '' ? ' — ' . $requestId : '')); + $url = lurl('admin/api-audit/view/' . ($data['id'] ?? '')); + } else { + $title = (string) ($data['description'] ?? ''); + $description = $label; + $url = lurl('admin/' . $key . '/edit/' . ($data['uuid'] ?? '')); + } + + if ($title === '' || $url === '') { + return null; + } + + return [ + 'type' => $label, + 'title' => $title, + 'description' => $description, + 'url' => $url, + 'image' => $image, + 'icon' => SearchUiMetaProvider::iconForKey($key), + ]; + } +} diff --git a/lib/Support/Search/SearchQueryNormalizer.php b/lib/Support/Search/SearchQueryNormalizer.php new file mode 100644 index 0000000..7ff7bf7 --- /dev/null +++ b/lib/Support/Search/SearchQueryNormalizer.php @@ -0,0 +1,37 @@ + t($actionKey), + 'mac' => $mac, + 'win' => $win, + ]; + } + + return $rows; + } + + private static function matchesHotkeyQueryWith(string $query, array $entries): bool + { + $query = trim(mb_strtolower($query)); + if ($query === '') { + return false; + } + + $tokens = preg_split('/[^a-z0-9]+/i', $query); + $tokens = is_array($tokens) ? array_values(array_filter($tokens, static fn ($part): bool => $part !== '')) : []; + + foreach (HotkeyService::searchKeywords() as $keywordRaw) { + $keyword = trim(mb_strtolower((string) $keywordRaw)); + if ($keyword === '') { + continue; + } + if (in_array($keyword, $tokens, true)) { + return true; + } + foreach ($tokens as $token) { + if (strlen($token) >= 3 && str_starts_with($keyword, $token)) { + return true; + } + } + if (strlen($query) >= 3 && str_starts_with($keyword, $query)) { + return true; + } + } + + foreach ($entries as $entry) { + $label = mb_strtolower((string) ($entry['label'] ?? '')); + $mac = mb_strtolower((string) ($entry['mac'] ?? '')); + $win = mb_strtolower((string) ($entry['win'] ?? '')); + if (($label !== '' && str_contains($label, $query)) + || ($mac !== '' && str_contains($mac, $query)) + || ($win !== '' && str_contains($win, $query))) { + return true; + } + } + + return false; + } + + private static function filterHotkeyEntries(string $query): array + { + $needle = trim(mb_strtolower($query)); + $entries = self::hotkeyEntries(); + + if (!self::matchesHotkeyQueryWith($query, $entries)) { + return []; + } + + if ($needle === '') { + return $entries; + } + + $filtered = []; + foreach ($entries as $entry) { + $label = mb_strtolower((string) ($entry['label'] ?? '')); + $mac = mb_strtolower((string) ($entry['mac'] ?? '')); + $win = mb_strtolower((string) ($entry['win'] ?? '')); + if (str_contains($label, $needle) || str_contains($mac, $needle) || str_contains($win, $needle)) { + $filtered[] = $entry; + } + } + + return $filtered; + } + + public static function hotkeyPreviewResource(string $query): ?array + { + $entries = self::filterHotkeyEntries($query); + if ($entries === []) { + return null; + } + + $items = []; + foreach ($entries as $entry) { + $items[] = [ + 'label' => trim((string) ($entry['label'] ?? '') . ' - ' . (string) ($entry['mac'] ?? '') . ' / ' . (string) ($entry['win'] ?? '')), + 'url' => lurl('help/hotkeys'), + ]; + } + + return [ + 'key' => 'hotkeys', + 'label' => t('Keyboard shortcuts'), + 'count' => count($items), + 'url' => lurl('help/hotkeys'), + 'items' => array_slice($items, 0, 5), + ]; + } + + public static function hotkeyResultItems(string $query): array + { + $entries = self::filterHotkeyEntries($query); + if ($entries === []) { + return []; + } + + $result = []; + foreach ($entries as $entry) { + $result[] = [ + 'type' => t('Keyboard shortcuts'), + 'title' => (string) ($entry['label'] ?? ''), + 'description' => trim((string) ($entry['mac'] ?? '') . ' / ' . (string) ($entry['win'] ?? '')), + 'url' => lurl('help/hotkeys'), + 'image' => '', + 'icon' => 'bi-keyboard', + ]; + } + + return $result; + } + + private static function docsResultUrl(string $slug, string $anchor = ''): string + { + $url = lurl('admin/docs/' . $slug); + if ($anchor !== '') { + $url .= '#' . $anchor; + } + + return $url; + } + + public static function docsPreviewResource(string $query): ?array + { + $matches = DocsCatalogService::search($query); + if ($matches === []) { + return null; + } + + $seenDocs = []; + $previewItems = []; + foreach ($matches as $match) { + $slug = trim((string) $match['slug']); + if ($slug === '') { + continue; + } + + $seenDocs[$slug] = true; + if (isset($previewItems[$slug])) { + continue; + } + + $title = trim((string) $match['title']); + if ($title === '') { + $title = $slug; + } + $section = trim((string) $match['section']); + $label = $title; + if ($section !== '' && mb_strtolower($section) !== mb_strtolower($title)) { + $label .= ' - ' . $section; + } + + $previewItems[$slug] = [ + 'label' => $label, + 'url' => self::docsResultUrl($slug, (string) $match['anchor']), + ]; + } + + return [ + 'key' => 'docs', + 'label' => t('Documentation'), + 'count' => count($seenDocs), + 'url' => lurl('admin/docs/' . DocsCatalogService::defaultSlug()) . '?search=' . urlencode($query), + 'items' => array_values(array_slice($previewItems, 0, 5)), + ]; + } + + public static function docsResultItems(string $query): array + { + $matches = DocsCatalogService::search($query); + if ($matches === []) { + return []; + } + + $items = []; + foreach ($matches as $match) { + $slug = trim((string) $match['slug']); + $title = trim((string) $match['title']); + if ($slug === '' || $title === '') { + continue; + } + + $section = trim((string) $match['section']); + $snippet = trim((string) $match['snippet']); + $descriptionParts = []; + if ($section !== '' && mb_strtolower($section) !== mb_strtolower($title)) { + $descriptionParts[] = $section; + } + if ($snippet !== '') { + $descriptionParts[] = $snippet; + } + + $rawScore = (int) $match['score']; + $searchScore = match (true) { + $rawScore >= 1000 => 400, + $rawScore >= 900 => 360, + $rawScore >= 800 => 330, + $rawScore >= 700 => 300, + $rawScore >= 650 => 260, + $rawScore >= 600 => 230, + $rawScore >= 500 => 200, + default => 150, + }; + + $items[] = [ + 'type' => t('Documentation'), + 'title' => $title, + 'description' => implode(' - ', $descriptionParts), + 'url' => self::docsResultUrl($slug, (string) $match['anchor']), + 'image' => '', + 'icon' => 'bi-journal-text', + 'search_score' => $searchScore, + ]; + } + + return $items; + } +} diff --git a/lib/Support/Search/SearchSqlResourceProvider.php b/lib/Support/Search/SearchSqlResourceProvider.php new file mode 100644 index 0000000..5676afc --- /dev/null +++ b/lib/Support/Search/SearchSqlResourceProvider.php @@ -0,0 +1,149 @@ + 'and exists (select 1 from user_tenants ut where ut.user_id = users.id and ut.tenant_id in (???))', + 'address-book' => 'and exists (select 1 from user_tenants ut where ut.user_id = users.id and ut.tenant_id in (???))', + 'tenants' => 'and id in (???)', + 'departments' => 'and tenant_id in (???)', + ]; + } + + public static function resources(string $query, string $locale): array + { + $like = SearchQueryNormalizer::normalizeLikeQuery($query); + + return [ + [ + 'key' => 'address-book', + 'label' => t('Address book'), + 'permission' => PermissionService::ADDRESS_BOOK_VIEW, + 'countSql' => "select count(*) from users where active = 1 and (first_name like ? escape '\\\\' or last_name like ? escape '\\\\' or email like ? escape '\\\\') {{tenantFilter}}", + 'countParams' => [$like, $like, $like], + 'previewSql' => "select uuid, first_name, last_name, email from users where active = 1 and (first_name like ? escape '\\\\' or last_name like ? escape '\\\\' or email like ? escape '\\\\') {{tenantFilter}} order by last_name, first_name limit ?", + 'previewParams' => [$like, $like, $like], + 'resultSql' => "select uuid, first_name, last_name, email from users where active = 1 and (first_name like ? escape '\\\\' or last_name like ? escape '\\\\' or email like ? escape '\\\\') {{tenantFilter}} order by last_name, first_name", + 'resultParams' => [$like, $like, $like], + ], + [ + 'key' => 'users', + 'label' => t('Users'), + 'permission' => PermissionService::USERS_VIEW, + 'countSql' => "select count(*) from users where (first_name like ? escape '\\\\' or last_name like ? escape '\\\\' or email like ? escape '\\\\') {{tenantFilter}}", + 'countParams' => [$like, $like, $like], + 'previewSql' => "select uuid, first_name, last_name, email from users where (first_name like ? escape '\\\\' or last_name like ? escape '\\\\' or email like ? escape '\\\\') {{tenantFilter}} order by last_name, first_name limit ?", + 'previewParams' => [$like, $like, $like], + 'resultSql' => "select uuid, first_name, last_name, email from users where (first_name like ? escape '\\\\' or last_name like ? escape '\\\\' or email like ? escape '\\\\') {{tenantFilter}} order by last_name, first_name", + 'resultParams' => [$like, $like, $like], + ], + [ + 'key' => 'tenants', + 'label' => t('Tenants'), + 'permission' => PermissionService::TENANTS_VIEW, + 'countSql' => "select count(*) from tenants where description like ? escape '\\\\' {{tenantFilter}}", + 'countParams' => [$like], + 'previewSql' => "select uuid, description from tenants where description like ? escape '\\\\' {{tenantFilter}} order by description limit ?", + 'previewParams' => [$like], + 'resultSql' => "select uuid, description from tenants where description like ? escape '\\\\' {{tenantFilter}} order by description", + 'resultParams' => [$like], + ], + [ + 'key' => 'departments', + 'label' => t('Departments'), + 'permission' => PermissionService::DEPARTMENTS_VIEW, + 'countSql' => "select count(*) from departments where description like ? escape '\\\\' {{tenantFilter}}", + 'countParams' => [$like], + 'previewSql' => "select uuid, description from departments where description like ? escape '\\\\' {{tenantFilter}} order by description limit ?", + 'previewParams' => [$like], + 'resultSql' => "select uuid, description from departments where description like ? escape '\\\\' {{tenantFilter}} order by description", + 'resultParams' => [$like], + ], + [ + 'key' => 'roles', + 'label' => t('Roles'), + 'permission' => PermissionService::ROLES_VIEW, + 'countSql' => "select count(*) from roles where active = 1 and description like ? escape '\\\\'", + 'countParams' => [$like], + 'previewSql' => "select uuid, description from roles where active = 1 and description like ? escape '\\\\' order by description limit ?", + 'previewParams' => [$like], + 'resultSql' => "select uuid, description from roles where active = 1 and description like ? escape '\\\\' order by description", + 'resultParams' => [$like], + ], + [ + 'key' => 'permissions', + 'label' => t('Permissions'), + 'permission' => PermissionService::PERMISSIONS_VIEW, + 'countSql' => "select count(*) from permissions where active = 1 and (`key` like ? escape '\\\\' or description like ? escape '\\\\')", + 'countParams' => [$like, $like], + 'previewSql' => "select id, description, `key` from permissions where active = 1 and (`key` like ? escape '\\\\' or description like ? escape '\\\\') order by `key` limit ?", + 'previewParams' => [$like, $like], + 'resultSql' => "select id, description, `key` from permissions where active = 1 and (`key` like ? escape '\\\\' or description like ? escape '\\\\') order by `key`", + 'resultParams' => [$like, $like], + ], + [ + 'key' => 'scheduled-jobs', + 'label' => t('Scheduled jobs'), + 'permission' => PermissionService::JOBS_VIEW, + 'countSql' => "select count(*) from scheduled_jobs where (job_key like ? escape '\\\\' or label like ? escape '\\\\' or description like ? escape '\\\\')", + 'countParams' => [$like, $like, $like], + 'previewSql' => "select id, job_key, label, enabled, next_run_at, last_run_status from scheduled_jobs where (job_key like ? escape '\\\\' or label like ? escape '\\\\' or description like ? escape '\\\\') order by label, job_key limit ?", + 'previewParams' => [$like, $like, $like], + 'resultSql' => "select id, job_key, label, enabled, next_run_at, last_run_status from scheduled_jobs where (job_key like ? escape '\\\\' or label like ? escape '\\\\' or description like ? escape '\\\\') order by label, job_key", + 'resultParams' => [$like, $like, $like], + ], + [ + 'key' => 'pages', + 'label' => t('Pages'), + 'permission' => '', + 'countSql' => "select count(*) from pages p join page_contents pc on pc.page_id = p.id and pc.locale = ? where p.slug like ? escape '\\\\' or pc.content like ? escape '\\\\'", + 'countParams' => [$locale, $like, $like], + 'previewSql' => "select p.slug from pages p join page_contents pc on pc.page_id = p.id and pc.locale = ? where p.slug like ? escape '\\\\' or pc.content like ? escape '\\\\' order by p.slug limit ?", + 'previewParams' => [$locale, $like, $like], + 'resultSql' => "select p.slug from pages p join page_contents pc on pc.page_id = p.id and pc.locale = ? where p.slug like ? escape '\\\\' or pc.content like ? escape '\\\\' order by p.slug", + 'resultParams' => [$locale, $like, $like], + ], + [ + 'key' => 'mail-log', + 'label' => t('Mail logs'), + 'permission' => PermissionService::MAIL_LOG_VIEW, + 'countSql' => "select count(*) from mail_log where (to_email like ? escape '\\\\' or subject like ? escape '\\\\' or template like ? escape '\\\\' or provider_message_id like ? escape '\\\\')", + 'countParams' => [$like, $like, $like, $like], + 'previewSql' => "select id, to_email, subject, status, created_at from mail_log where (to_email like ? escape '\\\\' or subject like ? escape '\\\\' or template like ? escape '\\\\' or provider_message_id like ? escape '\\\\') order by created_at desc limit ?", + 'previewParams' => [$like, $like, $like, $like], + 'resultSql' => "select id, to_email, subject, status, created_at from mail_log where (to_email like ? escape '\\\\' or subject like ? escape '\\\\' or template like ? escape '\\\\' or provider_message_id like ? escape '\\\\') order by created_at desc", + 'resultParams' => [$like, $like, $like, $like], + ], + [ + 'key' => 'api-audit', + 'label' => t('API audit'), + 'permission' => PermissionService::API_AUDIT_VIEW, + 'countSql' => "select count(*) from api_audit_log where (request_id like ? escape '\\\\' or path like ? escape '\\\\' or error_code like ? escape '\\\\' or ip like ? escape '\\\\')", + 'countParams' => [$like, $like, $like, $like], + 'previewSql' => "select id, request_id, method, path, status_code, created_at from api_audit_log where (request_id like ? escape '\\\\' or path like ? escape '\\\\' or error_code like ? escape '\\\\' or ip like ? escape '\\\\') order by created_at desc limit ?", + 'previewParams' => [$like, $like, $like, $like], + 'resultSql' => "select id, request_id, method, path, status_code, created_at from api_audit_log where (request_id like ? escape '\\\\' or path like ? escape '\\\\' or error_code like ? escape '\\\\' or ip like ? escape '\\\\') order by created_at desc", + 'resultParams' => [$like, $like, $like, $like], + ], + ]; + } + + public static function resourceKeys(): array + { + $keys = []; + foreach (self::resources('', '') as $resource) { + $key = (string) ($resource['key'] ?? ''); + if ($key !== '') { + $keys[] = $key; + } + } + + return $keys; + } +} diff --git a/lib/Support/Search/SearchUiMetaProvider.php b/lib/Support/Search/SearchUiMetaProvider.php new file mode 100644 index 0000000..9fd0d87 --- /dev/null +++ b/lib/Support/Search/SearchUiMetaProvider.php @@ -0,0 +1,67 @@ + 'bi-people', + 'users' => 'bi-person-badge', + 'tenants' => 'bi-buildings', + 'departments' => 'bi-diagram-3', + 'roles' => 'bi-people', + 'permissions' => 'bi-shield-lock', + 'scheduled-jobs' => 'bi-calendar-check', + 'pages' => 'bi-file-text', + 'mail-log' => 'bi-envelope-paper', + 'api-audit' => 'bi-shield-check', + 'docs' => 'bi-journal-text', + ]; + + private const LIST_URL_KEYS = [ + 'address-book', + 'users', + 'tenants', + 'departments', + 'roles', + 'permissions', + 'scheduled-jobs', + 'pages', + 'mail-log', + 'api-audit', + ]; + + public static function iconForKey(string $key): string + { + return self::ICONS[$key] ?? 'bi-search'; + } + + public static function listUrl(string $key, string $query): string + { + $encoded = urlencode($query); + + return match ($key) { + 'address-book' => lurl('address-book') . '?search=' . $encoded, + 'users' => lurl('admin/users') . '?search=' . $encoded, + 'tenants' => lurl('admin/tenants') . '?search=' . $encoded, + 'departments' => lurl('admin/departments') . '?search=' . $encoded, + 'roles' => lurl('admin/roles') . '?search=' . $encoded, + 'permissions' => lurl('admin/permissions') . '?search=' . $encoded, + 'scheduled-jobs' => lurl('admin/scheduled-jobs') . '?search=' . $encoded, + 'pages' => lurl(''), + 'mail-log' => lurl('admin/mail-log') . '?search=' . $encoded, + 'api-audit' => lurl('admin/api-audit') . '?search=' . $encoded, + default => lurl(''), + }; + } + + public static function hasIconForKey(string $key): bool + { + return array_key_exists($key, self::ICONS); + } + + public static function hasListUrlForKey(string $key): bool + { + return in_array($key, self::LIST_URL_KEYS, true); + } +} diff --git a/lib/Support/SearchConfig.php b/lib/Support/SearchConfig.php index 616fc25..f8948ad 100644 --- a/lib/Support/SearchConfig.php +++ b/lib/Support/SearchConfig.php @@ -2,257 +2,88 @@ namespace MintyPHP\Support; -use MintyPHP\Service\Access\PermissionService; -use MintyPHP\Service\User\UserAvatarService; +use MintyPHP\Support\Search\SearchItemMapperProvider; +use MintyPHP\Support\Search\SearchQueryNormalizer; +use MintyPHP\Support\Search\SearchSpecialResourceProvider; +use MintyPHP\Support\Search\SearchSqlResourceProvider; +use MintyPHP\Support\Search\SearchUiMetaProvider; class SearchConfig { public static function tenantScopeFilters(): array { - return [ - 'users' => 'and exists (select 1 from user_tenants ut where ut.user_id = users.id and ut.tenant_id in (???))', - 'address-book' => 'and exists (select 1 from user_tenants ut where ut.user_id = users.id and ut.tenant_id in (???))', - 'tenants' => 'and id in (???)', - 'departments' => 'and exists (select 1 from tenant_departments td where td.department_id = departments.id and td.tenant_id in (???))', - ]; - } - - - private static function normalizeLikeQuery(string $query): string - { - $query = trim($query); - if ($query === '') { - return ''; - } - $query = str_replace('\\', '\\\\', $query); - $query = str_replace('_', '\\_', $query); - $query = str_replace('*', '%', $query); - if (!str_starts_with($query, '%')) { - $query = '%' . $query; - } - if (!str_ends_with($query, '%')) { - $query = $query . '%'; - } - return $query; - } - - public static function normalizeScoreQuery(string $query): string - { - $query = trim($query); - if ($query == '') { - return ''; - } - return str_replace(['*', '%', '_'], '', $query); + return SearchSqlResourceProvider::tenantScopeFilters(); } public static function resources(string $query, string $locale): array { - $like = self::normalizeLikeQuery($query); - - return [ - [ - 'key' => 'address-book', - 'label' => t('Address book'), - 'permission' => PermissionService::ADDRESS_BOOK_VIEW, - 'countSql' => "select count(*) from users where active = 1 and (first_name like ? escape '\\\\' or last_name like ? escape '\\\\' or email like ? escape '\\\\') {{tenantFilter}}", - 'countParams' => [$like, $like, $like], - 'previewSql' => "select uuid, first_name, last_name, email from users where active = 1 and (first_name like ? escape '\\\\' or last_name like ? escape '\\\\' or email like ? escape '\\\\') {{tenantFilter}} order by last_name, first_name limit ?", - 'previewParams' => [$like, $like, $like], - 'resultSql' => "select uuid, first_name, last_name, email from users where active = 1 and (first_name like ? escape '\\\\' or last_name like ? escape '\\\\' or email like ? escape '\\\\') {{tenantFilter}} order by last_name, first_name", - 'resultParams' => [$like, $like, $like], - ], - [ - 'key' => 'users', - 'label' => t('Users'), - 'permission' => PermissionService::USERS_VIEW, - 'countSql' => "select count(*) from users where (first_name like ? escape '\\\\' or last_name like ? escape '\\\\' or email like ? escape '\\\\') {{tenantFilter}}", - 'countParams' => [$like, $like, $like], - 'previewSql' => "select uuid, first_name, last_name, email from users where (first_name like ? escape '\\\\' or last_name like ? escape '\\\\' or email like ? escape '\\\\') {{tenantFilter}} order by last_name, first_name limit ?", - 'previewParams' => [$like, $like, $like], - 'resultSql' => "select uuid, first_name, last_name, email from users where (first_name like ? escape '\\\\' or last_name like ? escape '\\\\' or email like ? escape '\\\\') {{tenantFilter}} order by last_name, first_name", - 'resultParams' => [$like, $like, $like], - ], - [ - 'key' => 'tenants', - 'label' => t('Tenants'), - 'permission' => PermissionService::TENANTS_VIEW, - 'countSql' => "select count(*) from tenants where description like ? escape '\\\\' {{tenantFilter}}", - 'countParams' => [$like], - 'previewSql' => "select uuid, description from tenants where description like ? escape '\\\\' {{tenantFilter}} order by description limit ?", - 'previewParams' => [$like], - 'resultSql' => "select uuid, description from tenants where description like ? escape '\\\\' {{tenantFilter}} order by description", - 'resultParams' => [$like], - ], - [ - 'key' => 'departments', - 'label' => t('Departments'), - 'permission' => PermissionService::DEPARTMENTS_VIEW, - 'countSql' => "select count(*) from departments where description like ? escape '\\\\' {{tenantFilter}}", - 'countParams' => [$like], - 'previewSql' => "select uuid, description from departments where description like ? escape '\\\\' {{tenantFilter}} order by description limit ?", - 'previewParams' => [$like], - 'resultSql' => "select uuid, description from departments where description like ? escape '\\\\' {{tenantFilter}} order by description", - 'resultParams' => [$like], - ], - [ - 'key' => 'roles', - 'label' => t('Roles'), - 'permission' => PermissionService::ROLES_VIEW, - 'countSql' => "select count(*) from roles where active = 1 and description like ? escape '\\\\'", - 'countParams' => [$like], - 'previewSql' => "select uuid, description from roles where active = 1 and description like ? escape '\\\\' order by description limit ?", - 'previewParams' => [$like], - 'resultSql' => "select uuid, description from roles where active = 1 and description like ? escape '\\\\' order by description", - 'resultParams' => [$like], - ], - [ - 'key' => 'permissions', - 'label' => t('Permissions'), - 'permission' => PermissionService::PERMISSIONS_VIEW, - 'countSql' => "select count(*) from permissions where active = 1 and (`key` like ? escape '\\\\' or description like ? escape '\\\\')", - 'countParams' => [$like, $like], - 'previewSql' => "select id, description, `key` from permissions where active = 1 and (`key` like ? escape '\\\\' or description like ? escape '\\\\') order by `key` limit ?", - 'previewParams' => [$like, $like], - 'resultSql' => "select id, description, `key` from permissions where active = 1 and (`key` like ? escape '\\\\' or description like ? escape '\\\\') order by `key`", - 'resultParams' => [$like, $like], - ], - [ - 'key' => 'pages', - 'label' => t('Pages'), - 'permission' => '', - 'countSql' => "select count(*) from pages p join page_contents pc on pc.page_id = p.id and pc.locale = ? where p.slug like ? escape '\\\\' or pc.content like ? escape '\\\\'", - 'countParams' => [$locale, $like, $like], - 'previewSql' => "select p.slug from pages p join page_contents pc on pc.page_id = p.id and pc.locale = ? where p.slug like ? escape '\\\\' or pc.content like ? escape '\\\\' order by p.slug limit ?", - 'previewParams' => [$locale, $like, $like], - 'resultSql' => "select p.slug from pages p join page_contents pc on pc.page_id = p.id and pc.locale = ? where p.slug like ? escape '\\\\' or pc.content like ? escape '\\\\' order by p.slug", - 'resultParams' => [$locale, $like, $like], - ], - [ - 'key' => 'settings', - 'label' => t('Settings'), - 'permission' => PermissionService::SETTINGS_VIEW, - 'countSql' => "select count(*) from settings where `key` like ? escape '\\\\' or value like ? escape '\\\\'", - 'countParams' => [$like, $like], - 'previewSql' => null, - 'previewParams' => [], - 'resultSql' => "select `key`, value from settings where `key` like ? escape '\\\\' or value like ? escape '\\\\' order by `key`", - 'resultParams' => [$like, $like], - ], - ]; + return SearchSqlResourceProvider::resources($query, $locale); } public static function iconForKey(string $key): string { - return match ($key) { - 'address-book' => 'bi-people', - 'users' => 'bi-person-badge', - 'tenants' => 'bi-buildings', - 'departments' => 'bi-diagram-3', - 'roles' => 'bi-people', - 'permissions' => 'bi-shield-lock', - 'pages' => 'bi-file-text', - 'settings' => 'bi-gear', - default => 'bi-search', - }; + return SearchUiMetaProvider::iconForKey($key); } public static function listUrl(string $key, string $query): string { - $encoded = urlencode($query); - - return match ($key) { - 'address-book' => lurl('address-book') . '?search=' . $encoded, - 'users' => lurl('admin/users') . '?search=' . $encoded, - 'tenants' => lurl('admin/tenants') . '?search=' . $encoded, - 'departments' => lurl('admin/departments') . '?search=' . $encoded, - 'roles' => lurl('admin/roles') . '?search=' . $encoded, - 'permissions' => lurl('admin/permissions') . '?search=' . $encoded, - 'pages' => lurl(''), - 'settings' => lurl('admin/settings'), - default => lurl(''), - }; + return SearchUiMetaProvider::listUrl($key, $query); } public static function mapPreviewItem(string $key, array $data, string $query): ?array { - if ($key === 'users') { - $label = trim(($data['first_name'] ?? '') . ' ' . ($data['last_name'] ?? '')); - $email = trim((string) ($data['email'] ?? '')); - if ($email !== '') { - $label = $label === '' ? $email : ($label . ' — ' . $email); - } - $url = lurl('admin/users/edit/' . ($data['uuid'] ?? '')) . '?search=' . urlencode($query); - } elseif ($key === 'address-book') { - $label = trim(($data['first_name'] ?? '') . ' ' . ($data['last_name'] ?? '')); - $email = trim((string) ($data['email'] ?? '')); - if ($email !== '') { - $label = $label === '' ? $email : ($label . ' — ' . $email); - } - $url = lurl('address-book/view/' . ($data['uuid'] ?? '')) . '?search=' . urlencode($query); - } elseif ($key === 'permissions') { - $label = (string) ($data['description'] ?? ''); - $url = lurl('admin/permissions/edit/' . ($data['id'] ?? '')) . '?search=' . urlencode($query); - } elseif ($key === 'pages') { - $label = (string) ($data['slug'] ?? ''); - $url = lurl('page/' . $label) . '?search=' . urlencode($query); - } else { - $label = (string) ($data['description'] ?? ''); - $url = lurl('admin/' . $key . '/edit/' . ($data['uuid'] ?? '')) . '?search=' . urlencode($query); - } - - if ($label === '' || $url === '') { - return null; - } - return ['label' => $label, 'url' => $url]; + return SearchItemMapperProvider::mapPreviewItem($key, $data, $query); } public static function mapResultItem(string $key, string $label, array $data): ?array { - $title = ''; - $description = ''; - $url = ''; - $image = ''; - if ($key === 'users') { - $title = trim(($data['first_name'] ?? '') . ' ' . ($data['last_name'] ?? '')); - $description = (string) ($data['email'] ?? ''); - $url = lurl('admin/users/edit/' . ($data['uuid'] ?? '')); - } elseif ($key === 'address-book') { - $title = trim(($data['first_name'] ?? '') . ' ' . ($data['last_name'] ?? '')); - $description = (string) ($data['email'] ?? ''); - $uuid = (string) ($data['uuid'] ?? ''); - $url = lurl('address-book/view/' . $uuid); - if ($uuid !== '' && UserAvatarService::hasAvatar($uuid)) { - $image = lurl('admin/users/avatar-file') . '?uuid=' . urlencode($uuid) . '&size=64'; - } - } elseif ($key === 'permissions') { - $title = (string) ($data['key'] ?? ''); - $description = (string) ($data['description'] ?? ''); - $url = lurl('admin/permissions/edit/' . ($data['id'] ?? '')); - } elseif ($key === 'pages') { - $title = (string) ($data['slug'] ?? ''); - $description = t('Page'); - $url = lurl('page/' . $title); - } elseif ($key === 'settings') { - $title = (string) ($data['key'] ?? ''); - $description = (string) ($data['value'] ?? ''); - $url = lurl('admin/settings'); - } else { - $title = (string) ($data['description'] ?? ''); - $description = $label; - $url = lurl('admin/' . $key . '/edit/' . ($data['uuid'] ?? '')); - } + return SearchItemMapperProvider::mapResultItem($key, $label, $data); + } - if ($title === '' || $url === '') { - return null; + public static function hotkeyPreviewResource(string $query): ?array + { + return SearchSpecialResourceProvider::hotkeyPreviewResource($query); + } + + public static function hotkeyResultItems(string $query): array + { + return SearchSpecialResourceProvider::hotkeyResultItems($query); + } + + public static function docsPreviewResource(string $query): ?array + { + return SearchSpecialResourceProvider::docsPreviewResource($query); + } + + public static function docsResultItems(string $query): array + { + return SearchSpecialResourceProvider::docsResultItems($query); + } + + public static function normalizeScoreQuery(string $query): string + { + return SearchQueryNormalizer::normalizeScoreQuery($query); + } + + /** + * @return array{missingIcons: string[], missingListUrls: string[]} + */ + public static function providerCoverageGaps(): array + { + $missingIcons = []; + $missingListUrls = []; + foreach (SearchSqlResourceProvider::resourceKeys() as $key) { + if (!SearchUiMetaProvider::hasIconForKey($key)) { + $missingIcons[] = $key; + } + if (!SearchUiMetaProvider::hasListUrlForKey($key)) { + $missingListUrls[] = $key; + } } return [ - 'type' => $label, - 'title' => $title, - 'description' => $description, - 'url' => $url, - 'image' => $image, - 'icon' => self::iconForKey($key), + 'missingIcons' => $missingIcons, + 'missingListUrls' => $missingListUrls, ]; } } diff --git a/lib/Support/helpers.php b/lib/Support/helpers.php index a889544..a5d190a 100644 --- a/lib/Support/helpers.php +++ b/lib/Support/helpers.php @@ -1,5 +1,6 @@ email. + */ function currentUserDisplayName(): string { $user = $_SESSION['user'] ?? []; @@ -85,57 +113,43 @@ function currentUserDisplayName(): string return trim((string) ($user['email'] ?? '')); } +/** + * Absolute logo URL (used in e-mails and metadata). + */ function appLogoUrlAbsolute(int $size = 128): string { $url = appLogoUrl($size); return appUrl($url); } +/** + * Read one cached app setting from config/settings.php. + */ function appSetting(string $key): ?string { - $cacheFile = dirname(__DIR__, 3) . '/config/settings.php'; - if (!is_file($cacheFile)) { + if (!class_exists('MintyPHP\\Service\\Settings\\SettingCacheService')) { return null; } - $settings = include $cacheFile; - if (!is_array($settings)) { - return null; - } - $value = $settings[$key] ?? null; - $value = $value !== null ? trim((string) $value) : ''; - return $value !== '' ? $value : null; + return \MintyPHP\Service\Settings\SettingCacheService::get($key); } +/** + * Load configured themes with a safe fallback list. + */ function appThemes(): array { - $file = dirname(__DIR__, 3) . '/config/themes.php'; - if (!is_file($file)) { + if (!class_exists('MintyPHP\\Service\\Settings\\ThemeConfigService')) { return [ 'light' => 'Light', 'dark' => 'Dark', ]; } - $themes = include $file; - if (!is_array($themes)) { - return [ - 'light' => 'Light', - 'dark' => 'Dark', - ]; - } - $clean = []; - foreach ($themes as $key => $label) { - $key = strtolower(trim((string) $key)); - $label = trim((string) $label); - if ($key !== '' && $label !== '') { - $clean[$key] = $label; - } - } - return $clean ?: [ - 'light' => 'Light', - 'dark' => 'Dark', - ]; + return \MintyPHP\Service\Settings\ThemeConfigService::all(); } +/** + * Resolve default locale only if it exists in APP_LOCALES. + */ function appDefaultLocale(): ?string { $locale = appSetting('app_locale'); @@ -149,9 +163,16 @@ function appDefaultLocale(): ?string return $locale; } +/** + * Resolve default theme from settings, then APP_THEME, then light. + */ function appDefaultTheme(): string { $themes = appThemes(); + $tenantTheme = strtolower(trim((string) ($_SESSION['current_tenant']['default_theme'] ?? ''))); + if ($tenantTheme !== '' && isset($themes[$tenantTheme])) { + return $tenantTheme; + } $setting = appSetting('app_theme'); if ($setting !== null && isset($themes[$setting])) { return $setting; @@ -161,6 +182,9 @@ function appDefaultTheme(): string return isset($themes[$envTheme]) ? $envTheme : 'light'; } +/** + * Resolve effective theme with optional per-user override. + */ function currentTheme(): string { $themes = appThemes(); @@ -176,8 +200,22 @@ function currentTheme(): string return $theme; } +/** + * Feature flag: allow users to choose their own theme. + */ function allowUserTheme(): bool { + $tenantValue = $_SESSION['current_tenant']['allow_user_theme'] ?? null; + if ($tenantValue !== null && $tenantValue !== '') { + $tenantValue = strtolower(trim((string) $tenantValue)); + if (in_array($tenantValue, ['1', 'true', 'yes', 'on'], true)) { + return true; + } + if (in_array($tenantValue, ['0', 'false', 'no', 'off'], true)) { + return false; + } + } + $value = appSetting('app_theme_user'); if ($value === null || $value === '') { return true; @@ -185,6 +223,9 @@ function allowUserTheme(): bool return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true); } +/** + * Feature flag: allow public self-registration. + */ function allowRegistration(): bool { $value = appSetting('app_registration'); @@ -194,8 +235,12 @@ function allowRegistration(): bool return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true); } +/** + * Resolve active primary color (tenant override -> app setting). + */ function appPrimaryColor(): ?string { + // Tenant-scoped branding has precedence over global settings. $tenantColor = $_SESSION['current_tenant']['primary_color'] ?? null; if ($tenantColor !== null) { $tenantColor = strtolower(trim((string) $tenantColor)); @@ -215,6 +260,9 @@ function appPrimaryColor(): ?string return $value; } +/** + * Convert current hex color to CSS HSL custom properties. + */ function appPrimaryCssVars(): string { $hex = appPrimaryColor(); @@ -229,6 +277,7 @@ function appPrimaryCssVars(): string $g = hexdec(substr($hex, 2, 2)) / 255; $b = hexdec(substr($hex, 4, 2)) / 255; + // Short-circuit grayscale values to avoid unstable hue math. $monoThreshold = 0.000001; if (abs($r - $g) < $monoThreshold && abs($g - $b) < $monoThreshold) { $l = round($r * 100, 2) . '%'; @@ -239,7 +288,7 @@ function appPrimaryCssVars(): string $min = min($r, $g, $b); $delta = $max - $min; - if ($delta == 0.0) { + if ($delta === 0.0) { $l = round((($max + $min) / 2) * 100, 2) . '%'; return "--app-primary-h-base: 0; --app-primary-s-base: 0%; --app-primary-l-base: {$l}; --app-primary-h-light: 0; --app-primary-s-light: 0%; --app-primary-l-light: {$l};"; } @@ -258,7 +307,7 @@ function appPrimaryCssVars(): string $l = ($max + $min) / 2; $denominator = 1 - abs(2 * $l - 1); - if ($delta === 0.0 || $denominator == 0.0) { + if ($delta === 0.0 || $denominator === 0.0) { $s = 0.0; } else { $s = $delta / $denominator; @@ -271,6 +320,9 @@ function appPrimaryCssVars(): string return "--app-primary-h-base: {$h}; --app-primary-s-base: {$s}; --app-primary-l-base: {$l}; --app-primary-h-light: {$h}; --app-primary-s-light: {$s}; --app-primary-l-light: {$l};"; } +/** + * Resolve app logo path (custom upload with fallback asset). + */ function appLogoUrl(?int $size = null): string { if (class_exists('MintyPHP\\Service\\Branding\\BrandingLogoService') && \MintyPHP\Service\Branding\BrandingLogoService::hasLogo()) { @@ -280,6 +332,9 @@ function appLogoUrl(?int $size = null): string return asset('brand/logo.svg'); } +/** + * Resolve favicon path (tenant favicon with global fallback). + */ function appFaviconUrl(string $file): string { $tenantUuid = $_SESSION['current_tenant']['uuid'] ?? ''; @@ -291,11 +346,9 @@ function appFaviconUrl(string $file): string return asset('favicon/' . ltrim($file, '/')); } -function d() -{ - return call_user_func_array('MintyPHP\\Debugger::debug', func_get_args()); -} - +/** + * Sort array items by 'description' case-insensitively. + */ function sortByDescription(array &$items): void { usort($items, static fn($a, $b) => diff --git a/lib/Support/helpers/auth.php b/lib/Support/helpers/auth.php index fa0a78b..f15ac99 100644 --- a/lib/Support/helpers/auth.php +++ b/lib/Support/helpers/auth.php @@ -1,5 +1,8 @@ + + + + + $item !== '')); + multiSelectFilter( + 'address-book-custom-filter-' . $idSuffix, + $label, + 'Select options', + $definitionOptions, + $currentValues + ); + ?> + + + + + + + + + +
    @@ -49,11 +161,18 @@ require templatePath('partials/app-breadcrumb.phtml'); const appBase = getAppBase(); const toolbar = document.querySelector('#address-book-toolbar'); + const saveFilterButton = document.querySelector('[data-address-book-save-filter]'); + const saveFilterForm = document.querySelector('#address-book-save-filter-form'); + const searchField = document.querySelector('#address-book-search'); + const tenantFilterInput = document.querySelector('#address-book-tenant-filter'); + const departmentFilterInput = document.querySelector('#address-book-department-filter'); + const roleFilterInput = document.querySelector('#address-book-role-filter'); + const customFilterInputs = Array.from(document.querySelectorAll('[data-address-book-custom-filter]')); const tenantDepartmentMap = toolbar?.dataset?.tenantDeptMap ? JSON.parse(toolbar.dataset.tenantDeptMap) : {}; - const uuidIndex = 1; + const uuidIndex = 0; const normalizeCommaSeparated = (value) => { if (Array.isArray(value)) return value.filter(Boolean); return String(value || '') @@ -61,38 +180,111 @@ require templatePath('partials/app-breadcrumb.phtml'); .map((item) => item.trim()) .filter(Boolean); }; - const initialsForRow = (row) => { - const first = row?.cells?.[2]?.data ?? ''; - const last = row?.cells?.[3]?.data ?? ''; - const parts = [first, last] - .map((value) => String(value || '').trim()) + const collectFilterState = () => { + const normalizeJoined = (value) => normalizeCommaSeparated(value).join(','); + const custom = {}; + customFilterInputs.forEach((input) => { + const param = String(input?.dataset?.addressBookCustomFilterParam || '').trim(); + if (!param) return; + + if (input.dataset.addressBookCustomFilterType === 'multiselect') { + const targetSelector = input.dataset.addressBookCustomFilterInput || ''; + const target = targetSelector ? document.querySelector(targetSelector) : null; + const joined = normalizeJoined(target?.value || ''); + if (joined) { + custom[param] = joined; + } + return; + } + + const value = String(input?.value || '').trim(); + if (value !== '') { + custom[param] = value; + } + }); + return { + search: String(searchField?.value || '').trim(), + tenants: normalizeJoined(tenantFilterInput?.value), + departments: normalizeJoined(departmentFilterInput?.value), + roles: normalizeJoined(roleFilterInput?.value), + custom + }; + }; + const initialsForName = (name) => { + const parts = String(name || '') + .trim() + .split(/\s+/) .filter(Boolean); - const chars = parts.map((value) => value[0] || '').join('').toUpperCase(); + const chars = parts.slice(0, 2).map((value) => value[0] || '').join('').toUpperCase(); return chars || '?'; }; + const baseFilters = [ + { + input: '#address-book-tenant-filter', + param: 'tenants', + default: [], + normalize: normalizeCommaSeparated + }, + { + input: '#address-book-department-filter', + param: 'departments', + default: [], + normalize: normalizeCommaSeparated + }, + { + input: '#address-book-role-filter', + param: 'roles', + default: [], + normalize: normalizeCommaSeparated + } + ]; + const customFilters = customFilterInputs.map((input) => { + const param = String(input?.dataset?.addressBookCustomFilterParam || '').trim(); + if (!param) return null; + if (input.dataset.addressBookCustomFilterType === 'multiselect') { + const targetSelector = input.dataset.addressBookCustomFilterInput || ''; + return { + input: targetSelector, + param, + default: [], + normalize: normalizeCommaSeparated + }; + } + return { + input, + param, + default: '' + }; + }).filter(Boolean); + createServerGrid({ gridjs: window.gridjs, container: '#address-book-grid', dataUrl: 'address-book/data', appBase, columns: [ + { name: 'UUID', hidden: true }, { - name: "", - sort: false, - formatter: (cell, row) => { - if (!cell) { - const initials = escapeHtml(initialsForRow(row)); - return gridjs.html(`${initials}`); + name: "", + sort: true, + formatter: (cell) => { + const nameValue = typeof cell === 'object' && cell !== null + ? (cell.name ?? '') + : cell; + const avatarUuid = typeof cell === 'object' && cell !== null + ? (cell.avatar_uuid ?? '') + : ''; + const name = escapeHtml(nameValue || '-'); + if (!avatarUuid) { + const initials = escapeHtml(initialsForName(nameValue)); + return gridjs.html(`${initials}${name}`); } - const src = new URL(`admin/users/avatar-file?uuid=${cell}&size=64`, appBase).toString(); - const full = new URL(`admin/users/avatar-file?uuid=${cell}&size=256`, appBase).toString(); - return gridjs.html(``); + const src = new URL(`admin/users/avatar-file?uuid=${avatarUuid}&size=64`, appBase).toString(); + const full = new URL(`admin/users/avatar-file?uuid=${avatarUuid}&size=256`, appBase).toString(); + return gridjs.html(`${name}`); } }, - { name: 'UUID', hidden: true }, - { name: "", sort: true }, - { name: "", sort: true }, { name: "", sort: true }, { name: "", sort: false }, { name: "", sort: false }, @@ -101,14 +293,15 @@ require templatePath('partials/app-breadcrumb.phtml'); { name: "", sort: false, formatter: (cell) => gridjs.html(buildBadgeList(cell)) }, { name: "", sort: false, formatter: (cell) => gridjs.html(buildBadgeList(cell)) } ], - sortColumns: [null, null, 'first_name', 'last_name', 'email', null, null, null, null, null, null], + sortColumns: [null, 'display_name', 'email', null, null, null, null, null, null], paginationLimit: 10, language: , mapData: (data) => data.data.map((row) => [ - row.has_avatar ? row.uuid : '', row.uuid, - row.first_name, - row.last_name, + { + name: row.display_name, + avatar_uuid: row.has_avatar ? row.uuid : '' + }, row.email, row.phone, row.mobile, @@ -122,26 +315,7 @@ require templatePath('partials/app-breadcrumb.phtml'); param: 'search', debounce: 250 }, - filters: [ - { - input: '#address-book-tenant-filter', - param: 'tenants', - default: [], - normalize: normalizeCommaSeparated - }, - { - input: '#address-book-department-filter', - param: 'departments', - default: [], - normalize: normalizeCommaSeparated - }, - { - input: '#address-book-role-filter', - param: 'roles', - default: [], - normalize: normalizeCommaSeparated - } - ], + filters: [...baseFilters, ...customFilters], urlSync: true, rowDataset: (row) => ({ uuid: row?.cells?.[uuidIndex]?.data @@ -163,4 +337,50 @@ require templatePath('partials/app-breadcrumb.phtml'); clearInvalid: true }); }); + + if (saveFilterButton && saveFilterForm) { + saveFilterButton.addEventListener('click', () => { + const promptText = saveFilterButton.dataset.saveFilterPrompt || ''; + const emptyText = saveFilterButton.dataset.saveFilterEmpty || ''; + const enteredName = window.prompt(promptText, ''); + if (enteredName === null) { + return; + } + const name = String(enteredName).trim(); + if (!name) { + if (emptyText) { + window.alert(emptyText); + } + return; + } + + const state = collectFilterState(); + const nameInput = saveFilterForm.querySelector('input[name="name"]'); + const searchInput = saveFilterForm.querySelector('input[name="search"]'); + const tenantsInput = saveFilterForm.querySelector('input[name="tenants"]'); + const departmentsInput = saveFilterForm.querySelector('input[name="departments"]'); + const rolesInput = saveFilterForm.querySelector('input[name="roles"]'); + const extrasContainer = saveFilterForm.querySelector('[data-address-book-save-filter-extra]'); + if (!nameInput || !searchInput || !tenantsInput || !departmentsInput || !rolesInput) { + return; + } + + nameInput.value = name; + searchInput.value = state.search; + tenantsInput.value = state.tenants; + departmentsInput.value = state.departments; + rolesInput.value = state.roles; + if (extrasContainer) { + extrasContainer.innerHTML = ''; + Object.entries(state.custom || {}).forEach(([key, value]) => { + const hidden = document.createElement('input'); + hidden.type = 'hidden'; + hidden.name = key; + hidden.value = String(value ?? ''); + extrasContainer.appendChild(hidden); + }); + } + saveFilterForm.requestSubmit(); + }); + } diff --git a/pages/address-book/saved-filter-delete().php b/pages/address-book/saved-filter-delete().php new file mode 100644 index 0000000..f3f8894 --- /dev/null +++ b/pages/address-book/saved-filter-delete().php @@ -0,0 +1,107 @@ + $rawValue) { + $key = strtolower(trim((string) $rawKey)); + if ($key === '') { + continue; + } + if (preg_match('/^cf_[a-f0-9-]{36}$/', $key)) { + $value = trim((string) $rawValue); + if ($value !== '') { + $custom[$key] = $value; + } + continue; + } + if (preg_match('/^cfm_[a-f0-9-]{36}$/', $key)) { + $ids = array_values(array_filter(array_map( + 'intval', + array_map('trim', explode(',', (string) $rawValue)) + ), static fn (int $id): bool => $id > 0)); + $ids = array_values(array_unique($ids)); + if ($ids) { + $custom[$key] = implode(',', $ids); + } + continue; + } + if (preg_match('/^cfd_[a-f0-9-]{36}_(from|to)$/', $key)) { + $value = trim((string) $rawValue); + if ($value !== '') { + $custom[$key] = $value; + } + } + } + ksort($custom, SORT_STRING); + return $custom; + }; + + if ($search !== '') { + $params['search'] = $search; + } + if ($tenants !== '') { + $params['tenants'] = $tenants; + } + if ($departments !== '') { + $params['departments'] = $departments; + } + if ($roles !== '') { + $params['roles'] = $roles; + } + foreach ($collectCustom($query) as $customKey => $customValue) { + $params[$customKey] = $customValue; + } + + if (!$params) { + return 'address-book'; + } + return 'address-book?' . http_build_query($params); +}; + +$redirect = $buildAddressBookRedirect($_POST); +if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { + Router::redirect($redirect); + return; +} + +if (!Session::checkCsrfToken()) { + Flash::error(t('Form expired, please try again'), 'address-book', 'address_book_saved_filters_csrf'); + Router::redirect($redirect); + return; +} + +$userId = (int) ($_SESSION['user']['id'] ?? 0); +if ($userId <= 0) { + Router::redirect('login'); + return; +} + +$uuid = trim((string) ($_POST['uuid'] ?? '')); +if ($uuid === '') { + Router::redirect($redirect); + return; +} + +$deleted = UserSavedFilterService::deleteAddressBookFilter($userId, $uuid); +if ($deleted) { + $_SESSION['address_book_saved_filters'] = UserSavedFilterService::listForAddressBook($userId); + Flash::success(t('Filter deleted'), 'address-book', 'address_book_saved_filter_deleted'); +} + +Router::redirect($redirect); diff --git a/pages/address-book/saved-filter-save().php b/pages/address-book/saved-filter-save().php new file mode 100644 index 0000000..d560340 --- /dev/null +++ b/pages/address-book/saved-filter-save().php @@ -0,0 +1,113 @@ + $rawValue) { + $key = strtolower(trim((string) $rawKey)); + if ($key === '') { + continue; + } + if (preg_match('/^cf_[a-f0-9-]{36}$/', $key)) { + $value = trim((string) $rawValue); + if ($value !== '') { + $custom[$key] = $value; + } + continue; + } + if (preg_match('/^cfm_[a-f0-9-]{36}$/', $key)) { + $ids = array_values(array_filter(array_map( + 'intval', + array_map('trim', explode(',', (string) $rawValue)) + ), static fn (int $id): bool => $id > 0)); + $ids = array_values(array_unique($ids)); + if ($ids) { + $custom[$key] = implode(',', $ids); + } + continue; + } + if (preg_match('/^cfd_[a-f0-9-]{36}_(from|to)$/', $key)) { + $value = trim((string) $rawValue); + if ($value !== '') { + $custom[$key] = $value; + } + } + } + ksort($custom, SORT_STRING); + return $custom; + }; + + if ($search !== '') { + $params['search'] = $search; + } + if ($tenants !== '') { + $params['tenants'] = $tenants; + } + if ($departments !== '') { + $params['departments'] = $departments; + } + if ($roles !== '') { + $params['roles'] = $roles; + } + foreach ($collectCustom($query) as $customKey => $customValue) { + $params[$customKey] = $customValue; + } + + if (!$params) { + return 'address-book'; + } + return 'address-book?' . http_build_query($params); +}; + +$redirect = $buildAddressBookRedirect($_POST); +if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { + Router::redirect($redirect); + return; +} + +if (!Session::checkCsrfToken()) { + Flash::error(t('Form expired, please try again'), 'address-book', 'address_book_saved_filters_csrf'); + Router::redirect($redirect); + return; +} + +$userId = (int) ($_SESSION['user']['id'] ?? 0); +if ($userId <= 0) { + Router::redirect('login'); + return; +} + +$name = trim((string) ($_POST['name'] ?? '')); +$result = UserSavedFilterService::saveAddressBookFilter($userId, $name, $_POST); + +if (!($result['ok'] ?? false)) { + $error = (string) ($result['error'] ?? ''); + if ($error === 'name_required') { + Flash::error(t('Filter name is required'), 'address-book', 'address_book_saved_filter_name_required'); + } elseif ($error === 'max_reached') { + Flash::error(t('Maximum number of saved filters reached'), 'address-book', 'address_book_saved_filter_max_reached'); + } else { + Flash::error(t('Filter save failed'), 'address-book', 'address_book_saved_filter_save_failed'); + } + Router::redirect($redirect); + return; +} + +$_SESSION['address_book_saved_filters'] = UserSavedFilterService::listForAddressBook($userId); +Flash::success(t('Filter saved'), 'address-book', 'address_book_saved_filter_saved'); +Router::redirect($redirect); diff --git a/pages/address-book/view($id).php b/pages/address-book/view($id).php index 690abd3..e22a355 100644 --- a/pages/address-book/view($id).php +++ b/pages/address-book/view($id).php @@ -61,14 +61,38 @@ $extractLabels = static function (array $rows): array { return array_values(array_unique($labels)); }; -$tenantRows = DB::select( - 'select t.id as id, t.description as description from user_tenants ut join tenants t on t.id = ut.tenant_id where ut.user_id = ? order by t.description asc', - (string) $userId -); -$departmentRows = DB::select( - 'select td.tenant_id as tenant_id, d.description as description from user_departments ud join tenant_departments td on td.department_id = ud.department_id join departments d on d.id = ud.department_id where ud.user_id = ? order by d.description asc', - (string) $userId -); +$viewerTenantIds = array_values(array_unique(array_map( + 'intval', + TenantScopeService::getUserTenantIds($currentUserId) +))); +$viewerTenantIds = array_values(array_filter($viewerTenantIds, static fn ($id) => $id > 0)); + +$tenantRows = []; +$departmentRows = []; +if ($viewerTenantIds) { + $tenantPlaceholders = implode(',', array_fill(0, count($viewerTenantIds), '?')); + $tenantRows = call_user_func_array(['MintyPHP\\DB', 'select'], array_merge( + [ + 'select t.id as id, t.description as description ' . + 'from user_tenants ut ' . + "join tenants t on t.id = ut.tenant_id and t.status = 'active' " . + 'where ut.user_id = ? and ut.tenant_id in (' . $tenantPlaceholders . ') ' . + 'order by t.description asc', + ], + array_merge([(string) $userId], array_map('strval', $viewerTenantIds)) + )); + $departmentRows = call_user_func_array(['MintyPHP\\DB', 'select'], array_merge( + [ + 'select d.tenant_id as tenant_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 = ? and d.tenant_id in (' . $tenantPlaceholders . ') ' . + 'order by d.description asc', + ], + array_merge([(string) $userId], array_map('strval', $viewerTenantIds)) + )); +} + $roleRows = DB::select( 'select r.description as description from user_roles ur join roles r on r.id = ur.role_id and r.active = 1 where ur.user_id = ? order by r.description asc', (string) $userId @@ -105,6 +129,7 @@ $roleLabels = $extractLabels(is_array($roleRows) ? $roleRows : []); $user['tenant_groups'] = $tenantGroups; $user['role_labels'] = $roleLabels; +Buffer::set('style_groups', json_encode(['address-book'])); $name = trim(($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? '')); $title = $name !== '' ? $name : ($user['email'] ?? t('Address book')); diff --git a/pages/address-book/view(default).phtml b/pages/address-book/view(default).phtml index e32f0ee..3e1ef30 100644 --- a/pages/address-book/view(default).phtml +++ b/pages/address-book/view(default).phtml @@ -62,13 +62,6 @@ if ($profileDescription !== '') {
    - t('Address book'), 'path' => 'address-book'], - ['label' => $displayName], - ]; - require templatePath('partials/app-breadcrumb.phtml'); - ?>
    diff --git a/pages/admin/api-audit/data().php b/pages/admin/api-audit/data().php new file mode 100644 index 0000000..d401fc4 --- /dev/null +++ b/pages/admin/api-audit/data().php @@ -0,0 +1,72 @@ + 'method_not_allowed']); +} + +$result = ApiAuditService::listPaged([ + 'limit' => (int) ($_GET['limit'] ?? 20), + 'offset' => (int) ($_GET['offset'] ?? 0), + 'search' => trim((string) ($_GET['search'] ?? '')), + 'status' => trim((string) ($_GET['status'] ?? '')), + 'method' => trim((string) ($_GET['method'] ?? '')), + 'created_from' => trim((string) ($_GET['created_from'] ?? '')), + 'created_to' => trim((string) ($_GET['created_to'] ?? '')), + 'tenant_id' => (int) ($_GET['tenant_id'] ?? 0), + 'user_id' => (int) ($_GET['user_id'] ?? 0), + 'order' => (string) ($_GET['order'] ?? 'created_at'), + 'dir' => (string) ($_GET['dir'] ?? 'desc'), +]); + +$rows = []; +foreach ($result['rows'] as $row) { + $statusCode = (int) ($row['status_code'] ?? 0); + $statusBadge = 'neutral'; + if ($statusCode >= 200 && $statusCode < 300) { + $statusBadge = 'success'; + } elseif ($statusCode >= 400 && $statusCode < 500) { + $statusBadge = 'warning'; + } elseif ($statusCode >= 500) { + $statusBadge = 'danger'; + } + + $userLabel = trim((string) ($row['user_display_name'] ?? '')); + $userEmail = trim((string) ($row['user_email'] ?? '')); + if ($userLabel === '') { + $userLabel = $userEmail !== '' ? $userEmail : '-'; + } + + $rows[] = [ + 'id' => (int) ($row['id'] ?? 0), + 'request_id' => (string) ($row['request_id'] ?? ''), + 'created_at' => dt((string) ($row['created_at'] ?? '')), + 'method' => strtoupper((string) ($row['method'] ?? '')), + 'path' => (string) ($row['path'] ?? ''), + 'status_code' => $statusCode, + 'status_badge' => $statusBadge, + 'duration_ms' => (int) ($row['duration_ms'] ?? 0), + 'error_code' => (string) ($row['error_code'] ?? ''), + 'tenant_id' => (int) ($row['tenant_id'] ?? 0), + 'tenant_label' => (string) ($row['tenant_description'] ?? ''), + 'tenant_uuid' => (string) ($row['tenant_uuid'] ?? ''), + 'user_id' => (int) ($row['user_id'] ?? 0), + 'user_label' => $userLabel, + 'user_uuid' => (string) ($row['user_uuid'] ?? ''), + 'ip' => (string) ($row['ip'] ?? ''), + ]; +} + +Router::json([ + 'data' => $rows, + 'total' => (int) ($result['total'] ?? 0), +]); + diff --git a/pages/admin/api-audit/index($slug).php b/pages/admin/api-audit/index($slug).php new file mode 100644 index 0000000..65d2509 --- /dev/null +++ b/pages/admin/api-audit/index($slug).php @@ -0,0 +1,15 @@ + + t('Home'), 'path' => 'admin'], + ['label' => t('API audit logs')], +]; +require templatePath('partials/app-breadcrumb.phtml'); +?> +
    +

    +
    + +
    + +
    + + + +
    +
    +
    + + + + + + + +
    +
    +
    +
    + + + diff --git a/pages/admin/api-audit/purge().php b/pages/admin/api-audit/purge().php new file mode 100644 index 0000000..d493917 --- /dev/null +++ b/pages/admin/api-audit/purge().php @@ -0,0 +1,23 @@ + 0 ? ApiAuditService::find($auditId) : null; +if (!$auditLog) { + Flash::error('API audit entry not found', 'admin/api-audit', 'api_audit_not_found'); + Router::redirect('admin/api-audit'); +} + +Buffer::set('title', t('View API audit entry')); + diff --git a/pages/admin/api-audit/view(default).phtml b/pages/admin/api-audit/view(default).phtml new file mode 100644 index 0000000..2320d81 --- /dev/null +++ b/pages/admin/api-audit/view(default).phtml @@ -0,0 +1,193 @@ += 200 && $statusCode < 300) { + $statusVariant = 'success'; +} elseif ($statusCode >= 400 && $statusCode < 500) { + $statusVariant = 'warning'; +} elseif ($statusCode >= 500) { + $statusVariant = 'danger'; +} + +$queryJson = trim((string) ($auditLog['query_json'] ?? '')); +$queryPretty = '-'; +if ($queryJson !== '') { + $decoded = json_decode($queryJson, true); + if (is_array($decoded)) { + $pretty = json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $queryPretty = is_string($pretty) ? $pretty : $queryJson; + } else { + $queryPretty = $queryJson; + } +} + +$userLabel = trim((string) ($auditLog['user_display_name'] ?? '')); +$userEmail = trim((string) ($auditLog['user_email'] ?? '')); +if ($userLabel === '') { + $userLabel = $userEmail !== '' ? $userEmail : '-'; +} +$tenantLabel = trim((string) ($auditLog['tenant_description'] ?? '')); +if ($tenantLabel === '') { + $tenantLabel = '-'; +} + +$requestId = trim((string) ($auditLog['request_id'] ?? '')); +$method = strtoupper(trim((string) ($auditLog['method'] ?? ''))); +$path = trim((string) ($auditLog['path'] ?? '')); +$errorCode = trim((string) ($auditLog['error_code'] ?? '')); +$ip = trim((string) ($auditLog['ip'] ?? '')); +$userAgent = trim((string) ($auditLog['user_agent'] ?? '')); +$durationMs = (int) ($auditLog['duration_ms'] ?? 0); +$tokenId = (int) ($auditLog['api_token_id'] ?? 0); +$tokenTenantId = (int) ($auditLog['token_tenant_id'] ?? 0); +?> + +
    +
    + t('Home'), 'path' => 'admin'], + ['label' => t('API audit logs'), 'path' => 'admin/api-audit'], + ['label' => t('View')], + ]; + require templatePath('partials/app-breadcrumb.phtml'); + + $titlebar = [ + 'title' => t('View API audit entry'), + 'backHref' => 'admin/api-audit', + 'backTitle' => t('Back'), + ]; + require templatePath('partials/app-details-titlebar.phtml'); + ?> + +
    +
    + +
    +
    +
    + +

    +
    +
    + +

    +
    +
    + +
    +
    + +

    +
    +
    + +

    +
    +
    + + +
    + + +
    + +
    + +
    +
    + +
    +
    +
    + +

    + + + + + +

    +
    +
    + +

    + + + + + +

    +
    +
    + 0 || $tokenTenantId > 0): ?> +
    +
    + +

    0 ? (string) $tokenId : '-'); ?>

    +
    +
    + +

    0 ? (string) $tokenTenantId : '-'); ?>

    +
    +
    + +
    + + +
    +
    + +
    + +
    + +
    +
    + + +
    diff --git a/pages/admin/api-docs/index().php b/pages/admin/api-docs/index().php new file mode 100644 index 0000000..b8567c0 --- /dev/null +++ b/pages/admin/api-docs/index().php @@ -0,0 +1,11 @@ + t('Home'), 'path' => 'admin'], + ['label' => t('API docs')], +]; +$specUrl = lurl('admin/api-docs/spec'); +require templatePath('partials/app-breadcrumb.phtml'); +?> + +
    +
    +
    + + + diff --git a/pages/admin/api-docs/spec().php b/pages/admin/api-docs/spec().php new file mode 100644 index 0000000..291ce7e --- /dev/null +++ b/pages/admin/api-docs/spec().php @@ -0,0 +1,30 @@ +
    -
    - Stammdaten -
    - -
    - - - +
    +
    + + + + +
    - -
    +
    - \ No newline at end of file + diff --git a/pages/admin/departments/create().php b/pages/admin/departments/create().php index 1324f98..b9fafb1 100644 --- a/pages/admin/departments/create().php +++ b/pages/admin/departments/create().php @@ -23,6 +23,7 @@ $errors = []; $warnings = []; $form = [ 'description' => '', + 'tenant_id' => 0, 'code' => '', 'cost_center' => '', 'active' => '1', @@ -36,29 +37,21 @@ if ($allowedTenantIds) { } elseif (TenantScopeService::isStrict()) { $tenants = []; } -$selectedTenantIds = []; if (isset($_POST['description'])) { - $result = DepartmentService::createFromAdmin($_POST, $currentUserId); + $selectedTenantId = (int) ($_POST['tenant_id'] ?? 0); + $selectedTenantIds = TenantScopeService::filterTenantIdsForUser([$selectedTenantId], $currentUserId); + $selectedTenantId = (int) ($selectedTenantIds[0] ?? 0); + $input = $_POST; + $input['tenant_id'] = $selectedTenantId; + + $result = DepartmentService::createFromAdmin($input, $currentUserId); $form = $result['form'] ?? $form; $errors = $result['errors'] ?? []; $warnings = $result['warnings'] ?? []; - $tenantIds = $_POST['tenant_ids'] ?? []; - if (!is_array($tenantIds)) { - $tenantIds = [$tenantIds]; - } - $selectedTenantIds = array_values(array_unique(array_map('intval', $tenantIds))); - $selectedTenantIds = TenantScopeService::filterTenantIdsForUser($selectedTenantIds, $currentUserId); - - if (TenantScopeService::isStrict() && !$selectedTenantIds) { - $errors[] = t('Please select at least one tenant'); - } + $form['tenant_id'] = $selectedTenantId; if (($result['ok'] ?? false) && !$errors) { - $departmentId = (int) ($result['id'] ?? 0); - if ($departmentId) { - DepartmentService::syncTenants($departmentId, $selectedTenantIds); - } $action = (string) ($_POST['action'] ?? 'create'); if ($action === 'create_close') { if ($warnings) { diff --git a/pages/admin/departments/create(default).phtml b/pages/admin/departments/create(default).phtml index eccaae0..a995aa8 100644 --- a/pages/admin/departments/create(default).phtml +++ b/pages/admin/departments/create(default).phtml @@ -15,21 +15,29 @@ ['label' => t('Create department')], ]; require templatePath('partials/app-breadcrumb.phtml'); + $titlebar = [ + 'title' => t('Create department'), + 'backHref' => 'admin/departments', + 'backTitle' => t('Cancel'), + 'actions' => [ + [ + 'form' => 'department-form', + 'name' => 'action', + 'value' => 'create', + 'class' => 'secondary outline', + 'label' => t('Create'), + ], + [ + 'form' => 'department-form', + 'name' => 'action', + 'value' => 'create_close', + 'class' => 'primary', + 'label' => t('Create & close'), + ], + ], + ]; + require templatePath('partials/app-details-titlebar.phtml'); ?> -
    -

    - - -

    -
    - - -
    -
      diff --git a/pages/admin/departments/data().php b/pages/admin/departments/data().php index d310527..fe402b4 100644 --- a/pages/admin/departments/data().php +++ b/pages/admin/departments/data().php @@ -2,6 +2,7 @@ use MintyPHP\Router; use MintyPHP\Support\Guard; +use MintyPHP\Repository\Org\UserDepartmentRepository; use MintyPHP\Service\Org\DepartmentService; use MintyPHP\Service\Settings\SettingService; use MintyPHP\Service\Access\PermissionService; @@ -34,9 +35,23 @@ $result = DepartmentService::listPaged([ ]); $defaultDepartmentId = SettingService::getDefaultDepartmentId(); +$departmentIds = []; +foreach ($result['rows'] as $departmentRow) { + $departmentId = (int) ($departmentRow['id'] ?? 0); + if ($departmentId > 0) { + $departmentIds[] = $departmentId; + } +} +$departmentIds = array_values(array_unique($departmentIds)); +$departmentUserCounts = UserDepartmentRepository::countUsersByDepartmentIds($departmentIds); +$departmentActiveUserCounts = UserDepartmentRepository::countActiveUsersByDepartmentIds($departmentIds); + $rows = []; foreach ($result['rows'] as $row) { $departmentId = (int) ($row['id'] ?? 0); + $usersTotal = (int) ($departmentUserCounts[$departmentId] ?? 0); + $usersActive = (int) ($departmentActiveUserCounts[$departmentId] ?? 0); + $usersInactive = max(0, $usersTotal - $usersActive); $rows[] = [ 'id' => $row['id'] ?? null, 'uuid' => $row['uuid'] ?? '', @@ -45,6 +60,8 @@ foreach ($result['rows'] as $row) { 'code' => $row['code'] ?? '', 'cost_center' => $row['cost_center'] ?? '', 'active' => $row['active'] ?? 1, + 'active_users' => $usersActive, + 'inactive_users' => $usersInactive, 'tenants' => $row['tenant_labels'] ?? [], 'created' => dt($row['created'] ?? ''), 'modified' => dt($row['modified'] ?? ''), diff --git a/pages/admin/departments/edit($id).php b/pages/admin/departments/edit($id).php index af3c311..8cfe489 100644 --- a/pages/admin/departments/edit($id).php +++ b/pages/admin/departments/edit($id).php @@ -5,10 +5,8 @@ use MintyPHP\Support\Flash; use MintyPHP\Support\Guard; use MintyPHP\Router; use MintyPHP\Service\Org\DepartmentService; -use MintyPHP\Repository\Tenant\TenantDepartmentRepository; use MintyPHP\Service\Tenant\TenantService; use MintyPHP\Service\User\UserService; -use MintyPHP\Service\Settings\SettingService; use MintyPHP\Service\Access\PermissionService; use MintyPHP\Service\Tenant\TenantScopeService; @@ -56,16 +54,17 @@ $errors = []; $warnings = []; $form = $department; $tenants = TenantService::list(); -$selectedTenantIds = TenantDepartmentRepository::listTenantIdsByDepartmentId($departmentId); if ($allowedTenantIds) { $tenants = array_values(array_filter($tenants, static function (array $tenant) use ($allowedTenantIds): bool { $tenantId = (int) ($tenant['id'] ?? 0); return $tenantId > 0 && in_array($tenantId, $allowedTenantIds, true); })); - $selectedTenantIds = array_values(array_intersect($selectedTenantIds, $allowedTenantIds)); + if (!in_array((int) ($form['tenant_id'] ?? 0), $allowedTenantIds, true)) { + $form['tenant_id'] = 0; + } } elseif (TenantScopeService::isStrict()) { $tenants = []; - $selectedTenantIds = []; + $form['tenant_id'] = 0; } if (isset($_POST['description'])) { @@ -73,29 +72,20 @@ if (isset($_POST['description'])) { Router::redirect('error/forbidden'); return; } - $result = DepartmentService::updateFromAdmin($departmentId, $_POST, $currentUserId); + $selectedTenantId = (int) ($_POST['tenant_id'] ?? 0); + $selectedTenantIds = TenantScopeService::filterTenantIdsForUser([$selectedTenantId], $currentUserId); + $selectedTenantId = (int) ($selectedTenantIds[0] ?? 0); + $input = $_POST; + $input['tenant_id'] = $selectedTenantId; + + $result = DepartmentService::updateFromAdmin($departmentId, $input, $currentUserId); $form = $result['form'] ?? $form; $errors = $result['errors'] ?? []; $warnings = $result['warnings'] ?? []; - $tenantIds = $_POST['tenant_ids'] ?? []; - if (!is_array($tenantIds)) { - $tenantIds = [$tenantIds]; - } - $selectedTenantIds = array_values(array_unique(array_map('intval', $tenantIds))); - $selectedTenantIds = TenantScopeService::filterTenantIdsForUser($selectedTenantIds, $currentUserId); - - if (TenantScopeService::isStrict() && !$selectedTenantIds) { - $errors[] = t('Please select at least one tenant'); - } + $form['tenant_id'] = $selectedTenantId; if (($result['ok'] ?? false) && !$errors) { - $existingTenantIds = TenantDepartmentRepository::listTenantIdsByDepartmentId($departmentId); - $finalTenantIds = TenantScopeService::mergeTenantIdsPreservingOutOfScope( - $selectedTenantIds, - $existingTenantIds, - $allowedTenantIds - ); - $cleaned = DepartmentService::syncTenants($departmentId, $finalTenantIds); + $cleaned = DepartmentService::cleanupUserAssignments($departmentId); $action = (string) ($_POST['action'] ?? 'save'); if ($cleaned > 0) { Flash::info(t('Department assignments cleaned: %d', $cleaned), "admin/departments/edit/{$uuid}", 'department_assignments_cleaned'); diff --git a/pages/admin/departments/edit(default).phtml b/pages/admin/departments/edit(default).phtml index 2af39f4..d6c7a91 100644 --- a/pages/admin/departments/edit(default).phtml +++ b/pages/admin/departments/edit(default).phtml @@ -22,41 +22,29 @@ $isReadOnly = !can('departments.update'); ['label' => t('Edit department')], ]; require templatePath('partials/app-breadcrumb.phtml'); + $titlebar = [ + 'title' => t('Edit department'), + 'backHref' => 'admin/departments', + 'backTitle' => t('Cancel'), + 'actions' => can('departments.update') ? [ + [ + 'form' => 'department-form', + 'name' => 'action', + 'value' => 'save', + 'class' => 'secondary outline', + 'label' => t('Save'), + ], + [ + 'form' => 'department-form', + 'name' => 'action', + 'value' => 'save_close', + 'class' => 'primary', + 'label' => t('Save & close'), + ], + ] : [], + ]; + require templatePath('partials/app-details-titlebar.phtml'); ?> -
      -

      - - -

      -
      - - - - - - - - -
      -
      -
      @@ -83,8 +71,23 @@ $isReadOnly = !can('departments.update'); + + +
    diff --git a/pages/admin/departments/index($slug).php b/pages/admin/departments/index($slug).php new file mode 100644 index 0000000..65d2509 --- /dev/null +++ b/pages/admin/departments/index($slug).php @@ -0,0 +1,15 @@ + 1; @@ -77,15 +76,16 @@ require templatePath('partials/app-breadcrumb.phtml'); diff --git a/pages/admin/import-audit/data().php b/pages/admin/import-audit/data().php new file mode 100644 index 0000000..0ccebb4 --- /dev/null +++ b/pages/admin/import-audit/data().php @@ -0,0 +1,66 @@ + 'method_not_allowed']); +} + +$result = ImportAuditService::listPaged([ + 'limit' => (int) ($_GET['limit'] ?? 20), + 'offset' => (int) ($_GET['offset'] ?? 0), + 'search' => trim((string) ($_GET['search'] ?? '')), + 'profile_key' => trim((string) ($_GET['profile_key'] ?? '')), + 'status' => trim((string) ($_GET['status'] ?? '')), + 'created_from' => trim((string) ($_GET['created_from'] ?? '')), + 'created_to' => trim((string) ($_GET['created_to'] ?? '')), + 'user_id' => (int) ($_GET['user_id'] ?? 0), + 'order' => (string) ($_GET['order'] ?? 'started_at'), + 'dir' => (string) ($_GET['dir'] ?? 'desc'), +]); + +$rows = []; +foreach ((array) ($result['rows'] ?? []) as $row) { + $status = strtolower(trim((string) ($row['status'] ?? ''))); + $statusBadge = 'neutral'; + if ($status === 'success') { + $statusBadge = 'success'; + } elseif ($status === 'partial') { + $statusBadge = 'warning'; + } elseif ($status === 'failed') { + $statusBadge = 'danger'; + } + + $userLabel = trim((string) ($row['user_display_name'] ?? '')); + $userEmail = trim((string) ($row['user_email'] ?? '')); + if ($userLabel === '') { + $userLabel = $userEmail !== '' ? $userEmail : '-'; + } + + $rows[] = [ + 'id' => (int) ($row['id'] ?? 0), + 'started_at' => dt((string) ($row['started_at'] ?? '')), + 'profile_key' => (string) ($row['profile_key'] ?? ''), + 'status' => $status, + 'status_badge' => $statusBadge, + 'duration_ms' => (int) ($row['duration_ms'] ?? 0), + 'rows_total' => (int) ($row['rows_total'] ?? 0), + 'created_count' => (int) ($row['created_count'] ?? 0), + 'skipped_count' => (int) ($row['skipped_count'] ?? 0), + 'failed_count' => (int) ($row['failed_count'] ?? 0), + 'user_uuid' => (string) ($row['user_uuid'] ?? ''), + 'user_label' => $userLabel, + ]; +} + +Router::json([ + 'data' => $rows, + 'total' => (int) ($result['total'] ?? 0), +]); diff --git a/pages/admin/import-audit/index($slug).php b/pages/admin/import-audit/index($slug).php new file mode 100644 index 0000000..65d2509 --- /dev/null +++ b/pages/admin/import-audit/index($slug).php @@ -0,0 +1,15 @@ + + t('Home'), 'path' => 'admin'], + ['label' => t('Import audit logs')], +]; +require templatePath('partials/app-breadcrumb.phtml'); +?> +
    +

    +
    + +
    + +
    + + + +
    +
    +
    + + + + + + +
    + +
    +
    +
    + + + diff --git a/pages/admin/import-audit/purge().php b/pages/admin/import-audit/purge().php new file mode 100644 index 0000000..e58c6b0 --- /dev/null +++ b/pages/admin/import-audit/purge().php @@ -0,0 +1,23 @@ + 0 ? ImportAuditService::find($runId) : null; +if (!$auditRun) { + Flash::error(t('Import audit entry not found'), 'admin/import-audit', 'import_audit_not_found'); + Router::redirect('admin/import-audit'); +} + +Buffer::set('title', t('View import audit entry')); diff --git a/pages/admin/import-audit/view(default).phtml b/pages/admin/import-audit/view(default).phtml new file mode 100644 index 0000000..4ffef09 --- /dev/null +++ b/pages/admin/import-audit/view(default).phtml @@ -0,0 +1,244 @@ + $item !== '')); +} + +$errorCodesJson = trim((string) ($auditRun['error_codes_json'] ?? '')); +$errorCodes = []; +if ($errorCodesJson !== '') { + $decoded = json_decode($errorCodesJson, true); + if (is_array($decoded)) { + foreach ($decoded as $code => $count) { + $code = trim((string) $code); + $count = (int) $count; + if ($code === '' || $count <= 0) { + continue; + } + $errorCodes[$code] = $count; + } + } +} +arsort($errorCodes); + +$userLabel = trim((string) ($auditRun['user_display_name'] ?? '')); +$userEmail = trim((string) ($auditRun['user_email'] ?? '')); +if ($userLabel === '') { + $userLabel = $userEmail !== '' ? $userEmail : '-'; +} +$tenantLabel = trim((string) ($auditRun['current_tenant_description'] ?? '')); +if ($tenantLabel === '') { + $tenantLabel = '-'; +} +?> + +
    +
    + t('Home'), 'path' => 'admin'], + ['label' => t('Import audit logs'), 'path' => 'admin/import-audit'], + ['label' => t('View')], + ]; + require templatePath('partials/app-breadcrumb.phtml'); + + $titlebar = [ + 'title' => t('View import audit entry'), + 'backHref' => 'admin/import-audit', + 'backTitle' => t('Back'), + ]; + require templatePath('partials/app-details-titlebar.phtml'); + ?> + +
    +
    + +
    +
    +
    + +

    +
    +
    + +

    +
    +
    +
    +
    + +

    +
    +
    + +

    +
    +
    +
    +
    + +

    0 ? (string) ((int) $auditRun['duration_ms']) : '-'); ?>

    +
    +
    + +

    +
    +
    +
    + +
    +
    + +
    +
    +
    + +

    +
    +
    + +

    +
    +
    +
    +
    + +

    +
    +
    + +

    +
    +
    +
    + +
    +
    + +
    +
    +
    + +

    + + + + + +

    +
    +
    + +

    + + + + + +

    +
    +
    +
    + + +
    +
    + +
    +

    +
    + + + +
    +
    + +
    +
    + + + + + + + + + $count): ?> + + + + + + +
    +
    +
    + +
    +
    + + +
    diff --git a/pages/admin/imports/index().php b/pages/admin/imports/index().php new file mode 100644 index 0000000..4c12b28 --- /dev/null +++ b/pages/admin/imports/index().php @@ -0,0 +1,133 @@ + $key, + 'label' => (string) $option['label'], + 'allowed' => $allowed, + 'permission' => $permission, + ]; +} + +$allowedProfileOptions = array_values(array_filter($profileOptions, static fn (array $option): bool => !empty($option['allowed']))); +$firstAllowedType = ''; +if ($allowedProfileOptions) { + $firstAllowedType = (string) ($allowedProfileOptions[0]['key'] ?? ''); +} + +$selectedType = trim((string) ($_POST['type'] ?? ($_GET['type'] ?? $firstAllowedType))); +if ($selectedType === '' || !ImportService::profile($selectedType)) { + $selectedType = $firstAllowedType; +} + +$step = 'upload'; +$errors = []; +$token = ''; +$state = null; +$mapping = []; +$preview = null; +$result = null; + +$csrfKey = Session::$csrfSessionKey; +$csrfToken = $_SESSION[$csrfKey] ?? ''; + +$hasImportPermission = static function (string $type) use ($currentUserId): bool { + $permission = ImportService::requiredPermissionForType($type); + return $permission !== '' && PermissionService::userHas($currentUserId, $permission); +}; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $stage = trim((string) ($_POST['stage'] ?? '')); + if (!Session::checkCsrfToken()) { + $errors[] = t('Form expired, please try again'); + } elseif ($stage === 'analyze') { + if ($selectedType === '' || !$hasImportPermission($selectedType)) { + $errors[] = t('Permission denied'); + } else { + $analysis = ImportService::analyzeUpload($_FILES['csv_file'] ?? [], $selectedType, $currentUserId); + if (!($analysis['ok'] ?? false)) { + $errors[] = ImportService::messageForCode((string) ($analysis['code'] ?? 'unexpected_error')); + } else { + $token = (string) ($analysis['token'] ?? ''); + $state = ImportService::state($token); + $mapping = is_array($analysis['auto_mapping'] ?? null) ? $analysis['auto_mapping'] : []; + $step = 'mapping'; + } + } + } elseif ($stage === 'preview') { + $token = trim((string) ($_POST['token'] ?? '')); + $state = ImportService::state($token); + $mapping = is_array($_POST['mapping'] ?? null) ? $_POST['mapping'] : []; + if (!$state) { + $errors[] = ImportService::messageForCode('invalid_file_type'); + } else { + $selectedType = (string) ($state['type'] ?? $selectedType); + if ($selectedType === '' || !$hasImportPermission($selectedType)) { + $errors[] = t('Permission denied'); + } else { + $preview = ImportService::preview($token, ['mapping' => $mapping], $currentUserId); + if (!($preview['ok'] ?? false)) { + $errors[] = ImportService::messageForCode((string) ($preview['code'] ?? 'unexpected_error')); + $step = 'mapping'; + } else { + $step = 'preview'; + } + } + } + } elseif ($stage === 'commit') { + $token = trim((string) ($_POST['token'] ?? '')); + $mapping = is_array($_POST['mapping'] ?? null) ? $_POST['mapping'] : []; + $state = ImportService::state($token); + if (!$state) { + $errors[] = ImportService::messageForCode('invalid_file_type'); + } else { + $selectedType = (string) ($state['type'] ?? $selectedType); + if ($selectedType === '' || !$hasImportPermission($selectedType)) { + $errors[] = t('Permission denied'); + } else { + $result = ImportService::commit($token, ['mapping' => $mapping], $currentUserId); + if (!($result['ok'] ?? false)) { + $errors[] = ImportService::messageForCode((string) ($result['code'] ?? 'unexpected_error')); + $step = 'mapping'; + } else { + $step = 'result'; + } + } + } + } +} + +if ($step === 'mapping' && !$state && $token !== '') { + $state = ImportService::state($token); +} + +$activeProfile = $selectedType !== '' ? ImportService::profile($selectedType) : null; +$allowedTargets = $activeProfile ? $activeProfile->allowedTargets() : []; +$requiredTargets = $activeProfile ? $activeProfile->requiredTargets() : []; +$requiredTargetsHint = implode(', ', $requiredTargets); +$showAssignmentPermissionHint = $activeProfile ? $activeProfile->requiresAssignmentPermission() : false; +$headers = is_array($state['headers'] ?? null) ? $state['headers'] : []; +$selectedProfileLabel = $selectedType; +foreach ($profileOptions as $option) { + if ((string) $option['key'] === $selectedType) { + $selectedProfileLabel = (string) $option['label']; + break; + } +} + +Buffer::set('title', t('Imports')); diff --git a/pages/admin/imports/index(default).phtml b/pages/admin/imports/index(default).phtml new file mode 100644 index 0000000..8d89b19 --- /dev/null +++ b/pages/admin/imports/index(default).phtml @@ -0,0 +1,258 @@ + t('Home'), 'path' => 'admin'], + ['label' => t('Imports')], +]; + +$hasAllowedProfile = !empty($allowedProfileOptions); + +$renderErrorTable = static function (array $rows): void { + if (!$rows) { +?> +
    + +
    + + + + + + + + + + + + + + + + + + + +
    +
    + + +
    +
    + +
    +

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

    + + + +
    + +
    + + + +
    + +
    + +
    +
    + +
    + + + + + +
    +

    +
    +
    + + + + + + + + + + + + + + + + +
    + +
    +
    +
    +
    + + +
    +
    + +
    +
    +

    +
    +
    +
    + + + + + +
    +
    +

    + +
    +
    + + + + + $target): ?> + + +
    + + +
    +
    +
    + +
    +
    +

    +
    +
    +
    + + + + +
    + +

    + +
    +

    + +

    + +
    + + +
    diff --git a/pages/admin/imports/sample($type).php b/pages/admin/imports/sample($type).php new file mode 100644 index 0000000..da25b8a --- /dev/null +++ b/pages/admin/imports/sample($type).php @@ -0,0 +1,81 @@ +allowedTargets()); +$sampleRows = []; +if ($type === 'users') { + $sampleRows[] = [ + 'first_name' => 'Max', + 'last_name' => 'Mustermann', + 'email' => 'max.mustermann@example.com', + 'job_title' => 'Sales Manager', + 'profile_description' => 'Imported user', + 'phone' => '+49 555 12345', + 'mobile' => '+49 171 5551234', + 'short_dial' => '123', + 'address' => 'Musterstrasse 1', + 'postal_code' => '12345', + 'city' => 'Berlin', + 'region' => 'Berlin', + 'country' => 'DE', + 'hire_date' => '2026-01-15', + 'locale' => 'de', + 'active' => 'active', + 'tenant' => 'TENANT_UUID_OR_ID', + 'role' => 'ROLE_UUID_OR_ID', + 'department' => 'DEPARTMENT_UUID_OR_ID', + ]; +} elseif ($type === 'departments') { + $sampleRows[] = [ + 'description' => 'Sales', + 'tenant' => 'TENANT_UUID', + 'code' => 'SALES', + 'cost_center' => 'CC-100', + 'active' => 'active', + ]; +} + +header('Content-Type: text/csv; charset=utf-8'); +header('Content-Disposition: attachment; filename="' . $type . '-import-sample.csv"'); +header('X-Content-Type-Options: nosniff'); + +$out = fopen('php://output', 'wb'); +if (!$out) { + http_response_code(500); + return; +} + +fwrite($out, "\xEF\xBB\xBF"); +fputcsv($out, $headers, ';', '"', '\\'); +foreach ($sampleRows as $row) { + $line = []; + foreach ($headers as $header) { + $line[] = (string) ($row[$header] ?? ''); + } + fputcsv($out, $line, ';', '"', '\\'); +} +fclose($out); +return; diff --git a/pages/admin/index($slug).php b/pages/admin/index($slug).php index 50332a3..65d2509 100644 --- a/pages/admin/index($slug).php +++ b/pages/admin/index($slug).php @@ -1,7 +1,8 @@ "> + \ No newline at end of file diff --git a/pages/admin/scheduled-jobs/index($slug).php b/pages/admin/scheduled-jobs/index($slug).php new file mode 100644 index 0000000..b285281 --- /dev/null +++ b/pages/admin/scheduled-jobs/index($slug).php @@ -0,0 +1,15 @@ + + t('Home'), 'path' => 'admin'], + ['label' => t('Scheduled jobs')], +]; +require templatePath('partials/app-breadcrumb.phtml'); +?> +
    +

    +
    + +
    + +
    + + + +
    +
    + +
    + + + +
    + +
    +
    +
    + + + diff --git a/pages/admin/scheduled-jobs/purge-runs().php b/pages/admin/scheduled-jobs/purge-runs().php new file mode 100644 index 0000000..6f92b8c --- /dev/null +++ b/pages/admin/scheduled-jobs/purge-runs().php @@ -0,0 +1,23 @@ + 'method_not_allowed']); +} + +$jobId = (int) ($id ?? 0); +if ($jobId <= 0) { + Router::json(['data' => [], 'total' => 0]); +} + +$result = ScheduledJobService::listRunsByJobId($jobId, [ + 'limit' => (int) ($_GET['limit'] ?? 10), + 'offset' => (int) ($_GET['offset'] ?? 0), + 'search' => trim((string) ($_GET['search'] ?? '')), + 'status' => trim((string) ($_GET['status'] ?? '')), + 'trigger_type' => trim((string) ($_GET['trigger_type'] ?? '')), + 'order' => (string) ($_GET['order'] ?? 'started_at'), + 'dir' => (string) ($_GET['dir'] ?? 'desc'), +]); + +$rows = []; +foreach ((array) ($result['rows'] ?? []) as $row) { + $status = strtolower(trim((string) ($row['status'] ?? ''))); + $statusBadge = 'neutral'; + if ($status === 'success') { + $statusBadge = 'success'; + } elseif ($status === 'failed') { + $statusBadge = 'danger'; + } elseif ($status === 'skipped') { + $statusBadge = 'warning'; + } + + $actorLabel = trim((string) ($row['actor_user_display_name'] ?? '')); + if ($actorLabel === '') { + $actorLabel = trim((string) ($row['actor_user_email'] ?? '')); + } + if ($actorLabel === '') { + $actorLabel = '-'; + } + + $rows[] = [ + 'id' => (int) ($row['id'] ?? 0), + 'run_uuid' => (string) ($row['run_uuid'] ?? ''), + 'trigger_type' => strtolower(trim((string) ($row['trigger_type'] ?? ''))), + 'status' => $status, + 'status_badge' => $statusBadge, + 'started_at' => dt((string) ($row['started_at'] ?? '')), + 'finished_at' => dt((string) ($row['finished_at'] ?? '')), + 'duration_ms' => (int) ($row['duration_ms'] ?? 0), + 'error_code' => (string) ($row['error_code'] ?? ''), + 'actor_label' => $actorLabel, + ]; +} + +Router::json([ + 'data' => $rows, + 'total' => (int) ($result['total'] ?? 0), +]); diff --git a/pages/admin/search/data().php b/pages/admin/search/data().php index fcfc1fa..0770068 100644 --- a/pages/admin/search/data().php +++ b/pages/admin/search/data().php @@ -89,4 +89,16 @@ foreach ($resources as $resource) { $results[] = $result; } +$hotkeyResource = SearchConfig::hotkeyPreviewResource($query); +if ($hotkeyResource !== null) { + $results[] = $hotkeyResource; +} + +if (PermissionService::userHas($userId, PermissionService::DOCS_VIEW)) { + $docsResource = SearchConfig::docsPreviewResource($query); + if ($docsResource !== null) { + $results[] = $docsResource; + } +} + Router::json($results); diff --git a/pages/admin/settings/index($slug).php b/pages/admin/settings/index($slug).php new file mode 100644 index 0000000..65d2509 --- /dev/null +++ b/pages/admin/settings/index($slug).php @@ -0,0 +1,15 @@ + SettingService::getDefaultTenantId(), 'default_role_id' => SettingService::getDefaultRoleId(), 'default_department_id' => SettingService::getDefaultDepartmentId(), - 'app_title' => SettingService::getAppTitle(), - 'app_locale' => SettingService::getAppLocale(), - 'app_theme' => SettingService::getAppTheme(), + // Read raw values for form display so existing DB values are always visible. + 'app_title' => SettingService::getValue(SettingService::APP_TITLE_KEY), + 'app_locale' => SettingService::getValue(SettingService::APP_LOCALE_KEY), + 'app_theme' => SettingService::getValue(SettingService::APP_THEME_KEY), 'app_theme_user' => SettingService::isUserThemeAllowed(), 'app_registration' => SettingService::isRegistrationEnabled(), - 'app_primary_color' => SettingService::getAppPrimaryColor(), - 'smtp_host' => SettingService::getSmtpHost(), - 'smtp_port' => SettingService::getSmtpPort(), - 'smtp_user' => SettingService::getSmtpUser(), + 'app_primary_color' => SettingService::getValue(SettingService::APP_PRIMARY_COLOR_KEY), + 'api_token_default_ttl_days' => SettingService::getApiTokenDefaultTtlDays(), + 'api_token_max_ttl_days' => SettingService::getApiTokenMaxTtlDays(), + 'user_inactivity_deactivate_days' => SettingService::getUserInactivityDeactivateDays(), + 'user_inactivity_delete_days' => SettingService::getUserInactivityDeleteDays(), + 'api_cors_allowed_origins' => SettingService::getApiCorsAllowedOriginsText(), + 'microsoft_shared_client_id' => SettingService::getValue(SettingService::MICROSOFT_SHARED_CLIENT_ID_KEY), + 'microsoft_authority' => SettingService::getMicrosoftAuthority(), + 'smtp_host' => SettingService::getValue(SettingService::SMTP_HOST_KEY), + 'smtp_port' => SettingService::getValue(SettingService::SMTP_PORT_KEY), + 'smtp_user' => SettingService::getValue(SettingService::SMTP_USER_KEY), 'smtp_secure' => SettingService::getSmtpSecure(), - 'smtp_from' => SettingService::getSmtpFrom(), - 'smtp_from_name' => SettingService::getSmtpFromName(), + 'smtp_from' => SettingService::getValue(SettingService::SMTP_FROM_KEY), + 'smtp_from_name' => SettingService::getValue(SettingService::SMTP_FROM_NAME_KEY), ]; $activeLoginTokens = RememberTokenRepository::countActive(); +$activeApiTokens = ApiTokenRepository::countActive(); $settings = [ 'default_tenant' => SettingService::get(SettingService::DEFAULT_TENANT_KEY), 'default_role' => SettingService::get(SettingService::DEFAULT_ROLE_KEY), @@ -53,6 +64,14 @@ $settings = [ 'app_theme_user' => SettingService::get(SettingService::APP_THEME_USER_KEY), 'app_registration' => SettingService::get(SettingService::APP_REGISTRATION_KEY), 'app_primary_color' => SettingService::get(SettingService::APP_PRIMARY_COLOR_KEY), + 'api_token_default_ttl_days' => SettingService::get(SettingService::API_TOKEN_DEFAULT_TTL_DAYS_KEY), + 'api_token_max_ttl_days' => SettingService::get(SettingService::API_TOKEN_MAX_TTL_DAYS_KEY), + 'user_inactivity_deactivate_days' => SettingService::get(SettingService::USER_INACTIVITY_DEACTIVATE_DAYS_KEY), + 'user_inactivity_delete_days' => SettingService::get(SettingService::USER_INACTIVITY_DELETE_DAYS_KEY), + 'api_cors_allowed_origins' => SettingService::get(SettingService::API_CORS_ALLOWED_ORIGINS_KEY), + 'microsoft_shared_client_id' => SettingService::get(SettingService::MICROSOFT_SHARED_CLIENT_ID_KEY), + 'microsoft_shared_client_secret_enc' => SettingService::get(SettingService::MICROSOFT_SHARED_CLIENT_SECRET_ENC_KEY), + 'microsoft_authority' => SettingService::get(SettingService::MICROSOFT_AUTHORITY_KEY), 'smtp_host' => SettingService::get(SettingService::SMTP_HOST_KEY), 'smtp_port' => SettingService::get(SettingService::SMTP_PORT_KEY), 'smtp_user' => SettingService::get(SettingService::SMTP_USER_KEY), @@ -75,6 +94,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $appTheme = trim((string) ($_POST['app_theme'] ?? '')); $appThemeUser = isset($_POST['app_theme_user']); $appRegistration = isset($_POST['app_registration']); + $apiTokenDefaultTtlDays = (int) ($_POST['api_token_default_ttl_days'] ?? 0); + $apiTokenMaxTtlDays = (int) ($_POST['api_token_max_ttl_days'] ?? 0); + $userInactivityDeactivateDays = (int) ($_POST['user_inactivity_deactivate_days'] ?? 0); + $userInactivityDeleteDays = (int) ($_POST['user_inactivity_delete_days'] ?? 0); + $apiCorsAllowedOrigins = $_POST['api_cors_allowed_origins'] ?? ''; + if (is_array($apiCorsAllowedOrigins)) { + $apiCorsAllowedOrigins = ''; + } + $apiCorsAllowedOrigins = trim((string) $apiCorsAllowedOrigins); $rawPrimaryColor = $_POST['app_primary_color'] ?? ''; if (is_array($rawPrimaryColor)) { $rawPrimaryColor = ''; @@ -90,6 +118,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $smtpSecure = trim((string) ($_POST['smtp_secure'] ?? '')); $smtpFrom = trim((string) ($_POST['smtp_from'] ?? '')); $smtpFromName = trim((string) ($_POST['smtp_from_name'] ?? '')); + $microsoftSharedClientId = trim((string) ($_POST['microsoft_shared_client_id'] ?? '')); + $microsoftSharedClientSecret = (string) ($_POST['microsoft_shared_client_secret'] ?? ''); + $microsoftAuthority = trim((string) ($_POST['microsoft_authority'] ?? '')); SettingService::setDefaultTenantId($defaultTenantId > 0 ? $defaultTenantId : null); SettingService::setDefaultRoleId($defaultRoleId > 0 ? $defaultRoleId : null); @@ -99,6 +130,45 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { SettingService::setAppTheme($appTheme); SettingService::setUserThemeAllowed($appThemeUser); SettingService::setRegistrationEnabled($appRegistration); + $apiTokenMaxTtlOk = SettingService::setApiTokenMaxTtlDays($apiTokenMaxTtlDays > 0 ? $apiTokenMaxTtlDays : null); + if (!$apiTokenMaxTtlOk) { + Flash::error('API token max lifetime is invalid', 'admin/settings', 'api_token_max_ttl_invalid'); + } + $apiTokenTtlOk = SettingService::setApiTokenDefaultTtlDays($apiTokenDefaultTtlDays > 0 ? $apiTokenDefaultTtlDays : null); + if (!$apiTokenTtlOk) { + Flash::error('API token default lifetime is invalid', 'admin/settings', 'api_token_default_ttl_invalid'); + } + $userDeactivateOk = SettingService::setUserInactivityDeactivateDays($userInactivityDeactivateDays); + if (!$userDeactivateOk) { + Flash::error( + 'User inactivity deactivation period is invalid', + 'admin/settings', + 'user_inactivity_deactivate_days_invalid' + ); + } + if ($userInactivityDeactivateDays > 0) { + $userDeleteOk = SettingService::setUserInactivityDeleteDays($userInactivityDeleteDays); + if (!$userDeleteOk) { + Flash::error( + 'User inactivity deletion period is invalid', + 'admin/settings', + 'user_inactivity_delete_days_invalid' + ); + } + } else { + if ($userInactivityDeleteDays > 0) { + Flash::error( + 'Auto-delete is ignored while auto-deactivate is disabled', + 'admin/settings', + 'user_inactivity_delete_requires_deactivate' + ); + } + SettingService::setUserInactivityDeleteDays(0); + } + $apiCorsOriginsOk = SettingService::setApiCorsAllowedOrigins($apiCorsAllowedOrigins); + if (!$apiCorsOriginsOk) { + Flash::error('CORS allowed origins are invalid', 'admin/settings', 'api_cors_allowed_origins_invalid'); + } $primaryOk = SettingService::setAppPrimaryColor($appPrimaryColor); if (!$primaryOk) { $appPrimaryColor = ''; @@ -113,22 +183,33 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { SettingService::setSmtpSecure($smtpSecure); SettingService::setSmtpFrom($smtpFrom); SettingService::setSmtpFromName($smtpFromName); - - $cacheFile = __DIR__ . '/../../../config/settings.php'; - $cacheData = []; - if (is_file($cacheFile)) { - $existing = include $cacheFile; - if (is_array($existing)) { - $cacheData = $existing; + $msClientIdOk = SettingService::setMicrosoftSharedClientId($microsoftSharedClientId); + if (!$msClientIdOk) { + Flash::error('Microsoft client ID is invalid', 'admin/settings', 'microsoft_client_id_invalid'); + } + $msAuthorityOk = SettingService::setMicrosoftAuthority($microsoftAuthority); + if (!$msAuthorityOk) { + Flash::error('Microsoft authority is invalid', 'admin/settings', 'microsoft_authority_invalid'); + } + if (trim($microsoftSharedClientSecret) !== '') { + try { + SettingService::setMicrosoftSharedClientSecret($microsoftSharedClientSecret); + } catch (\Throwable $exception) { + Flash::error('Microsoft client secret could not be encrypted', 'admin/settings', 'microsoft_client_secret_invalid'); } } - $cacheData['app_title'] = $appTitle !== '' ? $appTitle : null; - $cacheData['app_locale'] = $appLocale !== '' ? $appLocale : null; - $cacheData['app_theme'] = $appTheme !== '' ? $appTheme : null; - $cacheData['app_theme_user'] = $appThemeUser ? '1' : '0'; - $cacheData['app_registration'] = $appRegistration ? '1' : '0'; - $cacheData['app_primary_color'] = $appPrimaryColor !== '' ? $appPrimaryColor : null; - file_put_contents($cacheFile, " $appTitle !== '' ? $appTitle : null, + 'app_locale' => $appLocale !== '' ? $appLocale : null, + 'app_theme' => $appTheme !== '' ? $appTheme : null, + 'app_theme_user' => $appThemeUser ? '1' : '0', + 'app_registration' => $appRegistration ? '1' : '0', + 'app_primary_color' => $appPrimaryColor !== '' ? $appPrimaryColor : null, + 'api_token_default_ttl_days' => (string) SettingService::getApiTokenDefaultTtlDays(), + 'api_token_max_ttl_days' => (string) SettingService::getApiTokenMaxTtlDays(), + 'api_cors_allowed_origins' => SettingService::getValue(SettingService::API_CORS_ALLOWED_ORIGINS_KEY), + ]); Flash::success('Settings updated', 'admin/settings', 'settings_updated'); Router::redirect('admin/settings'); diff --git a/pages/admin/settings/index(default).phtml b/pages/admin/settings/index(default).phtml index d4cdd1f..38d8c3c 100644 --- a/pages/admin/settings/index(default).phtml +++ b/pages/admin/settings/index(default).phtml @@ -1,4 +1,5 @@
    -
    + -
    - -
    - -
    - - - - - - -
    -
    -
    -
    - -
    -
    -
    - - - -
    -
    - + +
    +
    + + + + + + + + + +
    + +
    +
    + +
    +
    +
    +
    + +
    + +
    +
    +
    + +
    +
    + + +
    + + +
    +
    + +
    +
    + +
    +
    +
    +
    + + + +
    +
    + + + +
    +
    + +
    + + + + + + -
    -
    - -
    -
    - - - - - - -
    -
    -
    -
    - -
    -
    - - -
    -
    - - -
    -
    - - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    -
    - - -
    - + +
    +
    + +
    + + +
    + + + + +
    + - - - - +
    +
    + + +
    +
    + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    +
    + +
    +
    + +
    +

    +
    + + +
    + + + +
    + +

    +

    + +
    + +
    +
    + +
    +
    + +
    +
    + +
    + + + +
    +
    + + +
    +
    + +
    +
    + +

    + +

    +

    + +

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

    - -

    -

    - -

    -
    - - -
    -
    -
    -
    -
    - diff --git a/pages/admin/settings/revoke-api-tokens().php b/pages/admin/settings/revoke-api-tokens().php new file mode 100644 index 0000000..b2192e5 --- /dev/null +++ b/pages/admin/settings/revoke-api-tokens().php @@ -0,0 +1,18 @@ + $value) { + if (is_array($value)) { + $flat = array_merge($flat, $value); + } elseif (is_string($key)) { + $flat[$key] = $value; + } + } + return $flat; +}; + +$ssoStatsAvailable = (int) (DB::selectValue( + "select count(*) from information_schema.tables where table_schema = database() and table_name = 'tenant_auth_microsoft'" +) ?? 0) === 1; +$ssoEnabledTenantCount = 0; +$ssoEnforcedTenantCount = 0; +$ssoEnabledTenantPercent = 0.0; +$usersMicrosoftOnlyCount = 0; +$usersMicrosoftSyncGapsCount = 0; +$ssoTenantRows = []; +$usersMicrosoftOnlyRows = []; +$usersMicrosoftSyncGapRows = []; + +if ($ssoStatsAvailable) { + $ssoEnabledTenantCount = (int) (DB::selectValue( + "select count(*) from tenants t " . + "left join tenant_auth_microsoft tam on tam.tenant_id = t.id " . + "where t.status = 'active' and coalesce(tam.enabled, 0) = 1" + ) ?? 0); + $ssoEnforcedTenantCount = (int) (DB::selectValue( + "select count(*) from tenants t " . + "left join tenant_auth_microsoft tam on tam.tenant_id = t.id " . + "where t.status = 'active' and coalesce(tam.enabled, 0) = 1 and coalesce(tam.enforce_microsoft_login, 0) = 1" + ) ?? 0); + if ($activeTenantCount > 0) { + $ssoEnabledTenantPercent = round(((float) $ssoEnabledTenantCount * 100) / (float) $activeTenantCount, 1); + } + + $usersMicrosoftOnlyCount = (int) (DB::selectValue( + "select count(*) from users u " . + "where u.active = 1 " . + "and exists ( " . + " select 1 from user_tenants ut " . + " join tenants t on t.id = ut.tenant_id and t.status = 'active' " . + " where ut.user_id = u.id " . + ") " . + "and not exists ( " . + " select 1 from user_tenants ut " . + " join tenants t on t.id = ut.tenant_id and t.status = 'active' " . + " left join tenant_auth_microsoft tam on tam.tenant_id = t.id " . + " where ut.user_id = u.id " . + " and (coalesce(tam.enabled, 0) = 0 or coalesce(tam.enforce_microsoft_login, 0) = 0) " . + ")" + ) ?? 0); + + $usersMicrosoftSyncGapsCount = (int) (DB::selectValue( + "select count(*) from ( " . + " select u.id, " . + " trim(coalesce(u.first_name, '')) as first_name_value, " . + " trim(coalesce(u.last_name, '')) as last_name_value, " . + " trim(coalesce(u.phone, '')) as phone_value, " . + " trim(coalesce(u.mobile, '')) as mobile_value, " . + " max(find_in_set('first_name', coalesce(nullif(tam.sync_profile_fields, ''), 'first_name,last_name')) > 0) as need_first_name, " . + " max(find_in_set('last_name', coalesce(nullif(tam.sync_profile_fields, ''), 'first_name,last_name')) > 0) as need_last_name, " . + " max(find_in_set('phone', coalesce(nullif(tam.sync_profile_fields, ''), 'first_name,last_name')) > 0) as need_phone, " . + " max(find_in_set('mobile', coalesce(nullif(tam.sync_profile_fields, ''), 'first_name,last_name')) > 0) as need_mobile " . + " from users u " . + " join user_tenants ut on ut.user_id = u.id " . + " join tenants t on t.id = ut.tenant_id and t.status = 'active' " . + " join tenant_auth_microsoft tam on tam.tenant_id = t.id " . + " where u.active = 1 and u.last_login_provider = 'microsoft' and tam.enabled = 1 and tam.sync_profile_on_login = 1 " . + " group by u.id, u.first_name, u.last_name, u.phone, u.mobile " . + ") x " . + "where (x.need_first_name = 1 and x.first_name_value = '') " . + " or (x.need_last_name = 1 and x.last_name_value = '') " . + " or (x.need_phone = 1 and x.phone_value = '') " . + " or (x.need_mobile = 1 and x.mobile_value = '')" + ) ?? 0); + + $ssoTenantRowsRaw = DB::select( + "select " . + " t.uuid as tenant_uuid, " . + " t.description as tenant_description, " . + " coalesce(tam.enabled, 0) as sso_enabled, " . + " coalesce(tam.enforce_microsoft_login, 0) as enforce_microsoft_login, " . + " coalesce(tam.sync_profile_on_login, 0) as sync_profile_on_login, " . + " coalesce(nullif(tam.sync_profile_fields, ''), '-') as sync_profile_fields " . + "from tenants t " . + "left join tenant_auth_microsoft tam on tam.tenant_id = t.id " . + "where t.status = 'active' " . + "order by sso_enabled desc, enforce_microsoft_login desc, t.description asc " . + "limit 10" + ); + $ssoTenantRows = is_array($ssoTenantRowsRaw) ? array_values(array_filter(array_map( + static function ($row) use ($flattenStatsRow): ?array { + $flat = $flattenStatsRow($row); + $tenantUuid = trim((string) ($flat['tenant_uuid'] ?? ($flat['uuid'] ?? ''))); + $tenantDescription = trim((string) ($flat['tenant_description'] ?? ($flat['description'] ?? ''))); + if ($tenantUuid === '' || $tenantDescription === '') { + return null; + } + return [ + 'tenant_uuid' => $tenantUuid, + 'tenant_description' => $tenantDescription, + 'sso_enabled' => (int) ($flat['sso_enabled'] ?? ($flat['enabled'] ?? 0)), + 'enforce_microsoft_login' => (int) ($flat['enforce_microsoft_login'] ?? 0), + 'sync_profile_on_login' => (int) ($flat['sync_profile_on_login'] ?? 0), + 'sync_profile_fields' => trim((string) ($flat['sync_profile_fields'] ?? '-')), + ]; + }, + $ssoTenantRowsRaw + ))) : []; + + $usersMicrosoftOnlyRowsRaw = DB::select( + "select " . + " u.uuid as user_uuid, " . + " u.display_name as display_name, " . + " u.email as email, " . + " group_concat(distinct t.description order by t.description separator ', ') as tenant_labels " . + "from users u " . + "join user_tenants ut on ut.user_id = u.id " . + "join tenants t on t.id = ut.tenant_id and t.status = 'active' " . + "left join tenant_auth_microsoft tam on tam.tenant_id = t.id " . + "where u.active = 1 " . + "group by u.id, u.uuid, u.display_name, u.email " . + "having sum(case when coalesce(tam.enabled, 0) = 0 or coalesce(tam.enforce_microsoft_login, 0) = 0 then 1 else 0 end) = 0 " . + "order by u.display_name asc " . + "limit 10" + ); + $usersMicrosoftOnlyRows = is_array($usersMicrosoftOnlyRowsRaw) ? array_values(array_filter(array_map( + static function ($row) use ($flattenStatsRow): ?array { + $flat = $flattenStatsRow($row); + $userUuid = trim((string) ($flat['user_uuid'] ?? ($flat['uuid'] ?? ''))); + $email = trim((string) ($flat['user_email'] ?? ($flat['email'] ?? ''))); + if ($userUuid === '' || $email === '') { + return null; + } + return [ + 'user_uuid' => $userUuid, + 'display_name' => trim((string) ($flat['user_display_name'] ?? ($flat['display_name'] ?? ''))), + 'email' => $email, + 'tenant_labels' => trim((string) ($flat['tenant_labels'] ?? ($flat['description'] ?? ''))), + ]; + }, + $usersMicrosoftOnlyRowsRaw + ))) : []; + + $usersMicrosoftSyncGapRowsRaw = DB::select( + "select " . + " x.user_uuid, x.user_display_name, x.user_email, " . + " trim(concat_ws(', ', " . + " if(x.need_first_name = 1, 'first_name', null), " . + " if(x.need_last_name = 1, 'last_name', null), " . + " if(x.need_phone = 1, 'phone', null), " . + " if(x.need_mobile = 1, 'mobile', null) " . + " )) as configured_fields, " . + " trim(concat_ws(', ', " . + " if(x.need_first_name = 1 and x.first_name_value = '', 'first_name', null), " . + " if(x.need_last_name = 1 and x.last_name_value = '', 'last_name', null), " . + " if(x.need_phone = 1 and x.phone_value = '', 'phone', null), " . + " if(x.need_mobile = 1 and x.mobile_value = '', 'mobile', null) " . + " )) as missing_fields " . + "from ( " . + " select " . + " u.id, u.uuid as user_uuid, u.display_name as user_display_name, u.email as user_email, " . + " trim(coalesce(u.first_name, '')) as first_name_value, " . + " trim(coalesce(u.last_name, '')) as last_name_value, " . + " trim(coalesce(u.phone, '')) as phone_value, " . + " trim(coalesce(u.mobile, '')) as mobile_value, " . + " max(find_in_set('first_name', coalesce(nullif(tam.sync_profile_fields, ''), 'first_name,last_name')) > 0) as need_first_name, " . + " max(find_in_set('last_name', coalesce(nullif(tam.sync_profile_fields, ''), 'first_name,last_name')) > 0) as need_last_name, " . + " max(find_in_set('phone', coalesce(nullif(tam.sync_profile_fields, ''), 'first_name,last_name')) > 0) as need_phone, " . + " max(find_in_set('mobile', coalesce(nullif(tam.sync_profile_fields, ''), 'first_name,last_name')) > 0) as need_mobile " . + " from users u " . + " join user_tenants ut on ut.user_id = u.id " . + " join tenants t on t.id = ut.tenant_id and t.status = 'active' " . + " join tenant_auth_microsoft tam on tam.tenant_id = t.id " . + " where u.active = 1 and u.last_login_provider = 'microsoft' and tam.enabled = 1 and tam.sync_profile_on_login = 1 " . + " group by u.id, u.uuid, u.display_name, u.email, u.first_name, u.last_name, u.phone, u.mobile " . + ") x " . + "where (x.need_first_name = 1 and x.first_name_value = '') " . + " or (x.need_last_name = 1 and x.last_name_value = '') " . + " or (x.need_phone = 1 and x.phone_value = '') " . + " or (x.need_mobile = 1 and x.mobile_value = '') " . + "order by x.user_display_name asc " . + "limit 10" + ); + $usersMicrosoftSyncGapRows = is_array($usersMicrosoftSyncGapRowsRaw) ? array_values(array_filter(array_map( + static function ($row) use ($flattenStatsRow): ?array { + $flat = $flattenStatsRow($row); + $userUuid = trim((string) ($flat['user_uuid'] ?? ($flat['uuid'] ?? ''))); + $email = trim((string) ($flat['user_email'] ?? ($flat['email'] ?? ''))); + if ($userUuid === '' || $email === '') { + return null; + } + return [ + 'user_uuid' => $userUuid, + 'display_name' => trim((string) ($flat['user_display_name'] ?? ($flat['display_name'] ?? ''))), + 'email' => $email, + 'configured_fields' => trim((string) ($flat['configured_fields'] ?? '')), + 'missing_fields' => trim((string) ($flat['missing_fields'] ?? '')), + ]; + }, + $usersMicrosoftSyncGapRowsRaw + ))) : []; +} + +$customFieldStatsAvailable = (int) (DB::selectValue( + "select count(*) from information_schema.tables " . + "where table_schema = database() " . + "and table_name in ('tenant_custom_field_definitions', 'tenant_custom_field_options', 'user_custom_field_values')" +) ?? 0) === 3; +$customFieldsSelectionWithoutOptionsCount = 0; +$customFieldTenantsWithSelectionWithoutOptionsCount = 0; +$customFieldsInactiveWithValuesCount = 0; +$customFieldsUnusedActiveCount = 0; +$customFieldsSelectionWithoutOptionsHref = 'admin/tenants'; +$customFieldTenantsWithSelectionWithoutOptionsHref = 'admin/tenants'; +$customFieldsInactiveWithValuesHref = 'admin/tenants'; +$customFieldsUnusedActiveHref = 'admin/tenants'; +$customFieldIssueTenantRows = []; + +if ($customFieldStatsAvailable) { + $loadCustomFieldIssueTenants = static function (string $query): array { + $rows = DB::select($query); + if (!is_array($rows)) { + return []; + } + $items = []; + foreach ($rows as $row) { + $tenant = $row['tenants'] ?? []; + $meta = $row[''] ?? []; + $tenantUuid = trim((string) ($tenant['uuid'] ?? '')); + $tenantDescription = trim((string) ($tenant['description'] ?? '')); + $issueCount = (int) ($meta['issue_count'] ?? 0); + if ($tenantUuid === '' || $tenantDescription === '' || $issueCount <= 0) { + continue; + } + $items[] = [ + 'tenant_uuid' => $tenantUuid, + 'tenant_description' => $tenantDescription, + 'issue_count' => $issueCount, + ]; + } + return $items; + }; + + $customFieldsSelectionWithoutOptionsCount = (int) (DB::selectValue( + "select count(*) from ( " . + "select d.id " . + "from tenant_custom_field_definitions d " . + "left join tenant_custom_field_options o on o.definition_id = d.id and o.active = 1 " . + "where d.active = 1 and d.type in ('select', 'multiselect') " . + "group by d.id " . + "having count(o.id) = 0 " . + ') missing_options' + ) ?? 0); + + $customFieldTenantsWithSelectionWithoutOptionsCount = (int) (DB::selectValue( + "select count(distinct missing_options.tenant_id) from ( " . + "select d.tenant_id " . + "from tenant_custom_field_definitions d " . + "left join tenant_custom_field_options o on o.definition_id = d.id and o.active = 1 " . + "where d.active = 1 and d.type in ('select', 'multiselect') " . + "group by d.id, d.tenant_id " . + "having count(o.id) = 0 " . + ') missing_options' + ) ?? 0); + + $customFieldsInactiveWithValuesCount = (int) (DB::selectValue( + 'select count(distinct d.id) from tenant_custom_field_definitions d ' . + 'inner join user_custom_field_values v on v.definition_id = d.id ' . + 'where d.active = 0' + ) ?? 0); + + $customFieldsUnusedActiveCount = (int) (DB::selectValue( + "select count(*) from ( " . + "select d.id " . + "from tenant_custom_field_definitions d " . + "left join user_custom_field_values v on v.definition_id = d.id " . + "where d.active = 1 " . + "group by d.id " . + "having count(v.id) = 0 " . + ') unused_active' + ) ?? 0); + + $selectionWithoutOptionsTenantUuid = (string) (DB::selectValue( + "select t.uuid from tenants t " . + "inner join ( " . + "select d.tenant_id " . + "from tenant_custom_field_definitions d " . + "left join tenant_custom_field_options o on o.definition_id = d.id and o.active = 1 " . + "where d.active = 1 and d.type in ('select', 'multiselect') " . + "group by d.id, d.tenant_id " . + "having count(o.id) = 0 " . + "order by d.tenant_id asc " . + "limit 1" . + ') issue on issue.tenant_id = t.id ' . + 'limit 1' + ) ?? ''); + + $inactiveWithValuesTenantUuid = (string) (DB::selectValue( + "select t.uuid from tenants t " . + "inner join ( " . + "select d.tenant_id " . + "from tenant_custom_field_definitions d " . + "inner join user_custom_field_values v on v.definition_id = d.id " . + "where d.active = 0 " . + "group by d.tenant_id " . + "order by d.tenant_id asc " . + "limit 1" . + ') issue on issue.tenant_id = t.id ' . + 'limit 1' + ) ?? ''); + + $unusedActiveTenantUuid = (string) (DB::selectValue( + "select t.uuid from tenants t " . + "inner join ( " . + "select d.tenant_id " . + "from tenant_custom_field_definitions d " . + "left join user_custom_field_values v on v.definition_id = d.id " . + "where d.active = 1 " . + "group by d.id, d.tenant_id " . + "having count(v.id) = 0 " . + "order by d.tenant_id asc " . + "limit 1" . + ') issue on issue.tenant_id = t.id ' . + 'limit 1' + ) ?? ''); + + if ($selectionWithoutOptionsTenantUuid !== '') { + $customFieldsSelectionWithoutOptionsHref = 'admin/tenants/edit/' . $selectionWithoutOptionsTenantUuid . '?tab=custom_fields'; + $customFieldTenantsWithSelectionWithoutOptionsHref = $customFieldsSelectionWithoutOptionsHref; + } + if ($inactiveWithValuesTenantUuid !== '') { + $customFieldsInactiveWithValuesHref = 'admin/tenants/edit/' . $inactiveWithValuesTenantUuid . '?tab=custom_fields'; + } + if ($unusedActiveTenantUuid !== '') { + $customFieldsUnusedActiveHref = 'admin/tenants/edit/' . $unusedActiveTenantUuid . '?tab=custom_fields'; + } + + $selectionIssueTenants = $loadCustomFieldIssueTenants( + "select t.uuid, t.description, count(*) as issue_count " . + "from tenants t " . + "inner join ( " . + "select d.id, d.tenant_id " . + "from tenant_custom_field_definitions d " . + "left join tenant_custom_field_options o on o.definition_id = d.id and o.active = 1 " . + "where d.active = 1 and d.type in ('select', 'multiselect') " . + "group by d.id, d.tenant_id " . + "having count(o.id) = 0 " . + ") issue on issue.tenant_id = t.id " . + "group by t.id, t.uuid, t.description " . + "order by issue_count desc, t.description asc " . + "limit 5" + ); + foreach ($selectionIssueTenants as $row) { + $customFieldIssueTenantRows[] = [ + 'issue' => t('Selection fields without options'), + 'tenant_uuid' => $row['tenant_uuid'], + 'tenant_description' => $row['tenant_description'], + 'issue_count' => $row['issue_count'], + ]; + } + + $inactiveIssueTenants = $loadCustomFieldIssueTenants( + "select t.uuid, t.description, count(distinct d.id) as issue_count " . + "from tenants t " . + "inner join tenant_custom_field_definitions d on d.tenant_id = t.id and d.active = 0 " . + "inner join user_custom_field_values v on v.definition_id = d.id " . + "group by t.id, t.uuid, t.description " . + "order by issue_count desc, t.description asc " . + "limit 5" + ); + foreach ($inactiveIssueTenants as $row) { + $customFieldIssueTenantRows[] = [ + 'issue' => t('Inactive custom fields with values'), + 'tenant_uuid' => $row['tenant_uuid'], + 'tenant_description' => $row['tenant_description'], + 'issue_count' => $row['issue_count'], + ]; + } + + $unusedIssueTenants = $loadCustomFieldIssueTenants( + "select t.uuid, t.description, count(*) as issue_count " . + "from tenants t " . + "inner join ( " . + "select d.id, d.tenant_id " . + "from tenant_custom_field_definitions d " . + "left join user_custom_field_values v on v.definition_id = d.id " . + "where d.active = 1 " . + "group by d.id, d.tenant_id " . + "having count(v.id) = 0 " . + ") issue on issue.tenant_id = t.id " . + "group by t.id, t.uuid, t.description " . + "order by issue_count desc, t.description asc " . + "limit 5" + ); + foreach ($unusedIssueTenants as $row) { + $customFieldIssueTenantRows[] = [ + 'issue' => t('Unused active custom fields'), + 'tenant_uuid' => $row['tenant_uuid'], + 'tenant_description' => $row['tenant_description'], + 'issue_count' => $row['issue_count'], + ]; + } +} + +$apiStatsAvailable = (int) (DB::selectValue( + "select count(*) from information_schema.tables " . + "where table_schema = database() and table_name in ('api_audit_log', 'user_api_tokens')" +) ?? 0) === 2; +$apiRequests24hCount = 0; +$apiRequestsPrev24hCount = 0; +$apiRequestTrendPercent = 0.0; +$apiErrors4xx24hCount = 0; +$apiErrors5xx24hCount = 0; +$apiP95DurationMs = 0; +$apiTokensExpiring7dCount = 0; +$apiTopErrorRows = []; +$apiSlowEndpointRows = []; + +if ($apiStatsAvailable) { + $apiRequests24hCount = (int) (DB::selectValue( + 'select count(*) from api_audit_log where created_at >= (NOW() - INTERVAL 24 HOUR)' + ) ?? 0); + $apiRequestsPrev24hCount = (int) (DB::selectValue( + 'select count(*) from api_audit_log where created_at >= (NOW() - INTERVAL 48 HOUR) and created_at < (NOW() - INTERVAL 24 HOUR)' + ) ?? 0); + if ($apiRequestsPrev24hCount > 0) { + $apiRequestTrendPercent = round((($apiRequests24hCount - $apiRequestsPrev24hCount) * 100) / $apiRequestsPrev24hCount, 1); + } + + $apiErrors4xx24hCount = (int) (DB::selectValue( + 'select count(*) from api_audit_log where created_at >= (NOW() - INTERVAL 24 HOUR) and status_code between 400 and 499' + ) ?? 0); + $apiErrors5xx24hCount = (int) (DB::selectValue( + 'select count(*) from api_audit_log where created_at >= (NOW() - INTERVAL 24 HOUR) and status_code between 500 and 599' + ) ?? 0); + + $durationCount = (int) (DB::selectValue( + 'select count(*) from api_audit_log where created_at >= (NOW() - INTERVAL 24 HOUR) and status_code between 200 and 299 and duration_ms is not null' + ) ?? 0); + if ($durationCount > 0) { + $offset = (int) floor(($durationCount - 1) * 0.95); + $apiP95DurationMs = (int) (DB::selectValue( + 'select duration_ms from api_audit_log ' . + 'where created_at >= (NOW() - INTERVAL 24 HOUR) and status_code between 200 and 299 and duration_ms is not null ' . + 'order by duration_ms asc limit ' . $offset . ', 1' + ) ?? 0); + } + + $apiTokensExpiring7dCount = (int) (DB::selectValue( + 'select count(*) from user_api_tokens ' . + 'where revoked_at is null and expires_at is not null and expires_at > NOW() and expires_at <= (NOW() + INTERVAL 7 DAY)' + ) ?? 0); + + $apiTopErrorRowsRaw = DB::select( + "select " . + " coalesce(nullif(error_code, ''), concat('http_', status_code)) as error_label, " . + " count(*) as hit_count " . + "from api_audit_log " . + "where created_at >= (NOW() - INTERVAL 24 HOUR) and status_code >= 400 " . + "group by error_label " . + "order by hit_count desc, error_label asc " . + "limit 5" + ); + $apiTopErrorRows = is_array($apiTopErrorRowsRaw) ? array_values(array_filter(array_map( + static function ($row) use ($flattenStatsRow): ?array { + $flat = $flattenStatsRow($row); + $label = trim((string) ($flat['error_label'] ?? '')); + $hits = (int) ($flat['hit_count'] ?? 0); + if ($label === '' || $hits <= 0) { + return null; + } + return [ + 'error_label' => $label, + 'hit_count' => $hits, + ]; + }, + $apiTopErrorRowsRaw + ))) : []; + + $apiSlowEndpointRowsRaw = DB::select( + "select " . + " path, " . + " count(*) as hit_count, " . + " round(avg(duration_ms), 1) as avg_duration_ms, " . + " max(duration_ms) as max_duration_ms " . + "from api_audit_log " . + "where created_at >= (NOW() - INTERVAL 24 HOUR) and status_code between 200 and 299 and duration_ms is not null " . + "group by path " . + "having count(*) >= 3 " . + "order by avg(duration_ms) desc, max(duration_ms) desc " . + "limit 5" + ); + $apiSlowEndpointRows = is_array($apiSlowEndpointRowsRaw) ? array_values(array_filter(array_map( + static function ($row) use ($flattenStatsRow): ?array { + $flat = $flattenStatsRow($row); + $path = trim((string) ($flat['path'] ?? '')); + $hits = (int) ($flat['hit_count'] ?? 0); + if ($path === '' || $hits <= 0) { + return null; + } + return [ + 'path' => $path, + 'hit_count' => $hits, + 'avg_duration_ms' => (float) ($flat['avg_duration_ms'] ?? 0), + 'max_duration_ms' => (int) ($flat['max_duration_ms'] ?? 0), + ]; + }, + $apiSlowEndpointRowsRaw + ))) : []; +} + +$importStatsAvailable = (int) (DB::selectValue( + "select count(*) from information_schema.tables " . + "where table_schema = database() and table_name = 'import_audit_runs'" +) ?? 0) === 1; +$importRuns7dCount = 0; +$importRunsPrev7dCount = 0; +$importRunTrendPercent = 0.0; +$importRowsCreated7dCount = 0; +$importRowsFailed7dCount = 0; +$importPartialOrFailedRuns7dCount = 0; +$importSuccessRate7dPercent = 0.0; +$importAverageDurationMs7d = 0; +$importRecentRunRows = []; +$importProfileRows = []; +$importTopErrorCodeRows = []; + +if ($importStatsAvailable) { + $importRuns7dCount = (int) (DB::selectValue( + "select count(*) from import_audit_runs where started_at >= (NOW() - INTERVAL 7 DAY)" + ) ?? 0); + $importRunsPrev7dCount = (int) (DB::selectValue( + "select count(*) from import_audit_runs where started_at >= (NOW() - INTERVAL 14 DAY) and started_at < (NOW() - INTERVAL 7 DAY)" + ) ?? 0); + if ($importRunsPrev7dCount > 0) { + $importRunTrendPercent = round((($importRuns7dCount - $importRunsPrev7dCount) * 100) / $importRunsPrev7dCount, 1); + } + + $importRowsCreated7dCount = (int) (DB::selectValue( + "select coalesce(sum(created_count), 0) from import_audit_runs where started_at >= (NOW() - INTERVAL 7 DAY)" + ) ?? 0); + $importRowsFailed7dCount = (int) (DB::selectValue( + "select coalesce(sum(failed_count), 0) from import_audit_runs where started_at >= (NOW() - INTERVAL 7 DAY)" + ) ?? 0); + $importPartialOrFailedRuns7dCount = (int) (DB::selectValue( + "select count(*) from import_audit_runs where started_at >= (NOW() - INTERVAL 7 DAY) and status in ('partial', 'failed')" + ) ?? 0); + $importAverageDurationMs7d = (int) (DB::selectValue( + "select round(avg(duration_ms), 0) from import_audit_runs " . + "where started_at >= (NOW() - INTERVAL 7 DAY) and duration_ms is not null" + ) ?? 0); + $importSuccessRuns7dCount = (int) (DB::selectValue( + "select count(*) from import_audit_runs where started_at >= (NOW() - INTERVAL 7 DAY) and status = 'success'" + ) ?? 0); + if ($importRuns7dCount > 0) { + $importSuccessRate7dPercent = round(($importSuccessRuns7dCount * 100) / $importRuns7dCount, 1); + } + + $importRecentRunRowsRaw = DB::select( + "select " . + " import_audit_runs.id, " . + " import_audit_runs.started_at, " . + " import_audit_runs.profile_key, " . + " import_audit_runs.status, " . + " import_audit_runs.rows_total, " . + " import_audit_runs.created_count, " . + " import_audit_runs.failed_count, " . + " import_audit_runs.duration_ms, " . + " users.uuid as user_uuid, " . + " users.display_name as user_display_name, " . + " users.email as user_email " . + "from import_audit_runs " . + "left join users on users.id = import_audit_runs.user_id " . + "order by import_audit_runs.started_at desc " . + "limit 10" + ); + $importRecentRunRows = is_array($importRecentRunRowsRaw) ? array_values(array_filter(array_map( + static function ($row) use ($flattenStatsRow): ?array { + $flat = $flattenStatsRow($row); + $id = (int) ($flat['id'] ?? 0); + $startedAt = trim((string) ($flat['started_at'] ?? '')); + $profileKey = trim((string) ($flat['profile_key'] ?? '')); + $status = trim((string) ($flat['status'] ?? '')); + if ($id <= 0 || $startedAt === '' || $profileKey === '' || $status === '') { + return null; + } + return [ + 'id' => $id, + 'started_at' => $startedAt, + 'profile_key' => $profileKey, + 'status' => $status, + 'rows_total' => (int) ($flat['rows_total'] ?? 0), + 'created_count' => (int) ($flat['created_count'] ?? 0), + 'failed_count' => (int) ($flat['failed_count'] ?? 0), + 'duration_ms' => (int) ($flat['duration_ms'] ?? 0), + 'user_uuid' => trim((string) ($flat['user_uuid'] ?? '')), + 'user_display_name' => trim((string) ($flat['user_display_name'] ?? '')), + 'user_email' => trim((string) ($flat['user_email'] ?? '')), + ]; + }, + $importRecentRunRowsRaw + ))) : []; + + $importProfileRowsRaw = DB::select( + "select " . + " profile_key, " . + " count(*) as run_count, " . + " sum(case when status = 'success' then 1 else 0 end) as success_count, " . + " coalesce(sum(created_count), 0) as created_count_sum, " . + " coalesce(sum(failed_count), 0) as failed_count_sum " . + "from import_audit_runs " . + "where started_at >= (NOW() - INTERVAL 7 DAY) " . + "group by profile_key " . + "order by run_count desc, profile_key asc " . + "limit 10" + ); + $importProfileRows = is_array($importProfileRowsRaw) ? array_values(array_filter(array_map( + static function ($row) use ($flattenStatsRow): ?array { + $flat = $flattenStatsRow($row); + $profileKey = trim((string) ($flat['profile_key'] ?? '')); + $runCount = (int) ($flat['run_count'] ?? 0); + if ($profileKey === '' || $runCount <= 0) { + return null; + } + $successCount = (int) ($flat['success_count'] ?? 0); + $successRate = $runCount > 0 ? round(($successCount * 100) / $runCount, 1) : 0.0; + return [ + 'profile_key' => $profileKey, + 'run_count' => $runCount, + 'created_count_sum' => (int) ($flat['created_count_sum'] ?? 0), + 'failed_count_sum' => (int) ($flat['failed_count_sum'] ?? 0), + 'success_rate' => $successRate, + ]; + }, + $importProfileRowsRaw + ))) : []; + + $importTopErrorRowsRaw = DB::select( + "select error_codes_json from import_audit_runs " . + "where started_at >= (NOW() - INTERVAL 7 DAY) " . + "and error_codes_json is not null and error_codes_json <> '' " . + "order by started_at desc " . + "limit 1000" + ); + $errorCodeCounts = []; + if (is_array($importTopErrorRowsRaw)) { + foreach ($importTopErrorRowsRaw as $row) { + $flat = $flattenStatsRow($row); + $json = (string) ($flat['error_codes_json'] ?? ''); + if ($json === '') { + continue; + } + $decoded = json_decode($json, true); + if (!is_array($decoded)) { + continue; + } + foreach ($decoded as $code => $count) { + $codeKey = trim((string) $code); + $countValue = (int) $count; + if ($codeKey === '' || $countValue <= 0) { + continue; + } + $errorCodeCounts[$codeKey] = (int) ($errorCodeCounts[$codeKey] ?? 0) + $countValue; + } + } + } + if (!empty($errorCodeCounts)) { + arsort($errorCodeCounts); + foreach (array_slice($errorCodeCounts, 0, 5, true) as $code => $count) { + $importTopErrorCodeRows[] = [ + 'error_code' => $code, + 'count' => (int) $count, + ]; + } + } +} + +$scheduledStatsAvailable = (int) (DB::selectValue( + "select count(*) from information_schema.tables " . + "where table_schema = database() and table_name in ('scheduled_jobs', 'scheduled_job_runs', 'scheduler_runtime_status')" +) ?? 0) === 3; +$scheduledEnabledJobsCount = 0; +$scheduledOverdueJobsCount = 0; +$scheduledRuns24hCount = 0; +$scheduledFailedRuns24hCount = 0; +$schedulerRunnerLastHeartbeatAt = ''; +$schedulerRunnerLastResult = ''; +$schedulerRunnerLastErrorCode = ''; +$schedulerRunnerIsActive = false; + +if ($scheduledStatsAvailable) { + $scheduledEnabledJobsCount = (int) (DB::selectValue( + "select count(*) from scheduled_jobs where enabled = 1" + ) ?? 0); + $scheduledOverdueJobsCount = (int) (DB::selectValue( + "select count(*) from scheduled_jobs " . + "where enabled = 1 and next_run_at is not null and next_run_at <= NOW()" + ) ?? 0); + $scheduledRuns24hCount = (int) (DB::selectValue( + "select count(*) from scheduled_job_runs " . + "where trigger_type = 'scheduler' and started_at >= (NOW() - INTERVAL 24 HOUR)" + ) ?? 0); + $scheduledFailedRuns24hCount = (int) (DB::selectValue( + "select count(*) from scheduled_job_runs " . + "where trigger_type = 'scheduler' and status = 'failed' and started_at >= (NOW() - INTERVAL 24 HOUR)" + ) ?? 0); + + $schedulerRunnerIsActive = (int) (DB::selectValue( + "select case when last_heartbeat_at >= (NOW() - INTERVAL 3 MINUTE) then 1 else 0 end " . + "from scheduler_runtime_status where id = 1" + ) ?? 0) === 1; + + $schedulerRuntimeRowRaw = DB::selectOne( + "select last_heartbeat_at, last_result, last_error_code from scheduler_runtime_status where id = 1 limit 1" + ); + $schedulerRuntimeRow = $flattenStatsRow($schedulerRuntimeRowRaw); + $schedulerRunnerLastHeartbeatAt = trim((string) ($schedulerRuntimeRow['last_heartbeat_at'] ?? '')); + $schedulerRunnerLastResult = trim((string) ($schedulerRuntimeRow['last_result'] ?? '')); + $schedulerRunnerLastErrorCode = trim((string) ($schedulerRuntimeRow['last_error_code'] ?? '')); +} $usersUnverifiedCount = (int) (DB::selectValue( 'select count(*) from users where active = 1 and email_verified_at is null' diff --git a/pages/admin/stats/index(default).phtml b/pages/admin/stats/index(default).phtml index d8b1a68..a4a7273 100644 --- a/pages/admin/stats/index(default).phtml +++ b/pages/admin/stats/index(default).phtml @@ -13,7 +13,58 @@ * @var int $permissionsWithoutRolesCount * @var int $usersWithoutRolesCount * @var int $usersWithoutTenantsCount - * @var int $departmentsWithoutTenantsCount + * @var bool $ssoStatsAvailable + * @var int $ssoEnabledTenantCount + * @var int $ssoEnforcedTenantCount + * @var float $ssoEnabledTenantPercent + * @var int $usersMicrosoftOnlyCount + * @var int $usersMicrosoftSyncGapsCount + * @var array> $ssoTenantRows + * @var array> $usersMicrosoftOnlyRows + * @var array> $usersMicrosoftSyncGapRows + * @var bool $customFieldStatsAvailable + * @var int $customFieldsSelectionWithoutOptionsCount + * @var int $customFieldTenantsWithSelectionWithoutOptionsCount + * @var int $customFieldsInactiveWithValuesCount + * @var int $customFieldsUnusedActiveCount + * @var string $customFieldsSelectionWithoutOptionsHref + * @var string $customFieldTenantsWithSelectionWithoutOptionsHref + * @var string $customFieldsInactiveWithValuesHref + * @var string $customFieldsUnusedActiveHref + * @var array> $customFieldIssueTenantRows + * @var bool $apiStatsAvailable + * @var int $apiRequests24hCount + * @var int $apiRequestsPrev24hCount + * @var float $apiRequestTrendPercent + * @var int $apiErrors4xx24hCount + * @var int $apiErrors5xx24hCount + * @var int $apiP95DurationMs + * @var int $apiTokensExpiring7dCount + * @var array> $apiTopErrorRows + * @var array> $apiSlowEndpointRows + * @var bool $importStatsAvailable + * @var int $importRuns7dCount + * @var int $importRunsPrev7dCount + * @var float $importRunTrendPercent + * @var int $importRowsCreated7dCount + * @var int $importRowsFailed7dCount + * @var int $importPartialOrFailedRuns7dCount + * @var float $importSuccessRate7dPercent + * @var int $importAverageDurationMs7d + * @var array> $importRecentRunRows + * @var array> $importProfileRows + * @var array> $importTopErrorCodeRows + * @var bool $scheduledStatsAvailable + * @var int $scheduledEnabledJobsCount + * @var int $scheduledOverdueJobsCount + * @var int $scheduledRuns24hCount + * @var int $scheduledFailedRuns24hCount + * @var bool $schedulerRunnerIsActive + * @var string $schedulerRunnerLastHeartbeatAt + * @var string $schedulerRunnerLastResult + * @var string $schedulerRunnerLastErrorCode + * @var int $usersNeverLoggedCount + * @var int $usersUnverifiedCount * @var int $mailLogSentCount * @var int $mailLogFailedCount * @var string $mailLogLastSentAt @@ -54,6 +105,31 @@ require templatePath('partials/app-breadcrumb.phtml'); + + + + + + + + + + + + + + + - - - - - - + + + + + + + + + +
    @@ -64,27 +122,6 @@ $useDefaultPrimaryColor = $primaryColor === ''; />
    - - -
    -
    - -
    - -
    - -
    - -
    - -
    -
    - - -
    -
    - - -
    -
    - -
    -
    -
    + +
    +
    > + +
    +
    + + +
    +
    -
    -
    - - -
    -
    + +
    + +

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

    + + + +
    + + + + +
    +
    + + +
    +
    +
    + + + +
    +
    + + + +
    +
    +
    + + +
    +
    +
    + + +
    +
    +
    + + +
    + + + +
    +
    + +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    + + + +
    +
    +
    + +
    + + + +
    +
    +
    + +
    + + +
    + +
    + + + +
    + + + +
    + +
    + + +
    + +
    + + + +
    +
    + +
    + $syncFieldLabel): ?> + + +
    + +
    + + +
    +
    + +
    + +
    + + +
    + + +
    + +
    +
    + + +
    @@ -170,6 +570,7 @@ $useDefaultPrimaryColor = $primaryColor === '';
    +
    @@ -190,7 +591,84 @@ $useDefaultPrimaryColor = $primaryColor === '';
    +
    +
    + + + +
    +
    + + + +
    +
    + t('Active'), + 'inactive' => t('Inactive'), + ]; + $statusFieldHint = t('Inactive tenants are excluded from user access and tenant-scoped data.'); + require templatePath('partials/app-visibility-status-field.phtml'); + ?> + +
    + +
    + + + + + + + + + diff --git a/pages/admin/tenants/avatar-file().php b/pages/admin/tenants/avatar-file().php new file mode 100644 index 0000000..628a127 --- /dev/null +++ b/pages/admin/tenants/avatar-file().php @@ -0,0 +1,43 @@ + '', @@ -31,16 +35,62 @@ $form = [ 'imprint_url' => '', 'primary_color' => '', 'primary_color_use_default' => 0, + 'default_theme' => '', + 'allow_user_theme_mode' => '', 'status' => 'active', ]; +$ssoUiState = $canManageSso ? TenantSsoService::buildMicrosoftUiState(0, $form) : []; if (isset($_POST['description'])) { - $currentUserId = (int) ($_SESSION['user']['id'] ?? 0); + $ssoFields = [ + 'microsoft_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', + 'clear_client_secret_override', + ]; + foreach ($ssoFields as $ssoField) { + if (array_key_exists($ssoField, $_POST) && !$canManageSso) { + Router::redirect('error/forbidden'); + return; + } + } $result = TenantService::createFromAdmin($_POST, $currentUserId); $form = $result['form'] ?? $form; $errors = $result['errors'] ?? []; + if ($canManageSso) { + $form = array_merge($form, [ + 'microsoft_enabled' => !empty($_POST['microsoft_enabled']) ? '1' : '0', + 'enforce_microsoft_login' => !empty($_POST['enforce_microsoft_login']) ? '1' : '0', + 'sync_profile_on_login' => !empty($_POST['sync_profile_on_login']) ? '1' : '0', + 'sync_profile_fields_list' => TenantSsoService::normalizeProfileSyncFields($_POST['sync_profile_fields'] ?? []), + 'entra_tenant_id' => trim((string) ($_POST['entra_tenant_id'] ?? '')), + 'allowed_domains' => trim((string) ($_POST['allowed_domains'] ?? '')), + 'use_shared_app' => !empty($_POST['use_shared_app']) ? '1' : '0', + 'client_id_override' => trim((string) ($_POST['client_id_override'] ?? '')), + ]); + $ssoUiState = TenantSsoService::buildMicrosoftUiState(0, $_POST); + } if ($result['ok'] ?? false) { + if ($canManageSso) { + $createdTenantId = (int) ($result['id'] ?? 0); + if ($createdTenantId > 0) { + $ssoSaveResult = TenantSsoService::saveTenantMicrosoftAuth($createdTenantId, $_POST); + if (!($ssoSaveResult['ok'] ?? false)) { + Flash::error( + (string) (($ssoSaveResult['errors'][0] ?? t('Tenant SSO settings could not be saved'))), + 'admin/tenants', + 'tenant_sso_create_failed' + ); + } + } + } $action = (string) ($_POST['action'] ?? 'create'); if ($action === 'create_close') { Flash::success('Tenant created', 'admin/tenants', 'tenant_created'); diff --git a/pages/admin/tenants/create(default).phtml b/pages/admin/tenants/create(default).phtml index 9e075d8..dc4bde8 100644 --- a/pages/admin/tenants/create(default).phtml +++ b/pages/admin/tenants/create(default).phtml @@ -15,21 +15,29 @@ ['label' => t('Create tenant')], ]; require templatePath('partials/app-breadcrumb.phtml'); + $titlebar = [ + 'title' => t('Create tenant'), + 'backHref' => 'admin/tenants', + 'backTitle' => t('Cancel'), + 'actions' => [ + [ + 'form' => 'tenant-form', + 'name' => 'action', + 'value' => 'create', + 'class' => 'secondary outline', + 'label' => t('Create'), + ], + [ + 'form' => 'tenant-form', + 'name' => 'action', + 'value' => 'create_close', + 'class' => 'primary', + 'label' => t('Create & close'), + ], + ], + ]; + require templatePath('partials/app-details-titlebar.phtml'); ?> -
    -

    - - -

    -
    - - -
    -
      @@ -42,6 +50,10 @@ diff --git a/pages/admin/tenants/custom-field-create($id).php b/pages/admin/tenants/custom-field-create($id).php new file mode 100644 index 0000000..52cd35d --- /dev/null +++ b/pages/admin/tenants/custom-field-create($id).php @@ -0,0 +1,52 @@ + 0) { + $tenantIds[] = $tenantId; + } + } + $tenantIds = array_values(array_unique($tenantIds)); + + $tenantSsoMap = TenantMicrosoftAuthRepository::listByTenantIds($tenantIds); + $tenantUserCounts = UserTenantRepository::countUsersByTenantIds($tenantIds); + $tenantActiveUserCounts = UserTenantRepository::countActiveUsersByTenantIds($tenantIds); + + $defaultTenantId = SettingService::getDefaultTenantId(); + $rows = []; + + foreach ($tenantRows as $row) { + $tenantId = (int) ($row['id'] ?? 0); + $tenantTheme = strtolower(trim((string) ($row['default_theme'] ?? ''))); + $themeIsTenantOverride = $tenantTheme !== '' && isset($themes[$tenantTheme]); + $resolvedThemeKey = $themeIsTenantOverride ? $tenantTheme : $globalDefaultTheme; + $resolvedThemeLabel = (string) ($themes[$resolvedThemeKey] ?? ucfirst($resolvedThemeKey)); + + $themePolicyRaw = $row['allow_user_theme'] ?? null; + $themePolicyMode = 'inherit'; + if ($themePolicyRaw !== null && $themePolicyRaw !== '') { + $normalized = strtolower(trim((string) $themePolicyRaw)); + if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) { + $themePolicyMode = 'allow'; + } elseif (in_array($normalized, ['0', 'false', 'no', 'off'], true)) { + $themePolicyMode = 'disallow'; + } + } + + $tenantSso = $tenantSsoMap[$tenantId] ?? null; + $ssoEnabled = !empty($tenantSso['enabled']); + $ssoMicrosoftOnly = $ssoEnabled && !empty($tenantSso['enforce_microsoft_login']); + $usersTotal = (int) ($tenantUserCounts[$tenantId] ?? 0); + $usersActive = (int) ($tenantActiveUserCounts[$tenantId] ?? 0); + $usersInactive = max(0, $usersTotal - $usersActive); + + $rows[] = [ + 'id' => $row['id'] ?? null, + 'uuid' => $row['uuid'] ?? '', + 'is_default' => $tenantId > 0 && $defaultTenantId === $tenantId, + 'description' => $row['description'] ?? '', + 'status' => $row['status'] ?? 'active', + 'theme' => [ + 'key' => $resolvedThemeKey, + 'label' => t($resolvedThemeLabel), + 'is_override' => $themeIsTenantOverride ? 1 : 0, + 'policy_mode' => $themePolicyMode, + ], + 'sso' => [ + 'enabled' => $ssoEnabled ? 1 : 0, + 'microsoft_only' => $ssoMicrosoftOnly ? 1 : 0, + ], + 'active_users' => $usersActive, + 'inactive_users' => $usersInactive, + 'created' => dt($row['created'] ?? ''), + 'modified' => dt($row['modified'] ?? ''), + ]; + } + + return $rows; +}; + +$themes = appThemes(); +$globalDefaultTheme = SettingService::getAppTheme(); +if ($globalDefaultTheme === null || !isset($themes[$globalDefaultTheme])) { + $envTheme = strtolower(trim((string) (getenv('APP_THEME') ?: 'light'))); + $globalDefaultTheme = isset($themes[$envTheme]) ? $envTheme : 'light'; +} $result = TenantService::listPaged([ 'limit' => $limit, 'offset' => $offset, 'search' => $search, - 'order' => $order, + 'order' => in_array($order, $computedOrderKeys, true) ? 'description' : $order, 'dir' => $dir, ]); -$defaultTenantId = SettingService::getDefaultTenantId(); -$defaultPrimaryColor = SettingService::getAppPrimaryColor() ?? '#2fa4a4'; $rows = []; -foreach ($result['rows'] as $row) { - $tenantId = (int) ($row['id'] ?? 0); - $primaryColor = (string) ($row['primary_color'] ?? ''); - $useDefaultColor = $primaryColor === ''; - $rows[] = [ - 'id' => $row['id'] ?? null, - 'uuid' => $row['uuid'] ?? '', - 'is_default' => $tenantId > 0 && $defaultTenantId === $tenantId, - 'description' => $row['description'] ?? '', - 'status' => $row['status'] ?? 'active', - 'primary_color' => $useDefaultColor ? $defaultPrimaryColor : $primaryColor, - 'primary_color_is_default' => $useDefaultColor ? 1 : 0, - 'created' => dt($row['created'] ?? ''), - 'modified' => dt($row['modified'] ?? ''), - ]; +$total = (int) ($result['total'] ?? 0); +if (in_array($order, $computedOrderKeys, true)) { + $fullResult = TenantService::listPaged([ + 'limit' => max(1, $total), + 'offset' => 0, + 'search' => $search, + 'order' => 'description', + 'dir' => 'asc', + ]); + $rows = $fetchRows($fullResult['rows'] ?? [], $themes, $globalDefaultTheme); + + usort($rows, static function (array $a, array $b) use ($order, $dir): int { + $valueA = ''; + $valueB = ''; + if ($order === 'theme') { + $valueA = strtolower((string) ($a['theme']['label'] ?? '')); + $valueB = strtolower((string) ($b['theme']['label'] ?? '')); + } elseif ($order === 'sso') { + $valueA = ((int) ($a['sso']['enabled'] ?? 0) * 10) + (int) ($a['sso']['microsoft_only'] ?? 0); + $valueB = ((int) ($b['sso']['enabled'] ?? 0) * 10) + (int) ($b['sso']['microsoft_only'] ?? 0); + } elseif ($order === 'inactive_users') { + $valueA = (int) ($a['inactive_users'] ?? 0); + $valueB = (int) ($b['inactive_users'] ?? 0); + } else { + $valueA = (int) ($a['active_users'] ?? 0); + $valueB = (int) ($b['active_users'] ?? 0); + } + $cmp = $valueA <=> $valueB; + if ($cmp === 0) { + $cmp = strcasecmp((string) ($a['description'] ?? ''), (string) ($b['description'] ?? '')); + } + return $dir === 'desc' ? -$cmp : $cmp; + }); + $rows = array_slice($rows, $offset, $limit); +} else { + $rows = $fetchRows($result['rows'] ?? [], $themes, $globalDefaultTheme); } Router::json([ 'data' => $rows, - 'total' => $result['total'] ?? 0, + 'total' => $total, ]); diff --git a/pages/admin/tenants/delete($id).php b/pages/admin/tenants/delete($id).php index 20673a4..b9395fb 100644 --- a/pages/admin/tenants/delete($id).php +++ b/pages/admin/tenants/delete($id).php @@ -17,6 +17,10 @@ if ($uuid === '') { $result = TenantService::deleteByUuid($uuid); if (!($result['ok'] ?? false)) { + if (($result['error'] ?? '') === 'tenant_has_departments') { + Flash::error('Tenant can not be deleted while departments are assigned', 'admin/tenants', 'tenant_has_departments'); + Router::redirect('admin/tenants'); + } Flash::error('Tenant not found', 'admin/tenants', 'tenant_not_found'); Router::redirect('admin/tenants'); } diff --git a/pages/admin/tenants/edit($id).php b/pages/admin/tenants/edit($id).php index cc2ff0b..1f478b9 100644 --- a/pages/admin/tenants/edit($id).php +++ b/pages/admin/tenants/edit($id).php @@ -8,6 +8,8 @@ use MintyPHP\Service\Tenant\TenantService; use MintyPHP\Service\User\UserService; use MintyPHP\Service\Settings\SettingService; use MintyPHP\Service\Access\PermissionService; +use MintyPHP\Service\CustomField\TenantCustomFieldService; +use MintyPHP\Service\Auth\TenantSsoService; Guard::requireLogin(); Guard::requirePermissionOrForbidden(PermissionService::TENANTS_VIEW); @@ -24,6 +26,13 @@ if (!$tenant) { } $tenantId = (int) ($tenant['id'] ?? 0); +$canManageCustomFields = PermissionService::userHas($currentUserId, PermissionService::CUSTOM_FIELDS_MANAGE); +$canManageSso = PermissionService::userHas($currentUserId, PermissionService::TENANTS_SSO_MANAGE); +$customFieldDefinitions = $canManageCustomFields + ? TenantCustomFieldService::listForTenant($tenantId) + : []; +$ssoConfig = $canManageSso ? TenantSsoService::getTenantMicrosoftAuth($tenantId) : []; +$ssoUiState = $canManageSso ? TenantSsoService::buildMicrosoftUiState($tenantId, $ssoConfig) : []; $creatorId = (int) ($tenant['created_by'] ?? 0); if ($creatorId > 0) { $creator = UserService::findById($creatorId); @@ -53,18 +62,45 @@ if ($statusChangedById > 0) { } $errors = []; -$form = $tenant; +$form = array_merge($tenant, $ssoConfig); if (isset($_POST['description'])) { if (!PermissionService::userHas($currentUserId, PermissionService::TENANTS_UPDATE)) { Router::redirect('error/forbidden'); return; } + $ssoFields = [ + 'microsoft_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', + 'clear_client_secret_override', + ]; + foreach ($ssoFields as $ssoField) { + if (array_key_exists($ssoField, $_POST) && !$canManageSso) { + Router::redirect('error/forbidden'); + return; + } + } $result = TenantService::updateFromAdmin($tenantId, $_POST, $currentUserId); $form = $result['form'] ?? $form; $errors = $result['errors'] ?? []; if ($result['ok'] ?? false) { + if ($canManageSso) { + $ssoSaveResult = TenantSsoService::saveTenantMicrosoftAuth($tenantId, $_POST); + if (!($ssoSaveResult['ok'] ?? false)) { + $errors = array_merge($errors, $ssoSaveResult['errors'] ?? [t('Tenant SSO settings could not be saved')]); + } + } + } + + if (($result['ok'] ?? false) && !$errors) { if ($currentUserId > 0) { \MintyPHP\Service\Auth\AuthService::loadTenantDataIntoSession($currentUserId); } @@ -77,6 +113,24 @@ if (isset($_POST['description'])) { Router::redirect("admin/tenants/edit/{$uuid}"); } } + + if ($canManageSso) { + $form = array_merge($form, [ + 'microsoft_enabled' => !empty($_POST['microsoft_enabled']) ? '1' : '0', + 'enforce_microsoft_login' => !empty($_POST['enforce_microsoft_login']) ? '1' : '0', + 'sync_profile_on_login' => !empty($_POST['sync_profile_on_login']) ? '1' : '0', + 'sync_profile_fields_list' => TenantSsoService::normalizeProfileSyncFields($_POST['sync_profile_fields'] ?? []), + 'entra_tenant_id' => trim((string) ($_POST['entra_tenant_id'] ?? '')), + 'allowed_domains' => trim((string) ($_POST['allowed_domains'] ?? '')), + 'use_shared_app' => !empty($_POST['use_shared_app']) ? '1' : '0', + 'client_id_override' => trim((string) ($_POST['client_id_override'] ?? '')), + ]); + $ssoUiState = TenantSsoService::buildMicrosoftUiState($tenantId, $_POST); + } +} + +if ($canManageSso && empty($ssoUiState)) { + $ssoUiState = TenantSsoService::buildMicrosoftUiState($tenantId, $form); } Buffer::set('title', t('Edit tenant')); diff --git a/pages/admin/tenants/edit(default).phtml b/pages/admin/tenants/edit(default).phtml index dd35043..004dab9 100644 --- a/pages/admin/tenants/edit(default).phtml +++ b/pages/admin/tenants/edit(default).phtml @@ -15,6 +15,7 @@ $isReadOnly = !can('tenants.update'); $avatarUuid = (string) ($values['uuid'] ?? ''); $hasAvatar = $avatarUuid !== '' && TenantAvatarService::hasAvatar($avatarUuid); $hasFavicon = $avatarUuid !== '' && TenantFaviconService::hasFavicon($avatarUuid); +$canDeleteTenant = can('tenants.delete'); ?> @@ -27,41 +28,29 @@ $hasFavicon = $avatarUuid !== '' && TenantFaviconService::hasFavicon($avatarUuid ['label' => t('Edit tenant')], ]; require templatePath('partials/app-breadcrumb.phtml'); + $titlebar = [ + 'title' => t('Edit tenant'), + 'backHref' => 'admin/tenants', + 'backTitle' => t('Cancel'), + 'actions' => can('tenants.update') ? [ + [ + 'form' => 'tenant-form', + 'name' => 'action', + 'value' => 'save', + 'class' => 'secondary outline', + 'label' => t('Save'), + ], + [ + 'form' => 'tenant-form', + 'name' => 'action', + 'value' => 'save_close', + 'class' => 'primary', + 'label' => t('Save & close'), + ], + ] : [], + ]; + require templatePath('partials/app-details-titlebar.phtml'); ?> -
      -

      - - -

      -
      - - - - - - - - -
      -
      -
      @@ -77,8 +66,27 @@ $hasFavicon = $avatarUuid !== '' && TenantFaviconService::hasFavicon($avatarUuid + + +
      diff --git a/pages/admin/tenants/index($slug).php b/pages/admin/tenants/index($slug).php new file mode 100644 index 0000000..65d2509 --- /dev/null +++ b/pages/admin/tenants/index($slug).php @@ -0,0 +1,15 @@ + "> + + diff --git a/pages/admin/user-lifecycle-audit/purge().php b/pages/admin/user-lifecycle-audit/purge().php new file mode 100644 index 0000000..5dcfcd4 --- /dev/null +++ b/pages/admin/user-lifecycle-audit/purge().php @@ -0,0 +1,28 @@ + 0 ? UserLifecycleAuditService::find($auditId) : null; +if (!$auditLog) { + Flash::error(t('Lifecycle audit entry not found'), 'admin/user-lifecycle-audit', 'user_lifecycle_audit_not_found'); + Router::redirect('admin/user-lifecycle-audit'); +} + +$snapshot = null; +if ((string) ($auditLog['action'] ?? '') === 'delete') { + $snapshot = UserLifecycleAuditService::decryptSnapshot($auditLog); +} + +Buffer::set('title', t('View user lifecycle audit entry')); + diff --git a/pages/admin/user-lifecycle-audit/view(default).phtml b/pages/admin/user-lifecycle-audit/view(default).phtml new file mode 100644 index 0000000..42197e7 --- /dev/null +++ b/pages/admin/user-lifecycle-audit/view(default).phtml @@ -0,0 +1,217 @@ + + +
      +
      + t('Home'), 'path' => 'admin'], + ['label' => t('User lifecycle logs'), 'path' => 'admin/user-lifecycle-audit'], + ['label' => t('View')], + ]; + require templatePath('partials/app-breadcrumb.phtml'); + + $titlebar = [ + 'title' => t('View user lifecycle audit entry'), + 'backHref' => 'admin/user-lifecycle-audit', + 'backTitle' => t('Back'), + ]; + require templatePath('partials/app-details-titlebar.phtml'); + ?> + +
      +
      + +
      +
      +
      + +

      +
      +
      + +

      +
      +
      +
      +
      + +

      +
      +
      + +

      +
      +
      +
      +
      + +

      +
      +
      + +

      +
      +
      +
      + +
      +
      + +
      +
      +
      + +

      +
      +
      + +

      +
      +
      +
      + +
      +
      + +
      +
      +
      + +

      +
      +
      + +

      +
      +
      +
      + + +
      +
      + +
      +
      +
      + +

      +
      +
      + +

      +
      +
      +
      +
      + +

      +
      +
      + +

      +
      +
      +
      +
      + +

      +
      +
      + +

      +
      +
      +
      + + +
      +
      + +
      +
      +
      + +

      +
      +
      + +

      +
      +
      +
      + +

      + + + + + + - + +

      +
      + +
      +
      + + +
      + +
      +
      +
      + + +
      + diff --git a/pages/admin/users/_form.phtml b/pages/admin/users/_form.phtml index 46d77df..0f895b9 100644 --- a/pages/admin/users/_form.phtml +++ b/pages/admin/users/_form.phtml @@ -15,7 +15,7 @@ * @var array $roles * @var array $selectedRoleIds * @var bool $showDepartments - * @var array $departments + * @var array $departmentOptionsByTenant * @var array $selectedDepartmentIds * @var bool $isReadOnly * @var bool $showPermissions @@ -24,6 +24,19 @@ * @var array $passwordResets * @var bool $showRememberTokens * @var array $rememberTokens + * @var bool $showCustomFieldsTab + * @var bool $canEditCustomFieldValues + * @var array $customFieldDefinitionsByTenant + * @var array $customFieldValueMap + * @var array $customFieldPostedValues + * @var bool $showDangerZone + * @var string|null $dangerZoneDeleteFormId + * @var string|null $dangerZoneWarning + * @var string|null $dangerZoneActionLabel + * @var bool $showTokenDangerAction + * @var string|null $tokenDangerFormId + * @var string|null $tokenDangerWarning + * @var string|null $tokenDangerActionLabel */ use MintyPHP\Session; @@ -45,7 +58,7 @@ $showRoles = $showRoles ?? false; $roles = $roles ?? []; $selectedRoleIds = $selectedRoleIds ?? []; $showDepartments = $showDepartments ?? false; -$departments = $departments ?? []; +$departmentOptionsByTenant = $departmentOptionsByTenant ?? []; $selectedDepartmentIds = $selectedDepartmentIds ?? []; $primaryTenantId = (int) ($values['primary_tenant_id'] ?? 0); if (!$primaryTenantId && count($selectedTenantIds) === 1) { @@ -58,34 +71,69 @@ $passwordResets = $passwordResets ?? []; $showPasswordResets = $showPasswordResets ?? false; $rememberTokens = $rememberTokens ?? []; $showRememberTokens = $showRememberTokens ?? false; +$apiTokens = $apiTokens ?? []; +$showApiTokens = $showApiTokens ?? false; +$canManageApiTokens = $canManageApiTokens ?? false; +$showCustomFieldsTab = (bool) $showCustomFieldsTab; +$canEditCustomFieldValues = (bool) $canEditCustomFieldValues; +$customFieldDefinitionsByTenant = (array) $customFieldDefinitionsByTenant; +$customFieldValueMap = (array) $customFieldValueMap; +$customFieldPostedValues = (array) $customFieldPostedValues; +$customFieldScalarPosted = is_array($customFieldPostedValues['custom_field_values'] ?? null) + ? $customFieldPostedValues['custom_field_values'] + : []; +$customFieldMultiPosted = is_array($customFieldPostedValues['custom_field_values_multi'] ?? null) + ? $customFieldPostedValues['custom_field_values_multi'] + : []; +$showDangerZone = (bool) ($showDangerZone ?? false); +$dangerZoneDeleteFormId = (string) ($dangerZoneDeleteFormId ?? ''); +$dangerZoneWarning = (string) ($dangerZoneWarning ?? t('This action cannot be undone.')); +$dangerZoneActionLabel = (string) ($dangerZoneActionLabel ?? t('Delete')); +$showTokenDangerAction = (bool) ($showTokenDangerAction ?? false); +$tokenDangerFormId = (string) ($tokenDangerFormId ?? ''); +$tokenDangerWarning = (string) ($tokenDangerWarning ?? t('This action cannot be undone.')); +$tokenDangerActionLabel = (string) ($tokenDangerActionLabel ?? t('Clear login tokens')); $minLength = UserService::passwordMinLength(); $requiredAttr = $passwordRequired ? 'required' : ''; $readonlyAttr = $isReadOnly ? 'readonly' : ''; $disabledAttr = $isReadOnly ? 'disabled' : ''; $dataDisabledAttr = $isReadOnly ? 'data-disabled="true"' : ''; +$visibilityDisabled = $isReadOnly || $isOwnAccount; +$visibilityDisabledAttr = $visibilityDisabled ? 'disabled' : ''; +$visibilityHint = $isOwnAccount + ? t('You cannot deactivate your own account') + : t('Inactive entries are hidden in lists and selections.'); $locales = defined('APP_LOCALES') ? APP_LOCALES : [I18n::$defaultLocale]; $primaryTenantDisabled = $isReadOnly || count($selectedTenantIds) === 1; $primaryTenantDisabledAttr = $primaryTenantDisabled ? 'disabled' : ''; $showOrganization = $showTenants || $showDepartments || $showRoles; +$showOrganizationTab = $showOrganization || $showPermissions; +$customFieldInputsDisabled = $isReadOnly || !$canEditCustomFieldValues; +$customFieldInputsDisabledAttr = $customFieldInputsDisabled ? 'disabled' : ''; +$customFieldTenantMap = []; +foreach ($tenants as $tenant) { + $tenantId = (int) ($tenant['id'] ?? 0); + if ($tenantId <= 0 || !in_array($tenantId, $selectedTenantIds, true)) { + continue; + } + $customFieldTenantMap[$tenantId] = $tenant; +} ?> -
      +
      - - - + + - - - + + - - - - - + + + +
      @@ -109,7 +157,7 @@ $showOrganization = $showTenants || $showDepartments || $showRoles;
      -
      +
      >

      -
      +
      >
      @@ -168,104 +216,8 @@ $showOrganization = $showTenants || $showDepartments || $showRoles;
      -
      - -
      - -
      -
      - - -
      -
      - -
        - -
      • - -
      • -
      -
      -
      - - - -
      - - -
      - -
      - -
      - - 0): ?> - - - - -
      -
      - - -
      - -
      -
      - -
      - -
      - - -
      - -
      - -
      -
      - -
      - - -
      -
      +
      +
      >
      @@ -287,7 +239,7 @@ $showOrganization = $showTenants || $showDepartments || $showRoles;

      -
      +
      >

      -
      +
      >
      - -
      -
      - - - - - - - - - - - - - - - - + +
      +
      + +
      +
      +
      + + +
      +
      + +
        + +
      • -
      -
      - - - - - -
      +
    • +
    +
    - - - -
    -
    - - - - - - - - - - - - - + + +
    +
    + +
    +
    +
    + - - - - - + + + + + - - -
    - - - -
    -
    -
    - - -
    -
    - - - - - - - - - - - - + + + + ?> + + + + + + + + + +
    + + + +
    +
    + + + +
    +
    + +
    +
    + + - - - - + + + + - - -
    - - - -
    -
    + + + + + + + + + + + + + + + + + +
    + + + +
    + +
    open> + +
    + +
    +

    + + + +
    + + +
    + +
    + + +
    +
    + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + + + +
    +
    + + +

    + +
    + + + + +
    + + +
    + +
    + + + 0): ?> + + + +
    +
    + + +
    + +
    + + +

    + + $tenant): ?> + +
    + + : + + + + +

    + +
    + + +
    +
    + + +
    + +
    + +
    + + + +
    +
    + +
    +
    + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +
    +
    + +
    + + + +
    + +

    + + + $tenant): ?> + +
    + + + +
    + +

    + +
    + + $id > 0)); + $currentOptionIds = is_array($currentValue['option_ids'] ?? null) + ? array_values(array_unique(array_map('intval', $currentValue['option_ids']))) + : []; + $currentOptionIds = array_values(array_filter($currentOptionIds, static fn (int $id): bool => $id > 0)); + $valueText = array_key_exists('value_text', $currentValue) ? (string) ($currentValue['value_text'] ?? '') : ''; + $valueBool = array_key_exists('value_bool', $currentValue) && $currentValue['value_bool'] !== null + ? (string) ((int) $currentValue['value_bool']) + : ''; + $valueDate = array_key_exists('value_date', $currentValue) ? (string) ($currentValue['value_date'] ?? '') : ''; + $valueOptionId = array_key_exists('option_id', $currentValue) && $currentValue['option_id'] !== null + ? (int) $currentValue['option_id'] + : 0; + ?> +
    + + + + + + + + + + + + + + + +
    + +
    + +
    +
    + + +

    + + +
    + + +
    + t('Active'), + '0' => t('Inactive'), + ]; + $statusFieldHint = $visibilityHint; + $statusFieldDisabledAttr = $visibilityDisabledAttr; + require templatePath('partials/app-visibility-status-field.phtml'); + ?> +
    + +
    + +
    + +

    + +
    + + + +
    diff --git a/pages/admin/users/access-pdf($id).php b/pages/admin/users/access-pdf($id).php new file mode 100644 index 0000000..0745669 --- /dev/null +++ b/pages/admin/users/access-pdf($id).php @@ -0,0 +1,58 @@ + 100) { + // Server-side hard limit to keep synchronous ZIP generation predictable. + Flash::error(t('Too many users selected'), 'admin/users', 'users_access_pdf_too_many'); + Router::redirect('admin/users'); + return; +} + +$currentUserId = (int) ($_SESSION['user']['id'] ?? 0); +$users = []; +foreach ($uuids as $uuid) { + $user = UserService::findByUuid($uuid); + if (!$user) { + continue; + } + $userId = (int) ($user['id'] ?? 0); + if ($userId <= 0) { + continue; + } + // Scope check per user prevents cross-tenant exports in mixed selections. + if (!TenantScopeService::canAccess('users', $userId, $currentUserId)) { + continue; + } + $users[] = $user; +} + +if (!$users) { + Flash::error(t('No valid users selected'), 'admin/users', 'users_access_pdf_not_allowed'); + Router::redirect('admin/users'); + return; +} + +if (!class_exists(ZipArchive::class)) { + // Explicit runtime dependency check for environments missing ext-zip. + Flash::error(t('ZIP support missing (ext-zip)'), 'admin/users', 'users_access_pdf_zip_missing'); + Router::redirect('admin/users'); + return; +} + +$zip = UserAccessPdfService::renderUsersAccessZip($users); +$zipPath = (string) ($zip['path'] ?? ''); +$zipFilename = trim((string) ($zip['filename'] ?? '')); +$zipCount = (int) ($zip['count'] ?? 0); + +if ($zipPath === '' || !is_file($zipPath) || $zipCount <= 0) { + Flash::error(t('Failed to generate access PDF'), 'admin/users', 'users_access_pdf_failed'); + Router::redirect('admin/users'); + return; +} + +if ($zipFilename === '') { + $zipFilename = 'user-invitations-' . gmdate('Ymd-His') . '.zip'; +} + +try { + header('Content-Type: application/zip'); + header('Content-Disposition: attachment; filename="' . $zipFilename . '"'); + header('Cache-Control: private, no-store, max-age=0'); + header('Pragma: no-cache'); + header('X-Content-Type-Options: nosniff'); + header('Content-Length: ' . filesize($zipPath)); + readfile($zipPath); +} finally { + // Always delete temporary archive, including partial-failure paths. + @unlink($zipPath); +} diff --git a/pages/admin/users/api-token-create($id).php b/pages/admin/users/api-token-create($id).php new file mode 100644 index 0000000..dac02d4 --- /dev/null +++ b/pages/admin/users/api-token-create($id).php @@ -0,0 +1,38 @@ + $result['token'], 'uuid' => $uuid]; + Flash::success(t('API token created. Copy it now — it will not be shown again.'), "admin/users/edit/{$uuid}", 'api_token_created'); +} else { + $error = $result['error'] ?? 'create_failed'; + Flash::error(t('Could not create API token: %s', $error), "admin/users/edit/{$uuid}", 'api_token_error'); +} + +Router::redirect("admin/users/edit/{$uuid}"); diff --git a/pages/admin/users/api-token-revoke($id).php b/pages/admin/users/api-token-revoke($id).php new file mode 100644 index 0000000..423aa4c --- /dev/null +++ b/pages/admin/users/api-token-revoke($id).php @@ -0,0 +1,26 @@ + 0) { + ApiTokenService::revoke($tokenId); + Flash::success(t('API token revoked'), "admin/users/edit/{$uuid}", 'api_token_revoked'); +} + +Router::redirect("admin/users/edit/{$uuid}"); diff --git a/pages/admin/users/avatar($id).php b/pages/admin/users/avatar($id).php index 30a960c..be9ccb4 100644 --- a/pages/admin/users/avatar($id).php +++ b/pages/admin/users/avatar($id).php @@ -10,14 +10,14 @@ use MintyPHP\Service\Tenant\TenantScopeService; Guard::requireLogin(); if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { - Router::redirect('admin/users'); + Router::redirect('admin'); return; } $uuid = trim((string) ($id ?? '')); if (!UserAvatarService::isValidUuid($uuid)) { - Flash::error('User not found', 'admin/users', 'user_not_found'); - Router::redirect('admin/users'); + Flash::error('User not found', 'admin', 'user_not_found'); + Router::redirect('admin'); return; } diff --git a/pages/admin/users/avatar-delete($id).php b/pages/admin/users/avatar-delete($id).php index e0ba8d7..d5ebe49 100644 --- a/pages/admin/users/avatar-delete($id).php +++ b/pages/admin/users/avatar-delete($id).php @@ -10,14 +10,14 @@ use MintyPHP\Service\Tenant\TenantScopeService; Guard::requireLogin(); if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { - Router::redirect('admin/users'); + Router::redirect('admin'); return; } $uuid = trim((string) ($id ?? '')); if (!UserAvatarService::isValidUuid($uuid)) { - Flash::error('User not found', 'admin/users', 'user_not_found'); - Router::redirect('admin/users'); + Flash::error('User not found', 'admin', 'user_not_found'); + Router::redirect('admin'); return; } diff --git a/pages/admin/users/bulk($action).php b/pages/admin/users/bulk($action).php index e826315..d85c843 100644 --- a/pages/admin/users/bulk($action).php +++ b/pages/admin/users/bulk($action).php @@ -1,12 +1,11 @@ appTitle(), - 'app_logo_url' => appLogoUrlAbsolute(128), - 'imprint_url' => appUrl(Request::withLocale('imprint', $locale)), - 'privacy_url' => appUrl(Request::withLocale('privacy', $locale)), - 'greeting' => $greeting, - 'username' => (string) ($user['email'] ?? ''), - 'login_url' => $loginUrl, - 'reset_url' => $resetUrl, - ]; + $context = UserAccessTemplateService::buildContext($user); + $locale = (string) ($context['locale'] ?? ''); + $subject = (string) ($context['subject'] ?? t('Your access details')); + $vars = is_array($context['vars'] ?? null) ? $context['vars'] : []; $result = MailService::sendTemplate('access_info', $vars, (string) $user['email'], $subject, $locale); if ($result['ok'] ?? false) { diff --git a/pages/admin/users/create().php b/pages/admin/users/create().php index b5a3bf7..9f0e891 100644 --- a/pages/admin/users/create().php +++ b/pages/admin/users/create().php @@ -11,6 +11,7 @@ use MintyPHP\Service\Tenant\TenantScopeService; use MintyPHP\Service\Tenant\TenantService; use MintyPHP\Service\Access\RoleService; use MintyPHP\Service\Org\DepartmentService; +use MintyPHP\Service\CustomField\UserCustomFieldValueService; use MintyPHP\Service\Settings\SettingService; Guard::requirePermissionOrForbidden(PermissionService::USERS_CREATE); @@ -28,10 +29,18 @@ if ($allowedTenantIds) { $tenants = []; } $roles = RoleService::listActive(); -$departments = $allowedTenantIds ? DepartmentService::listByTenantIds($allowedTenantIds) : []; -if (!$allowedTenantIds && !TenantScopeService::isStrict()) { - $departments = DepartmentService::list(); -} +$selectedTenantIds = []; +$selectedRoleIds = []; +$selectedDepartmentIds = []; +$departmentOptionsByTenant = []; +$canEditCustomFieldValues = PermissionService::userHas($currentUserId, PermissionService::CUSTOM_FIELDS_EDIT_VALUES) + && PermissionService::userHas($currentUserId, PermissionService::USERS_UPDATE_ASSIGNMENTS); +$customFieldDefinitionsByTenant = []; +$customFieldValueMap = []; +$customFieldPostedValues = [ + 'custom_field_values' => [], + 'custom_field_values_multi' => [], +]; $errors = []; $form = [ @@ -66,18 +75,75 @@ if (isset($_POST['email'])) { if (TenantScopeService::isStrict() && !$tenantIds && !$defaultTenantId) { $errors[] = t('Please select at least one tenant'); } + $selectedTenantIds = $tenantIds; + $selectedRoleIds = UserService::normalizeIdInput($input['role_ids'] ?? []); + $selectedDepartmentIds = UserService::normalizeIdInput($input['department_ids'] ?? []); + $departmentOptionsByTenant = DepartmentService::groupActiveByTenantIds($selectedTenantIds); + $customFieldDefinitionsByTenant = UserCustomFieldValueService::buildDefinitionsByTenant($selectedTenantIds); + $customFieldPostedValues = [ + 'custom_field_values' => is_array($_POST['custom_field_values'] ?? null) ? $_POST['custom_field_values'] : [], + 'custom_field_values_multi' => is_array($_POST['custom_field_values_multi'] ?? null) ? $_POST['custom_field_values_multi'] : [], + ]; + if ($canEditCustomFieldValues) { + $customFieldValidationResult = UserCustomFieldValueService::validateForTenants( + $selectedTenantIds, + $_POST, + true + ); + if (!($customFieldValidationResult['ok'] ?? false)) { + $errors = array_merge($errors, $customFieldValidationResult['errors'] ?? []); + foreach (array_keys($form) as $fieldKey) { + if (!array_key_exists($fieldKey, $input)) { + continue; + } + $fieldValue = $input[$fieldKey]; + if (is_scalar($fieldValue)) { + $form[$fieldKey] = trim((string) $fieldValue); + } + } + $form['active'] = array_key_exists('active', $input) ? 1 : 0; + } + } $input['tenant_ids'] = $tenantIds; - $result = UserService::createFromAdmin($input, $currentUserId); - $form = $result['form'] ?? $form; - $errors = array_merge($errors, $result['errors'] ?? []); + $input['role_ids'] = $selectedRoleIds; + $input['department_ids'] = $selectedDepartmentIds; + $result = ['ok' => false]; + if (!$errors) { + $result = UserService::createFromAdmin($input, $currentUserId); + $form = $result['form'] ?? $form; + $errors = array_merge($errors, $result['errors'] ?? []); + } if (($result['ok'] ?? false) && !$errors) { + $createdUuid = (string) ($result['uuid'] ?? ''); + if ($canEditCustomFieldValues && $createdUuid !== '') { + $createdUser = UserService::findByUuid($createdUuid); + $createdUserId = (int) ($createdUser['id'] ?? 0); + if ($createdUserId > 0) { + $customFieldSyncResult = UserCustomFieldValueService::syncForUser( + $createdUserId, + $selectedTenantIds, + $_POST, + true + ); + if (!($customFieldSyncResult['ok'] ?? false)) { + $syncErrors = $customFieldSyncResult['errors'] ?? []; + if ($syncErrors) { + Flash::error( + implode(' | ', $syncErrors), + "admin/users/edit/{$createdUuid}", + 'user_custom_field_sync_failed' + ); + } + } + } + } $action = (string) ($_POST['action'] ?? 'create'); if ($action === 'create_close') { Flash::success('User created', 'admin/users', 'user_created'); Router::redirect('admin/users'); } else { - $uuid = (string) ($result['uuid'] ?? ''); + $uuid = $createdUuid; if ($uuid !== '') { $target = "admin/users/edit/{$uuid}"; Flash::success('User created', $target, 'user_created'); @@ -90,4 +156,11 @@ if (isset($_POST['email'])) { } } +if (!$departmentOptionsByTenant && $selectedTenantIds) { + $departmentOptionsByTenant = DepartmentService::groupActiveByTenantIds($selectedTenantIds); +} +if (!$customFieldDefinitionsByTenant && $selectedTenantIds) { + $customFieldDefinitionsByTenant = UserCustomFieldValueService::buildDefinitionsByTenant($selectedTenantIds); +} + Buffer::set('title', t('Create user')); diff --git a/pages/admin/users/create(default).phtml b/pages/admin/users/create(default).phtml index 5a8bfe0..679719f 100644 --- a/pages/admin/users/create(default).phtml +++ b/pages/admin/users/create(default).phtml @@ -18,21 +18,29 @@ ['label' => t('Create user')], ]; require templatePath('partials/app-breadcrumb.phtml'); + $titlebar = [ + 'title' => t('Create user'), + 'backHref' => 'admin/users', + 'backTitle' => t('Cancel'), + 'actions' => [ + [ + 'form' => 'user-form', + 'name' => 'action', + 'value' => 'create', + 'class' => 'secondary outline', + 'label' => t('Create'), + ], + [ + 'form' => 'user-form', + 'name' => 'action', + 'value' => 'create_close', + 'class' => 'primary', + 'label' => t('Create & close'), + ], + ], + ]; + require templatePath('partials/app-details-titlebar.phtml'); ?> -
    -

    - - -

    -
    - - -
    -
      @@ -49,6 +57,20 @@ $passwordLabel = t('Password'); $passwordLegend = t('Password'); $passwordConfirmLabel = t('Password (again)'); + $showTenants = can('users.update_assignments'); + $showRoles = $showTenants; + $showDepartments = $showTenants; + $tenants = $tenants ?? []; + $roles = $roles ?? []; + $selectedTenantIds = $selectedTenantIds ?? []; + $selectedRoleIds = $selectedRoleIds ?? []; + $selectedDepartmentIds = $selectedDepartmentIds ?? []; + $departmentOptionsByTenant = $departmentOptionsByTenant ?? []; + $showCustomFieldsTab = true; + $canEditCustomFieldValues = !empty($canEditCustomFieldValues); + $customFieldDefinitionsByTenant = $customFieldDefinitionsByTenant ?? []; + $customFieldValueMap = $customFieldValueMap ?? []; + $customFieldPostedValues = $customFieldPostedValues ?? []; require __DIR__ . '/_form.phtml'; ?> diff --git a/pages/admin/users/edit($id).php b/pages/admin/users/edit($id).php index 6720b4f..06603a4 100644 --- a/pages/admin/users/edit($id).php +++ b/pages/admin/users/edit($id).php @@ -5,6 +5,7 @@ use MintyPHP\Support\Flash; use MintyPHP\Support\Guard; use MintyPHP\I18n; use MintyPHP\Router; +use MintyPHP\DB; use MintyPHP\Repository\Access\UserRoleRepository; use MintyPHP\Repository\Tenant\UserTenantRepository; use MintyPHP\Repository\Org\UserDepartmentRepository; @@ -12,6 +13,7 @@ use MintyPHP\Repository\Access\RolePermissionRepository; use MintyPHP\Repository\Auth\PasswordResetRepository; use MintyPHP\Repository\Auth\RememberTokenRepository; use MintyPHP\Service\Auth\AuthService; +use MintyPHP\Service\CustomField\UserCustomFieldValueService; use MintyPHP\Service\Org\DepartmentService; use MintyPHP\Service\Access\RoleService; use MintyPHP\Service\Tenant\TenantService; @@ -103,11 +105,26 @@ if (!$primaryTenantId && count($selectedTenantIds) === 1) { $roles = RoleService::listActive(); $selectedRoleIds = UserRoleRepository::listRoleIdsByUserId($userId); $permissionRows = RolePermissionRepository::listPermissionsWithRolesByRoleIds($selectedRoleIds); -$departments = DepartmentService::listForUserAssignments($selectedTenantIds, []); $selectedDepartmentIds = UserDepartmentRepository::listDepartmentIdsByUserId($userId); -if ($selectedDepartmentIds) { - $departments = DepartmentService::listForUserAssignments($selectedTenantIds, $selectedDepartmentIds); +$departmentOptionsByTenant = DepartmentService::groupActiveByTenantIds($selectedTenantIds); +$canEditCustomFieldValues = $canEditUser + && (PermissionService::userHas($currentUserId, PermissionService::CUSTOM_FIELDS_EDIT_VALUES) + || ($isOwnAccount && $canUpdateSelf)); +$customFieldDefinitionsByTenant = UserCustomFieldValueService::buildDefinitionsByTenant($selectedTenantIds); +$customFieldDefinitionIds = []; +foreach ($customFieldDefinitionsByTenant as $tenantDefinitions) { + foreach ($tenantDefinitions as $definition) { + $definitionId = (int) ($definition['id'] ?? 0); + if ($definitionId > 0) { + $customFieldDefinitionIds[] = $definitionId; + } + } } +$customFieldValueMap = UserCustomFieldValueService::buildUserValueMap($userId, $customFieldDefinitionIds); +$customFieldPostedValues = [ + 'custom_field_values' => [], + 'custom_field_values_multi' => [], +]; $passwordResets = []; if ($canViewUsers || $canEditUser) { $passwordResets = PasswordResetRepository::listByUserId($userId, 25); @@ -116,6 +133,13 @@ $rememberTokens = []; if ($canViewUsers || $canEditUser) { $rememberTokens = RememberTokenRepository::listByUserId($userId, 25); } +$apiTokens = []; +$canManageApiTokens = $canEditUser + && PermissionService::userHas($currentUserId, PermissionService::API_TOKENS_MANAGE); +if ($canViewUsers || $canEditUser) { + $apiTokens = \MintyPHP\Service\Auth\ApiTokenService::listForUser($userId); +} +$showApiTokens = !empty($apiTokens) || $canManageApiTokens; // Keep initial $isOwnAccount/$canEditUser values for view permissions. if (isset($_POST['email'])) { @@ -127,28 +151,77 @@ if (isset($_POST['email'])) { $form = $result['form'] ?? $form; $errors = $result['errors'] ?? []; $tenantIds = UserService::normalizeIdInput($_POST['tenant_ids'] ?? []); - if ($allowedTenantIds) { + $existingTenantIds = UserTenantRepository::listTenantIdsByUserId($userId); + $tenantIdsForSync = $tenantIds; + if ($canEditAssignments && $allowedTenantIds) { $tenantIds = array_values(array_intersect($tenantIds, $allowedTenantIds)); - } elseif (!$canManageTenants && TenantScopeService::isStrict()) { + $tenantIdsForSync = TenantScopeService::mergeTenantIdsPreservingOutOfScope( + $tenantIds, + $existingTenantIds, + $allowedTenantIds + ); + } elseif ($canEditAssignments && !$canManageTenants && TenantScopeService::isStrict()) { $tenantIds = []; + $tenantIdsForSync = $existingTenantIds; + } + if (!$canEditAssignments) { + $tenantIds = UserTenantRepository::listTenantIdsByUserId($userId); + if ($allowedTenantIds) { + $tenantIds = array_values(array_intersect($tenantIds, $allowedTenantIds)); + } + $tenantIdsForSync = $tenantIds; } $selectedTenantIds = $tenantIds; $primaryTenantId = (int) ($_POST['primary_tenant_id'] ?? 0); $selectedRoleIds = UserService::normalizeIdInput($_POST['role_ids'] ?? []); $selectedDepartmentIds = UserService::normalizeIdInput($_POST['department_ids'] ?? []); - $departments = DepartmentService::listForUserAssignments($selectedTenantIds, $selectedDepartmentIds); + $departmentOptionsByTenant = DepartmentService::groupActiveByTenantIds($selectedTenantIds); + $customFieldDefinitionsByTenant = UserCustomFieldValueService::buildDefinitionsByTenant($selectedTenantIds); + $customFieldDefinitionIds = []; + foreach ($customFieldDefinitionsByTenant as $tenantDefinitions) { + foreach ($tenantDefinitions as $definition) { + $definitionId = (int) ($definition['id'] ?? 0); + if ($definitionId > 0) { + $customFieldDefinitionIds[] = $definitionId; + } + } + } + $customFieldValueMap = UserCustomFieldValueService::buildUserValueMap($userId, $customFieldDefinitionIds); + $customFieldPostedValues = [ + 'custom_field_values' => is_array($_POST['custom_field_values'] ?? null) ? $_POST['custom_field_values'] : [], + 'custom_field_values_multi' => is_array($_POST['custom_field_values_multi'] ?? null) ? $_POST['custom_field_values_multi'] : [], + ]; if ($result['ok'] ?? false) { if ($canEditAssignments) { - UserService::syncTenants($userId, $tenantIds); - UserService::syncRoles($userId, $selectedRoleIds); - UserService::syncDepartments($userId, $selectedDepartmentIds); + $db = DB::handle(); + $db->begin_transaction(); + try { + UserService::syncTenants($userId, $tenantIdsForSync, false); + UserService::syncRoles($userId, $selectedRoleIds, false); + UserService::syncDepartments($userId, $selectedDepartmentIds, false); + UserService::bumpAuthzVersion($userId); + $db->commit(); + } catch (\Throwable $exception) { + $db->rollback(); + $errors[] = t('User assignments can not be updated'); + } // Update session if editing own account (tenant assignments changed) - if ($currentUserId === $userId) { + if ($currentUserId === $userId && !$errors) { AuthService::loadTenantDataIntoSession($userId); } } + $customFieldSyncResult = UserCustomFieldValueService::syncForUser( + $userId, + $selectedTenantIds, + $_POST, + $canEditCustomFieldValues + ); + if (!($customFieldSyncResult['ok'] ?? false)) { + $errors = array_merge($errors, $customFieldSyncResult['errors'] ?? []); + $customFieldValueMap = UserCustomFieldValueService::buildUserValueMap($userId, $customFieldDefinitionIds); + } if ($currentUserId === $userId && isset($form['theme'])) { $themes = appThemes(); $themeValue = strtolower(trim((string) ($form['theme'] ?? ''))); @@ -158,13 +231,15 @@ if (isset($_POST['email'])) { $_SESSION['user']['locale'] = $form['locale']; I18n::$locale = $form['locale']; } - $action = (string) ($_POST['action'] ?? 'save'); - if ($action === 'save_close') { - Flash::success('User updated', 'admin/users', 'user_updated'); - Router::redirect('admin/users'); - } else { - Flash::success('User updated', "admin/users/edit/{$uuid}", 'user_updated'); - Router::redirect("admin/users/edit/{$uuid}"); + if (!$errors) { + $action = (string) ($_POST['action'] ?? 'save'); + if ($action === 'save_close') { + Flash::success('User updated', 'admin/users', 'user_updated'); + Router::redirect('admin/users'); + } else { + Flash::success('User updated', "admin/users/edit/{$uuid}", 'user_updated'); + Router::redirect("admin/users/edit/{$uuid}"); + } } } } diff --git a/pages/admin/users/edit(default).phtml b/pages/admin/users/edit(default).phtml index 496ed6e..a8d8939 100644 --- a/pages/admin/users/edit(default).phtml +++ b/pages/admin/users/edit(default).phtml @@ -20,8 +20,26 @@ $canViewUsers = can('users.view'); $canViewAddressBook = can('address_book.view'); $canViewUserMeta = can('users.view_meta'); $canViewUserAudit = can('users.view_audit'); -$hasDetailsActions = $canViewAddressBook || !empty($canEditUser) || can('users.delete'); +$canAccessPdf = can('users.access_pdf'); +$canEditUser = !empty($canEditUser); +$canDeleteUser = can('users.delete') && !$isOwnAccount; $hideNavigation = $isOwnAccount && !$canViewUsers; +$lastLoginProvider = strtolower((string) ($values['last_login_provider'] ?? ($user['last_login_provider'] ?? ''))); +$lastLoginAt = (string) ($values['last_login_at'] ?? ($user['last_login_at'] ?? '')); +$lastLoginProviderLabel = '-'; +$lastLoginProviderVariant = 'neutral'; +if ($lastLoginAt !== '') { + if ($lastLoginProvider === 'microsoft') { + $lastLoginProviderLabel = t('Microsoft'); + $lastLoginProviderVariant = 'primary'; + } elseif ($lastLoginProvider === 'local') { + $lastLoginProviderLabel = t('Local'); + $lastLoginProviderVariant = 'secondary'; + } else { + $lastLoginProviderLabel = t('Unknown'); + $lastLoginProviderVariant = 'warning'; + } +} ?> @@ -37,72 +55,63 @@ $hideNavigation = $isOwnAccount && !$canViewUsers; require templatePath('partials/app-breadcrumb.phtml'); ?> -
      -

      - - - - -

      -
      - - - - - - - - - - -
      -
      + 'link', + 'href' => lurl('address-book/view/' . ($values['uuid'] ?? '')), + 'label' => t('View in address book'), + ]; + } + if ($canEditUser) { + $asideActions[] = [ + 'type' => 'form', + 'method' => 'post', + 'action' => 'admin/users/send-access/' . ($values['uuid'] ?? ''), + 'confirm' => t('Send access email to this user?'), + 'label' => t('Send access'), + ]; + } + if ($canAccessPdf) { + $asideActions[] = [ + 'type' => 'form', + 'method' => 'post', + 'action' => 'admin/users/access-pdf', + 'label' => t('Access PDF'), + 'target' => '_blank', + 'fields' => [ + 'uuid' => (string) ($values['uuid'] ?? ''), + ], + ]; + } + $titlebarActions = []; + if ($canEditUser) { + $titlebarActions[] = [ + 'form' => 'user-form', + 'name' => 'action', + 'value' => 'save', + 'class' => 'secondary outline', + 'label' => t('Save'), + ]; + if (!$hideNavigation) { + $titlebarActions[] = [ + 'form' => 'user-form', + 'name' => 'action', + 'value' => 'save_close', + 'class' => 'primary', + 'label' => t('Save & close'), + ]; + } + } + $titlebar = [ + 'title' => $titleText, + 'backHref' => $hideNavigation ? '' : 'admin/users', + 'backTitle' => t('Cancel'), + 'actions' => $titlebarActions, + ]; + require templatePath('partials/app-details-titlebar.phtml'); + ?>
      @@ -128,9 +137,9 @@ $hideNavigation = $isOwnAccount && !$canViewUsers; $roles = $roles ?? []; $selectedRoleIds = $selectedRoleIds ?? []; $showDepartments = !empty($canEditAssignments); - $departments = $departments ?? []; + $departmentOptionsByTenant = $departmentOptionsByTenant ?? []; $selectedDepartmentIds = $selectedDepartmentIds ?? []; - $isReadOnly = !(!empty($canEditUser)); + $isReadOnly = !$canEditUser; $canEditAssignments = $canEditAssignments ?? false; $permissionRows = $permissionRows ?? []; $showPermissions = !empty($permissionRows) && can('permissions.view'); @@ -138,8 +147,49 @@ $hideNavigation = $isOwnAccount && !$canViewUsers; $showPasswordResets = !empty($passwordResets); $rememberTokens = $rememberTokens ?? []; $showRememberTokens = !empty($rememberTokens); + $showDangerZone = $canDeleteUser; + $dangerZoneDeleteFormId = $showDangerZone ? 'user-delete-form' : ''; + $dangerZoneWarning = t('This will permanently delete this user.'); + $dangerZoneActionLabel = t('Delete user'); + $showTokenDangerAction = $canEditUser; + $tokenDangerFormId = $showTokenDangerAction ? 'user-forget-tokens-form' : ''; + $tokenDangerWarning = t('This will mark all remember-me tokens as expired. Users will need to sign in again.'); + $tokenDangerActionLabel = t('Clear login tokens'); + $showCustomFieldsTab = true; + $canEditCustomFieldValues = !empty($canEditCustomFieldValues); + $customFieldDefinitionsByTenant = $customFieldDefinitionsByTenant ?? []; + $customFieldValueMap = $customFieldValueMap ?? []; + $customFieldPostedValues = $customFieldPostedValues ?? []; require __DIR__ . '/_form.phtml'; ?> + + + + + + + + + diff --git a/pages/admin/users/index($slug).php b/pages/admin/users/index($slug).php new file mode 100644 index 0000000..65d2509 --- /dev/null +++ b/pages/admin/users/index($slug).php @@ -0,0 +1,15 @@ + 1; @@ -31,6 +33,11 @@ require templatePath('partials/app-breadcrumb.phtml'); + + + -

      -

      - ? -

      - - +

      + +

      + + + + +
      + +

      + +

      + +
      + + +
      + + + + + +

      + +

      +

      + +

      +

      + ? +

      + +
      + + + + + + + + + + + + + +
      + +
      + +
      +
      + + + +

      + +

      + +
      + +

      ?

      diff --git a/pages/auth/microsoft/callback().php b/pages/auth/microsoft/callback().php new file mode 100644 index 0000000..50d1bf2 --- /dev/null +++ b/pages/auth/microsoft/callback().php @@ -0,0 +1,57 @@ +
      -

      diff --git a/pages/auth/reset(login).phtml b/pages/auth/reset(login).phtml index 1986be5..0b1a5a5 100644 --- a/pages/auth/reset(login).phtml +++ b/pages/auth/reset(login).phtml @@ -2,7 +2,7 @@ /** * @var array $errors - * @var array $passwordHints + * @var array $passwordHints * @var int $passwordMinLength */ @@ -14,7 +14,6 @@ Buffer::set('title', t('Reset password'));
      -

      @@ -30,16 +29,16 @@ Buffer::set('title', t('Reset password'));
      + data-min-length="">
        diff --git a/pages/auth/tenant-avatar-file().php b/pages/auth/tenant-avatar-file().php new file mode 100644 index 0000000..58af080 --- /dev/null +++ b/pages/auth/tenant-avatar-file().php @@ -0,0 +1,33 @@ +
        -

        diff --git a/pages/auth/verify-email(login).phtml b/pages/auth/verify-email(login).phtml index 6f842f3..9d72fff 100644 --- a/pages/auth/verify-email(login).phtml +++ b/pages/auth/verify-email(login).phtml @@ -14,7 +14,6 @@ Buffer::set('title', t('Verify email'));
        -

        diff --git a/pages/error/forbidden(error).phtml b/pages/error/forbidden(error).phtml index 081843e..348a96f 100644 --- a/pages/error/forbidden(error).phtml +++ b/pages/error/forbidden(error).phtml @@ -2,13 +2,12 @@ use MintyPHP\Http\Request; -header('HTTP/1.1 403 Forbidden'); +http_response_code(403); -$path = Request::pathWithQuery(); +$path = Request::safeReturnTarget($_GET['url'] ?? ''); $pathDisplay = $path === '' ? '/' : '/' . $path; $home = lurl(''); -$backTarget = Request::safeReturnTarget(''); -$backUrl = $backTarget !== '' ? lurl($backTarget) : $home; +$backUrl = $path !== '' ? lurl($path) : $home; ?>
        diff --git a/pages/error/method_not_allowed(error).phtml b/pages/error/method_not_allowed(error).phtml index a1da22d..929027d 100644 --- a/pages/error/method_not_allowed(error).phtml +++ b/pages/error/method_not_allowed(error).phtml @@ -1,6 +1,6 @@ Submitting data using method GET is not allowed \ No newline at end of file diff --git a/pages/error/not_found(error).phtml b/pages/error/not_found(error).phtml index f67ccf4..ead3761 100644 --- a/pages/error/not_found(error).phtml +++ b/pages/error/not_found(error).phtml @@ -2,12 +2,12 @@ use MintyPHP\Http\Request; -header('HTTP/1.1 404 Not Found'); +http_response_code(404); -$path = Request::path(); +$path = Request::safeReturnTarget($_GET['url'] ?? ''); +$pathDisplay = $path === '' ? '/' : '/' . $path; $home = lurl(''); -$backTarget = Request::safeReturnTarget(''); -$backUrl = $backTarget !== '' ? lurl($backTarget) : $home; +$backUrl = $path !== '' ? lurl($path) : $home; ?>
        @@ -15,7 +15,7 @@ $backUrl = $backTarget !== '' ? lurl($backTarget) : $home;

        -

        :

        +

        :

        diff --git a/pages/flash/dismiss($id).php b/pages/flash/dismiss($id).php index cfff6fe..439f7d5 100644 --- a/pages/flash/dismiss($id).php +++ b/pages/flash/dismiss($id).php @@ -8,7 +8,10 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { Router::redirect(''); } -Flash::dismiss((string) $id); +$flashId = isset($id) ? trim((string) $id) : ''; +if ($flashId !== '') { + Flash::dismiss($flashId); +} $target = Request::safeReturnTarget($_POST['return'] ?? ''); Router::redirect($target); diff --git a/pages/help/hotkeys(default).phtml b/pages/help/hotkeys(default).phtml index 6e303b0..1c8c309 100644 --- a/pages/help/hotkeys(default).phtml +++ b/pages/help/hotkeys(default).phtml @@ -1,3 +1,10 @@ + +
        @@ -15,35 +22,6 @@
        - t('Open search'), - 'mac' => 'Cmd + K', - 'win' => 'Ctrl + K', - ], - [ - 'label' => t('Toggle sidebar'), - 'mac' => 'Cmd + B', - 'win' => 'Ctrl + B', - ], - [ - 'label' => t('Switch sidebar section'), - 'mac' => 'Cmd + 1 … 0', - 'win' => 'Ctrl + 1 … 0', - ], - [ - 'label' => t('Back'), - 'mac' => 'Alt + Left', - 'win' => 'Alt + Left', - ], - [ - 'label' => t('Forward'), - 'mac' => 'Alt + Right', - 'win' => 'Alt + Right', - ], - ]; - ?> @@ -53,11 +31,19 @@ - + + - - - + + + diff --git a/pages/page/index(default).phtml b/pages/page/index(page).phtml similarity index 100% rename from pages/page/index(default).phtml rename to pages/page/index(page).phtml diff --git a/pages/search/data().php b/pages/search/data().php index a7fc4e3..8851650 100644 --- a/pages/search/data().php +++ b/pages/search/data().php @@ -80,9 +80,19 @@ foreach ($resources as $resource) { } } +$items = array_merge($items, SearchConfig::hotkeyResultItems($query)); +if (PermissionService::userHas($userId, PermissionService::DOCS_VIEW)) { + $items = array_merge($items, SearchConfig::docsResultItems($query)); +} + $scoreQuery = SearchConfig::normalizeScoreQuery($query); $queryLower = mb_strtolower($scoreQuery); $scoreItem = static function (array $item) use ($queryLower): int { + $manualScore = (int) ($item['search_score'] ?? 0); + if ($manualScore > 0) { + return $manualScore; + } + $title = mb_strtolower((string) ($item['title'] ?? '')); $description = mb_strtolower((string) ($item['description'] ?? '')); if ($title === $queryLower) { diff --git a/phpstan.neon b/phpstan.neon index ae4eb5f..6c44bc4 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,13 +1,12 @@ parameters: level: 5 - phpVersion: 80500 + phpVersion: 80499 scanFiles: - web/index.php paths: - config - lib - pages - - templates + - tests fileExtensions: - php - - phtml diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..2f81b59 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,11 @@ + + + + + tests + + + diff --git a/templates/.DS_Store b/templates/.DS_Store deleted file mode 100644 index c09e888..0000000 Binary files a/templates/.DS_Store and /dev/null differ diff --git a/templates/default.phtml b/templates/default.phtml index 5514b1a..2b62373 100644 --- a/templates/default.phtml +++ b/templates/default.phtml @@ -4,8 +4,6 @@ use MintyPHP\Buffer; $defaultTitle = appTitle(); $user = $_SESSION['user'] ?? []; -$isLoggedIn = !empty($user['id']); -$themes = appThemes(); $theme = currentTheme(); $primaryVars = appPrimaryCssVars(); $pageTitle = $defaultTitle; @@ -18,7 +16,7 @@ if ($bufferTitle !== '') { ?> -style=""> +style="" > @@ -31,53 +29,17 @@ if ($bufferTitle !== '') { - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +
        - - - - + +
        - - - +
        @@ -91,4 +53,4 @@ if ($bufferTitle !== '') { - + \ No newline at end of file diff --git a/templates/error.phtml b/templates/error.phtml index 5ebf3d7..8da6709 100644 --- a/templates/error.phtml +++ b/templates/error.phtml @@ -2,12 +2,30 @@ use MintyPHP\Buffer; -Buffer::start('html'); +$defaultTitle = appTitle(); +$primaryVars = appPrimaryCssVars(); +$theme = appDefaultTheme(); +$statusCode = http_response_code() ?: 500; ?> -

        ERROR:

        - +style=""> -Buffer::end('html'); + + + + <?php e($statusCode . ' - ' . $defaultTitle); ?> + + + + -require __DIR__ . '/default.phtml'; + +
        +
        +
        + +
        + + + diff --git a/templates/login.phtml b/templates/login.phtml index b688454..78f816e 100644 --- a/templates/login.phtml +++ b/templates/login.phtml @@ -5,7 +5,6 @@ use MintyPHP\Buffer; $defaultTitle = appTitle(); $primaryVars = appPrimaryCssVars(); $theme = appDefaultTheme(); -$user = $_SESSION['user'] ?? []; $pageTitle = $defaultTitle; ob_start(); Buffer::get('title'); @@ -31,27 +30,21 @@ if ($bufferTitle !== '') { - - - - - - - - - - - + +
        +
        +
        +
        diff --git a/templates/page.phtml b/templates/page.phtml new file mode 100644 index 0000000..9d1120d --- /dev/null +++ b/templates/page.phtml @@ -0,0 +1,58 @@ + + +style=""> + + + + <?php e($pageTitle); ?> + + + + + + + + + + + + +
        + +
        +
        + + +
        +
        + +
        + + + diff --git a/templates/partials/.DS_Store b/templates/partials/.DS_Store deleted file mode 100644 index 0676a32..0000000 Binary files a/templates/partials/.DS_Store and /dev/null differ diff --git a/templates/partials/app-danger-zone-delete-field.phtml b/templates/partials/app-danger-zone-delete-field.phtml new file mode 100644 index 0000000..30edd04 --- /dev/null +++ b/templates/partials/app-danger-zone-delete-field.phtml @@ -0,0 +1,31 @@ + +
        +

        +
        +
        + +

        + +
        diff --git a/templates/partials/app-details-aside-actions.phtml b/templates/partials/app-details-aside-actions.phtml new file mode 100644 index 0000000..706c5f7 --- /dev/null +++ b/templates/partials/app-details-aside-actions.phtml @@ -0,0 +1,82 @@ +> $asideActions + */ + +use MintyPHP\Session; + +$asideActions = is_array($asideActions ?? null) ? $asideActions : []; +if (!$asideActions) { + return; +} +?> + +
        + +
        +
        + + + + + +
        target="" + onsubmit="return confirm('');" + > + + + + $fieldValue): ?> + + + + + + + + + + + target="" + rel="" + > + + + + + +
        +
        +
        diff --git a/templates/partials/app-details-aside-audit.phtml b/templates/partials/app-details-aside-audit.phtml new file mode 100644 index 0000000..4e8ddfc --- /dev/null +++ b/templates/partials/app-details-aside-audit.phtml @@ -0,0 +1,98 @@ + + + +
        name="" open> + +
        +
        +
        + +

        +
        +
        + +

        +
        +
        +
        +
        + +

        +
        +
        + +

        +
        +
        + +
        +
        + +

        +
        +
        + +

        +
        +
        + +
        diff --git a/templates/partials/app-details-aside-ids.phtml b/templates/partials/app-details-aside-ids.phtml new file mode 100644 index 0000000..ff39640 --- /dev/null +++ b/templates/partials/app-details-aside-ids.phtml @@ -0,0 +1,68 @@ + + +
        name="" open> + +
        + +
        + +
        + +

        + + + +

        +
        +
        + +

        + + + +

        +
        + +
        + +

        + + + +

        +
        +
        + +

        + + + +

        +
        + +
        + +
        + diff --git a/templates/partials/app-details-titlebar.phtml b/templates/partials/app-details-titlebar.phtml new file mode 100644 index 0000000..870892e --- /dev/null +++ b/templates/partials/app-details-titlebar.phtml @@ -0,0 +1,118 @@ + +
        +

        + + + + +

        +
        + + + + + + + + +
        +
        diff --git a/templates/partials/app-aside-icon-bar.phtml b/templates/partials/app-main-aside-icon-bar.phtml similarity index 100% rename from templates/partials/app-aside-icon-bar.phtml rename to templates/partials/app-main-aside-icon-bar.phtml diff --git a/templates/partials/app-aside.phtml b/templates/partials/app-main-aside.phtml similarity index 52% rename from templates/partials/app-aside.phtml rename to templates/partials/app-main-aside.phtml index 06e8bc9..9d0c17f 100644 --- a/templates/partials/app-aside.phtml +++ b/templates/partials/app-main-aside.phtml @@ -1,5 +1,6 @@ t('System'), - 'visible' => $hasSystemSection, + 'label' => t('Automation & integrations'), + 'visible' => $hasAutomationSection, + 'items' => [ + [ + 'label' => t('Imports'), + 'path' => 'admin/imports', + 'active' => navActive('admin/imports', true), + 'visible' => $canViewImports, + 'withTenant' => false, + ], + [ + 'label' => t('Scheduled jobs'), + 'path' => 'admin/scheduled-jobs', + 'active' => navActive('admin/scheduled-jobs', true), + 'visible' => $canViewJobs, + 'withTenant' => false, + ], + [ + 'label' => t('API docs'), + 'path' => 'admin/api-docs', + 'active' => navActive('admin/api-docs', true), + 'visible' => $canViewApiDocs, + 'withTenant' => false, + ], + ], + ], + [ + 'label' => t('Monitoring'), + 'visible' => $hasMonitoringSection, 'items' => [ [ 'label' => t('Statistics'), @@ -76,6 +114,12 @@ $navSections = [ 'visible' => $canViewStats, 'withTenant' => false, ], + ], + ], + [ + 'label' => t('Logs'), + 'visible' => $hasLogsSection, + 'items' => [ [ 'label' => t('Mail logs'), 'path' => 'admin/mail-log', @@ -83,6 +127,33 @@ $navSections = [ 'visible' => $canViewMailLog, 'withTenant' => false, ], + [ + 'label' => t('API audit'), + 'path' => 'admin/api-audit', + 'active' => navActive('admin/api-audit', true), + 'visible' => $canViewApiAudit, + 'withTenant' => false, + ], + [ + 'label' => t('Import logs'), + 'path' => 'admin/import-audit', + 'active' => navActive('admin/import-audit', true), + 'visible' => $canViewImportsAudit, + 'withTenant' => false, + ], + [ + 'label' => t('User lifecycle logs'), + 'path' => 'admin/user-lifecycle-audit', + 'active' => navActive('admin/user-lifecycle-audit', true), + 'visible' => $canViewUserLifecycleAudit, + 'withTenant' => false, + ], + ], + ], + [ + 'label' => t('System'), + 'visible' => $hasSystemSection, + 'items' => [ [ 'label' => t('Settings'), 'path' => 'admin/settings', @@ -90,6 +161,13 @@ $navSections = [ 'visible' => $canViewSettings, 'withTenant' => false, ], + [ + 'label' => t('Documentation'), + 'path' => 'admin/docs/erste-schritte', + 'active' => navActive('admin/docs', true), + 'visible' => $canViewDocs, + 'withTenant' => false, + ], ], ], ]; @@ -141,9 +219,62 @@ $tenantUuid = $currentTenant['uuid'] ?? ''; $tenantName = $currentTenant['description'] ?? ''; $hasTenantAvatar = $tenantUuid && TenantAvatarService::hasAvatar($tenantUuid); $addressBookUrl = lurl('address-book'); -$activeAddressTenants = array_filter(array_map('trim', explode(',', (string) ($_GET['tenants'] ?? '')))); -$activeAddressDepartments = array_filter(array_map('intval', explode(',', (string) ($_GET['departments'] ?? '')))); +$activeAddressSearch = trim((string) ($_GET['search'] ?? '')); +$normalizeStringList = static function ($value): array { + $raw = is_array($value) ? $value : explode(',', (string) $value); + $list = array_filter(array_map('trim', $raw), static fn ($item) => $item !== ''); + $list = array_values(array_unique($list)); + sort($list, SORT_STRING); + return $list; +}; +$normalizeIntList = static function ($value): array { + $raw = is_array($value) ? $value : explode(',', (string) $value); + $list = array_values(array_unique(array_map('intval', $raw))); + $list = array_values(array_filter($list, static fn ($item) => $item > 0)); + sort($list, SORT_NUMERIC); + return $list; +}; +$activeAddressTenants = $normalizeStringList($_GET['tenants'] ?? ''); +$activeAddressDepartments = $normalizeIntList($_GET['departments'] ?? ''); +$activeAddressRoles = $normalizeIntList($_GET['roles'] ?? ''); +$normalizeCustomFieldQuery = static function (array $query) use ($normalizeIntList): array { + $normalized = []; + foreach ($query as $rawKey => $rawValue) { + $key = strtolower(trim((string) $rawKey)); + if ($key === '') { + continue; + } + if (preg_match('/^cf_[a-f0-9-]{36}$/', $key)) { + $value = trim((string) $rawValue); + if ($value !== '') { + $normalized[$key] = $value; + } + continue; + } + if (preg_match('/^cfm_[a-f0-9-]{36}$/', $key)) { + $ids = $normalizeIntList($rawValue); + if ($ids) { + $normalized[$key] = $ids; + } + continue; + } + if (preg_match('/^cfd_[a-f0-9-]{36}_(from|to)$/', $key)) { + $value = trim((string) $rawValue); + $dt = \DateTimeImmutable::createFromFormat('Y-m-d', $value); + if ($dt && $dt->format('Y-m-d') === $value) { + $normalized[$key] = $value; + } + } + } + ksort($normalized, SORT_STRING); + return $normalized; +}; +$activeAddressCustomFilters = $normalizeCustomFieldQuery($_GET); $peopleGroups = $_SESSION['available_departments_by_tenant'] ?? []; +$savedAddressFilters = $_SESSION['address_book_saved_filters'] ?? []; +$savedAddressFilters = is_array($savedAddressFilters) ? $savedAddressFilters : []; +$csrfKey = Session::$csrfSessionKey; +$csrfToken = $_SESSION[$csrfKey] ?? ''; ?>
        + + + + +
        + Login QR + +

        Login per QR-Code

        +

        Scanne den Code, um die Login-Seite zu öffnen.

        +

        {{login_url}}

        +
        +

        Erstellt: {{generated_at}}

        +

        Viele Grüße
        {{app_name}}

        + {{email_footer}} + + diff --git a/templates/pdfs/de/partials/footer.html b/templates/pdfs/de/partials/footer.html new file mode 100644 index 0000000..a05ed5e --- /dev/null +++ b/templates/pdfs/de/partials/footer.html @@ -0,0 +1,16 @@ + + + + +
        + Impressum +  ·  + Datenschutz +
        + {{app_name}} · Dieses PDF wurde automatisch erstellt. + + + + + + diff --git a/templates/pdfs/de/partials/header.html b/templates/pdfs/de/partials/header.html new file mode 100644 index 0000000..40ca4a7 --- /dev/null +++ b/templates/pdfs/de/partials/header.html @@ -0,0 +1,14 @@ + + + + +
        + + + + + + + + + + +
        +
        + {{app_name}} + {{app_name}} +
        +
        diff --git a/templates/pdfs/en/access_info.html b/templates/pdfs/en/access_info.html new file mode 100644 index 0000000..fc07db7 --- /dev/null +++ b/templates/pdfs/en/access_info.html @@ -0,0 +1,33 @@ + + + + + {{pdf_title}} + + + {{email_header}} +

        {{greeting}}

        +

        Your access has been set up.

        +

        Username: {{username}}

        +

        Go to login

        +

        + If you do not know your password, you can reset it here: + Forgot password +

        + + + + + +
        + Login QR + +

        Login via QR code

        +

        Scan the code to open the login page.

        +

        {{login_url}}

        +
        +

        Generated: {{generated_at}}

        +

        Best regards
        {{app_name}}

        + {{email_footer}} + + diff --git a/templates/pdfs/en/partials/footer.html b/templates/pdfs/en/partials/footer.html new file mode 100644 index 0000000..25e6eca --- /dev/null +++ b/templates/pdfs/en/partials/footer.html @@ -0,0 +1,16 @@ +
        +
        + Imprint +  ·  + Privacy +
        + {{app_name}} · This PDF was generated automatically. +
        +
        diff --git a/templates/pdfs/en/partials/header.html b/templates/pdfs/en/partials/header.html new file mode 100644 index 0000000..40ca4a7 --- /dev/null +++ b/templates/pdfs/en/partials/header.html @@ -0,0 +1,14 @@ + + +
        + + + + + + \n"},cC.thead_close=function(){return"\n"},cC.tbody_open=function(){return"\n"},cC.tbody_close=function(){return"\n"},cC.tr_open=function(){return""},cC.tr_close=function(){return"\n"},cC.th_open=function(s,o){var i=s[o];return""},cC.th_close=function(){return""},cC.td_open=function(s,o){var i=s[o];return""},cC.td_close=function(){return""},cC.strong_open=function(){return""},cC.strong_close=function(){return""},cC.em_open=function(){return""},cC.em_close=function(){return""},cC.del_open=function(){return""},cC.del_close=function(){return""},cC.ins_open=function(){return""},cC.ins_close=function(){return""},cC.mark_open=function(){return""},cC.mark_close=function(){return""},cC.sub=function(s,o){return""+escapeHtml(s[o].content)+""},cC.sup=function(s,o){return""+escapeHtml(s[o].content)+""},cC.hardbreak=function(s,o,i){return i.xhtmlOut?"
        \n":"
        \n"},cC.softbreak=function(s,o,i){return i.breaks?i.xhtmlOut?"
        \n":"
        \n":"\n"},cC.text=function(s,o){return escapeHtml(s[o].content)},cC.htmlblock=function(s,o){return s[o].content},cC.htmltag=function(s,o){return s[o].content},cC.abbr_open=function(s,o){return''},cC.abbr_close=function(){return""},cC.footnote_ref=function(s,o){var i=Number(s[o].id+1).toString(),u="fnref"+i;return s[o].subId>0&&(u+=":"+s[o].subId),'['+i+"]"},cC.footnote_block_open=function(s,o,i){return(i.xhtmlOut?'
        \n':'
        \n')+'
        \n
          \n'},cC.footnote_block_close=function(){return"
        \n
        \n"},cC.footnote_open=function(s,o){return'
      • '},cC.footnote_close=function(){return"
      • \n"},cC.footnote_anchor=function(s,o){var i="fnref"+Number(s[o].id+1).toString();return s[o].subId>0&&(i+=":"+s[o].subId),' '},cC.dl_open=function(){return"
        \n"},cC.dt_open=function(){return"
        "},cC.dd_open=function(){return"
        "},cC.dl_close=function(){return"
        \n"},cC.dt_close=function(){return"\n"},cC.dd_close=function(){return"\n"};var uC=cC.getBreak=function getBreak(s,o){return(o=nextToken(s,o))1)break;if(41===i&&--u<0)break;o++}return w!==o&&(_=unescapeMd(s.src.slice(w,o)),!!s.parser.validateLink(_)&&(s.linkContent=_,s.pos=o,!0))}function parseLinkTitle(s,o){var i,u=o,_=s.posMax,w=s.src.charCodeAt(o);if(34!==w&&39!==w&&40!==w)return!1;for(o++,40===w&&(w=41);o<_;){if((i=s.src.charCodeAt(o))===w)return s.pos=o+1,s.linkContent=unescapeMd(s.src.slice(u+1,o)),!0;92===i&&o+1<_?o+=2:o++}return!1}function normalizeReference(s){return s.trim().replace(/\s+/g," ").toUpperCase()}function parseReference(s,o,i,u){var _,w,x,C,j,L,B,$,V;if(91!==s.charCodeAt(0))return-1;if(-1===s.indexOf("]:"))return-1;if((w=parseLinkLabel(_=new StateInline(s,o,i,u,[]),0))<0||58!==s.charCodeAt(w+1))return-1;for(C=_.posMax,x=w+2;x=s.length)&&!yC.test(s[o])}function replaceAt(s,o,i){return s.substr(0,o)+i+s.substr(o+1)}var vC=[["block",function block(s){s.inlineMode?s.tokens.push({type:"inline",content:s.src.replace(/\n/g," ").trim(),level:0,lines:[0,1],children:[]}):s.block.parse(s.src,s.options,s.env,s.tokens)}],["abbr",function abbr(s){var o,i,u,_,w=s.tokens;if(!s.inlineMode)for(o=1,i=w.length-1;o0?x[o].count:1,u=0;u<_;u++)s.tokens.push({type:"footnote_anchor",id:o,subId:u,level:B});w&&s.tokens.push(w),s.tokens.push({type:"footnote_close",level:--B})}s.tokens.push({type:"footnote_block_close",level:--B})}}],["abbr2",function abbr2(s){var o,i,u,_,w,x,C,j,L,B,$,V,U=s.tokens;if(s.env.abbreviations)for(s.env.abbrRegExp||(V="(^|["+pC.split("").map(regEscape).join("")+"])("+Object.keys(s.env.abbreviations).map((function(s){return s.substr(1)})).sort((function(s,o){return o.length-s.length})).map(regEscape).join("|")+")($|["+pC.split("").map(regEscape).join("")+"])",s.env.abbrRegExp=new RegExp(V,"g")),B=s.env.abbrRegExp,i=0,u=U.length;i=0;o--)if("text"===(w=_[o]).type){for(j=0,x=w.content,B.lastIndex=0,L=w.level,C=[];$=B.exec(x);)B.lastIndex>j&&C.push({type:"text",content:x.slice(j,$.index+$[1].length),level:L}),C.push({type:"abbr_open",title:s.env.abbreviations[":"+$[2]],level:L++}),C.push({type:"text",content:$[2],level:L}),C.push({type:"abbr_close",level:--L}),j=B.lastIndex-$[3].length;C.length&&(j=0;w--)if("inline"===s.tokens[w].type)for(o=(_=s.tokens[w].children).length-1;o>=0;o--)"text"===(i=_[o]).type&&(u=replaceScopedAbbr(u=i.content),hC.test(u)&&(u=u.replace(/\+-/g,"±").replace(/\.{2,}/g,"…").replace(/([?!])…/g,"$1..").replace(/([?!]){4,}/g,"$1$1$1").replace(/,{2,}/g,",").replace(/(^|[^-])---([^-]|$)/gm,"$1—$2").replace(/(^|\s)--(\s|$)/gm,"$1–$2").replace(/(^|[^-\s])--([^-\s]|$)/gm,"$1–$2")),i.content=u)}],["smartquotes",function smartquotes(s){var o,i,u,_,w,x,C,j,L,B,$,V,U,z,Y,Z,ee;if(s.options.typographer)for(ee=[],Y=s.tokens.length-1;Y>=0;Y--)if("inline"===s.tokens[Y].type)for(Z=s.tokens[Y].children,ee.length=0,o=0;o=0&&!(ee[U].level<=C);U--);ee.length=U+1,w=0,x=(u=i.content).length;e:for(;w=0&&(B=ee[U],!(ee[U].level=(_=s.eMarks[o])||42!==(i=s.src.charCodeAt(u++))&&45!==i&&43!==i||u<_&&32!==s.src.charCodeAt(u)?-1:u}function skipOrderedListMarker(s,o){var i,u=s.bMarks[o]+s.tShift[o],_=s.eMarks[o];if(u+1>=_)return-1;if((i=s.src.charCodeAt(u++))<48||i>57)return-1;for(;;){if(u>=_)return-1;if(!((i=s.src.charCodeAt(u++))>=48&&i<=57)){if(41===i||46===i)break;return-1}}return u<_&&32!==s.src.charCodeAt(u)?-1:u}Core.prototype.process=function(s){var o,i,u;for(o=0,i=(u=this.ruler.getRules("")).length;o=this.eMarks[s]},StateBlock.prototype.skipEmptyLines=function skipEmptyLines(s){for(var o=this.lineMax;si;)if(o!==this.src.charCodeAt(--s))return s+1;return s},StateBlock.prototype.getLines=function getLines(s,o,i,u){var _,w,x,C,j,L=s;if(s>=o)return"";if(L+1===o)return w=this.bMarks[L]+Math.min(this.tShift[L],i),x=u?this.eMarks[L]+1:this.eMarks[L],this.src.slice(w,x);for(C=new Array(o-s),_=0;Li&&(j=i),j<0&&(j=0),w=this.bMarks[L]+j,x=L+1]/,EC=/^<\/([a-zA-Z]{1,15})[\s>]/;function index_browser_getLine(s,o){var i=s.bMarks[o]+s.blkIndent,u=s.eMarks[o];return s.src.substr(i,u-i)}function skipMarker(s,o){var i,u,_=s.bMarks[o]+s.tShift[o],w=s.eMarks[o];return _>=w||126!==(u=s.src.charCodeAt(_++))&&58!==u||_===(i=s.skipSpaces(_))||i>=w?-1:i}var wC=[["code",function code(s,o,i){var u,_;if(s.tShift[o]-s.blkIndent<4)return!1;for(_=u=o+1;u=4))break;_=++u}return s.line=u,s.tokens.push({type:"code",content:s.getLines(o,_,4+s.blkIndent,!0),block:!0,lines:[o,s.line],level:s.level}),!0}],["fences",function fences(s,o,i,u){var _,w,x,C,j,L=!1,B=s.bMarks[o]+s.tShift[o],$=s.eMarks[o];if(B+3>$)return!1;if(126!==(_=s.src.charCodeAt(B))&&96!==_)return!1;if(j=B,(w=(B=s.skipChars(B,_))-j)<3)return!1;if((x=s.src.slice(B,$).trim()).indexOf("`")>=0)return!1;if(u)return!0;for(C=o;!(++C>=i)&&!((B=j=s.bMarks[C]+s.tShift[C])<($=s.eMarks[C])&&s.tShift[C]=4||(B=s.skipChars(B,_))-jZ)return!1;if(62!==s.src.charCodeAt(Y++))return!1;if(s.level>=s.options.maxNesting)return!1;if(u)return!0;for(32===s.src.charCodeAt(Y)&&Y++,j=s.blkIndent,s.blkIndent=0,C=[s.bMarks[o]],s.bMarks[o]=Y,w=(Y=Y=Z,x=[s.tShift[o]],s.tShift[o]=Y-s.bMarks[o],$=s.parser.ruler.getRules("blockquote"),_=o+1;_=(Z=s.eMarks[_]));_++)if(62!==s.src.charCodeAt(Y++)){if(w)break;for(z=!1,V=0,U=$.length;V=Z,x.push(s.tShift[_]),s.tShift[_]=Y-s.bMarks[_];for(L=s.parentType,s.parentType="blockquote",s.tokens.push({type:"blockquote_open",lines:B=[o,0],level:s.level++}),s.parser.tokenize(s,o,_),s.tokens.push({type:"blockquote_close",level:--s.level}),s.parentType=L,B[1]=s.line,V=0;Vj)return!1;if(42!==(_=s.src.charCodeAt(C++))&&45!==_&&95!==_)return!1;for(w=1;C=0)Y=!0;else{if(!(($=skipBulletListMarker(s,o))>=0))return!1;Y=!1}if(s.level>=s.options.maxNesting)return!1;if(z=s.src.charCodeAt($-1),u)return!0;for(ee=s.tokens.length,Y?(B=s.bMarks[o]+s.tShift[o],U=Number(s.src.substr(B,$-B-1)),s.tokens.push({type:"ordered_list_open",order:U,lines:ae=[o,0],level:s.level++})):s.tokens.push({type:"bullet_list_open",lines:ae=[o,0],level:s.level++}),_=o,ie=!1,ce=s.parser.ruler.getRules("list");!(!(_=s.eMarks[_]?1:Z-$)>4&&(V=1),V<1&&(V=1),w=$-s.bMarks[_]+V,s.tokens.push({type:"list_item_open",lines:le=[o,0],level:s.level++}),C=s.blkIndent,j=s.tight,x=s.tShift[o],L=s.parentType,s.tShift[o]=Z-s.bMarks[o],s.blkIndent=w,s.tight=!0,s.parentType="list",s.parser.tokenize(s,o,i,!0),s.tight&&!ie||(ye=!1),ie=s.line-o>1&&s.isEmpty(s.line-1),s.blkIndent=C,s.tShift[o]=x,s.tight=j,s.parentType=L,s.tokens.push({type:"list_item_close",level:--s.level}),_=o=s.line,le[1]=_,Z=s.bMarks[o],_>=i)||s.isEmpty(_)||s.tShift[_]B)return!1;if(91!==s.src.charCodeAt(L))return!1;if(94!==s.src.charCodeAt(L+1))return!1;if(s.level>=s.options.maxNesting)return!1;for(C=L+2;C=B||58!==s.src.charCodeAt(++C))&&(u||(C++,s.env.footnotes||(s.env.footnotes={}),s.env.footnotes.refs||(s.env.footnotes.refs={}),j=s.src.slice(L+2,C-2),s.env.footnotes.refs[":"+j]=-1,s.tokens.push({type:"footnote_reference_open",label:j,level:s.level++}),_=s.bMarks[o],w=s.tShift[o],x=s.parentType,s.tShift[o]=s.skipSpaces(C)-C,s.bMarks[o]=C,s.blkIndent+=4,s.parentType="footnote",s.tShift[o]=j)return!1;if(35!==(_=s.src.charCodeAt(C))||C>=j)return!1;for(w=1,_=s.src.charCodeAt(++C);35===_&&C6||CC&&32===s.src.charCodeAt(x-1)&&(j=x),s.line=o+1,s.tokens.push({type:"heading_open",hLevel:w,lines:[o,s.line],level:s.level}),C=i)&&(!(s.tShift[x]3)&&(!((_=s.bMarks[x]+s.tShift[x])>=(w=s.eMarks[x]))&&((45===(u=s.src.charCodeAt(_))||61===u)&&(_=s.skipChars(_,u),!((_=s.skipSpaces(_))3||C+2>=j)return!1;if(60!==s.src.charCodeAt(C))return!1;if(33===(_=s.src.charCodeAt(C+1))||63===_){if(u)return!0}else{if(47!==_&&!function isLetter$1(s){var o=32|s;return o>=97&&o<=122}(_))return!1;if(47===_){if(!(w=s.src.slice(C,j).match(EC)))return!1}else if(!(w=s.src.slice(C,j).match(_C)))return!1;if(!0!==bC[w[1].toLowerCase()])return!1;if(u)return!0}for(x=o+1;xi)return!1;if(j=o+1,s.tShift[j]=s.eMarks[j])return!1;if(124!==(_=s.src.charCodeAt(x))&&45!==_&&58!==_)return!1;if(w=index_browser_getLine(s,o+1),!/^[-:| ]+$/.test(w))return!1;if((L=w.split("|"))<=2)return!1;for($=[],C=0;C=0;if(B=o+1,s.isEmpty(B)&&++B>i)return!1;if(s.tShift[B]=s.options.maxNesting)return!1;L=s.tokens.length,s.tokens.push({type:"dl_open",lines:j=[o,0],level:s.level++}),x=o,w=B;e:for(;;){for(ee=!0,Z=!1,s.tokens.push({type:"dt_open",lines:[x,x],level:s.level++}),s.tokens.push({type:"inline",content:s.getLines(x,x+1,s.blkIndent,!1).trim(),level:s.level+1,lines:[x,x],children:[]}),s.tokens.push({type:"dt_close",level:--s.level});;){if(s.tokens.push({type:"dd_open",lines:C=[B,0],level:s.level++}),Y=s.tight,V=s.ddIndent,$=s.blkIndent,z=s.tShift[w],U=s.parentType,s.blkIndent=s.ddIndent=s.tShift[w]+2,s.tShift[w]=_-s.bMarks[w],s.tight=!0,s.parentType="deflist",s.parser.tokenize(s,w,i,!0),s.tight&&!Z||(ee=!1),Z=s.line-w>1&&s.isEmpty(s.line-1),s.tShift[w]=z,s.tight=Y,s.parentType=U,s.blkIndent=$,s.ddIndent=V,s.tokens.push({type:"dd_close",level:--s.level}),C[1]=B=s.line,B>=i)break e;if(s.tShift[B]=i)break;if(x=B,s.isEmpty(x))break;if(s.tShift[x]=i)break;if(s.isEmpty(w)&&w++,w>=i)break;if(s.tShift[w]3)){for(_=!1,w=0,x=C.length;w=i))&&!(s.tShift[x]=0&&(s=s.replace(SC,(function(o,i){var u;return 10===s.charCodeAt(i)?(w=i+1,x=0,o):(u=" ".slice((i-w-x)%4),x=i-w+1,u)}))),_=new StateBlock(s,this,o,i,u),this.tokenize(_,_.line,_.lineMax)};for(var CC=[],OC=0;OC<256;OC++)CC.push(0);function isAlphaNum(s){return s>=48&&s<=57||s>=65&&s<=90||s>=97&&s<=122}function scanDelims(s,o){var i,u,_,w=o,x=!0,C=!0,j=s.posMax,L=s.src.charCodeAt(o);for(i=o>0?s.src.charCodeAt(o-1):-1;w=j&&(x=!1),(_=w-o)>=4?x=C=!1:(32!==(u=w?@[]^_`{|}~-".split("").forEach((function(s){CC[s.charCodeAt(0)]=1}));var AC=/\\([ \\!"#$%&'()*+,.\/:;<=>?@[\]^_`{|}~-])/g;var jC=/\\([ \\!"#$%&'()*+,.\/:;<=>?@[\]^_`{|}~-])/g;var IC=["coap","doi","javascript","aaa","aaas","about","acap","cap","cid","crid","data","dav","dict","dns","file","ftp","geo","go","gopher","h323","http","https","iax","icap","im","imap","info","ipp","iris","iris.beep","iris.xpc","iris.xpcs","iris.lwz","ldap","mailto","mid","msrp","msrps","mtqp","mupdate","news","nfs","ni","nih","nntp","opaquelocktoken","pop","pres","rtsp","service","session","shttp","sieve","sip","sips","sms","snmp","soap.beep","soap.beeps","tag","tel","telnet","tftp","thismessage","tn3270","tip","tv","urn","vemmi","ws","wss","xcon","xcon-userid","xmlrpc.beep","xmlrpc.beeps","xmpp","z39.50r","z39.50s","adiumxtra","afp","afs","aim","apt","attachment","aw","beshare","bitcoin","bolo","callto","chrome","chrome-extension","com-eventbrite-attendee","content","cvs","dlna-playsingle","dlna-playcontainer","dtn","dvb","ed2k","facetime","feed","finger","fish","gg","git","gizmoproject","gtalk","hcp","icon","ipn","irc","irc6","ircs","itms","jar","jms","keyparc","lastfm","ldaps","magnet","maps","market","message","mms","ms-help","msnim","mumble","mvn","notes","oid","palm","paparazzi","platform","proxy","psyc","query","res","resource","rmi","rsync","rtmp","secondlife","sftp","sgn","skype","smb","soldat","spotify","ssh","steam","svn","teamspeak","things","udp","unreal","ut2004","ventrilo","view-source","webcal","wtai","wyciwyg","xfire","xri","ymsgr"],PC=/^<([a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)>/,MC=/^<([a-zA-Z.\-]{1,25}):([^<>\x00-\x20]*)>/;function replace$1(s,o){return s=s.source,o=o||"",function self(i,u){return i?(u=u.source||u,s=s.replace(i,u),self):new RegExp(s,o)}}var TC=replace$1(/(?:unquoted|single_quoted|double_quoted)/)("unquoted",/[^"'=<>`\x00-\x20]+/)("single_quoted",/'[^']*'/)("double_quoted",/"[^"]*"/)(),NC=replace$1(/(?:\s+attr_name(?:\s*=\s*attr_value)?)/)("attr_name",/[a-zA-Z_:][a-zA-Z0-9:._-]*/)("attr_value",TC)(),RC=replace$1(/<[A-Za-z][A-Za-z0-9]*attribute*\s*\/?>/)("attribute",NC)(),DC=replace$1(/^(?:open_tag|close_tag|comment|processing|declaration|cdata)/)("open_tag",RC)("close_tag",/<\/[A-Za-z][A-Za-z0-9]*\s*>/)("comment",/|/)("processing",/<[?].*?[?]>/)("declaration",/]*>/)("cdata",//)();var LC=/^&#((?:x[a-f0-9]{1,8}|[0-9]{1,8}));/i,BC=/^&([a-z][a-z0-9]{1,31});/i;var FC=[["text",function index_browser_text(s,o){for(var i=s.pos;i=0&&32===s.pending.charCodeAt(i))if(i>=1&&32===s.pending.charCodeAt(i-1)){for(var w=i-2;w>=0;w--)if(32!==s.pending.charCodeAt(w)){s.pending=s.pending.substring(0,w+1);break}s.push({type:"hardbreak",level:s.level})}else s.pending=s.pending.slice(0,-1),s.push({type:"softbreak",level:s.level});else s.push({type:"softbreak",level:s.level});for(_++;_=C)return!1;if(126!==s.src.charCodeAt(j+1))return!1;if(s.level>=s.options.maxNesting)return!1;if(w=j>0?s.src.charCodeAt(j-1):-1,x=s.src.charCodeAt(j+2),126===w)return!1;if(126===x)return!1;if(32===x||10===x)return!1;for(u=j+2;uj+3)return s.pos+=u-j,o||(s.pending+=s.src.slice(j,u)),!0;for(s.pos=j+2,_=1;s.pos+1=C)return!1;if(43!==s.src.charCodeAt(j+1))return!1;if(s.level>=s.options.maxNesting)return!1;if(w=j>0?s.src.charCodeAt(j-1):-1,x=s.src.charCodeAt(j+2),43===w)return!1;if(43===x)return!1;if(32===x||10===x)return!1;for(u=j+2;u=C)return!1;if(61!==s.src.charCodeAt(j+1))return!1;if(s.level>=s.options.maxNesting)return!1;if(w=j>0?s.src.charCodeAt(j-1):-1,x=s.src.charCodeAt(j+2),61===w)return!1;if(61===x)return!1;if(32===x||10===x)return!1;for(u=j+2;u=s.options.maxNesting)return!1;for(s.pos=B+i,C=[i];s.pos=_)return!1;if(s.level>=s.options.maxNesting)return!1;for(s.pos=w+1;s.pos<_;){if(126===s.src.charCodeAt(s.pos)){i=!0;break}s.parser.skipToken(s)}return i&&w+1!==s.pos?(u=s.src.slice(w+1,s.pos)).match(/(^|[^\\])(\\\\)*\s/)?(s.pos=w,!1):(s.posMax=s.pos,s.pos=w+1,o||s.push({type:"sub",level:s.level,content:u.replace(AC,"$1")}),s.pos=s.posMax+1,s.posMax=_,!0):(s.pos=w,!1)}],["sup",function sup(s,o){var i,u,_=s.posMax,w=s.pos;if(94!==s.src.charCodeAt(w))return!1;if(o)return!1;if(w+2>=_)return!1;if(s.level>=s.options.maxNesting)return!1;for(s.pos=w+1;s.pos<_;){if(94===s.src.charCodeAt(s.pos)){i=!0;break}s.parser.skipToken(s)}return i&&w+1!==s.pos?(u=s.src.slice(w+1,s.pos)).match(/(^|[^\\])(\\\\)*\s/)?(s.pos=w,!1):(s.posMax=s.pos,s.pos=w+1,o||s.push({type:"sup",level:s.level,content:u.replace(jC,"$1")}),s.pos=s.posMax+1,s.posMax=_,!0):(s.pos=w,!1)}],["links",function links(s,o){var i,u,_,w,x,C,j,L,B=!1,$=s.pos,V=s.posMax,U=s.pos,z=s.src.charCodeAt(U);if(33===z&&(B=!0,z=s.src.charCodeAt(++U)),91!==z)return!1;if(s.level>=s.options.maxNesting)return!1;if(i=U+1,(u=parseLinkLabel(s,U))<0)return!1;if((C=u+1)=V)return!1;for(U=C,parseLinkDestination(s,C)?(w=s.linkContent,C=s.pos):w="",U=C;C=V||41!==s.src.charCodeAt(C))return s.pos=$,!1;C++}else{if(s.linkLevel>0)return!1;for(;C=0?_=s.src.slice(U,C++):C=U-1),_||(void 0===_&&(C=u+1),_=s.src.slice(i,u)),!(j=s.env.references[normalizeReference(_)]))return s.pos=$,!1;w=j.href,x=j.title}return o||(s.pos=i,s.posMax=u,B?s.push({type:"image",src:w,title:x,alt:s.src.substr(i,u-i),level:s.level}):(s.push({type:"link_open",href:w,title:x,level:s.level++}),s.linkLevel++,s.parser.tokenize(s),s.linkLevel--,s.push({type:"link_close",level:--s.level}))),s.pos=C,s.posMax=V,!0}],["footnote_inline",function footnote_inline(s,o){var i,u,_,w,x=s.posMax,C=s.pos;return!(C+2>=x)&&(94===s.src.charCodeAt(C)&&(91===s.src.charCodeAt(C+1)&&(!(s.level>=s.options.maxNesting)&&(i=C+2,!((u=parseLinkLabel(s,C+1))<0)&&(o||(s.env.footnotes||(s.env.footnotes={}),s.env.footnotes.list||(s.env.footnotes.list=[]),_=s.env.footnotes.list.length,s.pos=i,s.posMax=u,s.push({type:"footnote_ref",id:_,level:s.level}),s.linkLevel++,w=s.tokens.length,s.parser.tokenize(s),s.env.footnotes.list[_]={tokens:s.tokens.splice(w)},s.linkLevel--),s.pos=u+1,s.posMax=x,!0)))))}],["footnote_ref",function footnote_ref(s,o){var i,u,_,w,x=s.posMax,C=s.pos;if(C+3>x)return!1;if(!s.env.footnotes||!s.env.footnotes.refs)return!1;if(91!==s.src.charCodeAt(C))return!1;if(94!==s.src.charCodeAt(C+1))return!1;if(s.level>=s.options.maxNesting)return!1;for(u=C+2;u=x)&&(u++,i=s.src.slice(C+2,u-1),void 0!==s.env.footnotes.refs[":"+i]&&(o||(s.env.footnotes.list||(s.env.footnotes.list=[]),s.env.footnotes.refs[":"+i]<0?(_=s.env.footnotes.list.length,s.env.footnotes.list[_]={label:i,count:0},s.env.footnotes.refs[":"+i]=_):_=s.env.footnotes.refs[":"+i],w=s.env.footnotes.list[_].count,s.env.footnotes.list[_].count++,s.push({type:"footnote_ref",id:_,subId:w,level:s.level})),s.pos=u,s.posMax=x,!0)))}],["autolink",function autolink(s,o){var i,u,_,w,x,C=s.pos;return 60===s.src.charCodeAt(C)&&(!((i=s.src.slice(C)).indexOf(">")<0)&&((u=i.match(MC))?!(IC.indexOf(u[1].toLowerCase())<0)&&(x=normalizeLink(w=u[0].slice(1,-1)),!!s.parser.validateLink(w)&&(o||(s.push({type:"link_open",href:x,level:s.level}),s.push({type:"text",content:w,level:s.level+1}),s.push({type:"link_close",level:s.level})),s.pos+=u[0].length,!0)):!!(_=i.match(PC))&&(x=normalizeLink("mailto:"+(w=_[0].slice(1,-1))),!!s.parser.validateLink(x)&&(o||(s.push({type:"link_open",href:x,level:s.level}),s.push({type:"text",content:w,level:s.level+1}),s.push({type:"link_close",level:s.level})),s.pos+=_[0].length,!0))))}],["htmltag",function htmltag(s,o){var i,u,_,w=s.pos;return!!s.options.html&&(_=s.posMax,!(60!==s.src.charCodeAt(w)||w+2>=_)&&(!(33!==(i=s.src.charCodeAt(w+1))&&63!==i&&47!==i&&!function isLetter$2(s){var o=32|s;return o>=97&&o<=122}(i))&&(!!(u=s.src.slice(w).match(DC))&&(o||s.push({type:"htmltag",content:s.src.slice(w,w+u[0].length),level:s.level}),s.pos+=u[0].length,!0))))}],["entity",function entity(s,o){var i,u,_=s.pos,w=s.posMax;if(38!==s.src.charCodeAt(_))return!1;if(_+10)s.pos=i;else{for(o=0;o<_;o++)if(u[o](s,!0))return void s.cacheSet(w,s.pos);s.pos++,s.cacheSet(w,s.pos)}},ParserInline.prototype.tokenize=function(s){for(var o,i,u=this.ruler.getRules(""),_=u.length,w=s.posMax;s.pos=w)break}else s.pending+=s.src[s.pos++]}s.pending&&s.pushPending()},ParserInline.prototype.parse=function(s,o,i,u){var _=new StateInline(s,this,o,i,u);this.tokenize(_)};var qC={default:{options:{html:!1,xhtmlOut:!1,breaks:!1,langPrefix:"language-",linkTarget:"",typographer:!1,quotes:"“”‘’",highlight:null,maxNesting:20},components:{core:{rules:["block","inline","references","replacements","smartquotes","references","abbr2","footnote_tail"]},block:{rules:["blockquote","code","fences","footnote","heading","hr","htmlblock","lheading","list","paragraph","table"]},inline:{rules:["autolink","backticks","del","emphasis","entity","escape","footnote_ref","htmltag","links","newline","text"]}}},full:{options:{html:!1,xhtmlOut:!1,breaks:!1,langPrefix:"language-",linkTarget:"",typographer:!1,quotes:"“”‘’",highlight:null,maxNesting:20},components:{core:{},block:{},inline:{}}},commonmark:{options:{html:!0,xhtmlOut:!0,breaks:!1,langPrefix:"language-",linkTarget:"",typographer:!1,quotes:"“”‘’",highlight:null,maxNesting:20},components:{core:{rules:["block","inline","references","abbr2"]},block:{rules:["blockquote","code","fences","heading","hr","htmlblock","lheading","list","paragraph"]},inline:{rules:["autolink","backticks","emphasis","entity","escape","htmltag","links","newline","text"]}}}};function StateCore(s,o,i){this.src=o,this.env=i,this.options=s.options,this.tokens=[],this.inlineMode=!1,this.inline=s.inline,this.block=s.block,this.renderer=s.renderer,this.typographer=s.typographer}function Remarkable(s,o){"string"!=typeof s&&(o=s,s="default"),o&&null!=o.linkify&&console.warn("linkify option is removed. Use linkify plugin instead:\n\nimport Remarkable from 'remarkable';\nimport linkify from 'remarkable/linkify';\nnew Remarkable().use(linkify)\n"),this.inline=new ParserInline,this.block=new ParserBlock,this.core=new Core,this.renderer=new Renderer,this.ruler=new Ruler,this.options={},this.configure(qC[s]),this.set(o||{})}Remarkable.prototype.set=function(s){index_browser_assign(this.options,s)},Remarkable.prototype.configure=function(s){var o=this;if(!s)throw new Error("Wrong `remarkable` preset, check name/content");s.options&&o.set(s.options),s.components&&Object.keys(s.components).forEach((function(i){s.components[i].rules&&o[i].ruler.enable(s.components[i].rules,!0)}))},Remarkable.prototype.use=function(s,o){return s(this,o),this},Remarkable.prototype.parse=function(s,o){var i=new StateCore(this,s,o);return this.core.process(i),i.tokens},Remarkable.prototype.render=function(s,o){return o=o||{},this.renderer.render(this.parse(s,o),this.options,o)},Remarkable.prototype.parseInline=function(s,o){var i=new StateCore(this,s,o);return i.inlineMode=!0,this.core.process(i),i.tokens},Remarkable.prototype.renderInline=function(s,o){return o=o||{},this.renderer.render(this.parseInline(s,o),this.options,o)};function indexOf(s,o){if(Array.prototype.indexOf)return s.indexOf(o);for(var i=0,u=s.length;i=0;i--)!0===o(s[i])&&s.splice(i,1)}function throwUnhandledCaseError(s){throw new Error("Unhandled case for value: '".concat(s,"'"))}var $C=function(){function HtmlTag(s){void 0===s&&(s={}),this.tagName="",this.attrs={},this.innerHTML="",this.whitespaceRegex=/\s+/,this.tagName=s.tagName||"",this.attrs=s.attrs||{},this.innerHTML=s.innerHtml||s.innerHTML||""}return HtmlTag.prototype.setTagName=function(s){return this.tagName=s,this},HtmlTag.prototype.getTagName=function(){return this.tagName||""},HtmlTag.prototype.setAttr=function(s,o){return this.getAttrs()[s]=o,this},HtmlTag.prototype.getAttr=function(s){return this.getAttrs()[s]},HtmlTag.prototype.setAttrs=function(s){return Object.assign(this.getAttrs(),s),this},HtmlTag.prototype.getAttrs=function(){return this.attrs||(this.attrs={})},HtmlTag.prototype.setClass=function(s){return this.setAttr("class",s)},HtmlTag.prototype.addClass=function(s){for(var o,i=this.getClass(),u=this.whitespaceRegex,_=i?i.split(u):[],w=s.split(u);o=w.shift();)-1===indexOf(_,o)&&_.push(o);return this.getAttrs().class=_.join(" "),this},HtmlTag.prototype.removeClass=function(s){for(var o,i=this.getClass(),u=this.whitespaceRegex,_=i?i.split(u):[],w=s.split(u);_.length&&(o=w.shift());){var x=indexOf(_,o);-1!==x&&_.splice(x,1)}return this.getAttrs().class=_.join(" "),this},HtmlTag.prototype.getClass=function(){return this.getAttrs().class||""},HtmlTag.prototype.hasClass=function(s){return-1!==(" "+this.getClass()+" ").indexOf(" "+s+" ")},HtmlTag.prototype.setInnerHTML=function(s){return this.innerHTML=s,this},HtmlTag.prototype.setInnerHtml=function(s){return this.setInnerHTML(s)},HtmlTag.prototype.getInnerHTML=function(){return this.innerHTML||""},HtmlTag.prototype.getInnerHtml=function(){return this.getInnerHTML()},HtmlTag.prototype.toAnchorString=function(){var s=this.getTagName(),o=this.buildAttrsStr();return["<",s,o=o?" "+o:"",">",this.getInnerHtml(),""].join("")},HtmlTag.prototype.buildAttrsStr=function(){if(!this.attrs)return"";var s=this.getAttrs(),o=[];for(var i in s)s.hasOwnProperty(i)&&o.push(i+'="'+s[i]+'"');return o.join(" ")},HtmlTag}();var VC=function(){function AnchorTagBuilder(s){void 0===s&&(s={}),this.newWindow=!1,this.truncate={},this.className="",this.newWindow=s.newWindow||!1,this.truncate=s.truncate||{},this.className=s.className||""}return AnchorTagBuilder.prototype.build=function(s){return new $C({tagName:"a",attrs:this.createAttrs(s),innerHtml:this.processAnchorText(s.getAnchorText())})},AnchorTagBuilder.prototype.createAttrs=function(s){var o={href:s.getAnchorHref()},i=this.createCssClass(s);return i&&(o.class=i),this.newWindow&&(o.target="_blank",o.rel="noopener noreferrer"),this.truncate&&this.truncate.length&&this.truncate.length=w)return x.host.length==o?(x.host.substr(0,o-_)+i).substr(0,w+u):buildSegment(j,w).substr(0,w+u);var L="";if(x.path&&(L+="/"+x.path),x.query&&(L+="?"+x.query),L){if((j+L).length>=w)return(j+L).length==o?(j+L).substr(0,o):(j+buildSegment(L,w-j.length)).substr(0,w+u);j+=L}if(x.fragment){var B="#"+x.fragment;if((j+B).length>=w)return(j+B).length==o?(j+B).substr(0,o):(j+buildSegment(B,w-j.length)).substr(0,w+u);j+=B}if(x.scheme&&x.host){var $=x.scheme+"://";if((j+$).length0&&(V=j.substr(-1*Math.floor(w/2))),(j.substr(0,Math.ceil(w/2))+i+V).substr(0,w+u)}(s,i):"middle"===u?function truncateMiddle(s,o,i){if(s.length<=o)return s;var u,_;null==i?(i="…",u=8,_=3):(u=i.length,_=i.length);var w=o-_,x="";return w>0&&(x=s.substr(-1*Math.floor(w/2))),(s.substr(0,Math.ceil(w/2))+i+x).substr(0,w+u)}(s,i):function truncateEnd(s,o,i){return function ellipsis(s,o,i){var u;return s.length>o&&(null==i?(i="…",u=3):u=i.length,s=s.substring(0,o-u)+i),s}(s,o,i)}(s,i)},AnchorTagBuilder}(),UC=function(){function Match(s){this.__jsduckDummyDocProp=null,this.matchedText="",this.offset=0,this.tagBuilder=s.tagBuilder,this.matchedText=s.matchedText,this.offset=s.offset}return Match.prototype.getMatchedText=function(){return this.matchedText},Match.prototype.setOffset=function(s){this.offset=s},Match.prototype.getOffset=function(){return this.offset},Match.prototype.getCssClassSuffixes=function(){return[this.getType()]},Match.prototype.buildTag=function(){return this.tagBuilder.build(this)},Match}(),extendStatics=function(s,o){return extendStatics=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(s,o){s.__proto__=o}||function(s,o){for(var i in o)Object.prototype.hasOwnProperty.call(o,i)&&(s[i]=o[i])},extendStatics(s,o)};function tslib_es6_extends(s,o){if("function"!=typeof o&&null!==o)throw new TypeError("Class extends value "+String(o)+" is not a constructor or null");function __(){this.constructor=s}extendStatics(s,o),s.prototype=null===o?Object.create(o):(__.prototype=o.prototype,new __)}var __assign=function(){return __assign=Object.assign||function __assign(s){for(var o,i=1,u=arguments.length;i-1},UrlMatchValidator.isValidUriScheme=function(s){var o=s.match(this.uriSchemeRegex),i=o&&o[0].toLowerCase();return"javascript:"!==i&&"vbscript:"!==i},UrlMatchValidator.urlMatchDoesNotHaveProtocolOrDot=function(s,o){return!(!s||o&&this.hasFullProtocolRegex.test(o)||-1!==s.indexOf("."))},UrlMatchValidator.urlMatchDoesNotHaveAtLeastOneWordChar=function(s,o){return!(!s||!o)&&(!this.hasFullProtocolRegex.test(o)&&!this.hasWordCharAfterProtocolRegex.test(s))},UrlMatchValidator.hasFullProtocolRegex=/^[A-Za-z][-.+A-Za-z0-9]*:\/\//,UrlMatchValidator.uriSchemeRegex=/^[A-Za-z][-.+A-Za-z0-9]*:/,UrlMatchValidator.hasWordCharAfterProtocolRegex=new RegExp(":[^\\s]*?["+nO+"]"),UrlMatchValidator.ipRegex=/[0-9][0-9]?[0-9]?\.[0-9][0-9]?[0-9]?\.[0-9][0-9]?[0-9]?\.[0-9][0-9]?[0-9]?(:[0-9]*)?\/?$/,UrlMatchValidator}(),vO=(zC=new RegExp("[/?#](?:["+aO+"\\-+&@#/%=~_()|'$*\\[\\]{}?!:,.;^✓]*["+aO+"\\-+&@#/%=~_()|'$*\\[\\]{}✓])?"),new RegExp(["(?:","(",/(?:[A-Za-z][-.+A-Za-z0-9]{0,63}:(?![A-Za-z][-.+A-Za-z0-9]{0,63}:\/\/)(?!\d+\/?)(?:\/\/)?)/.source,getDomainNameStr(2),")","|","(","(//)?",/(?:www\.)/.source,getDomainNameStr(6),")","|","(","(//)?",getDomainNameStr(10)+"\\.",hO.source,"(?![-"+iO+"])",")",")","(?::[0-9]+)?","(?:"+zC.source+")?"].join(""),"gi")),bO=new RegExp("["+aO+"]"),_O=function(s){function UrlMatcher(o){var i=s.call(this,o)||this;return i.stripPrefix={scheme:!0,www:!0},i.stripTrailingSlash=!0,i.decodePercentEncoding=!0,i.matcherRegex=vO,i.wordCharRegExp=bO,i.stripPrefix=o.stripPrefix,i.stripTrailingSlash=o.stripTrailingSlash,i.decodePercentEncoding=o.decodePercentEncoding,i}return tslib_es6_extends(UrlMatcher,s),UrlMatcher.prototype.parseMatches=function(s){for(var o,i=this.matcherRegex,u=this.stripPrefix,_=this.stripTrailingSlash,w=this.decodePercentEncoding,x=this.tagBuilder,C=[],_loop_1=function(){var i=o[0],L=o[1],B=o[4],$=o[5],V=o[9],U=o.index,z=$||V,Y=s.charAt(U-1);if(!yO.isValid(i,L))return"continue";if(U>0&&"@"===Y)return"continue";if(U>0&&z&&j.wordCharRegExp.test(Y))return"continue";if(/\?$/.test(i)&&(i=i.substr(0,i.length-1)),j.matchHasUnbalancedClosingParen(i))i=i.substr(0,i.length-1);else{var Z=j.matchHasInvalidCharAfterTld(i,L);Z>-1&&(i=i.substr(0,Z))}var ee=["http://","https://"].find((function(s){return!!L&&-1!==L.indexOf(s)}));if(ee){var ie=i.indexOf(ee);i=i.substr(ie),L=L.substr(ie),U+=ie}var ae=L?"scheme":B?"www":"tld",le=!!L;C.push(new GC({tagBuilder:x,matchedText:i,offset:U,urlMatchType:ae,url:i,protocolUrlMatch:le,protocolRelativeMatch:!!z,stripPrefix:u,stripTrailingSlash:_,decodePercentEncoding:w}))},j=this;null!==(o=i.exec(s));)_loop_1();return C},UrlMatcher.prototype.matchHasUnbalancedClosingParen=function(s){var o,i=s.charAt(s.length-1);if(")"===i)o="(";else if("]"===i)o="[";else{if("}"!==i)return!1;o="{"}for(var u=0,_=0,w=s.length-1;_-1&&w-x<=140){var _=s.slice(x,w),C=new KC({tagBuilder:o,matchedText:_,offset:x,serviceName:i,hashtag:_.slice(1)});u.push(C)}}},HashtagMatcher}(YC),SO=["twitter","facebook","instagram","tiktok"],xO=new RegExp("".concat(/(?:(?:(?:(\+)?\d{1,3}[-\040.]?)?\(?\d{3}\)?[-\040.]?\d{3}[-\040.]?\d{4})|(?:(\+)(?:9[976]\d|8[987530]\d|6[987]\d|5[90]\d|42\d|3[875]\d|2[98654321]\d|9[8543210]|8[6421]|6[6543210]|5[87654321]|4[987654310]|3[9643210]|2[70]|7|1)[-\040.]?(?:\d[-\040.]?){6,12}\d+))([,;]+[0-9]+#?)*/.source,"|").concat(/(0([1-9]{1}-?[1-9]\d{3}|[1-9]{2}-?\d{3}|[1-9]{2}\d{1}-?\d{2}|[1-9]{2}\d{2}-?\d{1})-?\d{4}|0[789]0-?\d{4}-?\d{4}|050-?\d{4}-?\d{4})/.source),"g"),kO=function(s){function PhoneMatcher(){var o=null!==s&&s.apply(this,arguments)||this;return o.matcherRegex=xO,o}return tslib_es6_extends(PhoneMatcher,s),PhoneMatcher.prototype.parseMatches=function(s){for(var o,i=this.matcherRegex,u=this.tagBuilder,_=[];null!==(o=i.exec(s));){var w=o[0],x=w.replace(/[^0-9,;#]/g,""),C=!(!o[1]&&!o[2]),j=0==o.index?"":s.substr(o.index-1,1),L=s.substr(o.index+w.length,1),B=!j.match(/\d/)&&!L.match(/\d/);this.testMatch(o[3])&&this.testMatch(w)&&B&&_.push(new JC({tagBuilder:u,matchedText:w,offset:o.index,number:x,plusSign:C}))}return _},PhoneMatcher.prototype.testMatch=function(s){return QC.test(s)},PhoneMatcher}(YC),CO=new RegExp("@[_".concat(aO,"]{1,50}(?![_").concat(aO,"])"),"g"),OO=new RegExp("@[_.".concat(aO,"]{1,30}(?![_").concat(aO,"])"),"g"),AO=new RegExp("@[-_.".concat(aO,"]{1,50}(?![-_").concat(aO,"])"),"g"),jO=new RegExp("@[_.".concat(aO,"]{1,23}[_").concat(aO,"](?![_").concat(aO,"])"),"g"),IO=new RegExp("[^"+aO+"]"),PO=function(s){function MentionMatcher(o){var i=s.call(this,o)||this;return i.serviceName="twitter",i.matcherRegexes={twitter:CO,instagram:OO,soundcloud:AO,tiktok:jO},i.nonWordCharRegex=IO,i.serviceName=o.serviceName,i}return tslib_es6_extends(MentionMatcher,s),MentionMatcher.prototype.parseMatches=function(s){var o,i=this.serviceName,u=this.matcherRegexes[this.serviceName],_=this.nonWordCharRegex,w=this.tagBuilder,x=[];if(!u)return x;for(;null!==(o=u.exec(s));){var C=o.index,j=s.charAt(C-1);if(0===C||_.test(j)){var L=o[0].replace(/\.+$/g,""),B=L.slice(1);x.push(new HC({tagBuilder:w,matchedText:L,offset:C,serviceName:i,mention:B}))}}return x},MentionMatcher}(YC);function parseHtml(s,o){for(var i=o.onOpenTag,u=o.onCloseTag,_=o.onText,w=o.onComment,x=o.onDoctype,C=new MO,j=0,L=s.length,B=0,$=0,V=C;j"===s?(V=new MO(__assign(__assign({},V),{name:captureTagName()})),emitTagAndPreviousTextNode()):XC.test(s)||ZC.test(s)||":"===s||resetToDataState()}function stateEndTagOpen(s){">"===s?resetToDataState():XC.test(s)?B=3:resetToDataState()}function stateBeforeAttributeName(s){eO.test(s)||("/"===s?B=12:">"===s?emitTagAndPreviousTextNode():"<"===s?startNewTag():"="===s||tO.test(s)||rO.test(s)?resetToDataState():B=5)}function stateAttributeName(s){eO.test(s)?B=6:"/"===s?B=12:"="===s?B=7:">"===s?emitTagAndPreviousTextNode():"<"===s?startNewTag():tO.test(s)&&resetToDataState()}function stateAfterAttributeName(s){eO.test(s)||("/"===s?B=12:"="===s?B=7:">"===s?emitTagAndPreviousTextNode():"<"===s?startNewTag():tO.test(s)?resetToDataState():B=5)}function stateBeforeAttributeValue(s){eO.test(s)||('"'===s?B=8:"'"===s?B=9:/[>=`]/.test(s)?resetToDataState():"<"===s?startNewTag():B=10)}function stateAttributeValueDoubleQuoted(s){'"'===s&&(B=11)}function stateAttributeValueSingleQuoted(s){"'"===s&&(B=11)}function stateAttributeValueUnquoted(s){eO.test(s)?B=4:">"===s?emitTagAndPreviousTextNode():"<"===s&&startNewTag()}function stateAfterAttributeValueQuoted(s){eO.test(s)?B=4:"/"===s?B=12:">"===s?emitTagAndPreviousTextNode():"<"===s?startNewTag():(B=4,function reconsumeCurrentCharacter(){j--}())}function stateSelfClosingStartTag(s){">"===s?(V=new MO(__assign(__assign({},V),{isClosing:!0})),emitTagAndPreviousTextNode()):B=4}function stateMarkupDeclarationOpen(o){"--"===s.substr(j,2)?(j+=2,V=new MO(__assign(__assign({},V),{type:"comment"})),B=14):"DOCTYPE"===s.substr(j,7).toUpperCase()?(j+=7,V=new MO(__assign(__assign({},V),{type:"doctype"})),B=20):resetToDataState()}function stateCommentStart(s){"-"===s?B=15:">"===s?resetToDataState():B=16}function stateCommentStartDash(s){"-"===s?B=18:">"===s?resetToDataState():B=16}function stateComment(s){"-"===s&&(B=17)}function stateCommentEndDash(s){B="-"===s?18:16}function stateCommentEnd(s){">"===s?emitTagAndPreviousTextNode():"!"===s?B=19:"-"===s||(B=16)}function stateCommentEndBang(s){"-"===s?B=17:">"===s?emitTagAndPreviousTextNode():B=16}function stateDoctype(s){">"===s?emitTagAndPreviousTextNode():"<"===s&&startNewTag()}function resetToDataState(){B=0,V=C}function startNewTag(){B=1,V=new MO({idx:j})}function emitTagAndPreviousTextNode(){var o=s.slice($,V.idx);o&&_(o,$),"comment"===V.type?w(V.idx):"doctype"===V.type?x(V.idx):(V.isOpening&&i(V.name,V.idx),V.isClosing&&u(V.name,V.idx)),resetToDataState(),$=j+1}function captureTagName(){var o=V.idx+(V.isClosing?2:1);return s.slice(o,j).toLowerCase()}$=0&&u++},onText:function(s,i){if(0===u){var w=function splitAndCapture(s,o){if(!o.global)throw new Error("`splitRegex` must have the 'g' flag set");for(var i,u=[],_=0;i=o.exec(s);)u.push(s.substring(_,i.index)),u.push(i[0]),_=i.index+i[0].length;return u.push(s.substring(_)),u}(s,/( | |<|<|>|>|"|"|')/gi),x=i;w.forEach((function(s,i){if(i%2==0){var u=o.parseText(s,x);_.push.apply(_,u)}x+=s.length}))}},onCloseTag:function(s){i.indexOf(s)>=0&&(u=Math.max(u-1,0))},onComment:function(s){},onDoctype:function(s){}}),_=this.compactMatches(_),_=this.removeUnwantedMatches(_)},Autolinker.prototype.compactMatches=function(s){s.sort((function(s,o){return s.getOffset()-o.getOffset()}));for(var o=0;o_?o:o+1;s.splice(x,1);continue}if(s[o+1].getOffset()/g,">"));for(var o=this.parse(s),i=[],u=0,_=0,w=o.length;_\s]/i.test(s)}function isLinkClose(s){return/^<\/a\s*>/i.test(s)}function createLinkifier(){var s=[],o=new NO({stripPrefix:!1,url:!0,email:!0,replaceFn:function(o){switch(o.getType()){case"url":s.push({text:o.matchedText,url:o.getUrl()});break;case"email":s.push({text:o.matchedText,url:"mailto:"+o.getEmail().replace(/^mailto:/i,"")})}return!1}});return{links:s,autolinker:o}}function parseTokens(s){var o,i,u,_,w,x,C,j,L,B,$,V,U,z=s.tokens,Y=null;for(i=0,u=z.length;i=0;o--)if("link_close"!==(w=_[o]).type){if("htmltag"===w.type&&(isLinkOpen(w.content)&&$>0&&$--,isLinkClose(w.content)&&$++),!($>0)&&"text"===w.type&&RO.test(w.content)){if(Y||(V=(Y=createLinkifier()).links,U=Y.autolinker),x=w.content,V.length=0,U.link(x),!V.length)continue;for(C=[],B=w.level,j=0;j({useUnsafeMarkdown:!1})}){if("string"!=typeof s)return null;const u=new Remarkable({html:!0,typographer:!0,breaks:!0,linkTarget:"_blank"}).use(linkify);u.core.ruler.disable(["replacements","smartquotes"]);const{useUnsafeMarkdown:_}=i(),w=u.render(s),x=sanitizer(w,{useUnsafeMarkdown:_});return s&&w&&x?Pe.createElement("div",{className:Hn()(o,"markdown"),dangerouslySetInnerHTML:{__html:x}}):null};function sanitizer(s,{useUnsafeMarkdown:o=!1}={}){const i=o,u=o?[]:["style","class"];return o&&!sanitizer.hasWarnedAboutDeprecation&&(console.warn("useUnsafeMarkdown display configuration parameter is deprecated since >3.26.0 and will be removed in v4.0.0."),sanitizer.hasWarnedAboutDeprecation=!0),LO().sanitize(s,{ADD_ATTR:["target"],FORBID_TAGS:["style","form"],ALLOW_DATA_ATTR:i,FORBID_ATTR:u})}sanitizer.hasWarnedAboutDeprecation=!1;class BaseLayout extends Pe.Component{render(){const{errSelectors:s,specSelectors:o,getComponent:i}=this.props,u=i("SvgAssets"),_=i("InfoContainer",!0),w=i("VersionPragmaFilter"),x=i("operations",!0),C=i("Models",!0),j=i("Webhooks",!0),L=i("Row"),B=i("Col"),$=i("errors",!0),V=i("ServersContainer",!0),U=i("SchemesContainer",!0),z=i("AuthorizeBtnContainer",!0),Y=i("FilterContainer",!0),Z=o.isSwagger2(),ee=o.isOAS3(),ie=o.isOAS31(),ae=!o.specStr(),le=o.loadingStatus();let ce=null;if("loading"===le&&(ce=Pe.createElement("div",{className:"info"},Pe.createElement("div",{className:"loading-container"},Pe.createElement("div",{className:"loading"})))),"failed"===le&&(ce=Pe.createElement("div",{className:"info"},Pe.createElement("div",{className:"loading-container"},Pe.createElement("h4",{className:"title"},"Failed to load API definition."),Pe.createElement($,null)))),"failedConfig"===le){const o=s.lastError(),i=o?o.get("message"):"";ce=Pe.createElement("div",{className:"info failed-config"},Pe.createElement("div",{className:"loading-container"},Pe.createElement("h4",{className:"title"},"Failed to load remote configuration."),Pe.createElement("p",null,i)))}if(!ce&&ae&&(ce=Pe.createElement("h4",null,"No API definition provided.")),ce)return Pe.createElement("div",{className:"swagger-ui"},Pe.createElement("div",{className:"loading-container"},ce));const pe=o.servers(),de=o.schemes(),fe=pe&&pe.size,ye=de&&de.size,be=!!o.securityDefinitions();return Pe.createElement("div",{className:"swagger-ui"},Pe.createElement(u,null),Pe.createElement(w,{isSwagger2:Z,isOAS3:ee,alsoShow:Pe.createElement($,null)},Pe.createElement($,null),Pe.createElement(L,{className:"information-container"},Pe.createElement(B,{mobile:12},Pe.createElement(_,null))),fe||ye||be?Pe.createElement("div",{className:"scheme-container"},Pe.createElement(B,{className:"schemes wrapper",mobile:12},fe||ye?Pe.createElement("div",{className:"schemes-server-container"},fe?Pe.createElement(V,null):null,ye?Pe.createElement(U,null):null):null,be?Pe.createElement(z,null):null)):null,Pe.createElement(Y,null),Pe.createElement(L,null,Pe.createElement(B,{mobile:12,desktop:12},Pe.createElement(x,null))),ie&&Pe.createElement(L,{className:"webhooks-container"},Pe.createElement(B,{mobile:12,desktop:12},Pe.createElement(j,null))),Pe.createElement(L,null,Pe.createElement(B,{mobile:12,desktop:12},Pe.createElement(C,null)))))}}const core_components=()=>({components:{App:fk,authorizationPopup:AuthorizationPopup,authorizeBtn:AuthorizeBtn,AuthorizeBtnContainer,authorizeOperationBtn:AuthorizeOperationBtn,auths:Auths,AuthItem:auth_item_Auths,authError:AuthError,oauth2:Oauth2,apiKeyAuth:ApiKeyAuth,basicAuth:BasicAuth,clear:Clear,liveResponse:LiveResponse,InitializedInput,info:qk,InfoContainer,InfoUrl,InfoBasePath,Contact:Vk,License:zk,JumpToPath,CopyToClipboardBtn,onlineValidatorBadge:OnlineValidatorBadge,operations:Operations,operation:operation_Operation,OperationSummary,OperationSummaryMethod,OperationSummaryPath,responses:responses_Responses,response:response_Response,ResponseExtension:response_extension,responseBody:ResponseBody,parameters:Parameters,parameterRow:ParameterRow,execute:Execute,headers:headers_Headers,errors:Errors,contentType:ContentType,overview:Overview,footer:Footer,FilterContainer,ParamBody,curl:Curl,Property:property,TryItOutButton,Markdown:BO,BaseLayout,VersionPragmaFilter,VersionStamp:version_stamp,OperationExt:operation_extensions,OperationExtRow:operation_extension_row,ParameterExt:parameter_extension,ParameterIncludeEmpty,OperationTag,OperationContainer,OpenAPIVersion:openapi_version,DeepLink:deep_link,SvgAssets:svg_assets,Example:example_Example,ExamplesSelect,ExamplesSelectValueRetainer}}),form_components=()=>({components:{...ye}}),base=()=>[configsPlugin,util,logs,view,view_legacy,plugins_spec,err,icons,plugins_layout,json_schema_5,json_schema_5_samples,core_components,form_components,swagger_client,auth,downloadUrlPlugin,deep_linking,filter,on_complete,plugins_request_snippets,syntax_highlighting,versions,safe_render()],FO=(0,qe.Map)();function onlyOAS3(s){return(o,i)=>(...u)=>{if(i.getSystem().specSelectors.isOAS3()){const o=s(...u);return"function"==typeof o?o(i):o}return o(...u)}}const qO=onlyOAS3(Ss()(null)),$O=onlyOAS3(((s,o)=>s=>s.getSystem().specSelectors.findSchema(o))),VO=onlyOAS3((()=>s=>{const o=s.getSystem().specSelectors.specJson().getIn(["components","schemas"]);return qe.Map.isMap(o)?o:FO})),UO=onlyOAS3((()=>s=>s.getSystem().specSelectors.specJson().hasIn(["servers",0]))),zO=onlyOAS3(Ut(Ms,(s=>s.getIn(["components","securitySchemes"])||null))),wrap_selectors_validOperationMethods=(s,o)=>(i,...u)=>o.specSelectors.isOAS3()?o.oas3Selectors.validOperationMethods():s(...u),WO=qO,KO=qO,HO=qO,JO=qO,GO=qO;const YO=function wrap_selectors_onlyOAS3(s){return(o,i)=>(...u)=>{if(i.getSystem().specSelectors.isOAS3()){let o=i.getState().getIn(["spec","resolvedSubtrees","components","securitySchemes"]);return s(i,o,...u)}return o(...u)}}(Ut((s=>s),(({specSelectors:s})=>s.securityDefinitions()),((s,o)=>{let i=(0,qe.List)();return o?(o.entrySeq().forEach((([s,o])=>{const u=o.get("type");if("oauth2"===u&&o.get("flows").entrySeq().forEach((([u,_])=>{let w=(0,qe.fromJS)({flow:u,authorizationUrl:_.get("authorizationUrl"),tokenUrl:_.get("tokenUrl"),scopes:_.get("scopes"),type:o.get("type"),description:o.get("description")});i=i.push(new qe.Map({[s]:w.filter((s=>void 0!==s))}))})),"http"!==u&&"apiKey"!==u||(i=i.push(new qe.Map({[s]:o}))),"openIdConnect"===u&&o.get("openIdConnectData")){let u=o.get("openIdConnectData");(u.get("grant_types_supported")||["authorization_code","implicit"]).forEach((_=>{let w=u.get("scopes_supported")&&u.get("scopes_supported").reduce(((s,o)=>s.set(o,"")),new qe.Map),x=(0,qe.fromJS)({flow:_,authorizationUrl:u.get("authorization_endpoint"),tokenUrl:u.get("token_endpoint"),scopes:w,type:"oauth2",openIdConnectUrl:o.get("openIdConnectUrl")});i=i.push(new qe.Map({[s]:x.filter((s=>void 0!==s))}))}))}})),i):i})));function OAS3ComponentWrapFactory(s){return(o,i)=>u=>"function"==typeof i.specSelectors?.isOAS3?i.specSelectors.isOAS3()?Pe.createElement(s,Rn()({},u,i,{Ori:o})):Pe.createElement(o,u):(console.warn("OAS3 wrapper: couldn't get spec"),null)}const XO=(0,qe.Map)(),selectors_isSwagger2=()=>s=>function isSwagger2(s){const o=s.get("swagger");return"string"==typeof o&&"2.0"===o}(s.getSystem().specSelectors.specJson()),selectors_isOAS30=()=>s=>function isOAS30(s){const o=s.get("openapi");return"string"==typeof o&&/^3\.0\.([0123])(?:-rc[012])?$/.test(o)}(s.getSystem().specSelectors.specJson()),selectors_isOAS3=()=>s=>s.getSystem().specSelectors.isOAS30();function selectors_onlyOAS3(s){return(o,...i)=>u=>{if(u.specSelectors.isOAS3()){const _=s(o,...i);return"function"==typeof _?_(u):_}return null}}const ZO=selectors_onlyOAS3((()=>s=>s.specSelectors.specJson().get("servers",XO))),findSchema=(s,o)=>{const i=s.getIn(["resolvedSubtrees","components","schemas",o],null),u=s.getIn(["json","components","schemas",o],null);return i||u||null},QO=selectors_onlyOAS3(((s,{callbacks:o,specPath:i})=>s=>{const u=s.specSelectors.validOperationMethods();return qe.Map.isMap(o)?o.reduce(((s,o,_)=>{if(!qe.Map.isMap(o))return s;const w=o.reduce(((s,o,w)=>{if(!qe.Map.isMap(o))return s;const x=o.entrySeq().filter((([s])=>u.includes(s))).map((([s,o])=>({operation:(0,qe.Map)({operation:o}),method:s,path:w,callbackName:_,specPath:i.concat([_,w,s])})));return s.concat(x)}),(0,qe.List)());return s.concat(w)}),(0,qe.List)()).groupBy((s=>s.callbackName)).map((s=>s.toArray())).toObject():{}})),callbacks=({callbacks:s,specPath:o,specSelectors:i,getComponent:u})=>{const _=i.callbacksOperations({callbacks:s,specPath:o}),w=Object.keys(_),x=u("OperationContainer",!0);return 0===w.length?Pe.createElement("span",null,"No callbacks"):Pe.createElement("div",null,w.map((s=>Pe.createElement("div",{key:`${s}`},Pe.createElement("h2",null,s),_[s].map((o=>Pe.createElement(x,{key:`${s}-${o.path}-${o.method}`,op:o.operation,tag:"callbacks",method:o.method,path:o.path,specPath:o.specPath,allowTryItOut:!1})))))))},getDefaultRequestBodyValue=(s,o,i,u)=>{const _=s.getIn(["content",o])??(0,qe.OrderedMap)(),w=_.get("schema",(0,qe.OrderedMap)()).toJS(),x=void 0!==_.get("examples"),C=_.get("example"),j=x?_.getIn(["examples",i,"value"]):C;return stringify(u.getSampleSchema(w,o,{includeWriteOnly:!0},j))},components_request_body=({userHasEditedBody:s,requestBody:o,requestBodyValue:i,requestBodyInclusionSetting:u,requestBodyErrors:_,getComponent:w,getConfigs:x,specSelectors:C,fn:j,contentType:L,isExecute:B,specPath:$,onChange:V,onChangeIncludeEmpty:U,activeExamplesKey:z,updateActiveExamplesKey:Y,setRetainRequestBodyValueFlag:Z})=>{const handleFile=s=>{V(s.target.files[0])},setIsIncludedOptions=s=>{let o={key:s,shouldDispatchInit:!1,defaultValue:!0};return"no value"===u.get(s,"no value")&&(o.shouldDispatchInit=!0),o},ee=w("Markdown",!0),ie=w("modelExample"),ae=w("RequestBodyEditor"),le=w("HighlightCode",!0),ce=w("ExamplesSelectValueRetainer"),pe=w("Example"),de=w("ParameterIncludeEmpty"),{showCommonExtensions:fe}=x(),ye=o?.get("description")??null,be=o?.get("content")??new qe.OrderedMap;L=L||be.keySeq().first()||"";const _e=be.get(L)??(0,qe.OrderedMap)(),we=_e.get("schema",(0,qe.OrderedMap)()),Se=_e.get("examples",null),xe=Se?.map(((s,i)=>{const u=s?.get("value",null);return u&&(s=s.set("value",getDefaultRequestBodyValue(o,L,i,j),u)),s}));if(_=qe.List.isList(_)?_:(0,qe.List)(),!_e.size)return null;const Te="object"===_e.getIn(["schema","type"]),Re="binary"===_e.getIn(["schema","format"]),$e="base64"===_e.getIn(["schema","format"]);if("application/octet-stream"===L||0===L.indexOf("image/")||0===L.indexOf("audio/")||0===L.indexOf("video/")||Re||$e){const s=w("Input");return B?Pe.createElement(s,{type:"file",onChange:handleFile}):Pe.createElement("i",null,"Example values are not available for ",Pe.createElement("code",null,L)," media types.")}if(Te&&("application/x-www-form-urlencoded"===L||0===L.indexOf("multipart/"))&&we.get("properties",(0,qe.OrderedMap)()).size>0){const s=w("JsonSchemaForm"),o=w("ParameterExt"),x=we.get("properties",(0,qe.OrderedMap)());return i=qe.Map.isMap(i)?i:(0,qe.OrderedMap)(),Pe.createElement("div",{className:"table-container"},ye&&Pe.createElement(ee,{source:ye}),Pe.createElement("table",null,Pe.createElement("tbody",null,qe.Map.isMap(x)&&x.entrySeq().map((([x,C])=>{if(C.get("readOnly"))return;const L=C.get("oneOf")?.get(0)?.toJS(),$=C.get("anyOf")?.get(0)?.toJS();C=(0,qe.fromJS)(j.mergeJsonSchema(C.toJS(),L??$??{}));let z=fe?getCommonExtensions(C):null;const Y=we.get("required",(0,qe.List)()).includes(x),Z=C.get("type"),ie=C.get("format"),ae=C.get("description"),le=i.getIn([x,"value"]),ce=i.getIn([x,"errors"])||_,pe=u.get(x)||!1;let ye=j.getSampleSchema(C,!1,{includeWriteOnly:!0});!1===ye&&(ye="false"),0===ye&&(ye="0"),"string"!=typeof ye&&"object"===Z&&(ye=stringify(ye)),"string"==typeof ye&&"array"===Z&&(ye=JSON.parse(ye));const be="string"===Z&&("binary"===ie||"base64"===ie);return Pe.createElement("tr",{key:x,className:"parameters","data-property-name":x},Pe.createElement("td",{className:"parameters-col_name"},Pe.createElement("div",{className:Y?"parameter__name required":"parameter__name"},x,Y?Pe.createElement("span",null," *"):null),Pe.createElement("div",{className:"parameter__type"},Z,ie&&Pe.createElement("span",{className:"prop-format"},"($",ie,")"),fe&&z.size?z.entrySeq().map((([s,i])=>Pe.createElement(o,{key:`${s}-${i}`,xKey:s,xVal:i}))):null),Pe.createElement("div",{className:"parameter__deprecated"},C.get("deprecated")?"deprecated":null)),Pe.createElement("td",{className:"parameters-col_description"},Pe.createElement(ee,{source:ae}),B?Pe.createElement("div",null,Pe.createElement(s,{fn:j,dispatchInitialValue:!be,schema:C,description:x,getComponent:w,value:void 0===le?ye:le,required:Y,errors:ce,onChange:s=>{V(s,[x])}}),Y?null:Pe.createElement(de,{onChange:s=>U(x,s),isIncluded:pe,isIncludedOptions:setIsIncludedOptions(x),isDisabled:Array.isArray(le)?0!==le.length:!isEmptyValue(le)})):null))})))))}const ze=getDefaultRequestBodyValue(o,L,z,j);let We=null;return getKnownSyntaxHighlighterLanguage(ze)&&(We="json"),Pe.createElement("div",null,ye&&Pe.createElement(ee,{source:ye}),xe?Pe.createElement(ce,{userHasEditedBody:s,examples:xe,currentKey:z,currentUserInputValue:i,onSelect:s=>{Y(s)},updateValue:V,defaultToFirstExample:!0,getComponent:w,setRetainRequestBodyValueFlag:Z}):null,B?Pe.createElement("div",null,Pe.createElement(ae,{value:i,errors:_,defaultValue:ze,onChange:V,getComponent:w})):Pe.createElement(ie,{getComponent:w,getConfigs:x,specSelectors:C,expandDepth:1,isExecute:B,schema:_e.get("schema"),specPath:$.push("content",L),example:Pe.createElement(le,{className:"body-param__example",language:We},stringify(i)||ze),includeWriteOnly:!0}),xe?Pe.createElement(pe,{example:xe.get(z),getComponent:w,getConfigs:x}):null)};class operation_link_OperationLink extends Pe.Component{render(){const{link:s,name:o,getComponent:i}=this.props,u=i("Markdown",!0);let _=s.get("operationId")||s.get("operationRef"),w=s.get("parameters")&&s.get("parameters").toJS(),x=s.get("description");return Pe.createElement("div",{className:"operation-link"},Pe.createElement("div",{className:"description"},Pe.createElement("b",null,Pe.createElement("code",null,o)),x?Pe.createElement(u,{source:x}):null),Pe.createElement("pre",null,"Operation `",_,"`",Pe.createElement("br",null),Pe.createElement("br",null),"Parameters ",function padString(s,o){if("string"!=typeof o)return"";return o.split("\n").map(((o,i)=>i>0?Array(s+1).join(" ")+o:o)).join("\n")}(0,JSON.stringify(w,null,2))||"{}",Pe.createElement("br",null)))}}const eA=operation_link_OperationLink,components_servers=({servers:s,currentServer:o,setSelectedServer:i,setServerVariableValue:u,getServerVariable:_,getEffectiveServerValue:w})=>{const x=(s.find((s=>s.get("url")===o))||(0,qe.OrderedMap)()).get("variables")||(0,qe.OrderedMap)(),C=0!==x.size;(0,Pe.useEffect)((()=>{o||i(s.first()?.get("url"))}),[]),(0,Pe.useEffect)((()=>{const _=s.find((s=>s.get("url")===o));if(!_)return void i(s.first().get("url"));(_.get("variables")||(0,qe.OrderedMap)()).map(((s,i)=>{u({server:o,key:i,val:s.get("default")||""})}))}),[o,s]);const j=(0,Pe.useCallback)((s=>{i(s.target.value)}),[i]),L=(0,Pe.useCallback)((s=>{const i=s.target.getAttribute("data-variable"),_=s.target.value;u({server:o,key:i,val:_})}),[u,o]);return Pe.createElement("div",{className:"servers"},Pe.createElement("label",{htmlFor:"servers"},Pe.createElement("select",{onChange:j,value:o,id:"servers"},s.valueSeq().map((s=>Pe.createElement("option",{value:s.get("url"),key:s.get("url")},s.get("url"),s.get("description")&&` - ${s.get("description")}`))).toArray())),C&&Pe.createElement("div",null,Pe.createElement("div",{className:"computed-url"},"Computed URL:",Pe.createElement("code",null,w(o))),Pe.createElement("h4",null,"Server variables"),Pe.createElement("table",null,Pe.createElement("tbody",null,x.entrySeq().map((([s,i])=>Pe.createElement("tr",{key:s},Pe.createElement("td",null,s),Pe.createElement("td",null,i.get("enum")?Pe.createElement("select",{"data-variable":s,onChange:L},i.get("enum").map((i=>Pe.createElement("option",{selected:i===_(o,s),key:i,value:i},i)))):Pe.createElement("input",{type:"text",value:_(o,s)||"",onChange:L,"data-variable":s})))))))))};class ServersContainer extends Pe.Component{render(){const{specSelectors:s,oas3Selectors:o,oas3Actions:i,getComponent:u}=this.props,_=s.servers(),w=u("Servers");return _&&_.size?Pe.createElement("div",null,Pe.createElement("span",{className:"servers-title"},"Servers"),Pe.createElement(w,{servers:_,currentServer:o.selectedServer(),setSelectedServer:i.setSelectedServer,setServerVariableValue:i.setServerVariableValue,getServerVariable:o.serverVariableValue,getEffectiveServerValue:o.serverEffectiveValue})):null}}const tA=Function.prototype;class RequestBodyEditor extends Pe.PureComponent{static defaultProps={onChange:tA,userHasEditedBody:!1};constructor(s,o){super(s,o),this.state={value:stringify(s.value)||s.defaultValue},s.onChange(s.value)}applyDefaultValue=s=>{const{onChange:o,defaultValue:i}=s||this.props;return this.setState({value:i}),o(i)};onChange=s=>{this.props.onChange(stringify(s))};onDomChange=s=>{const o=s.target.value;this.setState({value:o},(()=>this.onChange(o)))};UNSAFE_componentWillReceiveProps(s){this.props.value!==s.value&&s.value!==this.state.value&&this.setState({value:stringify(s.value)}),!s.value&&s.defaultValue&&this.state.value&&this.applyDefaultValue(s)}render(){let{getComponent:s,errors:o}=this.props,{value:i}=this.state,u=o.size>0;const _=s("TextArea");return Pe.createElement("div",{className:"body-param"},Pe.createElement(_,{className:Hn()("body-param__text",{invalid:u}),title:o.size?o.join(", "):"",value:i,onChange:this.onDomChange}))}}class HttpAuth extends Pe.Component{constructor(s,o){super(s,o);let{name:i,schema:u}=this.props,_=this.getValue();this.state={name:i,schema:u,value:_}}getValue(){let{name:s,authorized:o}=this.props;return o&&o.getIn([s,"value"])}onChange=s=>{let{onChange:o}=this.props,{value:i,name:u}=s.target,_=Object.assign({},this.state.value);u?_[u]=i:_=i,this.setState({value:_},(()=>o(this.state)))};render(){let{schema:s,getComponent:o,errSelectors:i,name:u}=this.props;const _=o("Input"),w=o("Row"),x=o("Col"),C=o("authError"),j=o("Markdown",!0),L=o("JumpToPath",!0),B=(s.get("scheme")||"").toLowerCase();let $=this.getValue(),V=i.allErrors().filter((s=>s.get("authId")===u));if("basic"===B){let o=$?$.get("username"):null;return Pe.createElement("div",null,Pe.createElement("h4",null,Pe.createElement("code",null,u||s.get("name")),"  (http, Basic)",Pe.createElement(L,{path:["securityDefinitions",u]})),o&&Pe.createElement("h6",null,"Authorized"),Pe.createElement(w,null,Pe.createElement(j,{source:s.get("description")})),Pe.createElement(w,null,Pe.createElement("label",{htmlFor:"auth-basic-username"},"Username:"),o?Pe.createElement("code",null," ",o," "):Pe.createElement(x,null,Pe.createElement(_,{id:"auth-basic-username",type:"text",required:"required",name:"username","aria-label":"auth-basic-username",onChange:this.onChange,autoFocus:!0}))),Pe.createElement(w,null,Pe.createElement("label",{htmlFor:"auth-basic-password"},"Password:"),o?Pe.createElement("code",null," ****** "):Pe.createElement(x,null,Pe.createElement(_,{id:"auth-basic-password",autoComplete:"new-password",name:"password",type:"password","aria-label":"auth-basic-password",onChange:this.onChange}))),V.valueSeq().map(((s,o)=>Pe.createElement(C,{error:s,key:o}))))}return"bearer"===B?Pe.createElement("div",null,Pe.createElement("h4",null,Pe.createElement("code",null,u||s.get("name")),"  (http, Bearer)",Pe.createElement(L,{path:["securityDefinitions",u]})),$&&Pe.createElement("h6",null,"Authorized"),Pe.createElement(w,null,Pe.createElement(j,{source:s.get("description")})),Pe.createElement(w,null,Pe.createElement("label",{htmlFor:"auth-bearer-value"},"Value:"),$?Pe.createElement("code",null," ****** "):Pe.createElement(x,null,Pe.createElement(_,{id:"auth-bearer-value",type:"text","aria-label":"auth-bearer-value",onChange:this.onChange,autoFocus:!0}))),V.valueSeq().map(((s,o)=>Pe.createElement(C,{error:s,key:o})))):Pe.createElement("div",null,Pe.createElement("em",null,Pe.createElement("b",null,u)," HTTP authentication: unsupported scheme ",`'${B}'`))}}class operation_servers_OperationServers extends Pe.Component{setSelectedServer=s=>{const{path:o,method:i}=this.props;return this.forceUpdate(),this.props.setSelectedServer(s,`${o}:${i}`)};setServerVariableValue=s=>{const{path:o,method:i}=this.props;return this.forceUpdate(),this.props.setServerVariableValue({...s,namespace:`${o}:${i}`})};getSelectedServer=()=>{const{path:s,method:o}=this.props;return this.props.getSelectedServer(`${s}:${o}`)};getServerVariable=(s,o)=>{const{path:i,method:u}=this.props;return this.props.getServerVariable({namespace:`${i}:${u}`,server:s},o)};getEffectiveServerValue=s=>{const{path:o,method:i}=this.props;return this.props.getEffectiveServerValue({server:s,namespace:`${o}:${i}`})};render(){const{operationServers:s,pathServers:o,getComponent:i}=this.props;if(!s&&!o)return null;const u=i("Servers"),_=s||o,w=s?"operation":"path";return Pe.createElement("div",{className:"opblock-section operation-servers"},Pe.createElement("div",{className:"opblock-section-header"},Pe.createElement("div",{className:"tab-header"},Pe.createElement("h4",{className:"opblock-title"},"Servers"))),Pe.createElement("div",{className:"opblock-description-wrapper"},Pe.createElement("h4",{className:"message"},"These ",w,"-level options override the global server options."),Pe.createElement(u,{servers:_,currentServer:this.getSelectedServer(),setSelectedServer:this.setSelectedServer,setServerVariableValue:this.setServerVariableValue,getServerVariable:this.getServerVariable,getEffectiveServerValue:this.getEffectiveServerValue})))}}const rA={Callbacks:callbacks,HttpAuth,RequestBody:components_request_body,Servers:components_servers,ServersContainer,RequestBodyEditor,OperationServers:operation_servers_OperationServers,operationLink:eA},nA=new Remarkable("commonmark");nA.block.ruler.enable(["table"]),nA.set({linkTarget:"_blank"});const sA=OAS3ComponentWrapFactory((({source:s,className:o="",getConfigs:i=()=>({useUnsafeMarkdown:!1})})=>{if("string"!=typeof s)return null;if(s){const{useUnsafeMarkdown:u}=i(),_=sanitizer(nA.render(s),{useUnsafeMarkdown:u});let w;return"string"==typeof _&&(w=_.trim()),Pe.createElement("div",{dangerouslySetInnerHTML:{__html:w},className:Hn()(o,"renderedMarkdown")})}return null})),oA=OAS3ComponentWrapFactory((({Ori:s,...o})=>{const{schema:i,getComponent:u,errSelectors:_,authorized:w,onAuthChange:x,name:C}=o,j=u("HttpAuth");return"http"===i.get("type")?Pe.createElement(j,{key:C,schema:i,name:C,errSelectors:_,authorized:w,getComponent:u,onChange:x}):Pe.createElement(s,o)})),iA=OAS3ComponentWrapFactory(OnlineValidatorBadge);class ModelComponent extends Pe.Component{render(){let{getConfigs:s,schema:o,Ori:i}=this.props,u=["model-box"],_=null;return!0===o.get("deprecated")&&(u.push("deprecated"),_=Pe.createElement("span",{className:"model-deprecated-warning"},"Deprecated:")),Pe.createElement("div",{className:u.join(" ")},_,Pe.createElement(i,Rn()({},this.props,{getConfigs:s,depth:1,expandDepth:this.props.expandDepth||0})))}}const aA=OAS3ComponentWrapFactory(ModelComponent),lA=OAS3ComponentWrapFactory((({Ori:s,...o})=>{const{schema:i,getComponent:u,errors:_,onChange:w}=o,x=i&&i.get?i.get("format"):null,C=i&&i.get?i.get("type"):null,j=u("Input");return C&&"string"===C&&x&&("binary"===x||"base64"===x)?Pe.createElement(j,{type:"file",className:_.length?"invalid":"",title:_.length?_:"",onChange:s=>{w(s.target.files[0])},disabled:s.isDisabled}):Pe.createElement(s,o)})),cA={Markdown:sA,AuthItem:oA,OpenAPIVersion:function OAS30ComponentWrapFactory(s){return(o,i)=>u=>"function"==typeof i.specSelectors?.isOAS30?i.specSelectors.isOAS30()?Pe.createElement(s,Rn()({},u,i,{Ori:o})):Pe.createElement(o,u):(console.warn("OAS30 wrapper: couldn't get spec"),null)}((s=>{const{Ori:o}=s;return Pe.createElement(o,{oasVersion:"3.0"})})),JsonSchema_string:lA,model:aA,onlineValidatorBadge:iA},uA="oas3_set_servers",pA="oas3_set_request_body_value",hA="oas3_set_request_body_retain_flag",dA="oas3_set_request_body_inclusion",fA="oas3_set_active_examples_member",mA="oas3_set_request_content_type",gA="oas3_set_response_content_type",yA="oas3_set_server_variable_value",vA="oas3_set_request_body_validate_error",bA="oas3_clear_request_body_validate_error",_A="oas3_clear_request_body_value";function setSelectedServer(s,o){return{type:uA,payload:{selectedServerUrl:s,namespace:o}}}function setRequestBodyValue({value:s,pathMethod:o}){return{type:pA,payload:{value:s,pathMethod:o}}}const setRetainRequestBodyValueFlag=({value:s,pathMethod:o})=>({type:hA,payload:{value:s,pathMethod:o}});function setRequestBodyInclusion({value:s,pathMethod:o,name:i}){return{type:dA,payload:{value:s,pathMethod:o,name:i}}}function setActiveExamplesMember({name:s,pathMethod:o,contextType:i,contextName:u}){return{type:fA,payload:{name:s,pathMethod:o,contextType:i,contextName:u}}}function setRequestContentType({value:s,pathMethod:o}){return{type:mA,payload:{value:s,pathMethod:o}}}function setResponseContentType({value:s,path:o,method:i}){return{type:gA,payload:{value:s,path:o,method:i}}}function setServerVariableValue({server:s,namespace:o,key:i,val:u}){return{type:yA,payload:{server:s,namespace:o,key:i,val:u}}}const setRequestBodyValidateError=({path:s,method:o,validationErrors:i})=>({type:vA,payload:{path:s,method:o,validationErrors:i}}),clearRequestBodyValidateError=({path:s,method:o})=>({type:bA,payload:{path:s,method:o}}),initRequestBodyValidateError=({pathMethod:s})=>({type:bA,payload:{path:s[0],method:s[1]}}),clearRequestBodyValue=({pathMethod:s})=>({type:_A,payload:{pathMethod:s}});var EA=__webpack_require__(60680),wA=__webpack_require__.n(EA);const oas3_selectors_onlyOAS3=s=>(o,...i)=>u=>{if(u.getSystem().specSelectors.isOAS3()){const _=s(o,...i);return"function"==typeof _?_(u):_}return null};const SA=oas3_selectors_onlyOAS3(((s,o)=>{const i=o?[o,"selectedServer"]:["selectedServer"];return s.getIn(i)||""})),xA=oas3_selectors_onlyOAS3(((s,o,i)=>s.getIn(["requestData",o,i,"bodyValue"])||null)),kA=oas3_selectors_onlyOAS3(((s,o,i)=>s.getIn(["requestData",o,i,"retainBodyValue"])||!1)),selectDefaultRequestBodyValue=(s,o,i)=>s=>{const{oas3Selectors:u,specSelectors:_,fn:w}=s.getSystem();if(_.isOAS3()){const s=u.requestContentType(o,i);if(s)return getDefaultRequestBodyValue(_.specResolvedSubtree(["paths",o,i,"requestBody"]),s,u.activeExamplesMember(o,i,"requestBody","requestBody"),w)}return null},CA=oas3_selectors_onlyOAS3(((s,o,i)=>s=>{const{oas3Selectors:u,specSelectors:_,fn:w}=s;let x=!1;const C=u.requestContentType(o,i);let j=u.requestBodyValue(o,i);const L=_.specResolvedSubtree(["paths",o,i,"requestBody"]);if(!L)return!1;if(qe.Map.isMap(j)&&(j=stringify(j.mapEntries((s=>qe.Map.isMap(s[1])?[s[0],s[1].get("value")]:s)).toJS())),qe.List.isList(j)&&(j=stringify(j)),C){const s=getDefaultRequestBodyValue(L,C,u.activeExamplesMember(o,i,"requestBody","requestBody"),w);x=!!j&&j!==s}return x})),OA=oas3_selectors_onlyOAS3(((s,o,i)=>s.getIn(["requestData",o,i,"bodyInclusion"])||(0,qe.Map)())),AA=oas3_selectors_onlyOAS3(((s,o,i)=>s.getIn(["requestData",o,i,"errors"])||null)),jA=oas3_selectors_onlyOAS3(((s,o,i,u,_)=>s.getIn(["examples",o,i,u,_,"activeExample"])||null)),IA=oas3_selectors_onlyOAS3(((s,o,i)=>s.getIn(["requestData",o,i,"requestContentType"])||null)),PA=oas3_selectors_onlyOAS3(((s,o,i)=>s.getIn(["requestData",o,i,"responseContentType"])||null)),MA=oas3_selectors_onlyOAS3(((s,o,i)=>{let u;if("string"!=typeof o){const{server:s,namespace:_}=o;u=_?[_,"serverVariableValues",s,i]:["serverVariableValues",s,i]}else{u=["serverVariableValues",o,i]}return s.getIn(u)||null})),TA=oas3_selectors_onlyOAS3(((s,o)=>{let i;if("string"!=typeof o){const{server:s,namespace:u}=o;i=u?[u,"serverVariableValues",s]:["serverVariableValues",s]}else{i=["serverVariableValues",o]}return s.getIn(i)||(0,qe.OrderedMap)()})),NA=oas3_selectors_onlyOAS3(((s,o)=>{var i,u;if("string"!=typeof o){const{server:_,namespace:w}=o;u=_,i=w?s.getIn([w,"serverVariableValues",u]):s.getIn(["serverVariableValues",u])}else u=o,i=s.getIn(["serverVariableValues",u]);i=i||(0,qe.OrderedMap)();let _=u;return i.map(((s,o)=>{_=_.replace(new RegExp(`{${wA()(o)}}`,"g"),s)})),_})),RA=function validateRequestBodyIsRequired(s){return(...o)=>i=>{const u=i.getSystem().specSelectors.specJson();let _=[...o][1]||[];return!u.getIn(["paths",..._,"requestBody","required"])||s(...o)}}(((s,o)=>((s,o)=>(o=o||[],!!s.getIn(["requestData",...o,"bodyValue"])))(s,o))),validateShallowRequired=(s,{oas3RequiredRequestBodyContentType:o,oas3RequestContentType:i,oas3RequestBodyValue:u})=>{let _=[];if(!qe.Map.isMap(u))return _;let w=[];return Object.keys(o.requestContentType).forEach((s=>{if(s===i){o.requestContentType[s].forEach((s=>{w.indexOf(s)<0&&w.push(s)}))}})),w.forEach((s=>{u.getIn([s,"value"])||_.push(s)})),_},DA=Ss()(["get","put","post","delete","options","head","patch","trace"]),LA={[uA]:(s,{payload:{selectedServerUrl:o,namespace:i}})=>{const u=i?[i,"selectedServer"]:["selectedServer"];return s.setIn(u,o)},[pA]:(s,{payload:{value:o,pathMethod:i}})=>{let[u,_]=i;if(!qe.Map.isMap(o))return s.setIn(["requestData",u,_,"bodyValue"],o);let w,x=s.getIn(["requestData",u,_,"bodyValue"])||(0,qe.Map)();qe.Map.isMap(x)||(x=(0,qe.Map)());const[...C]=o.keys();return C.forEach((s=>{let i=o.getIn([s]);x.has(s)&&qe.Map.isMap(i)||(w=x.setIn([s,"value"],i))})),s.setIn(["requestData",u,_,"bodyValue"],w)},[hA]:(s,{payload:{value:o,pathMethod:i}})=>{let[u,_]=i;return s.setIn(["requestData",u,_,"retainBodyValue"],o)},[dA]:(s,{payload:{value:o,pathMethod:i,name:u}})=>{let[_,w]=i;return s.setIn(["requestData",_,w,"bodyInclusion",u],o)},[fA]:(s,{payload:{name:o,pathMethod:i,contextType:u,contextName:_}})=>{let[w,x]=i;return s.setIn(["examples",w,x,u,_,"activeExample"],o)},[mA]:(s,{payload:{value:o,pathMethod:i}})=>{let[u,_]=i;return s.setIn(["requestData",u,_,"requestContentType"],o)},[gA]:(s,{payload:{value:o,path:i,method:u}})=>s.setIn(["requestData",i,u,"responseContentType"],o),[yA]:(s,{payload:{server:o,namespace:i,key:u,val:_}})=>{const w=i?[i,"serverVariableValues",o,u]:["serverVariableValues",o,u];return s.setIn(w,_)},[vA]:(s,{payload:{path:o,method:i,validationErrors:u}})=>{let _=[];if(_.push("Required field is not provided"),u.missingBodyValue)return s.setIn(["requestData",o,i,"errors"],(0,qe.fromJS)(_));if(u.missingRequiredKeys&&u.missingRequiredKeys.length>0){const{missingRequiredKeys:w}=u;return s.updateIn(["requestData",o,i,"bodyValue"],(0,qe.fromJS)({}),(s=>w.reduce(((s,o)=>s.setIn([o,"errors"],(0,qe.fromJS)(_))),s)))}return console.warn("unexpected result: SET_REQUEST_BODY_VALIDATE_ERROR"),s},[bA]:(s,{payload:{path:o,method:i}})=>{const u=s.getIn(["requestData",o,i,"bodyValue"]);if(!qe.Map.isMap(u))return s.setIn(["requestData",o,i,"errors"],(0,qe.fromJS)([]));const[..._]=u.keys();return _?s.updateIn(["requestData",o,i,"bodyValue"],(0,qe.fromJS)({}),(s=>_.reduce(((s,o)=>s.setIn([o,"errors"],(0,qe.fromJS)([]))),s))):s},[_A]:(s,{payload:{pathMethod:o}})=>{let[i,u]=o;const _=s.getIn(["requestData",i,u,"bodyValue"]);return _?qe.Map.isMap(_)?s.setIn(["requestData",i,u,"bodyValue"],(0,qe.Map)()):s.setIn(["requestData",i,u,"bodyValue"],""):s}};function oas3(){return{components:rA,wrapComponents:cA,statePlugins:{spec:{wrapSelectors:be,selectors:we},auth:{wrapSelectors:_e},oas3:{actions:{...Se},reducers:LA,selectors:{...xe}}}}}const webhooks=({specSelectors:s,getComponent:o})=>{const i=s.selectWebhooksOperations(),u=Object.keys(i),_=o("OperationContainer",!0);return 0===u.length?null:Pe.createElement("div",{className:"webhooks"},Pe.createElement("h2",null,"Webhooks"),u.map((s=>Pe.createElement("div",{key:`${s}-webhook`},i[s].map((o=>Pe.createElement(_,{key:`${s}-${o.method}-webhook`,op:o.operation,tag:"webhooks",method:o.method,path:s,specPath:(0,qe.List)(o.specPath),allowTryItOut:!1})))))))},oas31_components_license=({getComponent:s,specSelectors:o})=>{const i=o.selectLicenseNameField(),u=o.selectLicenseUrl(),_=s("Link");return Pe.createElement("div",{className:"info__license"},u?Pe.createElement("div",{className:"info__license__url"},Pe.createElement(_,{target:"_blank",href:sanitizeUrl(u)},i)):Pe.createElement("span",null,i))},oas31_components_contact=({getComponent:s,specSelectors:o})=>{const i=o.selectContactNameField(),u=o.selectContactUrl(),_=o.selectContactEmailField(),w=s("Link");return Pe.createElement("div",{className:"info__contact"},u&&Pe.createElement("div",null,Pe.createElement(w,{href:sanitizeUrl(u),target:"_blank"},i," - Website")),_&&Pe.createElement(w,{href:sanitizeUrl(`mailto:${_}`)},u?`Send email to ${i}`:`Contact ${i}`))},oas31_components_info=({getComponent:s,specSelectors:o})=>{const i=o.version(),u=o.url(),_=o.basePath(),w=o.host(),x=o.selectInfoSummaryField(),C=o.selectInfoDescriptionField(),j=o.selectInfoTitleField(),L=o.selectInfoTermsOfServiceUrl(),B=o.selectExternalDocsUrl(),$=o.selectExternalDocsDescriptionField(),V=o.contact(),U=o.license(),z=s("Markdown",!0),Y=s("Link"),Z=s("VersionStamp"),ee=s("OpenAPIVersion"),ie=s("InfoUrl"),ae=s("InfoBasePath"),le=s("License",!0),ce=s("Contact",!0),pe=s("JsonSchemaDialect",!0);return Pe.createElement("div",{className:"info"},Pe.createElement("hgroup",{className:"main"},Pe.createElement("h2",{className:"title"},j,Pe.createElement("span",null,i&&Pe.createElement(Z,{version:i}),Pe.createElement(ee,{oasVersion:"3.1"}))),(w||_)&&Pe.createElement(ae,{host:w,basePath:_}),u&&Pe.createElement(ie,{getComponent:s,url:u})),x&&Pe.createElement("p",{className:"info__summary"},x),Pe.createElement("div",{className:"info__description description"},Pe.createElement(z,{source:C})),L&&Pe.createElement("div",{className:"info__tos"},Pe.createElement(Y,{target:"_blank",href:sanitizeUrl(L)},"Terms of service")),V.size>0&&Pe.createElement(ce,null),U.size>0&&Pe.createElement(le,null),B&&Pe.createElement(Y,{className:"info__extdocs",target:"_blank",href:sanitizeUrl(B)},$||B),Pe.createElement(pe,null))},json_schema_dialect=({getComponent:s,specSelectors:o})=>{const i=o.selectJsonSchemaDialectField(),u=o.selectJsonSchemaDialectDefault(),_=s("Link");return Pe.createElement(Pe.Fragment,null,i&&i===u&&Pe.createElement("p",{className:"info__jsonschemadialect"},"JSON Schema dialect:"," ",Pe.createElement(_,{target:"_blank",href:sanitizeUrl(i)},i)),i&&i!==u&&Pe.createElement("div",{className:"error-wrapper"},Pe.createElement("div",{className:"no-margin"},Pe.createElement("div",{className:"errors"},Pe.createElement("div",{className:"errors-wrapper"},Pe.createElement("h4",{className:"center"},"Warning"),Pe.createElement("p",{className:"message"},Pe.createElement("strong",null,"OpenAPI.jsonSchemaDialect")," field contains a value different from the default value of"," ",Pe.createElement(_,{target:"_blank",href:u},u),". Values different from the default one are currently not supported. Please either omit the field or provide it with the default value."))))))},version_pragma_filter=({bypass:s,isSwagger2:o,isOAS3:i,isOAS31:u,alsoShow:_,children:w})=>s?Pe.createElement("div",null,w):o&&(i||u)?Pe.createElement("div",{className:"version-pragma"},_,Pe.createElement("div",{className:"version-pragma__message version-pragma__message--ambiguous"},Pe.createElement("div",null,Pe.createElement("h3",null,"Unable to render this definition"),Pe.createElement("p",null,Pe.createElement("code",null,"swagger")," and ",Pe.createElement("code",null,"openapi")," fields cannot be present in the same Swagger or OpenAPI definition. Please remove one of the fields."),Pe.createElement("p",null,"Supported version fields are ",Pe.createElement("code",null,'swagger: "2.0"')," and those that match ",Pe.createElement("code",null,"openapi: 3.x.y")," (for example,"," ",Pe.createElement("code",null,"openapi: 3.1.0"),").")))):o||i||u?Pe.createElement("div",null,w):Pe.createElement("div",{className:"version-pragma"},_,Pe.createElement("div",{className:"version-pragma__message version-pragma__message--missing"},Pe.createElement("div",null,Pe.createElement("h3",null,"Unable to render this definition"),Pe.createElement("p",null,"The provided definition does not specify a valid version field."),Pe.createElement("p",null,"Please indicate a valid Swagger or OpenAPI version field. Supported version fields are ",Pe.createElement("code",null,'swagger: "2.0"')," and those that match ",Pe.createElement("code",null,"openapi: 3.x.y")," (for example,"," ",Pe.createElement("code",null,"openapi: 3.1.0"),").")))),getModelName=s=>"string"==typeof s&&s.includes("#/components/schemas/")?(s=>{const o=s.replace(/~1/g,"/").replace(/~0/g,"~");try{return decodeURIComponent(o)}catch{return o}})(s.replace(/^.*#\/components\/schemas\//,"")):null,BA=(0,Pe.forwardRef)((({schema:s,getComponent:o,onToggle:i=()=>{}},u)=>{const _=o("JSONSchema202012"),w=getModelName(s.get("$$ref")),x=(0,Pe.useCallback)(((s,o)=>{i(w,o)}),[w,i]);return Pe.createElement(_,{name:w,schema:s.toJS(),ref:u,onExpand:x})})),FA=BA,models=({specActions:s,specSelectors:o,layoutSelectors:i,layoutActions:u,getComponent:_,getConfigs:w,fn:x})=>{const C=o.selectSchemas(),j=Object.keys(C).length>0,L=["components","schemas"],{docExpansion:B,defaultModelsExpandDepth:$}=w(),V=$>0&&"none"!==B,U=i.isShown(L,V),z=_("Collapse"),Y=_("JSONSchema202012"),Z=_("ArrowUpIcon"),ee=_("ArrowDownIcon"),{getTitle:ie}=x.jsonSchema202012.useFn();(0,Pe.useEffect)((()=>{const i=U&&$>1,u=null!=o.specResolvedSubtree(L);i&&!u&&s.requestResolvedSubtree(L)}),[U,$]);const ae=(0,Pe.useCallback)((()=>{u.show(L,!U)}),[U]),le=(0,Pe.useCallback)((s=>{null!==s&&u.readyToScroll(L,s)}),[]),handleJSONSchema202012Ref=s=>o=>{null!==o&&u.readyToScroll([...L,s],o)},handleJSONSchema202012Expand=i=>(u,_)=>{if(_){const u=[...L,i];null!=o.specResolvedSubtree(u)||s.requestResolvedSubtree([...L,i])}};return!j||$<0?null:Pe.createElement("section",{className:Hn()("models",{"is-open":U}),ref:le},Pe.createElement("h4",null,Pe.createElement("button",{"aria-expanded":U,className:"models-control",onClick:ae},Pe.createElement("span",null,"Schemas"),U?Pe.createElement(Z,null):Pe.createElement(ee,null))),Pe.createElement(z,{isOpened:U},Object.entries(C).map((([s,o])=>{const i=ie(o,{lookup:"basic"})||s;return Pe.createElement(Y,{key:s,ref:handleJSONSchema202012Ref(s),schema:o,name:i,onExpand:handleJSONSchema202012Expand(s)})}))))},mutual_tls_auth=({schema:s,getComponent:o})=>{const i=o("JumpToPath",!0);return Pe.createElement("div",null,Pe.createElement("h4",null,s.get("name")," (mutualTLS)"," ",Pe.createElement(i,{path:["securityDefinitions",s.get("name")]})),Pe.createElement("p",null,"Mutual TLS is required by this API/Operation. Certificates are managed via your Operating System and/or your browser."),Pe.createElement("p",null,s.get("description")))};class auths_Auths extends Pe.Component{constructor(s,o){super(s,o),this.state={}}onAuthChange=s=>{let{name:o}=s;this.setState({[o]:s})};submitAuth=s=>{s.preventDefault();let{authActions:o}=this.props;o.authorizeWithPersistOption(this.state)};logoutClick=s=>{s.preventDefault();let{authActions:o,definitions:i}=this.props,u=i.map(((s,o)=>o)).toArray();this.setState(u.reduce(((s,o)=>(s[o]="",s)),{})),o.logoutWithPersistOption(u)};close=s=>{s.preventDefault();let{authActions:o}=this.props;o.showDefinitions(!1)};render(){let{definitions:s,getComponent:o,authSelectors:i,errSelectors:u}=this.props;const _=o("AuthItem"),w=o("oauth2",!0),x=o("Button"),C=i.authorized(),j=s.filter(((s,o)=>!!C.get(o))),L=s.filter((s=>"oauth2"!==s.get("type")&&"mutualTLS"!==s.get("type"))),B=s.filter((s=>"oauth2"===s.get("type"))),$=s.filter((s=>"mutualTLS"===s.get("type")));return Pe.createElement("div",{className:"auth-container"},L.size>0&&Pe.createElement("form",{onSubmit:this.submitAuth},L.map(((s,i)=>Pe.createElement(_,{key:i,schema:s,name:i,getComponent:o,onAuthChange:this.onAuthChange,authorized:C,errSelectors:u}))).toArray(),Pe.createElement("div",{className:"auth-btn-wrapper"},L.size===j.size?Pe.createElement(x,{className:"btn modal-btn auth",onClick:this.logoutClick,"aria-label":"Remove authorization"},"Logout"):Pe.createElement(x,{type:"submit",className:"btn modal-btn auth authorize","aria-label":"Apply credentials"},"Authorize"),Pe.createElement(x,{className:"btn modal-btn auth btn-done",onClick:this.close},"Close"))),B.size>0?Pe.createElement("div",null,Pe.createElement("div",{className:"scope-def"},Pe.createElement("p",null,"Scopes are used to grant an application different levels of access to data on behalf of the end user. Each API may declare one or more scopes."),Pe.createElement("p",null,"API requires the following scopes. Select which ones you want to grant to Swagger UI.")),s.filter((s=>"oauth2"===s.get("type"))).map(((s,o)=>Pe.createElement("div",{key:o},Pe.createElement(w,{authorized:C,schema:s,name:o})))).toArray()):null,$.size>0&&Pe.createElement("div",null,$.map(((s,i)=>Pe.createElement(_,{key:i,schema:s,name:i,getComponent:o,onAuthChange:this.onAuthChange,authorized:C,errSelectors:u}))).toArray()))}}const qA=auths_Auths,isOAS31=s=>{const o=s.get("openapi");return"string"==typeof o&&/^3\.1\.(?:[1-9]\d*|0)$/.test(o)},fn_createOnlyOAS31Selector=s=>(o,...i)=>u=>{if(u.getSystem().specSelectors.isOAS31()){const _=s(o,...i);return"function"==typeof _?_(u):_}return null},createOnlyOAS31SelectorWrapper=s=>(o,i)=>(u,..._)=>{if(i.getSystem().specSelectors.isOAS31()){const w=s(u,..._);return"function"==typeof w?w(o,i):w}return o(..._)},fn_createSystemSelector=s=>(o,...i)=>u=>{const _=s(o,u,...i);return"function"==typeof _?_(u):_},createOnlyOAS31ComponentWrapper=s=>(o,i)=>u=>i.specSelectors.isOAS31()?Pe.createElement(s,Rn()({},u,{originalComponent:o,getSystem:i.getSystem})):Pe.createElement(o,u),$A=createOnlyOAS31ComponentWrapper((({getSystem:s})=>{const o=s().getComponent("OAS31License",!0);return Pe.createElement(o,null)})),VA=createOnlyOAS31ComponentWrapper((({getSystem:s})=>{const o=s().getComponent("OAS31Contact",!0);return Pe.createElement(o,null)})),UA=createOnlyOAS31ComponentWrapper((({getSystem:s})=>{const o=s().getComponent("OAS31Info",!0);return Pe.createElement(o,null)})),zA=createOnlyOAS31ComponentWrapper((({getSystem:s,...o})=>{const i=s(),{getComponent:u,fn:_,getConfigs:w}=i,x=w(),C=u("OAS31Model"),j=u("JSONSchema202012"),L=u("JSONSchema202012Keyword$schema"),B=u("JSONSchema202012Keyword$vocabulary"),$=u("JSONSchema202012Keyword$id"),V=u("JSONSchema202012Keyword$anchor"),U=u("JSONSchema202012Keyword$dynamicAnchor"),z=u("JSONSchema202012Keyword$ref"),Y=u("JSONSchema202012Keyword$dynamicRef"),Z=u("JSONSchema202012Keyword$defs"),ee=u("JSONSchema202012Keyword$comment"),ie=u("JSONSchema202012KeywordAllOf"),ae=u("JSONSchema202012KeywordAnyOf"),le=u("JSONSchema202012KeywordOneOf"),ce=u("JSONSchema202012KeywordNot"),pe=u("JSONSchema202012KeywordIf"),de=u("JSONSchema202012KeywordThen"),fe=u("JSONSchema202012KeywordElse"),ye=u("JSONSchema202012KeywordDependentSchemas"),be=u("JSONSchema202012KeywordPrefixItems"),_e=u("JSONSchema202012KeywordItems"),we=u("JSONSchema202012KeywordContains"),Se=u("JSONSchema202012KeywordProperties"),xe=u("JSONSchema202012KeywordPatternProperties"),Te=u("JSONSchema202012KeywordAdditionalProperties"),Re=u("JSONSchema202012KeywordPropertyNames"),qe=u("JSONSchema202012KeywordUnevaluatedItems"),$e=u("JSONSchema202012KeywordUnevaluatedProperties"),ze=u("JSONSchema202012KeywordType"),We=u("JSONSchema202012KeywordEnum"),He=u("JSONSchema202012KeywordConst"),Ye=u("JSONSchema202012KeywordConstraint"),Xe=u("JSONSchema202012KeywordDependentRequired"),Qe=u("JSONSchema202012KeywordContentSchema"),et=u("JSONSchema202012KeywordTitle"),tt=u("JSONSchema202012KeywordDescription"),rt=u("JSONSchema202012KeywordDefault"),nt=u("JSONSchema202012KeywordDeprecated"),st=u("JSONSchema202012KeywordReadOnly"),ot=u("JSONSchema202012KeywordWriteOnly"),it=u("JSONSchema202012Accordion"),at=u("JSONSchema202012ExpandDeepButton"),lt=u("JSONSchema202012ChevronRightIcon"),ct=u("withJSONSchema202012Context")(C,{config:{default$schema:"https://spec.openapis.org/oas/3.1/dialect/base",defaultExpandedLevels:x.defaultModelExpandDepth,includeReadOnly:Boolean(o.includeReadOnly),includeWriteOnly:Boolean(o.includeWriteOnly)},components:{JSONSchema:j,Keyword$schema:L,Keyword$vocabulary:B,Keyword$id:$,Keyword$anchor:V,Keyword$dynamicAnchor:U,Keyword$ref:z,Keyword$dynamicRef:Y,Keyword$defs:Z,Keyword$comment:ee,KeywordAllOf:ie,KeywordAnyOf:ae,KeywordOneOf:le,KeywordNot:ce,KeywordIf:pe,KeywordThen:de,KeywordElse:fe,KeywordDependentSchemas:ye,KeywordPrefixItems:be,KeywordItems:_e,KeywordContains:we,KeywordProperties:Se,KeywordPatternProperties:xe,KeywordAdditionalProperties:Te,KeywordPropertyNames:Re,KeywordUnevaluatedItems:qe,KeywordUnevaluatedProperties:$e,KeywordType:ze,KeywordEnum:We,KeywordConst:He,KeywordConstraint:Ye,KeywordDependentRequired:Xe,KeywordContentSchema:Qe,KeywordTitle:et,KeywordDescription:tt,KeywordDefault:rt,KeywordDeprecated:nt,KeywordReadOnly:st,KeywordWriteOnly:ot,Accordion:it,ExpandDeepButton:at,ChevronRightIcon:lt},fn:{upperFirst:_.upperFirst,isExpandable:_.jsonSchema202012.isExpandable,getProperties:_.jsonSchema202012.getProperties}});return Pe.createElement(ct,o)})),WA=zA,KA=createOnlyOAS31ComponentWrapper((({getSystem:s})=>{const{getComponent:o,fn:i,getConfigs:u}=s(),_=u();if(KA.ModelsWithJSONSchemaContext)return Pe.createElement(KA.ModelsWithJSONSchemaContext,null);const w=o("OAS31Models",!0),x=o("JSONSchema202012"),C=o("JSONSchema202012Keyword$schema"),j=o("JSONSchema202012Keyword$vocabulary"),L=o("JSONSchema202012Keyword$id"),B=o("JSONSchema202012Keyword$anchor"),$=o("JSONSchema202012Keyword$dynamicAnchor"),V=o("JSONSchema202012Keyword$ref"),U=o("JSONSchema202012Keyword$dynamicRef"),z=o("JSONSchema202012Keyword$defs"),Y=o("JSONSchema202012Keyword$comment"),Z=o("JSONSchema202012KeywordAllOf"),ee=o("JSONSchema202012KeywordAnyOf"),ie=o("JSONSchema202012KeywordOneOf"),ae=o("JSONSchema202012KeywordNot"),le=o("JSONSchema202012KeywordIf"),ce=o("JSONSchema202012KeywordThen"),pe=o("JSONSchema202012KeywordElse"),de=o("JSONSchema202012KeywordDependentSchemas"),fe=o("JSONSchema202012KeywordPrefixItems"),ye=o("JSONSchema202012KeywordItems"),be=o("JSONSchema202012KeywordContains"),_e=o("JSONSchema202012KeywordProperties"),we=o("JSONSchema202012KeywordPatternProperties"),Se=o("JSONSchema202012KeywordAdditionalProperties"),xe=o("JSONSchema202012KeywordPropertyNames"),Te=o("JSONSchema202012KeywordUnevaluatedItems"),Re=o("JSONSchema202012KeywordUnevaluatedProperties"),qe=o("JSONSchema202012KeywordType"),$e=o("JSONSchema202012KeywordEnum"),ze=o("JSONSchema202012KeywordConst"),We=o("JSONSchema202012KeywordConstraint"),He=o("JSONSchema202012KeywordDependentRequired"),Ye=o("JSONSchema202012KeywordContentSchema"),Xe=o("JSONSchema202012KeywordTitle"),Qe=o("JSONSchema202012KeywordDescription"),et=o("JSONSchema202012KeywordDefault"),tt=o("JSONSchema202012KeywordDeprecated"),rt=o("JSONSchema202012KeywordReadOnly"),nt=o("JSONSchema202012KeywordWriteOnly"),st=o("JSONSchema202012Accordion"),ot=o("JSONSchema202012ExpandDeepButton"),it=o("JSONSchema202012ChevronRightIcon"),at=o("withJSONSchema202012Context");return KA.ModelsWithJSONSchemaContext=at(w,{config:{default$schema:"https://spec.openapis.org/oas/3.1/dialect/base",defaultExpandedLevels:_.defaultModelsExpandDepth-1,includeReadOnly:!0,includeWriteOnly:!0},components:{JSONSchema:x,Keyword$schema:C,Keyword$vocabulary:j,Keyword$id:L,Keyword$anchor:B,Keyword$dynamicAnchor:$,Keyword$ref:V,Keyword$dynamicRef:U,Keyword$defs:z,Keyword$comment:Y,KeywordAllOf:Z,KeywordAnyOf:ee,KeywordOneOf:ie,KeywordNot:ae,KeywordIf:le,KeywordThen:ce,KeywordElse:pe,KeywordDependentSchemas:de,KeywordPrefixItems:fe,KeywordItems:ye,KeywordContains:be,KeywordProperties:_e,KeywordPatternProperties:we,KeywordAdditionalProperties:Se,KeywordPropertyNames:xe,KeywordUnevaluatedItems:Te,KeywordUnevaluatedProperties:Re,KeywordType:qe,KeywordEnum:$e,KeywordConst:ze,KeywordConstraint:We,KeywordDependentRequired:He,KeywordContentSchema:Ye,KeywordTitle:Xe,KeywordDescription:Qe,KeywordDefault:et,KeywordDeprecated:tt,KeywordReadOnly:rt,KeywordWriteOnly:nt,Accordion:st,ExpandDeepButton:ot,ChevronRightIcon:it},fn:{upperFirst:i.upperFirst,isExpandable:i.jsonSchema202012.isExpandable,getProperties:i.jsonSchema202012.getProperties}}),Pe.createElement(KA.ModelsWithJSONSchemaContext,null)}));KA.ModelsWithJSONSchemaContext=null;const HA=KA,wrap_components_version_pragma_filter=(s,o)=>s=>{const i=o.specSelectors.isOAS31(),u=o.getComponent("OAS31VersionPragmaFilter");return Pe.createElement(u,Rn()({isOAS31:i},s))},JA=createOnlyOAS31ComponentWrapper((({originalComponent:s,...o})=>{const{getComponent:i,schema:u}=o,_=i("MutualTLSAuth",!0);return"mutualTLS"===u.get("type")?Pe.createElement(_,{schema:u}):Pe.createElement(s,o)})),GA=JA,YA=createOnlyOAS31ComponentWrapper((({getSystem:s,...o})=>{const i=s().getComponent("OAS31Auths",!0);return Pe.createElement(i,o)})),XA=(0,qe.Map)(),ZA=Ut(((s,o)=>o.specSelectors.specJson()),isOAS31),selectors_webhooks=()=>s=>{const o=s.specSelectors.specJson().get("webhooks");return qe.Map.isMap(o)?o:XA},QA=Ut([(s,o)=>o.specSelectors.webhooks(),(s,o)=>o.specSelectors.validOperationMethods(),(s,o)=>o.specSelectors.specResolvedSubtree(["webhooks"])],((s,o)=>s.reduce(((s,i,u)=>{if(!qe.Map.isMap(i))return s;const _=i.entrySeq().filter((([s])=>o.includes(s))).map((([s,o])=>({operation:(0,qe.Map)({operation:o}),method:s,path:u,specPath:["webhooks",u,s]})));return s.concat(_)}),(0,qe.List)()).groupBy((s=>s.path)).map((s=>s.toArray())).toObject())),selectors_license=()=>s=>{const o=s.specSelectors.info().get("license");return qe.Map.isMap(o)?o:XA},selectLicenseNameField=()=>s=>s.specSelectors.license().get("name","License"),selectLicenseUrlField=()=>s=>s.specSelectors.license().get("url"),ej=Ut([(s,o)=>o.specSelectors.url(),(s,o)=>o.oas3Selectors.selectedServer(),(s,o)=>o.specSelectors.selectLicenseUrlField()],((s,o,i)=>{if(i)return safeBuildUrl(i,s,{selectedServer:o})})),selectLicenseIdentifierField=()=>s=>s.specSelectors.license().get("identifier"),selectors_contact=()=>s=>{const o=s.specSelectors.info().get("contact");return qe.Map.isMap(o)?o:XA},selectContactNameField=()=>s=>s.specSelectors.contact().get("name","the developer"),selectContactEmailField=()=>s=>s.specSelectors.contact().get("email"),selectContactUrlField=()=>s=>s.specSelectors.contact().get("url"),fj=Ut([(s,o)=>o.specSelectors.url(),(s,o)=>o.oas3Selectors.selectedServer(),(s,o)=>o.specSelectors.selectContactUrlField()],((s,o,i)=>{if(i)return safeBuildUrl(i,s,{selectedServer:o})})),selectInfoTitleField=()=>s=>s.specSelectors.info().get("title"),selectInfoSummaryField=()=>s=>s.specSelectors.info().get("summary"),selectInfoDescriptionField=()=>s=>s.specSelectors.info().get("description"),selectInfoTermsOfServiceField=()=>s=>s.specSelectors.info().get("termsOfService"),mj=Ut([(s,o)=>o.specSelectors.url(),(s,o)=>o.oas3Selectors.selectedServer(),(s,o)=>o.specSelectors.selectInfoTermsOfServiceField()],((s,o,i)=>{if(i)return safeBuildUrl(i,s,{selectedServer:o})})),selectExternalDocsDescriptionField=()=>s=>s.specSelectors.externalDocs().get("description"),selectExternalDocsUrlField=()=>s=>s.specSelectors.externalDocs().get("url"),_j=Ut([(s,o)=>o.specSelectors.url(),(s,o)=>o.oas3Selectors.selectedServer(),(s,o)=>o.specSelectors.selectExternalDocsUrlField()],((s,o,i)=>{if(i)return safeBuildUrl(i,s,{selectedServer:o})})),selectJsonSchemaDialectField=()=>s=>s.specSelectors.specJson().get("jsonSchemaDialect"),selectJsonSchemaDialectDefault=()=>"https://spec.openapis.org/oas/3.1/dialect/base",Cj=Ut(((s,o)=>o.specSelectors.definitions()),((s,o)=>o.specSelectors.specResolvedSubtree(["components","schemas"])),((s,o)=>qe.Map.isMap(s)?qe.Map.isMap(o)?Object.entries(s.toJS()).reduce(((s,[i,u])=>{const _=o.get(i);return s[i]=_?.toJS()||u,s}),{}):s.toJS():{})),wrap_selectors_isOAS3=(s,o)=>(i,...u)=>o.specSelectors.isOAS31()||s(...u),Aj=createOnlyOAS31SelectorWrapper((()=>(s,o)=>o.oas31Selectors.selectLicenseUrl())),Nj=createOnlyOAS31SelectorWrapper((()=>(s,o)=>{const i=o.specSelectors.securityDefinitions();let u=s();return i?(i.entrySeq().forEach((([s,o])=>{"mutualTLS"===o.get("type")&&(u=u.push(new qe.Map({[s]:o})))})),u):u})),Bj=Ut([(s,o)=>o.specSelectors.url(),(s,o)=>o.oas3Selectors.selectedServer(),(s,o)=>o.specSelectors.selectLicenseUrlField(),(s,o)=>o.specSelectors.selectLicenseIdentifierField()],((s,o,i,u)=>i?safeBuildUrl(i,s,{selectedServer:o}):u?`https://spdx.org/licenses/${u}.html`:void 0)),keywords_Example=({schema:s,getSystem:o})=>{const{fn:i}=o(),{hasKeyword:u,stringify:_}=i.jsonSchema202012.useFn();return u(s,"example")?Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--example"},Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary"},"Example"),Pe.createElement("span",{className:"json-schema-2020-12-keyword__value json-schema-2020-12-keyword__value--const"},_(s.example))):null},keywords_Xml=({schema:s,getSystem:o})=>{const i=s?.xml||{},{fn:u,getComponent:_}=o(),{useIsExpandedDeeply:w,useComponent:x}=u.jsonSchema202012,C=w(),j=!!(i.name||i.namespace||i.prefix),[L,B]=(0,Pe.useState)(C),[$,V]=(0,Pe.useState)(!1),U=x("Accordion"),z=x("ExpandDeepButton"),Y=_("JSONSchema202012DeepExpansionContext")(),Z=(0,Pe.useCallback)((()=>{B((s=>!s))}),[]),ee=(0,Pe.useCallback)(((s,o)=>{B(o),V(o)}),[]);return 0===Object.keys(i).length?null:Pe.createElement(Y.Provider,{value:$},Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--xml"},j?Pe.createElement(Pe.Fragment,null,Pe.createElement(U,{expanded:L,onChange:Z},Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary"},"XML")),Pe.createElement(z,{expanded:L,onClick:ee})):Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary"},"XML"),!0===i.attribute&&Pe.createElement("span",{className:"json-schema-2020-12__attribute json-schema-2020-12__attribute--muted"},"attribute"),!0===i.wrapped&&Pe.createElement("span",{className:"json-schema-2020-12__attribute json-schema-2020-12__attribute--muted"},"wrapped"),Pe.createElement("strong",{className:"json-schema-2020-12__attribute json-schema-2020-12__attribute--primary"},"object"),Pe.createElement("ul",{className:Hn()("json-schema-2020-12-keyword__children",{"json-schema-2020-12-keyword__children--collapsed":!L})},L&&Pe.createElement(Pe.Fragment,null,i.name&&Pe.createElement("li",{className:"json-schema-2020-12-property"},Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword"},Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary"},"name"),Pe.createElement("span",{className:"json-schema-2020-12-keyword__value json-schema-2020-12-keyword__value--secondary"},i.name))),i.namespace&&Pe.createElement("li",{className:"json-schema-2020-12-property"},Pe.createElement("div",{className:"json-schema-2020-12-keyword"},Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary"},"namespace"),Pe.createElement("span",{className:"json-schema-2020-12-keyword__value json-schema-2020-12-keyword__value--secondary"},i.namespace))),i.prefix&&Pe.createElement("li",{className:"json-schema-2020-12-property"},Pe.createElement("div",{className:"json-schema-2020-12-keyword"},Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary"},"prefix"),Pe.createElement("span",{className:"json-schema-2020-12-keyword__value json-schema-2020-12-keyword__value--secondary"},i.prefix)))))))},Discriminator_DiscriminatorMapping=({discriminator:s})=>{const o=s?.mapping||{};return 0===Object.keys(o).length?null:Object.entries(o).map((([s,o])=>Pe.createElement("div",{key:`${s}-${o}`,className:"json-schema-2020-12-keyword"},Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary"},s),Pe.createElement("span",{className:"json-schema-2020-12-keyword__value json-schema-2020-12-keyword__value--secondary"},o))))},keywords_Discriminator_Discriminator=({schema:s,getSystem:o})=>{const i=s?.discriminator||{},{fn:u,getComponent:_}=o(),{useIsExpandedDeeply:w,useComponent:x}=u.jsonSchema202012,C=w(),j=!!i.mapping,[L,B]=(0,Pe.useState)(C),[$,V]=(0,Pe.useState)(!1),U=x("Accordion"),z=x("ExpandDeepButton"),Y=_("JSONSchema202012DeepExpansionContext")(),Z=(0,Pe.useCallback)((()=>{B((s=>!s))}),[]),ee=(0,Pe.useCallback)(((s,o)=>{B(o),V(o)}),[]);return 0===Object.keys(i).length?null:Pe.createElement(Y.Provider,{value:$},Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--discriminator"},j?Pe.createElement(Pe.Fragment,null,Pe.createElement(U,{expanded:L,onChange:Z},Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary"},"Discriminator")),Pe.createElement(z,{expanded:L,onClick:ee})):Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary"},"Discriminator"),i.propertyName&&Pe.createElement("span",{className:"json-schema-2020-12__attribute json-schema-2020-12__attribute--muted"},i.propertyName),Pe.createElement("strong",{className:"json-schema-2020-12__attribute json-schema-2020-12__attribute--primary"},"object"),Pe.createElement("ul",{className:Hn()("json-schema-2020-12-keyword__children",{"json-schema-2020-12-keyword__children--collapsed":!L})},L&&Pe.createElement("li",{className:"json-schema-2020-12-property"},Pe.createElement(Discriminator_DiscriminatorMapping,{discriminator:i})))))},keywords_ExternalDocs=({schema:s,getSystem:o})=>{const i=s?.externalDocs||{},{fn:u,getComponent:_}=o(),{useIsExpandedDeeply:w,useComponent:x}=u.jsonSchema202012,C=w(),j=!(!i.description&&!i.url),[L,B]=(0,Pe.useState)(C),[$,V]=(0,Pe.useState)(!1),U=x("Accordion"),z=x("ExpandDeepButton"),Y=_("JSONSchema202012KeywordDescription"),Z=_("Link"),ee=_("JSONSchema202012DeepExpansionContext")(),ie=(0,Pe.useCallback)((()=>{B((s=>!s))}),[]),ae=(0,Pe.useCallback)(((s,o)=>{B(o),V(o)}),[]);return 0===Object.keys(i).length?null:Pe.createElement(ee.Provider,{value:$},Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--externalDocs"},j?Pe.createElement(Pe.Fragment,null,Pe.createElement(U,{expanded:L,onChange:ie},Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary"},"External documentation")),Pe.createElement(z,{expanded:L,onClick:ae})):Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary"},"External documentation"),Pe.createElement("strong",{className:"json-schema-2020-12__attribute json-schema-2020-12__attribute--primary"},"object"),Pe.createElement("ul",{className:Hn()("json-schema-2020-12-keyword__children",{"json-schema-2020-12-keyword__children--collapsed":!L})},L&&Pe.createElement(Pe.Fragment,null,i.description&&Pe.createElement("li",{className:"json-schema-2020-12-property"},Pe.createElement(Y,{schema:i,getSystem:o})),i.url&&Pe.createElement("li",{className:"json-schema-2020-12-property"},Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword"},Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary"},"url"),Pe.createElement("span",{className:"json-schema-2020-12-keyword__value json-schema-2020-12-keyword__value--secondary"},Pe.createElement(Z,{target:"_blank",href:sanitizeUrl(i.url)},i.url))))))))},keywords_Description=({schema:s,getSystem:o})=>{if(!s?.description)return null;const{getComponent:i}=o(),u=i("Markdown");return Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--description"},Pe.createElement("div",{className:"json-schema-2020-12-core-keyword__value json-schema-2020-12-core-keyword__value--secondary"},Pe.createElement(u,{source:s.description})))},$j=createOnlyOAS31ComponentWrapper(keywords_Description),zj=createOnlyOAS31ComponentWrapper((({schema:s,getSystem:o,originalComponent:i})=>{const{getComponent:u}=o(),_=u("JSONSchema202012KeywordDiscriminator"),w=u("JSONSchema202012KeywordXml"),x=u("JSONSchema202012KeywordExample"),C=u("JSONSchema202012KeywordExternalDocs");return Pe.createElement(Pe.Fragment,null,Pe.createElement(i,{schema:s}),Pe.createElement(_,{schema:s,getSystem:o}),Pe.createElement(w,{schema:s,getSystem:o}),Pe.createElement(C,{schema:s,getSystem:o}),Pe.createElement(x,{schema:s,getSystem:o}))})),Kj=zj,keywords_Properties=({schema:s,getSystem:o})=>{const{fn:i}=o(),{useComponent:u}=i.jsonSchema202012,{getDependentRequired:_,getProperties:w}=i.jsonSchema202012.useFn(),x=i.jsonSchema202012.useConfig(),C=Array.isArray(s?.required)?s.required:[],j=u("JSONSchema"),L=w(s,x);return 0===Object.keys(L).length?null:Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--properties"},Pe.createElement("ul",null,Object.entries(L).map((([o,i])=>{const u=C.includes(o),w=_(o,s);return Pe.createElement("li",{key:o,className:Hn()("json-schema-2020-12-property",{"json-schema-2020-12-property--required":u})},Pe.createElement(j,{name:o,schema:i,dependentRequired:w}))}))))},Jj=createOnlyOAS31ComponentWrapper(keywords_Properties),getProperties=(s,{includeReadOnly:o,includeWriteOnly:i})=>{if(!s?.properties)return{};const u=Object.entries(s.properties).filter((([,s])=>(!(!0===s?.readOnly)||o)&&(!(!0===s?.writeOnly)||i)));return Object.fromEntries(u)};const Gj=function oas31_after_load_afterLoad({fn:s,getSystem:o}){if(s.jsonSchema202012){const i=((s,o)=>{const{fn:i}=o();if("function"!=typeof s)return null;const{hasKeyword:u}=i.jsonSchema202012;return o=>s(o)||u(o,"example")||o?.xml||o?.discriminator||o?.externalDocs})(s.jsonSchema202012.isExpandable,o);Object.assign(this.fn.jsonSchema202012,{isExpandable:i,getProperties})}if("function"==typeof s.sampleFromSchema&&s.jsonSchema202012){const i=((s,o)=>{const{fn:i,specSelectors:u}=o;return Object.fromEntries(Object.entries(s).map((([s,o])=>{const _=i[s];return[s,(...s)=>u.isOAS31()?o(...s):"function"==typeof _?_(...s):void 0]})))})({sampleFromSchema:s.jsonSchema202012.sampleFromSchema,sampleFromSchemaGeneric:s.jsonSchema202012.sampleFromSchemaGeneric,createXMLExample:s.jsonSchema202012.createXMLExample,memoizedSampleFromSchema:s.jsonSchema202012.memoizedSampleFromSchema,memoizedCreateXMLExample:s.jsonSchema202012.memoizedCreateXMLExample,getJsonSampleSchema:s.jsonSchema202012.getJsonSampleSchema,getYamlSampleSchema:s.jsonSchema202012.getYamlSampleSchema,getXmlSampleSchema:s.jsonSchema202012.getXmlSampleSchema,getSampleSchema:s.jsonSchema202012.getSampleSchema,mergeJsonSchema:s.jsonSchema202012.mergeJsonSchema},o());Object.assign(this.fn,i)}},oas31=({fn:s})=>{const o=s.createSystemSelector||fn_createSystemSelector,i=s.createOnlyOAS31Selector||fn_createOnlyOAS31Selector;return{afterLoad:Gj,fn:{isOAS31,createSystemSelector:fn_createSystemSelector,createOnlyOAS31Selector:fn_createOnlyOAS31Selector},components:{Webhooks:webhooks,JsonSchemaDialect:json_schema_dialect,MutualTLSAuth:mutual_tls_auth,OAS31Info:oas31_components_info,OAS31License:oas31_components_license,OAS31Contact:oas31_components_contact,OAS31VersionPragmaFilter:version_pragma_filter,OAS31Model:FA,OAS31Models:models,OAS31Auths:qA,JSONSchema202012KeywordExample:keywords_Example,JSONSchema202012KeywordXml:keywords_Xml,JSONSchema202012KeywordDiscriminator:keywords_Discriminator_Discriminator,JSONSchema202012KeywordExternalDocs:keywords_ExternalDocs},wrapComponents:{InfoContainer:UA,License:$A,Contact:VA,VersionPragmaFilter:wrap_components_version_pragma_filter,Model:WA,Models:HA,AuthItem:GA,auths:YA,JSONSchema202012KeywordDescription:$j,JSONSchema202012KeywordDefault:Kj,JSONSchema202012KeywordProperties:Jj},statePlugins:{auth:{wrapSelectors:{definitionsToAuthorize:Nj}},spec:{selectors:{isOAS31:o(ZA),license:selectors_license,selectLicenseNameField,selectLicenseUrlField,selectLicenseIdentifierField:i(selectLicenseIdentifierField),selectLicenseUrl:o(ej),contact:selectors_contact,selectContactNameField,selectContactEmailField,selectContactUrlField,selectContactUrl:o(fj),selectInfoTitleField,selectInfoSummaryField:i(selectInfoSummaryField),selectInfoDescriptionField,selectInfoTermsOfServiceField,selectInfoTermsOfServiceUrl:o(mj),selectExternalDocsDescriptionField,selectExternalDocsUrlField,selectExternalDocsUrl:o(_j),webhooks:i(selectors_webhooks),selectWebhooksOperations:i(o(QA)),selectJsonSchemaDialectField,selectJsonSchemaDialectDefault,selectSchemas:o(Cj)},wrapSelectors:{isOAS3:wrap_selectors_isOAS3,selectLicenseUrl:Aj}},oas31:{selectors:{selectLicenseUrl:i(o(Bj))}}}}},Xj=ts().object,eI=ts().bool,tI=(ts().oneOfType([Xj,eI]),(0,Pe.createContext)(null));tI.displayName="JSONSchemaContext";const rI=(0,Pe.createContext)(0);rI.displayName="JSONSchemaLevelContext";const nI=(0,Pe.createContext)(!1);nI.displayName="JSONSchemaDeepExpansionContext";const sI=(0,Pe.createContext)(new Set),useConfig=()=>{const{config:s}=(0,Pe.useContext)(tI);return s},useComponent=s=>{const{components:o}=(0,Pe.useContext)(tI);return o[s]||null},useFn=(s=void 0)=>{const{fn:o}=(0,Pe.useContext)(tI);return void 0!==s?o[s]:o},useLevel=()=>{const s=(0,Pe.useContext)(rI);return[s,s+1]},useIsExpanded=()=>{const[s]=useLevel(),{defaultExpandedLevels:o}=useConfig();return o-s>0},useIsExpandedDeeply=()=>(0,Pe.useContext)(nI),useRenderedSchemas=(s=void 0)=>{if(void 0===s)return(0,Pe.useContext)(sI);const o=(0,Pe.useContext)(sI);return new Set([...o,s])},oI=(0,Pe.forwardRef)((({schema:s,name:o="",dependentRequired:i=[],onExpand:u=()=>{}},_)=>{const w=useFn(),x=useIsExpanded(),C=useIsExpandedDeeply(),[j,L]=(0,Pe.useState)(x||C),[B,$]=(0,Pe.useState)(C),[V,U]=useLevel(),z=(()=>{const[s]=useLevel();return s>0})(),Y=w.isExpandable(s)||i.length>0,Z=(s=>useRenderedSchemas().has(s))(s),ee=useRenderedSchemas(s),ie=w.stringifyConstraints(s),ae=useComponent("Accordion"),le=useComponent("Keyword$schema"),ce=useComponent("Keyword$vocabulary"),pe=useComponent("Keyword$id"),de=useComponent("Keyword$anchor"),fe=useComponent("Keyword$dynamicAnchor"),ye=useComponent("Keyword$ref"),be=useComponent("Keyword$dynamicRef"),_e=useComponent("Keyword$defs"),we=useComponent("Keyword$comment"),Se=useComponent("KeywordAllOf"),xe=useComponent("KeywordAnyOf"),Te=useComponent("KeywordOneOf"),Re=useComponent("KeywordNot"),qe=useComponent("KeywordIf"),$e=useComponent("KeywordThen"),ze=useComponent("KeywordElse"),We=useComponent("KeywordDependentSchemas"),He=useComponent("KeywordPrefixItems"),Ye=useComponent("KeywordItems"),Xe=useComponent("KeywordContains"),Qe=useComponent("KeywordProperties"),et=useComponent("KeywordPatternProperties"),tt=useComponent("KeywordAdditionalProperties"),rt=useComponent("KeywordPropertyNames"),nt=useComponent("KeywordUnevaluatedItems"),st=useComponent("KeywordUnevaluatedProperties"),ot=useComponent("KeywordType"),it=useComponent("KeywordEnum"),at=useComponent("KeywordConst"),lt=useComponent("KeywordConstraint"),ct=useComponent("KeywordDependentRequired"),ut=useComponent("KeywordContentSchema"),pt=useComponent("KeywordTitle"),ht=useComponent("KeywordDescription"),dt=useComponent("KeywordDefault"),mt=useComponent("KeywordDeprecated"),gt=useComponent("KeywordReadOnly"),yt=useComponent("KeywordWriteOnly"),vt=useComponent("ExpandDeepButton");(0,Pe.useEffect)((()=>{$(C)}),[C]),(0,Pe.useEffect)((()=>{$(B)}),[B]);const bt=(0,Pe.useCallback)(((s,o)=>{L(o),!o&&$(!1),u(s,o,!1)}),[u]),_t=(0,Pe.useCallback)(((s,o)=>{L(o),$(o),u(s,o,!0)}),[u]);return Pe.createElement(rI.Provider,{value:U},Pe.createElement(nI.Provider,{value:B},Pe.createElement(sI.Provider,{value:ee},Pe.createElement("article",{ref:_,"data-json-schema-level":V,className:Hn()("json-schema-2020-12",{"json-schema-2020-12--embedded":z,"json-schema-2020-12--circular":Z})},Pe.createElement("div",{className:"json-schema-2020-12-head"},Y&&!Z?Pe.createElement(Pe.Fragment,null,Pe.createElement(ae,{expanded:j,onChange:bt},Pe.createElement(pt,{title:o,schema:s})),Pe.createElement(vt,{expanded:j,onClick:_t})):Pe.createElement(pt,{title:o,schema:s}),Pe.createElement(mt,{schema:s}),Pe.createElement(gt,{schema:s}),Pe.createElement(yt,{schema:s}),Pe.createElement(ot,{schema:s,isCircular:Z}),ie.length>0&&ie.map((s=>Pe.createElement(lt,{key:`${s.scope}-${s.value}`,constraint:s})))),Pe.createElement("div",{className:Hn()("json-schema-2020-12-body",{"json-schema-2020-12-body--collapsed":!j})},j&&Pe.createElement(Pe.Fragment,null,Pe.createElement(ht,{schema:s}),!Z&&Y&&Pe.createElement(Pe.Fragment,null,Pe.createElement(Qe,{schema:s}),Pe.createElement(et,{schema:s}),Pe.createElement(tt,{schema:s}),Pe.createElement(st,{schema:s}),Pe.createElement(rt,{schema:s}),Pe.createElement(Se,{schema:s}),Pe.createElement(xe,{schema:s}),Pe.createElement(Te,{schema:s}),Pe.createElement(Re,{schema:s}),Pe.createElement(qe,{schema:s}),Pe.createElement($e,{schema:s}),Pe.createElement(ze,{schema:s}),Pe.createElement(We,{schema:s}),Pe.createElement(He,{schema:s}),Pe.createElement(Ye,{schema:s}),Pe.createElement(nt,{schema:s}),Pe.createElement(Xe,{schema:s}),Pe.createElement(ut,{schema:s})),Pe.createElement(it,{schema:s}),Pe.createElement(at,{schema:s}),Pe.createElement(ct,{schema:s,dependentRequired:i}),Pe.createElement(dt,{schema:s}),Pe.createElement(le,{schema:s}),Pe.createElement(ce,{schema:s}),Pe.createElement(pe,{schema:s}),Pe.createElement(de,{schema:s}),Pe.createElement(fe,{schema:s}),Pe.createElement(ye,{schema:s}),!Z&&Y&&Pe.createElement(_e,{schema:s}),Pe.createElement(be,{schema:s}),Pe.createElement(we,{schema:s})))))))})),iI=oI,keywords_$schema=({schema:s})=>s?.$schema?Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--$schema"},Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary"},"$schema"),Pe.createElement("span",{className:"json-schema-2020-12-keyword__value json-schema-2020-12-keyword__value--secondary"},s.$schema)):null,$vocabulary_$vocabulary=({schema:s})=>{const o=useIsExpanded(),i=useIsExpandedDeeply(),[u,_]=(0,Pe.useState)(o||i),w=useComponent("Accordion"),x=(0,Pe.useCallback)((()=>{_((s=>!s))}),[]);return s?.$vocabulary?"object"!=typeof s.$vocabulary?null:Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--$vocabulary"},Pe.createElement(w,{expanded:u,onChange:x},Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary"},"$vocabulary")),Pe.createElement("strong",{className:"json-schema-2020-12__attribute json-schema-2020-12__attribute--primary"},"object"),Pe.createElement("ul",null,u&&Object.entries(s.$vocabulary).map((([s,o])=>Pe.createElement("li",{key:s,className:Hn()("json-schema-2020-12-$vocabulary-uri",{"json-schema-2020-12-$vocabulary-uri--disabled":!o})},Pe.createElement("span",{className:"json-schema-2020-12-keyword__value json-schema-2020-12-keyword__value--secondary"},s)))))):null},keywords_$id=({schema:s})=>s?.$id?Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--$id"},Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary"},"$id"),Pe.createElement("span",{className:"json-schema-2020-12-keyword__value json-schema-2020-12-keyword__value--secondary"},s.$id)):null,keywords_$anchor=({schema:s})=>s?.$anchor?Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--$anchor"},Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary"},"$anchor"),Pe.createElement("span",{className:"json-schema-2020-12-keyword__value json-schema-2020-12-keyword__value--secondary"},s.$anchor)):null,keywords_$dynamicAnchor=({schema:s})=>s?.$dynamicAnchor?Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--$dynamicAnchor"},Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary"},"$dynamicAnchor"),Pe.createElement("span",{className:"json-schema-2020-12-keyword__value json-schema-2020-12-keyword__value--secondary"},s.$dynamicAnchor)):null,keywords_$ref=({schema:s})=>s?.$ref?Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--$ref"},Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary"},"$ref"),Pe.createElement("span",{className:"json-schema-2020-12-keyword__value json-schema-2020-12-keyword__value--secondary"},s.$ref)):null,keywords_$dynamicRef=({schema:s})=>s?.$dynamicRef?Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--$dynamicRef"},Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary"},"$dynamicRef"),Pe.createElement("span",{className:"json-schema-2020-12-keyword__value json-schema-2020-12-keyword__value--secondary"},s.$dynamicRef)):null,keywords_$defs=({schema:s})=>{const o=s?.$defs||{},i=useIsExpanded(),u=useIsExpandedDeeply(),[_,w]=(0,Pe.useState)(i||u),[x,C]=(0,Pe.useState)(!1),j=useComponent("Accordion"),L=useComponent("ExpandDeepButton"),B=useComponent("JSONSchema"),$=(0,Pe.useCallback)((()=>{w((s=>!s))}),[]),V=(0,Pe.useCallback)(((s,o)=>{w(o),C(o)}),[]);return 0===Object.keys(o).length?null:Pe.createElement(nI.Provider,{value:x},Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--$defs"},Pe.createElement(j,{expanded:_,onChange:$},Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary"},"$defs")),Pe.createElement(L,{expanded:_,onClick:V}),Pe.createElement("strong",{className:"json-schema-2020-12__attribute json-schema-2020-12__attribute--primary"},"object"),Pe.createElement("ul",{className:Hn()("json-schema-2020-12-keyword__children",{"json-schema-2020-12-keyword__children--collapsed":!_})},_&&Pe.createElement(Pe.Fragment,null,Object.entries(o).map((([s,o])=>Pe.createElement("li",{key:s,className:"json-schema-2020-12-property"},Pe.createElement(B,{name:s,schema:o}))))))))},keywords_$comment=({schema:s})=>s?.$comment?Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--$comment"},Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary"},"$comment"),Pe.createElement("span",{className:"json-schema-2020-12-keyword__value json-schema-2020-12-keyword__value--secondary"},s.$comment)):null,keywords_AllOf=({schema:s})=>{const o=s?.allOf||[],i=useFn(),u=useIsExpanded(),_=useIsExpandedDeeply(),[w,x]=(0,Pe.useState)(u||_),[C,j]=(0,Pe.useState)(!1),L=useComponent("Accordion"),B=useComponent("ExpandDeepButton"),$=useComponent("JSONSchema"),V=useComponent("KeywordType"),U=(0,Pe.useCallback)((()=>{x((s=>!s))}),[]),z=(0,Pe.useCallback)(((s,o)=>{x(o),j(o)}),[]);return Array.isArray(o)&&0!==o.length?Pe.createElement(nI.Provider,{value:C},Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--allOf"},Pe.createElement(L,{expanded:w,onChange:U},Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary"},"All of")),Pe.createElement(B,{expanded:w,onClick:z}),Pe.createElement(V,{schema:{allOf:o}}),Pe.createElement("ul",{className:Hn()("json-schema-2020-12-keyword__children",{"json-schema-2020-12-keyword__children--collapsed":!w})},w&&Pe.createElement(Pe.Fragment,null,o.map(((s,o)=>Pe.createElement("li",{key:`#${o}`,className:"json-schema-2020-12-property"},Pe.createElement($,{name:`#${o} ${i.getTitle(s)}`,schema:s})))))))):null},keywords_AnyOf=({schema:s})=>{const o=s?.anyOf||[],i=useFn(),u=useIsExpanded(),_=useIsExpandedDeeply(),[w,x]=(0,Pe.useState)(u||_),[C,j]=(0,Pe.useState)(!1),L=useComponent("Accordion"),B=useComponent("ExpandDeepButton"),$=useComponent("JSONSchema"),V=useComponent("KeywordType"),U=(0,Pe.useCallback)((()=>{x((s=>!s))}),[]),z=(0,Pe.useCallback)(((s,o)=>{x(o),j(o)}),[]);return Array.isArray(o)&&0!==o.length?Pe.createElement(nI.Provider,{value:C},Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--anyOf"},Pe.createElement(L,{expanded:w,onChange:U},Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary"},"Any of")),Pe.createElement(B,{expanded:w,onClick:z}),Pe.createElement(V,{schema:{anyOf:o}}),Pe.createElement("ul",{className:Hn()("json-schema-2020-12-keyword__children",{"json-schema-2020-12-keyword__children--collapsed":!w})},w&&Pe.createElement(Pe.Fragment,null,o.map(((s,o)=>Pe.createElement("li",{key:`#${o}`,className:"json-schema-2020-12-property"},Pe.createElement($,{name:`#${o} ${i.getTitle(s)}`,schema:s})))))))):null},keywords_OneOf=({schema:s})=>{const o=s?.oneOf||[],i=useFn(),u=useIsExpanded(),_=useIsExpandedDeeply(),[w,x]=(0,Pe.useState)(u||_),[C,j]=(0,Pe.useState)(!1),L=useComponent("Accordion"),B=useComponent("ExpandDeepButton"),$=useComponent("JSONSchema"),V=useComponent("KeywordType"),U=(0,Pe.useCallback)((()=>{x((s=>!s))}),[]),z=(0,Pe.useCallback)(((s,o)=>{x(o),j(o)}),[]);return Array.isArray(o)&&0!==o.length?Pe.createElement(nI.Provider,{value:C},Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--oneOf"},Pe.createElement(L,{expanded:w,onChange:U},Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary"},"One of")),Pe.createElement(B,{expanded:w,onClick:z}),Pe.createElement(V,{schema:{oneOf:o}}),Pe.createElement("ul",{className:Hn()("json-schema-2020-12-keyword__children",{"json-schema-2020-12-keyword__children--collapsed":!w})},w&&Pe.createElement(Pe.Fragment,null,o.map(((s,o)=>Pe.createElement("li",{key:`#${o}`,className:"json-schema-2020-12-property"},Pe.createElement($,{name:`#${o} ${i.getTitle(s)}`,schema:s})))))))):null},keywords_Not=({schema:s})=>{const o=useFn(),i=useComponent("JSONSchema");if(!o.hasKeyword(s,"not"))return null;const u=Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary"},"Not");return Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--not"},Pe.createElement(i,{name:u,schema:s.not}))},keywords_If=({schema:s})=>{const o=useFn(),i=useComponent("JSONSchema");if(!o.hasKeyword(s,"if"))return null;const u=Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary"},"If");return Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--if"},Pe.createElement(i,{name:u,schema:s.if}))},keywords_Then=({schema:s})=>{const o=useFn(),i=useComponent("JSONSchema");if(!o.hasKeyword(s,"then"))return null;const u=Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary"},"Then");return Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--then"},Pe.createElement(i,{name:u,schema:s.then}))},keywords_Else=({schema:s})=>{const o=useFn(),i=useComponent("JSONSchema");if(!o.hasKeyword(s,"else"))return null;const u=Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary"},"Else");return Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--if"},Pe.createElement(i,{name:u,schema:s.else}))},keywords_DependentSchemas=({schema:s})=>{const o=s?.dependentSchemas||[],i=useIsExpanded(),u=useIsExpandedDeeply(),[_,w]=(0,Pe.useState)(i||u),[x,C]=(0,Pe.useState)(!1),j=useComponent("Accordion"),L=useComponent("ExpandDeepButton"),B=useComponent("JSONSchema"),$=(0,Pe.useCallback)((()=>{w((s=>!s))}),[]),V=(0,Pe.useCallback)(((s,o)=>{w(o),C(o)}),[]);return"object"!=typeof o||0===Object.keys(o).length?null:Pe.createElement(nI.Provider,{value:x},Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--dependentSchemas"},Pe.createElement(j,{expanded:_,onChange:$},Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary"},"Dependent schemas")),Pe.createElement(L,{expanded:_,onClick:V}),Pe.createElement("strong",{className:"json-schema-2020-12__attribute json-schema-2020-12__attribute--primary"},"object"),Pe.createElement("ul",{className:Hn()("json-schema-2020-12-keyword__children",{"json-schema-2020-12-keyword__children--collapsed":!_})},_&&Pe.createElement(Pe.Fragment,null,Object.entries(o).map((([s,o])=>Pe.createElement("li",{key:s,className:"json-schema-2020-12-property"},Pe.createElement(B,{name:s,schema:o}))))))))},keywords_PrefixItems=({schema:s})=>{const o=s?.prefixItems||[],i=useFn(),u=useIsExpanded(),_=useIsExpandedDeeply(),[w,x]=(0,Pe.useState)(u||_),[C,j]=(0,Pe.useState)(!1),L=useComponent("Accordion"),B=useComponent("ExpandDeepButton"),$=useComponent("JSONSchema"),V=useComponent("KeywordType"),U=(0,Pe.useCallback)((()=>{x((s=>!s))}),[]),z=(0,Pe.useCallback)(((s,o)=>{x(o),j(o)}),[]);return Array.isArray(o)&&0!==o.length?Pe.createElement(nI.Provider,{value:C},Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--prefixItems"},Pe.createElement(L,{expanded:w,onChange:U},Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary"},"Prefix items")),Pe.createElement(B,{expanded:w,onClick:z}),Pe.createElement(V,{schema:{prefixItems:o}}),Pe.createElement("ul",{className:Hn()("json-schema-2020-12-keyword__children",{"json-schema-2020-12-keyword__children--collapsed":!w})},w&&Pe.createElement(Pe.Fragment,null,o.map(((s,o)=>Pe.createElement("li",{key:`#${o}`,className:"json-schema-2020-12-property"},Pe.createElement($,{name:`#${o} ${i.getTitle(s)}`,schema:s})))))))):null},keywords_Items=({schema:s})=>{const o=useFn(),i=useComponent("JSONSchema");if(!o.hasKeyword(s,"items"))return null;const u=Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary"},"Items");return Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--items"},Pe.createElement(i,{name:u,schema:s.items}))},keywords_Contains=({schema:s})=>{const o=useFn(),i=useComponent("JSONSchema");if(!o.hasKeyword(s,"contains"))return null;const u=Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary"},"Contains");return Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--contains"},Pe.createElement(i,{name:u,schema:s.contains}))},keywords_Properties_Properties=({schema:s})=>{const o=useFn(),i=s?.properties||{},u=Array.isArray(s?.required)?s.required:[],_=useComponent("JSONSchema");return 0===Object.keys(i).length?null:Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--properties"},Pe.createElement("ul",null,Object.entries(i).map((([i,w])=>{const x=u.includes(i),C=o.getDependentRequired(i,s);return Pe.createElement("li",{key:i,className:Hn()("json-schema-2020-12-property",{"json-schema-2020-12-property--required":x})},Pe.createElement(_,{name:i,schema:w,dependentRequired:C}))}))))},PatternProperties_PatternProperties=({schema:s})=>{const o=s?.patternProperties||{},i=useComponent("JSONSchema");return 0===Object.keys(o).length?null:Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--patternProperties"},Pe.createElement("ul",null,Object.entries(o).map((([s,o])=>Pe.createElement("li",{key:s,className:"json-schema-2020-12-property"},Pe.createElement(i,{name:s,schema:o}))))))},keywords_AdditionalProperties=({schema:s})=>{const o=useFn(),{additionalProperties:i}=s,u=useComponent("JSONSchema");if(!o.hasKeyword(s,"additionalProperties"))return null;const _=Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary"},"Additional properties");return Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--additionalProperties"},!0===i?Pe.createElement(Pe.Fragment,null,_,Pe.createElement("span",{className:"json-schema-2020-12__attribute json-schema-2020-12__attribute--primary"},"allowed")):!1===i?Pe.createElement(Pe.Fragment,null,_,Pe.createElement("span",{className:"json-schema-2020-12__attribute json-schema-2020-12__attribute--primary"},"forbidden")):Pe.createElement(u,{name:_,schema:i}))},keywords_PropertyNames=({schema:s})=>{const o=useFn(),{propertyNames:i}=s,u=useComponent("JSONSchema"),_=Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary"},"Property names");return o.hasKeyword(s,"propertyNames")?Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--propertyNames"},Pe.createElement(u,{name:_,schema:i})):null},keywords_UnevaluatedItems=({schema:s})=>{const o=useFn(),{unevaluatedItems:i}=s,u=useComponent("JSONSchema");if(!o.hasKeyword(s,"unevaluatedItems"))return null;const _=Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary"},"Unevaluated items");return Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--unevaluatedItems"},Pe.createElement(u,{name:_,schema:i}))},keywords_UnevaluatedProperties=({schema:s})=>{const o=useFn(),{unevaluatedProperties:i}=s,u=useComponent("JSONSchema");if(!o.hasKeyword(s,"unevaluatedProperties"))return null;const _=Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary"},"Unevaluated properties");return Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--unevaluatedProperties"},Pe.createElement(u,{name:_,schema:i}))},keywords_Type=({schema:s,isCircular:o=!1})=>{const i=useFn().getType(s),u=o?" [circular]":"";return Pe.createElement("strong",{className:"json-schema-2020-12__attribute json-schema-2020-12__attribute--primary"},`${i}${u}`)},Enum_Enum=({schema:s})=>{const o=useFn();return Array.isArray(s?.enum)?Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--enum"},Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary"},"Allowed values"),Pe.createElement("ul",null,s.enum.map((s=>{const i=o.stringify(s);return Pe.createElement("li",{key:i},Pe.createElement("span",{className:"json-schema-2020-12-keyword__value json-schema-2020-12-keyword__value--const"},i))})))):null},keywords_Const=({schema:s})=>{const o=useFn();return o.hasKeyword(s,"const")?Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--const"},Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary"},"Const"),Pe.createElement("span",{className:"json-schema-2020-12-keyword__value json-schema-2020-12-keyword__value--const"},o.stringify(s.const))):null},Constraint=({constraint:s})=>Pe.createElement("span",{className:`json-schema-2020-12__constraint json-schema-2020-12__constraint--${s.scope}`},s.value),aI=Pe.memo(Constraint),DependentRequired_DependentRequired=({dependentRequired:s})=>0===s.length?null:Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--dependentRequired"},Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary"},"Required when defined"),Pe.createElement("ul",null,s.map((s=>Pe.createElement("li",{key:s},Pe.createElement("span",{className:"json-schema-2020-12-keyword__value json-schema-2020-12-keyword__value--warning"},s)))))),keywords_ContentSchema=({schema:s})=>{const o=useFn(),i=useComponent("JSONSchema");if(!o.hasKeyword(s,"contentSchema"))return null;const u=Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary"},"Content schema");return Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--contentSchema"},Pe.createElement(i,{name:u,schema:s.contentSchema}))},Title_Title=({title:s="",schema:o})=>{const i=useFn(),u=s||i.getTitle(o);return u?Pe.createElement("div",{className:"json-schema-2020-12__title"},u):null},keywords_Description_Description=({schema:s})=>s?.description?Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--description"},Pe.createElement("div",{className:"json-schema-2020-12-core-keyword__value json-schema-2020-12-core-keyword__value--secondary"},s.description)):null,keywords_Default=({schema:s})=>{const o=useFn();return o.hasKeyword(s,"default")?Pe.createElement("div",{className:"json-schema-2020-12-keyword json-schema-2020-12-keyword--default"},Pe.createElement("span",{className:"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary"},"Default"),Pe.createElement("span",{className:"json-schema-2020-12-keyword__value json-schema-2020-12-keyword__value--const"},o.stringify(s.default))):null},keywords_Deprecated=({schema:s})=>!0!==s?.deprecated?null:Pe.createElement("span",{className:"json-schema-2020-12__attribute json-schema-2020-12__attribute--warning"},"deprecated"),keywords_ReadOnly=({schema:s})=>!0!==s?.readOnly?null:Pe.createElement("span",{className:"json-schema-2020-12__attribute json-schema-2020-12__attribute--muted"},"read-only"),keywords_WriteOnly=({schema:s})=>!0!==s?.writeOnly?null:Pe.createElement("span",{className:"json-schema-2020-12__attribute json-schema-2020-12__attribute--muted"},"write-only"),Accordion_Accordion=({expanded:s=!1,children:o,onChange:i})=>{const u=useComponent("ChevronRightIcon"),_=(0,Pe.useCallback)((o=>{i(o,!s)}),[s,i]);return Pe.createElement("button",{type:"button",className:"json-schema-2020-12-accordion",onClick:_},Pe.createElement("div",{className:"json-schema-2020-12-accordion__children"},o),Pe.createElement("span",{className:Hn()("json-schema-2020-12-accordion__icon",{"json-schema-2020-12-accordion__icon--expanded":s,"json-schema-2020-12-accordion__icon--collapsed":!s})},Pe.createElement(u,null)))},ExpandDeepButton_ExpandDeepButton=({expanded:s,onClick:o})=>{const i=(0,Pe.useCallback)((i=>{o(i,!s)}),[s,o]);return Pe.createElement("button",{type:"button",className:"json-schema-2020-12-expand-deep-button",onClick:i},s?"Collapse all":"Expand all")},icons_ChevronRight=()=>Pe.createElement("svg",{xmlns:"http://www.w3.org/2000/svg",width:"24",height:"24",viewBox:"0 0 24 24"},Pe.createElement("path",{d:"M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"})),fn_upperFirst=s=>"string"==typeof s?`${s.charAt(0).toUpperCase()}${s.slice(1)}`:s,getTitle=(s,{lookup:o="extended"}={})=>{const i=useFn();if(null!=s?.title)return i.upperFirst(String(s.title));if("extended"===o){if(null!=s?.$anchor)return i.upperFirst(String(s.$anchor));if(null!=s?.$id)return String(s.$id)}return""},getType=(s,o=new WeakSet)=>{const i=useFn();if(null==s)return"any";if(i.isBooleanJSONSchema(s))return s?"any":"never";if("object"!=typeof s)return"any";if(o.has(s))return"any";o.add(s);const{type:u,prefixItems:_,items:w}=s,getArrayType=()=>{if(Array.isArray(_)){const s=_.map((s=>getType(s,o))),i=w?getType(w,o):"any";return`array<[${s.join(", ")}], ${i}>`}if(w){return`array<${getType(w,o)}>`}return"array"};if(s.not&&"any"===getType(s.not))return"never";const handleCombiningKeywords=(i,u)=>{if(Array.isArray(s[i])){return`(${s[i].map((s=>getType(s,o))).join(u)})`}return null},x=[Array.isArray(u)?u.map((s=>"array"===s?getArrayType():s)).join(" | "):"array"===u?getArrayType():["null","boolean","object","array","number","integer","string"].includes(u)?u:(()=>{if(Object.hasOwn(s,"prefixItems")||Object.hasOwn(s,"items")||Object.hasOwn(s,"contains"))return getArrayType();if(Object.hasOwn(s,"properties")||Object.hasOwn(s,"additionalProperties")||Object.hasOwn(s,"patternProperties"))return"object";if(["int32","int64"].includes(s.format))return"integer";if(["float","double"].includes(s.format))return"number";if(Object.hasOwn(s,"minimum")||Object.hasOwn(s,"maximum")||Object.hasOwn(s,"exclusiveMinimum")||Object.hasOwn(s,"exclusiveMaximum")||Object.hasOwn(s,"multipleOf"))return"number | integer";if(Object.hasOwn(s,"pattern")||Object.hasOwn(s,"format")||Object.hasOwn(s,"minLength")||Object.hasOwn(s,"maxLength"))return"string";if(void 0!==s.const){if(null===s.const)return"null";if("boolean"==typeof s.const)return"boolean";if("number"==typeof s.const)return Number.isInteger(s.const)?"integer":"number";if("string"==typeof s.const)return"string";if(Array.isArray(s.const))return"array";if("object"==typeof s.const)return"object"}return null})(),handleCombiningKeywords("oneOf"," | "),handleCombiningKeywords("anyOf"," | "),handleCombiningKeywords("allOf"," & ")].filter(Boolean).join(" | ");return o.delete(s),x||"any"},isBooleanJSONSchema=s=>"boolean"==typeof s,hasKeyword=(s,o)=>null!==s&&"object"==typeof s&&Object.hasOwn(s,o),isExpandable=s=>{const o=useFn();return s?.$schema||s?.$vocabulary||s?.$id||s?.$anchor||s?.$dynamicAnchor||s?.$ref||s?.$dynamicRef||s?.$defs||s?.$comment||s?.allOf||s?.anyOf||s?.oneOf||o.hasKeyword(s,"not")||o.hasKeyword(s,"if")||o.hasKeyword(s,"then")||o.hasKeyword(s,"else")||s?.dependentSchemas||s?.prefixItems||o.hasKeyword(s,"items")||o.hasKeyword(s,"contains")||s?.properties||s?.patternProperties||o.hasKeyword(s,"additionalProperties")||o.hasKeyword(s,"propertyNames")||o.hasKeyword(s,"unevaluatedItems")||o.hasKeyword(s,"unevaluatedProperties")||s?.description||s?.enum||o.hasKeyword(s,"const")||o.hasKeyword(s,"contentSchema")||o.hasKeyword(s,"default")},fn_stringify=s=>null===s||["number","bigint","boolean"].includes(typeof s)?String(s):Array.isArray(s)?`[${s.map(fn_stringify).join(", ")}]`:JSON.stringify(s),stringifyConstraintRange=(s,o,i)=>{const u="number"==typeof o,_="number"==typeof i;return u&&_?o===i?`${o} ${s}`:`[${o}, ${i}] ${s}`:u?`>= ${o} ${s}`:_?`<= ${i} ${s}`:null},stringifyConstraints=s=>{const o=[],i=(s=>{if("number"!=typeof s?.multipleOf)return null;if(s.multipleOf<=0)return null;if(1===s.multipleOf)return null;const{multipleOf:o}=s;if(Number.isInteger(o))return`multiple of ${o}`;const i=10**o.toString().split(".")[1].length;return`multiple of ${o*i}/${i}`})(s);null!==i&&o.push({scope:"number",value:i});const u=(s=>{const o=s?.minimum,i=s?.maximum,u=s?.exclusiveMinimum,_=s?.exclusiveMaximum,w="number"==typeof o,x="number"==typeof i,C="number"==typeof u,j="number"==typeof _,L=C&&(!w||o_);if((w||C)&&(x||j))return`${L?"(":"["}${L?u:o}, ${B?_:i}${B?")":"]"}`;if(w||C)return`${L?">":"≥"} ${L?u:o}`;if(x||j)return`${B?"<":"≤"} ${B?_:i}`;return null})(s);null!==u&&o.push({scope:"number",value:u}),s?.format&&o.push({scope:"string",value:s.format});const _=stringifyConstraintRange("characters",s?.minLength,s?.maxLength);null!==_&&o.push({scope:"string",value:_}),s?.pattern&&o.push({scope:"string",value:`matches ${s?.pattern}`}),s?.contentMediaType&&o.push({scope:"string",value:`media type: ${s.contentMediaType}`}),s?.contentEncoding&&o.push({scope:"string",value:`encoding: ${s.contentEncoding}`});const w=stringifyConstraintRange(s?.hasUniqueItems?"unique items":"items",s?.minItems,s?.maxItems);null!==w&&o.push({scope:"array",value:w});const x=stringifyConstraintRange("contained items",s?.minContains,s?.maxContains);null!==x&&o.push({scope:"array",value:x});const C=stringifyConstraintRange("properties",s?.minProperties,s?.maxProperties);return null!==C&&o.push({scope:"object",value:C}),o},getDependentRequired=(s,o)=>o?.dependentRequired?Array.from(Object.entries(o.dependentRequired).reduce(((o,[i,u])=>Array.isArray(u)&&u.includes(s)?(o.add(i),o):o),new Set)):[],withJSONSchemaContext=(s,o={})=>{const i={components:{JSONSchema:iI,Keyword$schema:keywords_$schema,Keyword$vocabulary:$vocabulary_$vocabulary,Keyword$id:keywords_$id,Keyword$anchor:keywords_$anchor,Keyword$dynamicAnchor:keywords_$dynamicAnchor,Keyword$ref:keywords_$ref,Keyword$dynamicRef:keywords_$dynamicRef,Keyword$defs:keywords_$defs,Keyword$comment:keywords_$comment,KeywordAllOf:keywords_AllOf,KeywordAnyOf:keywords_AnyOf,KeywordOneOf:keywords_OneOf,KeywordNot:keywords_Not,KeywordIf:keywords_If,KeywordThen:keywords_Then,KeywordElse:keywords_Else,KeywordDependentSchemas:keywords_DependentSchemas,KeywordPrefixItems:keywords_PrefixItems,KeywordItems:keywords_Items,KeywordContains:keywords_Contains,KeywordProperties:keywords_Properties_Properties,KeywordPatternProperties:PatternProperties_PatternProperties,KeywordAdditionalProperties:keywords_AdditionalProperties,KeywordPropertyNames:keywords_PropertyNames,KeywordUnevaluatedItems:keywords_UnevaluatedItems,KeywordUnevaluatedProperties:keywords_UnevaluatedProperties,KeywordType:keywords_Type,KeywordEnum:Enum_Enum,KeywordConst:keywords_Const,KeywordConstraint:aI,KeywordDependentRequired:DependentRequired_DependentRequired,KeywordContentSchema:keywords_ContentSchema,KeywordTitle:Title_Title,KeywordDescription:keywords_Description_Description,KeywordDefault:keywords_Default,KeywordDeprecated:keywords_Deprecated,KeywordReadOnly:keywords_ReadOnly,KeywordWriteOnly:keywords_WriteOnly,Accordion:Accordion_Accordion,ExpandDeepButton:ExpandDeepButton_ExpandDeepButton,ChevronRightIcon:icons_ChevronRight,...o.components},config:{default$schema:"https://json-schema.org/draft/2020-12/schema",defaultExpandedLevels:0,...o.config},fn:{upperFirst:fn_upperFirst,getTitle,getType,isBooleanJSONSchema,hasKeyword,isExpandable,stringify:fn_stringify,stringifyConstraints,getDependentRequired,...o.fn}},HOC=o=>Pe.createElement(tI.Provider,{value:i},Pe.createElement(s,o));return HOC.contexts={JSONSchemaContext:tI},HOC.displayName=s.displayName,HOC},json_schema_2020_12=()=>({components:{JSONSchema202012:iI,JSONSchema202012Keyword$schema:keywords_$schema,JSONSchema202012Keyword$vocabulary:$vocabulary_$vocabulary,JSONSchema202012Keyword$id:keywords_$id,JSONSchema202012Keyword$anchor:keywords_$anchor,JSONSchema202012Keyword$dynamicAnchor:keywords_$dynamicAnchor,JSONSchema202012Keyword$ref:keywords_$ref,JSONSchema202012Keyword$dynamicRef:keywords_$dynamicRef,JSONSchema202012Keyword$defs:keywords_$defs,JSONSchema202012Keyword$comment:keywords_$comment,JSONSchema202012KeywordAllOf:keywords_AllOf,JSONSchema202012KeywordAnyOf:keywords_AnyOf,JSONSchema202012KeywordOneOf:keywords_OneOf,JSONSchema202012KeywordNot:keywords_Not,JSONSchema202012KeywordIf:keywords_If,JSONSchema202012KeywordThen:keywords_Then,JSONSchema202012KeywordElse:keywords_Else,JSONSchema202012KeywordDependentSchemas:keywords_DependentSchemas,JSONSchema202012KeywordPrefixItems:keywords_PrefixItems,JSONSchema202012KeywordItems:keywords_Items,JSONSchema202012KeywordContains:keywords_Contains,JSONSchema202012KeywordProperties:keywords_Properties_Properties,JSONSchema202012KeywordPatternProperties:PatternProperties_PatternProperties,JSONSchema202012KeywordAdditionalProperties:keywords_AdditionalProperties,JSONSchema202012KeywordPropertyNames:keywords_PropertyNames,JSONSchema202012KeywordUnevaluatedItems:keywords_UnevaluatedItems,JSONSchema202012KeywordUnevaluatedProperties:keywords_UnevaluatedProperties,JSONSchema202012KeywordType:keywords_Type,JSONSchema202012KeywordEnum:Enum_Enum,JSONSchema202012KeywordConst:keywords_Const,JSONSchema202012KeywordConstraint:aI,JSONSchema202012KeywordDependentRequired:DependentRequired_DependentRequired,JSONSchema202012KeywordContentSchema:keywords_ContentSchema,JSONSchema202012KeywordTitle:Title_Title,JSONSchema202012KeywordDescription:keywords_Description_Description,JSONSchema202012KeywordDefault:keywords_Default,JSONSchema202012KeywordDeprecated:keywords_Deprecated,JSONSchema202012KeywordReadOnly:keywords_ReadOnly,JSONSchema202012KeywordWriteOnly:keywords_WriteOnly,JSONSchema202012Accordion:Accordion_Accordion,JSONSchema202012ExpandDeepButton:ExpandDeepButton_ExpandDeepButton,JSONSchema202012ChevronRightIcon:icons_ChevronRight,withJSONSchema202012Context:withJSONSchemaContext,JSONSchema202012DeepExpansionContext:()=>nI},fn:{upperFirst:fn_upperFirst,jsonSchema202012:{isExpandable,hasKeyword,useFn,useConfig,useComponent,useIsExpandedDeeply}}});var lI=__webpack_require__(11331),cI=__webpack_require__.n(lI);const array=(s,{sample:o})=>((s,o={})=>{const{minItems:i,maxItems:u,uniqueItems:_}=o,{contains:w,minContains:x,maxContains:C}=o;let j=[...s];if(null!=w&&"object"==typeof w){if(Number.isInteger(x)&&x>1){const s=j.at(0);for(let o=1;o0&&(j=s.slice(0,u)),Number.isInteger(i)&&i>0)for(let s=0;j.length{throw new Error("Not implemented")},bytes=s=>St()(s),random_pick=s=>s.at(0),predicates_isBooleanJSONSchema=s=>"boolean"==typeof s,isJSONSchemaObject=s=>cI()(s),isJSONSchema=s=>predicates_isBooleanJSONSchema(s)||isJSONSchemaObject(s);const uI=class Registry{data={};register(s,o){this.data[s]=o}unregister(s){void 0===s?this.data={}:delete this.data[s]}get(s){return this.data[s]}},int32=()=>2**30>>>0,int64=()=>2**53-1,generators_float=()=>.1,generators_double=()=>.1,email=()=>"user@example.com",idn_email=()=>"실례@example.com",hostname=()=>"example.com",idn_hostname=()=>"실례.com",ipv4=()=>"198.51.100.42",ipv6=()=>"2001:0db8:5b96:0000:0000:426f:8e17:642a",uri=()=>"https://example.com/",uri_reference=()=>"path/index.html",iri=()=>"https://실례.com/",iri_reference=()=>"path/실례.html",uuid=()=>"3fa85f64-5717-4562-b3fc-2c963f66afa6",uri_template=()=>"https://example.com/dictionary/{term:1}/{term}",json_pointer=()=>"/a/b/c",relative_json_pointer=()=>"1/0",date_time=()=>(new Date).toISOString(),date=()=>(new Date).toISOString().substring(0,10),time=()=>(new Date).toISOString().substring(11),duration=()=>"P3D",generators_password=()=>"********",regex=()=>"^[a-z]+$";const pI=new class FormatRegistry extends uI{#t={int32,int64,float:generators_float,double:generators_double,email,"idn-email":idn_email,hostname,"idn-hostname":idn_hostname,ipv4,ipv6,uri,"uri-reference":uri_reference,iri,"iri-reference":iri_reference,uuid,"uri-template":uri_template,"json-pointer":json_pointer,"relative-json-pointer":relative_json_pointer,"date-time":date_time,date,time,duration,password:generators_password,regex};data={...this.#t};get defaults(){return{...this.#t}}},formatAPI=(s,o)=>"function"==typeof o?pI.register(s,o):null===o?pI.unregister(s):pI.get(s);formatAPI.getDefaults=()=>pI.defaults;const hI=formatAPI;var dI=__webpack_require__(48287).Buffer;const _7bit=s=>dI.from(s).toString("ascii");var fI=__webpack_require__(48287).Buffer;const _8bit=s=>fI.from(s).toString("utf8");var mI=__webpack_require__(48287).Buffer;const encoders_binary=s=>mI.from(s).toString("binary"),quoted_printable=s=>{let o="";for(let i=0;i=33&&u<=60||u>=62&&u<=126||9===u||32===u)o+=s.charAt(i);else if(13===u||10===u)o+="\r\n";else if(u>126){const u=unescape(encodeURIComponent(s.charAt(i)));for(let s=0;sgI.from(s).toString("hex");var yI=__webpack_require__(48287).Buffer;const base32=s=>{const o=yI.from(s).toString("utf8"),i="ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";let u=0,_="",w=0,x=0;for(let s=0;s=5;)_+=i.charAt(w>>>x-5&31),x-=5;x>0&&(_+=i.charAt(w<<5-x&31),u=(8-8*o.length%5)%5);for(let s=0;svI.from(s).toString("base64");var bI=__webpack_require__(48287).Buffer;const base64url=s=>bI.from(s).toString("base64url");const _I=new class EncoderRegistry extends uI{#t={"7bit":_7bit,"8bit":_8bit,binary:encoders_binary,"quoted-printable":quoted_printable,base16,base32,base64,base64url};data={...this.#t};get defaults(){return{...this.#t}}},encoderAPI=(s,o)=>"function"==typeof o?_I.register(s,o):null===o?_I.unregister(s):_I.get(s);encoderAPI.getDefaults=()=>_I.defaults;const EI=encoderAPI,wI={"text/plain":()=>"string","text/css":()=>".selector { border: 1px solid red }","text/csv":()=>"value1,value2,value3","text/html":()=>"

        content

        ","text/calendar":()=>"BEGIN:VCALENDAR","text/javascript":()=>"console.dir('Hello world!');","text/xml":()=>'John Doe',"text/*":()=>"string"},SI={"image/*":()=>bytes(25).toString("binary")},xI={"audio/*":()=>bytes(25).toString("binary")},kI={"video/*":()=>bytes(25).toString("binary")},CI={"application/json":()=>'{"key":"value"}',"application/ld+json":()=>'{"name": "John Doe"}',"application/x-httpd-php":()=>"Hello World!

        '; ?>","application/rtf":()=>String.raw`{\rtf1\adeflang1025\ansi\ansicpg1252\uc1`,"application/x-sh":()=>'echo "Hello World!"',"application/xhtml+xml":()=>"

        content

        ","application/*":()=>bytes(25).toString("binary")};const OI=new class MediaTypeRegistry extends uI{#t={...wI,...SI,...xI,...kI,...CI};data={...this.#t};get defaults(){return{...this.#t}}},mediaTypeAPI=(s,o)=>{if("function"==typeof o)return OI.register(s,o);if(null===o)return OI.unregister(s);const i=s.split(";").at(0),u=`${i.split("/").at(0)}/*`;return OI.get(s)||OI.get(i)||OI.get(u)};mediaTypeAPI.getDefaults=()=>OI.defaults;const AI=mediaTypeAPI,applyStringConstraints=(s,o={})=>{const{maxLength:i,minLength:u}=o;let _=s;if(Number.isInteger(i)&&i>0&&(_=_.slice(0,i)),Number.isInteger(u)&&u>0){let s=0;for(;_.length{const{contentEncoding:i,contentMediaType:u,contentSchema:_}=s,{pattern:w,format:x}=s,C=EI(i)||Mx();let j;return j="string"==typeof w?applyStringConstraints((s=>{try{return new(us())(s).gen()}catch{return"string"}})(w),s):"string"==typeof x?(s=>{const{format:o}=s,i=hI(o);return"function"==typeof i?i(s):"string"})(s):isJSONSchema(_)&&"string"==typeof u&&void 0!==o?Array.isArray(o)||"object"==typeof o?JSON.stringify(o):applyStringConstraints(String(o),s):"string"==typeof u?(s=>{const{contentMediaType:o}=s,i=AI(o);return"function"==typeof i?i(s):"string"})(s):applyStringConstraints("string",s),C(j)},applyNumberConstraints=(s,o={})=>{const{minimum:i,maximum:u,exclusiveMinimum:_,exclusiveMaximum:w}=o,{multipleOf:x}=o,C=Number.isInteger(s)?1:Number.EPSILON;let j="number"==typeof i?i:null,L="number"==typeof u?u:null,B=s;if("number"==typeof _&&(j=null!==j?Math.max(j,_+C):_+C),"number"==typeof w&&(L=null!==L?Math.min(L,w-C):w-C),B=j>L&&s||j||L||B,"number"==typeof x&&x>0){const s=B%x;B=0===s?B:B+x-s}return B},types_number=s=>{const{format:o}=s;let i;return i="string"==typeof o?(s=>{const{format:o}=s,i=hI(o);return"function"==typeof i?i(s):0})(s):0,applyNumberConstraints(i,s)},types_integer=s=>{const{format:o}=s;let i;return i="string"==typeof o?(s=>{const{format:o}=s,i=hI(o);if("function"==typeof i)return i(s);switch(o){case"int32":return int32();case"int64":return int64()}return 0})(s):0,applyNumberConstraints(i,s)},types_boolean=s=>"boolean"!=typeof s.default||s.default,jI=new Proxy({array,object,string:types_string,number:types_number,integer:types_integer,boolean:types_boolean,null:()=>null},{get:(s,o)=>"string"==typeof o&&Object.hasOwn(s,o)?s[o]:()=>`Unknown Type: ${o}`}),II=["array","object","number","integer","string","boolean","null"],hasExample=s=>{if(!isJSONSchemaObject(s))return!1;const{examples:o,example:i,default:u}=s;return!!(Array.isArray(o)&&o.length>=1)||(void 0!==u||void 0!==i)},extractExample=s=>{if(!isJSONSchemaObject(s))return null;const{examples:o,example:i,default:u}=s;return Array.isArray(o)&&o.length>=1?o.at(0):void 0!==u?u:void 0!==i?i:void 0},PI={array:["items","prefixItems","contains","maxContains","minContains","maxItems","minItems","uniqueItems","unevaluatedItems"],object:["properties","additionalProperties","patternProperties","propertyNames","minProperties","maxProperties","required","dependentSchemas","dependentRequired","unevaluatedProperties"],string:["pattern","format","minLength","maxLength","contentEncoding","contentMediaType","contentSchema"],integer:["minimum","maximum","exclusiveMinimum","exclusiveMaximum","multipleOf"]};PI.number=PI.integer;const MI="string",inferTypeFromValue=s=>void 0===s?null:null===s?"null":Array.isArray(s)?"array":Number.isInteger(s)?"integer":typeof s,foldType=s=>{if(Array.isArray(s)&&s.length>=1){if(s.includes("array"))return"array";if(s.includes("object"))return"object";{const o=random_pick(s);if(II.includes(o))return o}}return II.includes(s)?s:null},inferType=(s,o=new WeakSet)=>{if(!isJSONSchemaObject(s))return MI;if(o.has(s))return MI;o.add(s);let{type:i,const:u}=s;if(i=foldType(i),"string"!=typeof i){const o=Object.keys(PI);e:for(let u=0;u{if(Array.isArray(s[i])){const u=s[i].map((s=>inferType(s,o)));return foldType(u)}return null},u=combineTypes("allOf"),_=combineTypes("anyOf"),w=combineTypes("oneOf"),x=s.not?inferType(s.not,o):null;(u||_||w||x)&&(i=foldType([u,_,w,x].filter(Boolean)))}if("string"!=typeof i&&hasExample(s)){const o=extractExample(s),u=inferTypeFromValue(o);i="string"==typeof u?u:i}return o.delete(s),i||MI},type_getType=s=>inferType(s),typeCast=s=>predicates_isBooleanJSONSchema(s)?(s=>!1===s?{not:{}}:{})(s):isJSONSchemaObject(s)?s:{},merge_merge=(s,o,i={})=>{if(predicates_isBooleanJSONSchema(s)&&!0===s)return!0;if(predicates_isBooleanJSONSchema(s)&&!1===s)return!1;if(predicates_isBooleanJSONSchema(o)&&!0===o)return!0;if(predicates_isBooleanJSONSchema(o)&&!1===o)return!1;if(!isJSONSchema(s))return o;if(!isJSONSchema(o))return s;const u={...o,...s};if(o.type&&s.type&&Array.isArray(o.type)&&"string"==typeof o.type){const i=normalizeArray(o.type).concat(s.type);u.type=Array.from(new Set(i))}if(Array.isArray(o.required)&&Array.isArray(s.required)&&(u.required=[...new Set([...s.required,...o.required])]),o.properties&&s.properties){const _=new Set([...Object.keys(o.properties),...Object.keys(s.properties)]);u.properties={};for(const w of _){const _=o.properties[w]||{},x=s.properties[w]||{};_.readOnly&&!i.includeReadOnly||_.writeOnly&&!i.includeWriteOnly?u.required=(u.required||[]).filter((s=>s!==w)):u.properties[w]=merge_merge(x,_,i)}}return isJSONSchema(o.items)&&isJSONSchema(s.items)&&(u.items=merge_merge(s.items,o.items,i)),isJSONSchema(o.contains)&&isJSONSchema(s.contains)&&(u.contains=merge_merge(s.contains,o.contains,i)),isJSONSchema(o.contentSchema)&&isJSONSchema(s.contentSchema)&&(u.contentSchema=merge_merge(s.contentSchema,o.contentSchema,i)),u},TI=merge_merge,main_sampleFromSchemaGeneric=(s,o={},i=void 0,u=!1)=>{if(null==s&&void 0===i)return;"function"==typeof s?.toJS&&(s=s.toJS()),s=typeCast(s);let _=void 0!==i||hasExample(s);const w=!_&&Array.isArray(s.oneOf)&&s.oneOf.length>0,x=!_&&Array.isArray(s.anyOf)&&s.anyOf.length>0;if(!_&&(w||x)){const i=typeCast(random_pick(w?s.oneOf:s.anyOf));!(s=TI(s,i,o)).xml&&i.xml&&(s.xml=i.xml),hasExample(s)&&hasExample(i)&&(_=!0)}const C={};let{xml:j,properties:L,additionalProperties:B,items:$,contains:V}=s||{},U=type_getType(s),{includeReadOnly:z,includeWriteOnly:Y}=o;j=j||{};let Z,{name:ee,prefix:ie,namespace:ae}=j,le={};if(Object.hasOwn(s,"type")||(s.type=U),u&&(ee=ee||"notagname",Z=(ie?`${ie}:`:"")+ee,ae)){C[ie?`xmlns:${ie}`:"xmlns"]=ae}u&&(le[Z]=[]);const ce=objectify(L);let pe,de=0;const hasExceededMaxProperties=()=>Number.isInteger(s.maxProperties)&&s.maxProperties>0&&de>=s.maxProperties,canAddProperty=o=>!(Number.isInteger(s.maxProperties)&&s.maxProperties>0)||!hasExceededMaxProperties()&&(!(o=>!Array.isArray(s.required)||0===s.required.length||!s.required.includes(o))(o)||s.maxProperties-de-(()=>{if(!Array.isArray(s.required)||0===s.required.length)return 0;let o=0;return u?s.required.forEach((s=>o+=void 0===le[s]?0:1)):s.required.forEach((s=>{o+=void 0===le[Z]?.find((o=>void 0!==o[s]))?0:1})),s.required.length-o})()>0);if(pe=u?(i,_=void 0)=>{if(s&&ce[i]){if(ce[i].xml=ce[i].xml||{},ce[i].xml.attribute){const s=Array.isArray(ce[i].enum)?random_pick(ce[i].enum):void 0;if(hasExample(ce[i]))C[ce[i].xml.name||i]=extractExample(ce[i]);else if(void 0!==s)C[ce[i].xml.name||i]=s;else{const s=typeCast(ce[i]),o=type_getType(s),u=ce[i].xml.name||i;C[u]=jI[o](s)}return}ce[i].xml.name=ce[i].xml.name||i}else ce[i]||!1===B||(ce[i]={xml:{name:i}});let w=main_sampleFromSchemaGeneric(ce[i],o,_,u);canAddProperty(i)&&(de++,Array.isArray(w)?le[Z]=le[Z].concat(w):le[Z].push(w))}:(i,_)=>{if(canAddProperty(i)){if(cI()(s.discriminator?.mapping)&&s.discriminator.propertyName===i&&"string"==typeof s.$$ref){for(const o in s.discriminator.mapping)if(-1!==s.$$ref.search(s.discriminator.mapping[o])){le[i]=o;break}}else le[i]=main_sampleFromSchemaGeneric(ce[i],o,_,u);de++}},_){let _;if(_=void 0!==i?i:extractExample(s),!u){if("number"==typeof _&&"string"===U)return`${_}`;if("string"!=typeof _||"string"===U)return _;try{return JSON.parse(_)}catch{return _}}if("array"===U){if(!Array.isArray(_)){if("string"==typeof _)return _;_=[_]}let i=[];return isJSONSchemaObject($)&&($.xml=$.xml||j||{},$.xml.name=$.xml.name||j.name,i=_.map((s=>main_sampleFromSchemaGeneric($,o,s,u)))),isJSONSchemaObject(V)&&(V.xml=V.xml||j||{},V.xml.name=V.xml.name||j.name,i=[main_sampleFromSchemaGeneric(V,o,void 0,u),...i]),i=jI.array(s,{sample:i}),j.wrapped?(le[Z]=i,hs()(C)||le[Z].push({_attr:C})):le=i,le}if("object"===U){if("string"==typeof _)return _;for(const s in _)Object.hasOwn(_,s)&&(ce[s]?.readOnly&&!z||ce[s]?.writeOnly&&!Y||(ce[s]?.xml?.attribute?C[ce[s].xml.name||s]=_[s]:pe(s,_[s])));return hs()(C)||le[Z].push({_attr:C}),le}return le[Z]=hs()(C)?_:[{_attr:C},_],le}if("array"===U){let i=[];if(isJSONSchemaObject(V))if(u&&(V.xml=V.xml||s.xml||{},V.xml.name=V.xml.name||j.name),Array.isArray(V.anyOf)){const{anyOf:s,..._}=$;i.push(...V.anyOf.map((s=>main_sampleFromSchemaGeneric(TI(s,_,o),o,void 0,u))))}else if(Array.isArray(V.oneOf)){const{oneOf:s,..._}=$;i.push(...V.oneOf.map((s=>main_sampleFromSchemaGeneric(TI(s,_,o),o,void 0,u))))}else{if(!(!u||u&&j.wrapped))return main_sampleFromSchemaGeneric(V,o,void 0,u);i.push(main_sampleFromSchemaGeneric(V,o,void 0,u))}if(isJSONSchemaObject($))if(u&&($.xml=$.xml||s.xml||{},$.xml.name=$.xml.name||j.name),Array.isArray($.anyOf)){const{anyOf:s,..._}=$;i.push(...$.anyOf.map((s=>main_sampleFromSchemaGeneric(TI(s,_,o),o,void 0,u))))}else if(Array.isArray($.oneOf)){const{oneOf:s,..._}=$;i.push(...$.oneOf.map((s=>main_sampleFromSchemaGeneric(TI(s,_,o),o,void 0,u))))}else{if(!(!u||u&&j.wrapped))return main_sampleFromSchemaGeneric($,o,void 0,u);i.push(main_sampleFromSchemaGeneric($,o,void 0,u))}return i=jI.array(s,{sample:i}),u&&j.wrapped?(le[Z]=i,hs()(C)||le[Z].push({_attr:C}),le):i}if("object"===U){for(let s in ce)Object.hasOwn(ce,s)&&(ce[s]?.deprecated||ce[s]?.readOnly&&!z||ce[s]?.writeOnly&&!Y||pe(s));if(u&&C&&le[Z].push({_attr:C}),hasExceededMaxProperties())return le;if(predicates_isBooleanJSONSchema(B)&&B)u?le[Z].push({additionalProp:"Anything can be here"}):le.additionalProp1={},de++;else if(isJSONSchemaObject(B)){const i=B,_=main_sampleFromSchemaGeneric(i,o,void 0,u);if(u&&"string"==typeof i?.xml?.name&&"notagname"!==i?.xml?.name)le[Z].push(_);else{const o=Number.isInteger(s.minProperties)&&s.minProperties>0&&de{const u=main_sampleFromSchemaGeneric(s,o,i,!0);if(u)return"string"==typeof u?u:ls()(u,{declaration:!0,indent:"\t"})},main_sampleFromSchema=(s,o,i)=>main_sampleFromSchemaGeneric(s,o,i,!1),main_resolver=(s,o,i)=>[s,JSON.stringify(o),JSON.stringify(i)],NI=utils_memoizeN(main_createXMLExample,main_resolver),RI=utils_memoizeN(main_sampleFromSchema,main_resolver);const DI=new class OptionRegistry extends uI{#t={};data={...this.#t};get defaults(){return{...this.#t}}},api_optionAPI=(s,o)=>(void 0!==o&&DI.register(s,o),DI.get(s)),LI=[{when:/json/,shouldStringifyTypes:["string"]}],BI=["object"],fn_get_json_sample_schema=s=>(o,i,u,_)=>{const{fn:w}=s(),x=w.jsonSchema202012.memoizedSampleFromSchema(o,i,_),C=typeof x,j=LI.reduce(((s,o)=>o.when.test(u)?[...s,...o.shouldStringifyTypes]:s),BI);return mt()(j,(s=>s===C))?JSON.stringify(x,null,2):x},fn_get_yaml_sample_schema=s=>(o,i,u,_)=>{const{fn:w}=s(),x=w.jsonSchema202012.getJsonSampleSchema(o,i,u,_);let C;try{C=mn.dump(mn.load(x),{lineWidth:-1},{schema:nn}),"\n"===C[C.length-1]&&(C=C.slice(0,C.length-1))}catch(s){return console.error(s),"error: could not generate yaml example"}return C.replace(/\t/g," ")},fn_get_xml_sample_schema=s=>(o,i,u)=>{const{fn:_}=s();if(o&&!o.xml&&(o.xml={}),o&&!o.xml.name){if(!o.$$ref&&(o.type||o.items||o.properties||o.additionalProperties))return'\n\x3c!-- XML example cannot be generated; root element name is undefined --\x3e';if(o.$$ref){let s=o.$$ref.match(/\S*\/(\S+)$/);o.xml.name=s[1]}}return _.jsonSchema202012.memoizedCreateXMLExample(o,i,u)},fn_get_sample_schema=s=>(o,i="",u={},_=void 0)=>{const{fn:w}=s();return"function"==typeof o?.toJS&&(o=o.toJS()),"function"==typeof _?.toJS&&(_=_.toJS()),/xml/.test(i)?w.jsonSchema202012.getXmlSampleSchema(o,u,_):/(yaml|yml)/.test(i)?w.jsonSchema202012.getYamlSampleSchema(o,u,i,_):w.jsonSchema202012.getJsonSampleSchema(o,u,i,_)},json_schema_2020_12_samples=({getSystem:s})=>{const o=fn_get_json_sample_schema(s),i=fn_get_yaml_sample_schema(s),u=fn_get_xml_sample_schema(s),_=fn_get_sample_schema(s);return{fn:{jsonSchema202012:{sampleFromSchema:main_sampleFromSchema,sampleFromSchemaGeneric:main_sampleFromSchemaGeneric,sampleOptionAPI:api_optionAPI,sampleEncoderAPI:EI,sampleFormatAPI:hI,sampleMediaTypeAPI:AI,createXMLExample:main_createXMLExample,memoizedSampleFromSchema:RI,memoizedCreateXMLExample:NI,getJsonSampleSchema:o,getYamlSampleSchema:i,getXmlSampleSchema:u,getSampleSchema:_,mergeJsonSchema:TI}}}};function PresetApis(){return[base,oas3,json_schema_2020_12,json_schema_2020_12_samples,oas31]}const inline_plugin=s=>()=>({fn:s.fn,components:s.components}),factorization_system=s=>{const o=We()({layout:{layout:s.layout,filter:s.filter},spec:{spec:"",url:s.url},requestSnippets:s.requestSnippets},s.initialState);if(s.initialState)for(const[i,u]of Object.entries(s.initialState))void 0===u&&delete o[i];return{system:{configs:s.configs},plugins:s.presets,state:o}},sources_query=()=>s=>{const o=s.queryConfigEnabled?(()=>{const s=new URLSearchParams(at.location.search);return Object.fromEntries(s)})():{};return Object.entries(o).reduce(((s,[o,i])=>("config"===o?s.configUrl=i:"urls.primaryName"===o?s[o]=i:s=ao()(s,o,i),s)),{})},sources_url=({url:s,system:o})=>async i=>{if(!s)return{};if("function"!=typeof o.configsActions?.getConfigByUrl)return{};const u=(()=>{const s={};return s.promise=new Promise(((o,i)=>{s.resolve=o,s.reject=i})),s})();return o.configsActions.getConfigByUrl({url:s,loadRemoteConfig:!0,requestInterceptor:i.requestInterceptor,responseInterceptor:i.responseInterceptor},(s=>{u.resolve(s)})),u.promise},runtime=()=>()=>{const s={};return globalThis.location&&(s.oauth2RedirectUrl=`${globalThis.location.protocol}//${globalThis.location.host}${globalThis.location.pathname.substring(0,globalThis.location.pathname.lastIndexOf("/"))}/oauth2-redirect.html`),s},FI=Object.freeze({dom_id:null,domNode:null,spec:{},url:"",urls:null,configUrl:null,layout:"BaseLayout",docExpansion:"list",maxDisplayedTags:-1,filter:!1,validatorUrl:"https://validator.swagger.io/validator",oauth2RedirectUrl:void 0,persistAuthorization:!1,configs:{},displayOperationId:!1,displayRequestDuration:!1,deepLinking:!1,tryItOutEnabled:!1,requestInterceptor:s=>(s.curlOptions=[],s),responseInterceptor:s=>s,showMutatedRequest:!0,defaultModelRendering:"example",defaultModelExpandDepth:1,defaultModelsExpandDepth:1,showExtensions:!1,showCommonExtensions:!1,withCredentials:!1,requestSnippetsEnabled:!1,requestSnippets:{generators:{curl_bash:{title:"cURL (bash)",syntax:"bash"},curl_powershell:{title:"cURL (PowerShell)",syntax:"powershell"},curl_cmd:{title:"cURL (CMD)",syntax:"bash"}},defaultExpanded:!0,languages:null},supportedSubmitMethods:["get","put","post","delete","options","head","patch","trace"],queryConfigEnabled:!1,presets:[PresetApis],plugins:[],initialState:{},fn:{},components:{},syntaxHighlight:{activated:!0,theme:"agate"},operationsSorter:null,tagsSorter:null,onComplete:null,modelPropertyMacro:null,parameterMacro:null});var qI=__webpack_require__(61448),$I=__webpack_require__.n(qI),VI=__webpack_require__(77731),UI=__webpack_require__.n(VI);const type_casters_array=(s,o=[])=>Array.isArray(s)?s:o,type_casters_boolean=(s,o=!1)=>!0===s||"true"===s||1===s||"1"===s||!1!==s&&"false"!==s&&0!==s&&"0"!==s&&o,dom_node=s=>null===s||"null"===s?null:s,type_casters_filter=s=>{const o=String(s);return type_casters_boolean(s,o)},type_casters_function=(s,o)=>"function"==typeof s?s:o,nullable_array=s=>Array.isArray(s)?s:null,nullable_function=s=>"function"==typeof s?s:null,nullable_string=s=>null===s||"null"===s?null:String(s),type_casters_number=(s,o=-1)=>{const i=parseInt(s,10);return Number.isNaN(i)?o:i},type_casters_object=(s,o={})=>cI()(s)?s:o,sorter=s=>"function"==typeof s||"string"==typeof s?s:null,type_casters_string=s=>String(s),syntax_highlight=(s,o)=>cI()(s)?s:!1===s||"false"===s||0===s||"0"===s?{activated:!1}:o,undefined_string=s=>void 0===s||"undefined"===s?void 0:String(s),zI={components:{typeCaster:type_casters_object},configs:{typeCaster:type_casters_object},configUrl:{typeCaster:nullable_string},deepLinking:{typeCaster:type_casters_boolean,defaultValue:FI.deepLinking},defaultModelExpandDepth:{typeCaster:type_casters_number,defaultValue:FI.defaultModelExpandDepth},defaultModelRendering:{typeCaster:type_casters_string},defaultModelsExpandDepth:{typeCaster:type_casters_number,defaultValue:FI.defaultModelsExpandDepth},displayOperationId:{typeCaster:type_casters_boolean,defaultValue:FI.displayOperationId},displayRequestDuration:{typeCaster:type_casters_boolean,defaultValue:FI.displayRequestDuration},docExpansion:{typeCaster:type_casters_string},dom_id:{typeCaster:nullable_string},domNode:{typeCaster:dom_node},filter:{typeCaster:type_casters_filter},fn:{typeCaster:type_casters_object},initialState:{typeCaster:type_casters_object},layout:{typeCaster:type_casters_string},maxDisplayedTags:{typeCaster:type_casters_number,defaultValue:FI.maxDisplayedTags},modelPropertyMacro:{typeCaster:nullable_function},oauth2RedirectUrl:{typeCaster:undefined_string},onComplete:{typeCaster:nullable_function},operationsSorter:{typeCaster:sorter},paramaterMacro:{typeCaster:nullable_function},persistAuthorization:{typeCaster:type_casters_boolean,defaultValue:FI.persistAuthorization},plugins:{typeCaster:type_casters_array,defaultValue:FI.plugins},presets:{typeCaster:type_casters_array,defaultValue:FI.presets},requestInterceptor:{typeCaster:type_casters_function,defaultValue:FI.requestInterceptor},requestSnippets:{typeCaster:type_casters_object,defaultValue:FI.requestSnippets},requestSnippetsEnabled:{typeCaster:type_casters_boolean,defaultValue:FI.requestSnippetsEnabled},responseInterceptor:{typeCaster:type_casters_function,defaultValue:FI.responseInterceptor},showCommonExtensions:{typeCaster:type_casters_boolean,defaultValue:FI.showCommonExtensions},showExtensions:{typeCaster:type_casters_boolean,defaultValue:FI.showExtensions},showMutatedRequest:{typeCaster:type_casters_boolean,defaultValue:FI.showMutatedRequest},spec:{typeCaster:type_casters_object,defaultValue:FI.spec},supportedSubmitMethods:{typeCaster:type_casters_array,defaultValue:FI.supportedSubmitMethods},syntaxHighlight:{typeCaster:syntax_highlight,defaultValue:FI.syntaxHighlight},"syntaxHighlight.activated":{typeCaster:type_casters_boolean,defaultValue:FI.syntaxHighlight.activated},"syntaxHighlight.theme":{typeCaster:type_casters_string},tagsSorter:{typeCaster:sorter},tryItOutEnabled:{typeCaster:type_casters_boolean,defaultValue:FI.tryItOutEnabled},url:{typeCaster:type_casters_string},urls:{typeCaster:nullable_array},"urls.primaryName":{typeCaster:type_casters_string},validatorUrl:{typeCaster:nullable_string},withCredentials:{typeCaster:type_casters_boolean,defaultValue:FI.withCredentials}},type_cast=s=>Object.entries(zI).reduce(((s,[o,{typeCaster:i,defaultValue:u}])=>{if($I()(s,o)){const _=i(jn()(s,o),u);s=UI()(o,_,s)}return s}),{...s}),config_merge=(s,...o)=>{let i=Symbol.for("domNode"),u=Symbol.for("primaryName");const _=[];for(const s of o){const o={...s};Object.hasOwn(o,"domNode")&&(i=o.domNode,delete o.domNode),Object.hasOwn(o,"urls.primaryName")?(u=o["urls.primaryName"],delete o["urls.primaryName"]):Array.isArray(o.urls)&&Object.hasOwn(o.urls,"primaryName")&&(u=o.urls.primaryName,delete o.urls.primaryName),_.push(o)}const w=We()(s,..._);return i!==Symbol.for("domNode")&&(w.domNode=i),u!==Symbol.for("primaryName")&&Array.isArray(w.urls)&&(w.urls.primaryName=u),type_cast(w)};function SwaggerUI(s){const o=sources_query()(s),i=runtime()(),u=SwaggerUI.config.merge({},SwaggerUI.config.defaults,i,s,o),_=factorization_system(u),w=inline_plugin(u),x=new Store(_);x.register([u.plugins,w]);const C=x.getSystem(),persistConfigs=s=>{x.setConfigs(s),C.configsActions.loaded()},updateSpec=s=>{!o.url&&"object"==typeof s.spec&&Object.keys(s.spec).length>0?(C.specActions.updateUrl(""),C.specActions.updateLoadingStatus("success"),C.specActions.updateSpec(JSON.stringify(s.spec))):"function"==typeof C.specActions.download&&s.url&&!s.urls&&(C.specActions.updateUrl(s.url),C.specActions.download(s.url))},render=s=>{if(s.domNode)C.render(s.domNode,"App");else if(s.dom_id){const o=document.querySelector(s.dom_id);C.render(o,"App")}else null===s.dom_id||null===s.domNode||console.error("Skipped rendering: no `dom_id` or `domNode` was specified")};return u.configUrl?((async()=>{const{configUrl:s}=u,i=await sources_url({url:s,system:C})(u),_=SwaggerUI.config.merge({},u,i,o);persistConfigs(_),null!==i&&updateSpec(_),render(_)})(),C):(persistConfigs(u),updateSpec(u),render(u),C)}SwaggerUI.System=Store,SwaggerUI.config={defaults:FI,merge:config_merge,typeCast:type_cast,typeCastMappings:zI},SwaggerUI.presets={base,apis:PresetApis},SwaggerUI.plugins={Auth:auth,Configs:configsPlugin,DeepLining:deep_linking,Err:err,Filter:filter,Icons:icons,JSONSchema5:json_schema_5,JSONSchema5Samples:json_schema_5_samples,JSONSchema202012:json_schema_2020_12,JSONSchema202012Samples:json_schema_2020_12_samples,Layout:plugins_layout,Logs:logs,OpenAPI30:oas3,OpenAPI31:oas3,OnComplete:on_complete,RequestSnippets:plugins_request_snippets,Spec:plugins_spec,SwaggerClient:swagger_client,Util:util,View:view,ViewLegacy:view_legacy,DownloadUrl:downloadUrlPlugin,SyntaxHighlighting:syntax_highlighting,Versions:versions,SafeRender:safe_render};const WI=SwaggerUI})(),_=_.default})())); \ No newline at end of file diff --git a/web/vendor/swagger-ui/swagger-ui-standalone-preset.js b/web/vendor/swagger-ui/swagger-ui-standalone-preset.js new file mode 100644 index 0000000..6ea56ce --- /dev/null +++ b/web/vendor/swagger-ui/swagger-ui-standalone-preset.js @@ -0,0 +1,2 @@ +/*! For license information please see swagger-ui-standalone-preset.js.LICENSE.txt */ +!function webpackUniversalModuleDefinition(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.SwaggerUIStandalonePreset=t():e.SwaggerUIStandalonePreset=t()}(this,(()=>(()=>{var e={9119:(e,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.BLANK_URL=t.relativeFirstCharacters=t.whitespaceEscapeCharsRegex=t.urlSchemeRegex=t.ctrlCharactersRegex=t.htmlCtrlEntityRegex=t.htmlEntitiesRegex=t.invalidProtocolRegex=void 0,t.invalidProtocolRegex=/^([^\w]*)(javascript|data|vbscript)/im,t.htmlEntitiesRegex=/&#(\w+)(^\w|;)?/g,t.htmlCtrlEntityRegex=/&(newline|tab);/gi,t.ctrlCharactersRegex=/[\u0000-\u001F\u007F-\u009F\u2000-\u200D\uFEFF]/gim,t.urlSchemeRegex=/^.+(:|:)/gim,t.whitespaceEscapeCharsRegex=/(\\|%5[cC])((%(6[eE]|72|74))|[nrt])/g,t.relativeFirstCharacters=[".","/"],t.BLANK_URL="about:blank"},6750:(e,t,r)=>{"use strict";var n=r(9119);function decodeURI(e){try{return decodeURIComponent(e)}catch(t){return e}}},7526:(e,t)=>{"use strict";t.byteLength=function byteLength(e){var t=getLens(e),r=t[0],n=t[1];return 3*(r+n)/4-n},t.toByteArray=function toByteArray(e){var t,r,o=getLens(e),a=o[0],s=o[1],u=new i(function _byteLength(e,t,r){return 3*(t+r)/4-r}(0,a,s)),c=0,f=s>0?a-4:a;for(r=0;r>16&255,u[c++]=t>>8&255,u[c++]=255&t;2===s&&(t=n[e.charCodeAt(r)]<<2|n[e.charCodeAt(r+1)]>>4,u[c++]=255&t);1===s&&(t=n[e.charCodeAt(r)]<<10|n[e.charCodeAt(r+1)]<<4|n[e.charCodeAt(r+2)]>>2,u[c++]=t>>8&255,u[c++]=255&t);return u},t.fromByteArray=function fromByteArray(e){for(var t,n=e.length,i=n%3,o=[],a=16383,s=0,u=n-i;su?u:s+a));1===i?(t=e[n-1],o.push(r[t>>2]+r[t<<4&63]+"==")):2===i&&(t=(e[n-2]<<8)+e[n-1],o.push(r[t>>10]+r[t>>4&63]+r[t<<2&63]+"="));return o.join("")};for(var r=[],n=[],i="undefined"!=typeof Uint8Array?Uint8Array:Array,o="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",a=0;a<64;++a)r[a]=o[a],n[o.charCodeAt(a)]=a;function getLens(e){var t=e.length;if(t%4>0)throw new Error("Invalid string. Length must be a multiple of 4");var r=e.indexOf("=");return-1===r&&(r=t),[r,r===t?0:4-r%4]}function encodeChunk(e,t,n){for(var i,o,a=[],s=t;s>18&63]+r[o>>12&63]+r[o>>6&63]+r[63&o]);return a.join("")}n["-".charCodeAt(0)]=62,n["_".charCodeAt(0)]=63},8287:(e,t,r)=>{"use strict";const n=r(7526),i=r(251),o="function"==typeof Symbol&&"function"==typeof Symbol.for?Symbol.for("nodejs.util.inspect.custom"):null;t.Buffer=Buffer,t.SlowBuffer=function SlowBuffer(e){+e!=e&&(e=0);return Buffer.alloc(+e)},t.INSPECT_MAX_BYTES=50;const a=2147483647;function createBuffer(e){if(e>a)throw new RangeError('The value "'+e+'" is invalid for option "size"');const t=new Uint8Array(e);return Object.setPrototypeOf(t,Buffer.prototype),t}function Buffer(e,t,r){if("number"==typeof e){if("string"==typeof t)throw new TypeError('The "string" argument must be of type string. Received type number');return allocUnsafe(e)}return from(e,t,r)}function from(e,t,r){if("string"==typeof e)return function fromString(e,t){"string"==typeof t&&""!==t||(t="utf8");if(!Buffer.isEncoding(t))throw new TypeError("Unknown encoding: "+t);const r=0|byteLength(e,t);let n=createBuffer(r);const i=n.write(e,t);i!==r&&(n=n.slice(0,i));return n}(e,t);if(ArrayBuffer.isView(e))return function fromArrayView(e){if(isInstance(e,Uint8Array)){const t=new Uint8Array(e);return fromArrayBuffer(t.buffer,t.byteOffset,t.byteLength)}return fromArrayLike(e)}(e);if(null==e)throw new TypeError("The first argument must be one of type string, Buffer, ArrayBuffer, Array, or Array-like Object. Received type "+typeof e);if(isInstance(e,ArrayBuffer)||e&&isInstance(e.buffer,ArrayBuffer))return fromArrayBuffer(e,t,r);if("undefined"!=typeof SharedArrayBuffer&&(isInstance(e,SharedArrayBuffer)||e&&isInstance(e.buffer,SharedArrayBuffer)))return fromArrayBuffer(e,t,r);if("number"==typeof e)throw new TypeError('The "value" argument must not be of type number. Received type number');const n=e.valueOf&&e.valueOf();if(null!=n&&n!==e)return Buffer.from(n,t,r);const i=function fromObject(e){if(Buffer.isBuffer(e)){const t=0|checked(e.length),r=createBuffer(t);return 0===r.length||e.copy(r,0,0,t),r}if(void 0!==e.length)return"number"!=typeof e.length||numberIsNaN(e.length)?createBuffer(0):fromArrayLike(e);if("Buffer"===e.type&&Array.isArray(e.data))return fromArrayLike(e.data)}(e);if(i)return i;if("undefined"!=typeof Symbol&&null!=Symbol.toPrimitive&&"function"==typeof e[Symbol.toPrimitive])return Buffer.from(e[Symbol.toPrimitive]("string"),t,r);throw new TypeError("The first argument must be one of type string, Buffer, ArrayBuffer, Array, or Array-like Object. Received type "+typeof e)}function assertSize(e){if("number"!=typeof e)throw new TypeError('"size" argument must be of type number');if(e<0)throw new RangeError('The value "'+e+'" is invalid for option "size"')}function allocUnsafe(e){return assertSize(e),createBuffer(e<0?0:0|checked(e))}function fromArrayLike(e){const t=e.length<0?0:0|checked(e.length),r=createBuffer(t);for(let n=0;n=a)throw new RangeError("Attempt to allocate Buffer larger than maximum size: 0x"+a.toString(16)+" bytes");return 0|e}function byteLength(e,t){if(Buffer.isBuffer(e))return e.length;if(ArrayBuffer.isView(e)||isInstance(e,ArrayBuffer))return e.byteLength;if("string"!=typeof e)throw new TypeError('The "string" argument must be one of type string, Buffer, or ArrayBuffer. Received type '+typeof e);const r=e.length,n=arguments.length>2&&!0===arguments[2];if(!n&&0===r)return 0;let i=!1;for(;;)switch(t){case"ascii":case"latin1":case"binary":return r;case"utf8":case"utf-8":return utf8ToBytes(e).length;case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return 2*r;case"hex":return r>>>1;case"base64":return base64ToBytes(e).length;default:if(i)return n?-1:utf8ToBytes(e).length;t=(""+t).toLowerCase(),i=!0}}function slowToString(e,t,r){let n=!1;if((void 0===t||t<0)&&(t=0),t>this.length)return"";if((void 0===r||r>this.length)&&(r=this.length),r<=0)return"";if((r>>>=0)<=(t>>>=0))return"";for(e||(e="utf8");;)switch(e){case"hex":return hexSlice(this,t,r);case"utf8":case"utf-8":return utf8Slice(this,t,r);case"ascii":return asciiSlice(this,t,r);case"latin1":case"binary":return latin1Slice(this,t,r);case"base64":return base64Slice(this,t,r);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return utf16leSlice(this,t,r);default:if(n)throw new TypeError("Unknown encoding: "+e);e=(e+"").toLowerCase(),n=!0}}function swap(e,t,r){const n=e[t];e[t]=e[r],e[r]=n}function bidirectionalIndexOf(e,t,r,n,i){if(0===e.length)return-1;if("string"==typeof r?(n=r,r=0):r>2147483647?r=2147483647:r<-2147483648&&(r=-2147483648),numberIsNaN(r=+r)&&(r=i?0:e.length-1),r<0&&(r=e.length+r),r>=e.length){if(i)return-1;r=e.length-1}else if(r<0){if(!i)return-1;r=0}if("string"==typeof t&&(t=Buffer.from(t,n)),Buffer.isBuffer(t))return 0===t.length?-1:arrayIndexOf(e,t,r,n,i);if("number"==typeof t)return t&=255,"function"==typeof Uint8Array.prototype.indexOf?i?Uint8Array.prototype.indexOf.call(e,t,r):Uint8Array.prototype.lastIndexOf.call(e,t,r):arrayIndexOf(e,[t],r,n,i);throw new TypeError("val must be string, number or Buffer")}function arrayIndexOf(e,t,r,n,i){let o,a=1,s=e.length,u=t.length;if(void 0!==n&&("ucs2"===(n=String(n).toLowerCase())||"ucs-2"===n||"utf16le"===n||"utf-16le"===n)){if(e.length<2||t.length<2)return-1;a=2,s/=2,u/=2,r/=2}function read(e,t){return 1===a?e[t]:e.readUInt16BE(t*a)}if(i){let n=-1;for(o=r;os&&(r=s-u),o=r;o>=0;o--){let r=!0;for(let n=0;ni&&(n=i):n=i;const o=t.length;let a;for(n>o/2&&(n=o/2),a=0;a>8,i=r%256,o.push(i),o.push(n);return o}(t,e.length-r),e,r,n)}function base64Slice(e,t,r){return 0===t&&r===e.length?n.fromByteArray(e):n.fromByteArray(e.slice(t,r))}function utf8Slice(e,t,r){r=Math.min(e.length,r);const n=[];let i=t;for(;i239?4:t>223?3:t>191?2:1;if(i+a<=r){let r,n,s,u;switch(a){case 1:t<128&&(o=t);break;case 2:r=e[i+1],128==(192&r)&&(u=(31&t)<<6|63&r,u>127&&(o=u));break;case 3:r=e[i+1],n=e[i+2],128==(192&r)&&128==(192&n)&&(u=(15&t)<<12|(63&r)<<6|63&n,u>2047&&(u<55296||u>57343)&&(o=u));break;case 4:r=e[i+1],n=e[i+2],s=e[i+3],128==(192&r)&&128==(192&n)&&128==(192&s)&&(u=(15&t)<<18|(63&r)<<12|(63&n)<<6|63&s,u>65535&&u<1114112&&(o=u))}}null===o?(o=65533,a=1):o>65535&&(o-=65536,n.push(o>>>10&1023|55296),o=56320|1023&o),n.push(o),i+=a}return function decodeCodePointsArray(e){const t=e.length;if(t<=s)return String.fromCharCode.apply(String,e);let r="",n=0;for(;nn.length?(Buffer.isBuffer(t)||(t=Buffer.from(t)),t.copy(n,i)):Uint8Array.prototype.set.call(n,t,i);else{if(!Buffer.isBuffer(t))throw new TypeError('"list" argument must be an Array of Buffers');t.copy(n,i)}i+=t.length}return n},Buffer.byteLength=byteLength,Buffer.prototype._isBuffer=!0,Buffer.prototype.swap16=function swap16(){const e=this.length;if(e%2!=0)throw new RangeError("Buffer size must be a multiple of 16-bits");for(let t=0;tr&&(e+=" ... "),""},o&&(Buffer.prototype[o]=Buffer.prototype.inspect),Buffer.prototype.compare=function compare(e,t,r,n,i){if(isInstance(e,Uint8Array)&&(e=Buffer.from(e,e.offset,e.byteLength)),!Buffer.isBuffer(e))throw new TypeError('The "target" argument must be one of type Buffer or Uint8Array. Received type '+typeof e);if(void 0===t&&(t=0),void 0===r&&(r=e?e.length:0),void 0===n&&(n=0),void 0===i&&(i=this.length),t<0||r>e.length||n<0||i>this.length)throw new RangeError("out of range index");if(n>=i&&t>=r)return 0;if(n>=i)return-1;if(t>=r)return 1;if(this===e)return 0;let o=(i>>>=0)-(n>>>=0),a=(r>>>=0)-(t>>>=0);const s=Math.min(o,a),u=this.slice(n,i),c=e.slice(t,r);for(let e=0;e>>=0,isFinite(r)?(r>>>=0,void 0===n&&(n="utf8")):(n=r,r=void 0)}const i=this.length-t;if((void 0===r||r>i)&&(r=i),e.length>0&&(r<0||t<0)||t>this.length)throw new RangeError("Attempt to write outside buffer bounds");n||(n="utf8");let o=!1;for(;;)switch(n){case"hex":return hexWrite(this,e,t,r);case"utf8":case"utf-8":return utf8Write(this,e,t,r);case"ascii":case"latin1":case"binary":return asciiWrite(this,e,t,r);case"base64":return base64Write(this,e,t,r);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return ucs2Write(this,e,t,r);default:if(o)throw new TypeError("Unknown encoding: "+n);n=(""+n).toLowerCase(),o=!0}},Buffer.prototype.toJSON=function toJSON(){return{type:"Buffer",data:Array.prototype.slice.call(this._arr||this,0)}};const s=4096;function asciiSlice(e,t,r){let n="";r=Math.min(e.length,r);for(let i=t;in)&&(r=n);let i="";for(let n=t;nr)throw new RangeError("Trying to access beyond buffer length")}function checkInt(e,t,r,n,i,o){if(!Buffer.isBuffer(e))throw new TypeError('"buffer" argument must be a Buffer instance');if(t>i||te.length)throw new RangeError("Index out of range")}function wrtBigUInt64LE(e,t,r,n,i){checkIntBI(t,n,i,e,r,7);let o=Number(t&BigInt(4294967295));e[r++]=o,o>>=8,e[r++]=o,o>>=8,e[r++]=o,o>>=8,e[r++]=o;let a=Number(t>>BigInt(32)&BigInt(4294967295));return e[r++]=a,a>>=8,e[r++]=a,a>>=8,e[r++]=a,a>>=8,e[r++]=a,r}function wrtBigUInt64BE(e,t,r,n,i){checkIntBI(t,n,i,e,r,7);let o=Number(t&BigInt(4294967295));e[r+7]=o,o>>=8,e[r+6]=o,o>>=8,e[r+5]=o,o>>=8,e[r+4]=o;let a=Number(t>>BigInt(32)&BigInt(4294967295));return e[r+3]=a,a>>=8,e[r+2]=a,a>>=8,e[r+1]=a,a>>=8,e[r]=a,r+8}function checkIEEE754(e,t,r,n,i,o){if(r+n>e.length)throw new RangeError("Index out of range");if(r<0)throw new RangeError("Index out of range")}function writeFloat(e,t,r,n,o){return t=+t,r>>>=0,o||checkIEEE754(e,0,r,4),i.write(e,t,r,n,23,4),r+4}function writeDouble(e,t,r,n,o){return t=+t,r>>>=0,o||checkIEEE754(e,0,r,8),i.write(e,t,r,n,52,8),r+8}Buffer.prototype.slice=function slice(e,t){const r=this.length;(e=~~e)<0?(e+=r)<0&&(e=0):e>r&&(e=r),(t=void 0===t?r:~~t)<0?(t+=r)<0&&(t=0):t>r&&(t=r),t>>=0,t>>>=0,r||checkOffset(e,t,this.length);let n=this[e],i=1,o=0;for(;++o>>=0,t>>>=0,r||checkOffset(e,t,this.length);let n=this[e+--t],i=1;for(;t>0&&(i*=256);)n+=this[e+--t]*i;return n},Buffer.prototype.readUint8=Buffer.prototype.readUInt8=function readUInt8(e,t){return e>>>=0,t||checkOffset(e,1,this.length),this[e]},Buffer.prototype.readUint16LE=Buffer.prototype.readUInt16LE=function readUInt16LE(e,t){return e>>>=0,t||checkOffset(e,2,this.length),this[e]|this[e+1]<<8},Buffer.prototype.readUint16BE=Buffer.prototype.readUInt16BE=function readUInt16BE(e,t){return e>>>=0,t||checkOffset(e,2,this.length),this[e]<<8|this[e+1]},Buffer.prototype.readUint32LE=Buffer.prototype.readUInt32LE=function readUInt32LE(e,t){return e>>>=0,t||checkOffset(e,4,this.length),(this[e]|this[e+1]<<8|this[e+2]<<16)+16777216*this[e+3]},Buffer.prototype.readUint32BE=Buffer.prototype.readUInt32BE=function readUInt32BE(e,t){return e>>>=0,t||checkOffset(e,4,this.length),16777216*this[e]+(this[e+1]<<16|this[e+2]<<8|this[e+3])},Buffer.prototype.readBigUInt64LE=defineBigIntMethod((function readBigUInt64LE(e){validateNumber(e>>>=0,"offset");const t=this[e],r=this[e+7];void 0!==t&&void 0!==r||boundsError(e,this.length-8);const n=t+256*this[++e]+65536*this[++e]+this[++e]*2**24,i=this[++e]+256*this[++e]+65536*this[++e]+r*2**24;return BigInt(n)+(BigInt(i)<>>=0,"offset");const t=this[e],r=this[e+7];void 0!==t&&void 0!==r||boundsError(e,this.length-8);const n=t*2**24+65536*this[++e]+256*this[++e]+this[++e],i=this[++e]*2**24+65536*this[++e]+256*this[++e]+r;return(BigInt(n)<>>=0,t>>>=0,r||checkOffset(e,t,this.length);let n=this[e],i=1,o=0;for(;++o=i&&(n-=Math.pow(2,8*t)),n},Buffer.prototype.readIntBE=function readIntBE(e,t,r){e>>>=0,t>>>=0,r||checkOffset(e,t,this.length);let n=t,i=1,o=this[e+--n];for(;n>0&&(i*=256);)o+=this[e+--n]*i;return i*=128,o>=i&&(o-=Math.pow(2,8*t)),o},Buffer.prototype.readInt8=function readInt8(e,t){return e>>>=0,t||checkOffset(e,1,this.length),128&this[e]?-1*(255-this[e]+1):this[e]},Buffer.prototype.readInt16LE=function readInt16LE(e,t){e>>>=0,t||checkOffset(e,2,this.length);const r=this[e]|this[e+1]<<8;return 32768&r?4294901760|r:r},Buffer.prototype.readInt16BE=function readInt16BE(e,t){e>>>=0,t||checkOffset(e,2,this.length);const r=this[e+1]|this[e]<<8;return 32768&r?4294901760|r:r},Buffer.prototype.readInt32LE=function readInt32LE(e,t){return e>>>=0,t||checkOffset(e,4,this.length),this[e]|this[e+1]<<8|this[e+2]<<16|this[e+3]<<24},Buffer.prototype.readInt32BE=function readInt32BE(e,t){return e>>>=0,t||checkOffset(e,4,this.length),this[e]<<24|this[e+1]<<16|this[e+2]<<8|this[e+3]},Buffer.prototype.readBigInt64LE=defineBigIntMethod((function readBigInt64LE(e){validateNumber(e>>>=0,"offset");const t=this[e],r=this[e+7];void 0!==t&&void 0!==r||boundsError(e,this.length-8);const n=this[e+4]+256*this[e+5]+65536*this[e+6]+(r<<24);return(BigInt(n)<>>=0,"offset");const t=this[e],r=this[e+7];void 0!==t&&void 0!==r||boundsError(e,this.length-8);const n=(t<<24)+65536*this[++e]+256*this[++e]+this[++e];return(BigInt(n)<>>=0,t||checkOffset(e,4,this.length),i.read(this,e,!0,23,4)},Buffer.prototype.readFloatBE=function readFloatBE(e,t){return e>>>=0,t||checkOffset(e,4,this.length),i.read(this,e,!1,23,4)},Buffer.prototype.readDoubleLE=function readDoubleLE(e,t){return e>>>=0,t||checkOffset(e,8,this.length),i.read(this,e,!0,52,8)},Buffer.prototype.readDoubleBE=function readDoubleBE(e,t){return e>>>=0,t||checkOffset(e,8,this.length),i.read(this,e,!1,52,8)},Buffer.prototype.writeUintLE=Buffer.prototype.writeUIntLE=function writeUIntLE(e,t,r,n){if(e=+e,t>>>=0,r>>>=0,!n){checkInt(this,e,t,r,Math.pow(2,8*r)-1,0)}let i=1,o=0;for(this[t]=255&e;++o>>=0,r>>>=0,!n){checkInt(this,e,t,r,Math.pow(2,8*r)-1,0)}let i=r-1,o=1;for(this[t+i]=255&e;--i>=0&&(o*=256);)this[t+i]=e/o&255;return t+r},Buffer.prototype.writeUint8=Buffer.prototype.writeUInt8=function writeUInt8(e,t,r){return e=+e,t>>>=0,r||checkInt(this,e,t,1,255,0),this[t]=255&e,t+1},Buffer.prototype.writeUint16LE=Buffer.prototype.writeUInt16LE=function writeUInt16LE(e,t,r){return e=+e,t>>>=0,r||checkInt(this,e,t,2,65535,0),this[t]=255&e,this[t+1]=e>>>8,t+2},Buffer.prototype.writeUint16BE=Buffer.prototype.writeUInt16BE=function writeUInt16BE(e,t,r){return e=+e,t>>>=0,r||checkInt(this,e,t,2,65535,0),this[t]=e>>>8,this[t+1]=255&e,t+2},Buffer.prototype.writeUint32LE=Buffer.prototype.writeUInt32LE=function writeUInt32LE(e,t,r){return e=+e,t>>>=0,r||checkInt(this,e,t,4,4294967295,0),this[t+3]=e>>>24,this[t+2]=e>>>16,this[t+1]=e>>>8,this[t]=255&e,t+4},Buffer.prototype.writeUint32BE=Buffer.prototype.writeUInt32BE=function writeUInt32BE(e,t,r){return e=+e,t>>>=0,r||checkInt(this,e,t,4,4294967295,0),this[t]=e>>>24,this[t+1]=e>>>16,this[t+2]=e>>>8,this[t+3]=255&e,t+4},Buffer.prototype.writeBigUInt64LE=defineBigIntMethod((function writeBigUInt64LE(e,t=0){return wrtBigUInt64LE(this,e,t,BigInt(0),BigInt("0xffffffffffffffff"))})),Buffer.prototype.writeBigUInt64BE=defineBigIntMethod((function writeBigUInt64BE(e,t=0){return wrtBigUInt64BE(this,e,t,BigInt(0),BigInt("0xffffffffffffffff"))})),Buffer.prototype.writeIntLE=function writeIntLE(e,t,r,n){if(e=+e,t>>>=0,!n){const n=Math.pow(2,8*r-1);checkInt(this,e,t,r,n-1,-n)}let i=0,o=1,a=0;for(this[t]=255&e;++i>>=0,!n){const n=Math.pow(2,8*r-1);checkInt(this,e,t,r,n-1,-n)}let i=r-1,o=1,a=0;for(this[t+i]=255&e;--i>=0&&(o*=256);)e<0&&0===a&&0!==this[t+i+1]&&(a=1),this[t+i]=(e/o|0)-a&255;return t+r},Buffer.prototype.writeInt8=function writeInt8(e,t,r){return e=+e,t>>>=0,r||checkInt(this,e,t,1,127,-128),e<0&&(e=255+e+1),this[t]=255&e,t+1},Buffer.prototype.writeInt16LE=function writeInt16LE(e,t,r){return e=+e,t>>>=0,r||checkInt(this,e,t,2,32767,-32768),this[t]=255&e,this[t+1]=e>>>8,t+2},Buffer.prototype.writeInt16BE=function writeInt16BE(e,t,r){return e=+e,t>>>=0,r||checkInt(this,e,t,2,32767,-32768),this[t]=e>>>8,this[t+1]=255&e,t+2},Buffer.prototype.writeInt32LE=function writeInt32LE(e,t,r){return e=+e,t>>>=0,r||checkInt(this,e,t,4,2147483647,-2147483648),this[t]=255&e,this[t+1]=e>>>8,this[t+2]=e>>>16,this[t+3]=e>>>24,t+4},Buffer.prototype.writeInt32BE=function writeInt32BE(e,t,r){return e=+e,t>>>=0,r||checkInt(this,e,t,4,2147483647,-2147483648),e<0&&(e=4294967295+e+1),this[t]=e>>>24,this[t+1]=e>>>16,this[t+2]=e>>>8,this[t+3]=255&e,t+4},Buffer.prototype.writeBigInt64LE=defineBigIntMethod((function writeBigInt64LE(e,t=0){return wrtBigUInt64LE(this,e,t,-BigInt("0x8000000000000000"),BigInt("0x7fffffffffffffff"))})),Buffer.prototype.writeBigInt64BE=defineBigIntMethod((function writeBigInt64BE(e,t=0){return wrtBigUInt64BE(this,e,t,-BigInt("0x8000000000000000"),BigInt("0x7fffffffffffffff"))})),Buffer.prototype.writeFloatLE=function writeFloatLE(e,t,r){return writeFloat(this,e,t,!0,r)},Buffer.prototype.writeFloatBE=function writeFloatBE(e,t,r){return writeFloat(this,e,t,!1,r)},Buffer.prototype.writeDoubleLE=function writeDoubleLE(e,t,r){return writeDouble(this,e,t,!0,r)},Buffer.prototype.writeDoubleBE=function writeDoubleBE(e,t,r){return writeDouble(this,e,t,!1,r)},Buffer.prototype.copy=function copy(e,t,r,n){if(!Buffer.isBuffer(e))throw new TypeError("argument should be a Buffer");if(r||(r=0),n||0===n||(n=this.length),t>=e.length&&(t=e.length),t||(t=0),n>0&&n=this.length)throw new RangeError("Index out of range");if(n<0)throw new RangeError("sourceEnd out of bounds");n>this.length&&(n=this.length),e.length-t>>=0,r=void 0===r?this.length:r>>>0,e||(e=0),"number"==typeof e)for(i=t;i=n+4;r-=3)t=`_${e.slice(r-3,r)}${t}`;return`${e.slice(0,r)}${t}`}function checkIntBI(e,t,r,n,i,o){if(e>r||e3?0===t||t===BigInt(0)?`>= 0${n} and < 2${n} ** ${8*(o+1)}${n}`:`>= -(2${n} ** ${8*(o+1)-1}${n}) and < 2 ** ${8*(o+1)-1}${n}`:`>= ${t}${n} and <= ${r}${n}`,new u.ERR_OUT_OF_RANGE("value",i,e)}!function checkBounds(e,t,r){validateNumber(t,"offset"),void 0!==e[t]&&void 0!==e[t+r]||boundsError(t,e.length-(r+1))}(n,i,o)}function validateNumber(e,t){if("number"!=typeof e)throw new u.ERR_INVALID_ARG_TYPE(t,"number",e)}function boundsError(e,t,r){if(Math.floor(e)!==e)throw validateNumber(e,r),new u.ERR_OUT_OF_RANGE(r||"offset","an integer",e);if(t<0)throw new u.ERR_BUFFER_OUT_OF_BOUNDS;throw new u.ERR_OUT_OF_RANGE(r||"offset",`>= ${r?1:0} and <= ${t}`,e)}E("ERR_BUFFER_OUT_OF_BOUNDS",(function(e){return e?`${e} is outside of buffer bounds`:"Attempt to access memory outside buffer bounds"}),RangeError),E("ERR_INVALID_ARG_TYPE",(function(e,t){return`The "${e}" argument must be of type number. Received type ${typeof t}`}),TypeError),E("ERR_OUT_OF_RANGE",(function(e,t,r){let n=`The value of "${e}" is out of range.`,i=r;return Number.isInteger(r)&&Math.abs(r)>2**32?i=addNumericalSeparator(String(r)):"bigint"==typeof r&&(i=String(r),(r>BigInt(2)**BigInt(32)||r<-(BigInt(2)**BigInt(32)))&&(i=addNumericalSeparator(i)),i+="n"),n+=` It must be ${t}. Received ${i}`,n}),RangeError);const c=/[^+/0-9A-Za-z-_]/g;function utf8ToBytes(e,t){let r;t=t||1/0;const n=e.length;let i=null;const o=[];for(let a=0;a55295&&r<57344){if(!i){if(r>56319){(t-=3)>-1&&o.push(239,191,189);continue}if(a+1===n){(t-=3)>-1&&o.push(239,191,189);continue}i=r;continue}if(r<56320){(t-=3)>-1&&o.push(239,191,189),i=r;continue}r=65536+(i-55296<<10|r-56320)}else i&&(t-=3)>-1&&o.push(239,191,189);if(i=null,r<128){if((t-=1)<0)break;o.push(r)}else if(r<2048){if((t-=2)<0)break;o.push(r>>6|192,63&r|128)}else if(r<65536){if((t-=3)<0)break;o.push(r>>12|224,r>>6&63|128,63&r|128)}else{if(!(r<1114112))throw new Error("Invalid code point");if((t-=4)<0)break;o.push(r>>18|240,r>>12&63|128,r>>6&63|128,63&r|128)}}return o}function base64ToBytes(e){return n.toByteArray(function base64clean(e){if((e=(e=e.split("=")[0]).trim().replace(c,"")).length<2)return"";for(;e.length%4!=0;)e+="=";return e}(e))}function blitBuffer(e,t,r,n){let i;for(i=0;i=t.length||i>=e.length);++i)t[i+r]=e[i];return i}function isInstance(e,t){return e instanceof t||null!=e&&null!=e.constructor&&null!=e.constructor.name&&e.constructor.name===t.name}function numberIsNaN(e){return e!=e}const f=function(){const e="0123456789abcdef",t=new Array(256);for(let r=0;r<16;++r){const n=16*r;for(let i=0;i<16;++i)t[n+i]=e[r]+e[i]}return t}();function defineBigIntMethod(e){return"undefined"==typeof BigInt?BufferBigIntNotDefined:e}function BufferBigIntNotDefined(){throw new Error("BigInt not supported")}},2205:function(e,t,r){var n;n=void 0!==r.g?r.g:this,e.exports=function(e){if(e.CSS&&e.CSS.escape)return e.CSS.escape;var cssEscape=function(e){if(0==arguments.length)throw new TypeError("`CSS.escape` requires an argument.");for(var t,r=String(e),n=r.length,i=-1,o="",a=r.charCodeAt(0);++i=1&&t<=31||127==t||0==i&&t>=48&&t<=57||1==i&&t>=48&&t<=57&&45==a?"\\"+t.toString(16)+" ":0==i&&1==n&&45==t||!(t>=128||45==t||95==t||t>=48&&t<=57||t>=65&&t<=90||t>=97&&t<=122)?"\\"+r.charAt(i):r.charAt(i):o+="�";return o};return e.CSS||(e.CSS={}),e.CSS.escape=cssEscape,cssEscape}(n)},251:(e,t)=>{t.read=function(e,t,r,n,i){var o,a,s=8*i-n-1,u=(1<>1,f=-7,l=r?i-1:0,h=r?-1:1,p=e[t+l];for(l+=h,o=p&(1<<-f)-1,p>>=-f,f+=s;f>0;o=256*o+e[t+l],l+=h,f-=8);for(a=o&(1<<-f)-1,o>>=-f,f+=n;f>0;a=256*a+e[t+l],l+=h,f-=8);if(0===o)o=1-c;else{if(o===u)return a?NaN:1/0*(p?-1:1);a+=Math.pow(2,n),o-=c}return(p?-1:1)*a*Math.pow(2,o-n)},t.write=function(e,t,r,n,i,o){var a,s,u,c=8*o-i-1,f=(1<>1,h=23===i?Math.pow(2,-24)-Math.pow(2,-77):0,p=n?0:o-1,d=n?1:-1,_=t<0||0===t&&1/t<0?1:0;for(t=Math.abs(t),isNaN(t)||t===1/0?(s=isNaN(t)?1:0,a=f):(a=Math.floor(Math.log(t)/Math.LN2),t*(u=Math.pow(2,-a))<1&&(a--,u*=2),(t+=a+l>=1?h/u:h*Math.pow(2,1-l))*u>=2&&(a++,u/=2),a+l>=f?(s=0,a=f):a+l>=1?(s=(t*u-1)*Math.pow(2,i),a+=l):(s=t*Math.pow(2,l-1)*Math.pow(2,i),a=0));i>=8;e[r+p]=255&s,p+=d,s/=256,i-=8);for(a=a<0;e[r+p]=255&a,p+=d,a/=256,c-=8);e[r+p-d]|=128*_}},9404:function(e){e.exports=function(){"use strict";var e=Array.prototype.slice;function createClass(e,t){t&&(e.prototype=Object.create(t.prototype)),e.prototype.constructor=e}function Iterable(e){return isIterable(e)?e:Seq(e)}function KeyedIterable(e){return isKeyed(e)?e:KeyedSeq(e)}function IndexedIterable(e){return isIndexed(e)?e:IndexedSeq(e)}function SetIterable(e){return isIterable(e)&&!isAssociative(e)?e:SetSeq(e)}function isIterable(e){return!(!e||!e[t])}function isKeyed(e){return!(!e||!e[r])}function isIndexed(e){return!(!e||!e[n])}function isAssociative(e){return isKeyed(e)||isIndexed(e)}function isOrdered(e){return!(!e||!e[i])}createClass(KeyedIterable,Iterable),createClass(IndexedIterable,Iterable),createClass(SetIterable,Iterable),Iterable.isIterable=isIterable,Iterable.isKeyed=isKeyed,Iterable.isIndexed=isIndexed,Iterable.isAssociative=isAssociative,Iterable.isOrdered=isOrdered,Iterable.Keyed=KeyedIterable,Iterable.Indexed=IndexedIterable,Iterable.Set=SetIterable;var t="@@__IMMUTABLE_ITERABLE__@@",r="@@__IMMUTABLE_KEYED__@@",n="@@__IMMUTABLE_INDEXED__@@",i="@@__IMMUTABLE_ORDERED__@@",o="delete",a=5,s=1<>>0;if(""+r!==t||4294967295===r)return NaN;t=r}return t<0?ensureSize(e)+t:t}function returnTrue(){return!0}function wholeSlice(e,t,r){return(0===e||void 0!==r&&e<=-r)&&(void 0===t||void 0!==r&&t>=r)}function resolveBegin(e,t){return resolveIndex(e,t,0)}function resolveEnd(e,t){return resolveIndex(e,t,t)}function resolveIndex(e,t,r){return void 0===e?r:e<0?Math.max(0,t+e):void 0===t?e:Math.min(t,e)}var h=0,p=1,d=2,_="function"==typeof Symbol&&Symbol.iterator,y="@@iterator",m=_||y;function Iterator(e){this.next=e}function iteratorValue(e,t,r,n){var i=0===e?t:1===e?r:[t,r];return n?n.value=i:n={value:i,done:!1},n}function iteratorDone(){return{value:void 0,done:!0}}function hasIterator(e){return!!getIteratorFn(e)}function isIterator(e){return e&&"function"==typeof e.next}function getIterator(e){var t=getIteratorFn(e);return t&&t.call(e)}function getIteratorFn(e){var t=e&&(_&&e[_]||e[y]);if("function"==typeof t)return t}function isArrayLike(e){return e&&"number"==typeof e.length}function Seq(e){return null==e?emptySequence():isIterable(e)?e.toSeq():seqFromValue(e)}function KeyedSeq(e){return null==e?emptySequence().toKeyedSeq():isIterable(e)?isKeyed(e)?e.toSeq():e.fromEntrySeq():keyedSeqFromValue(e)}function IndexedSeq(e){return null==e?emptySequence():isIterable(e)?isKeyed(e)?e.entrySeq():e.toIndexedSeq():indexedSeqFromValue(e)}function SetSeq(e){return(null==e?emptySequence():isIterable(e)?isKeyed(e)?e.entrySeq():e:indexedSeqFromValue(e)).toSetSeq()}Iterator.prototype.toString=function(){return"[Iterator]"},Iterator.KEYS=h,Iterator.VALUES=p,Iterator.ENTRIES=d,Iterator.prototype.inspect=Iterator.prototype.toSource=function(){return this.toString()},Iterator.prototype[m]=function(){return this},createClass(Seq,Iterable),Seq.of=function(){return Seq(arguments)},Seq.prototype.toSeq=function(){return this},Seq.prototype.toString=function(){return this.__toString("Seq {","}")},Seq.prototype.cacheResult=function(){return!this._cache&&this.__iterateUncached&&(this._cache=this.entrySeq().toArray(),this.size=this._cache.length),this},Seq.prototype.__iterate=function(e,t){return seqIterate(this,e,t,!0)},Seq.prototype.__iterator=function(e,t){return seqIterator(this,e,t,!0)},createClass(KeyedSeq,Seq),KeyedSeq.prototype.toKeyedSeq=function(){return this},createClass(IndexedSeq,Seq),IndexedSeq.of=function(){return IndexedSeq(arguments)},IndexedSeq.prototype.toIndexedSeq=function(){return this},IndexedSeq.prototype.toString=function(){return this.__toString("Seq [","]")},IndexedSeq.prototype.__iterate=function(e,t){return seqIterate(this,e,t,!1)},IndexedSeq.prototype.__iterator=function(e,t){return seqIterator(this,e,t,!1)},createClass(SetSeq,Seq),SetSeq.of=function(){return SetSeq(arguments)},SetSeq.prototype.toSetSeq=function(){return this},Seq.isSeq=isSeq,Seq.Keyed=KeyedSeq,Seq.Set=SetSeq,Seq.Indexed=IndexedSeq;var g,v,b,w="@@__IMMUTABLE_SEQ__@@";function ArraySeq(e){this._array=e,this.size=e.length}function ObjectSeq(e){var t=Object.keys(e);this._object=e,this._keys=t,this.size=t.length}function IterableSeq(e){this._iterable=e,this.size=e.length||e.size}function IteratorSeq(e){this._iterator=e,this._iteratorCache=[]}function isSeq(e){return!(!e||!e[w])}function emptySequence(){return g||(g=new ArraySeq([]))}function keyedSeqFromValue(e){var t=Array.isArray(e)?new ArraySeq(e).fromEntrySeq():isIterator(e)?new IteratorSeq(e).fromEntrySeq():hasIterator(e)?new IterableSeq(e).fromEntrySeq():"object"==typeof e?new ObjectSeq(e):void 0;if(!t)throw new TypeError("Expected Array or iterable object of [k, v] entries, or keyed object: "+e);return t}function indexedSeqFromValue(e){var t=maybeIndexedSeqFromValue(e);if(!t)throw new TypeError("Expected Array or iterable object of values: "+e);return t}function seqFromValue(e){var t=maybeIndexedSeqFromValue(e)||"object"==typeof e&&new ObjectSeq(e);if(!t)throw new TypeError("Expected Array or iterable object of values, or keyed object: "+e);return t}function maybeIndexedSeqFromValue(e){return isArrayLike(e)?new ArraySeq(e):isIterator(e)?new IteratorSeq(e):hasIterator(e)?new IterableSeq(e):void 0}function seqIterate(e,t,r,n){var i=e._cache;if(i){for(var o=i.length-1,a=0;a<=o;a++){var s=i[r?o-a:a];if(!1===t(s[1],n?s[0]:a,e))return a+1}return a}return e.__iterateUncached(t,r)}function seqIterator(e,t,r,n){var i=e._cache;if(i){var o=i.length-1,a=0;return new Iterator((function(){var e=i[r?o-a:a];return a++>o?iteratorDone():iteratorValue(t,n?e[0]:a-1,e[1])}))}return e.__iteratorUncached(t,r)}function fromJS(e,t){return t?fromJSWith(t,e,"",{"":e}):fromJSDefault(e)}function fromJSWith(e,t,r,n){return Array.isArray(t)?e.call(n,r,IndexedSeq(t).map((function(r,n){return fromJSWith(e,r,n,t)}))):isPlainObj(t)?e.call(n,r,KeyedSeq(t).map((function(r,n){return fromJSWith(e,r,n,t)}))):t}function fromJSDefault(e){return Array.isArray(e)?IndexedSeq(e).map(fromJSDefault).toList():isPlainObj(e)?KeyedSeq(e).map(fromJSDefault).toMap():e}function isPlainObj(e){return e&&(e.constructor===Object||void 0===e.constructor)}function is(e,t){if(e===t||e!=e&&t!=t)return!0;if(!e||!t)return!1;if("function"==typeof e.valueOf&&"function"==typeof t.valueOf){if((e=e.valueOf())===(t=t.valueOf())||e!=e&&t!=t)return!0;if(!e||!t)return!1}return!("function"!=typeof e.equals||"function"!=typeof t.equals||!e.equals(t))}function deepEqual(e,t){if(e===t)return!0;if(!isIterable(t)||void 0!==e.size&&void 0!==t.size&&e.size!==t.size||void 0!==e.__hash&&void 0!==t.__hash&&e.__hash!==t.__hash||isKeyed(e)!==isKeyed(t)||isIndexed(e)!==isIndexed(t)||isOrdered(e)!==isOrdered(t))return!1;if(0===e.size&&0===t.size)return!0;var r=!isAssociative(e);if(isOrdered(e)){var n=e.entries();return t.every((function(e,t){var i=n.next().value;return i&&is(i[1],e)&&(r||is(i[0],t))}))&&n.next().done}var i=!1;if(void 0===e.size)if(void 0===t.size)"function"==typeof e.cacheResult&&e.cacheResult();else{i=!0;var o=e;e=t,t=o}var a=!0,s=t.__iterate((function(t,n){if(r?!e.has(t):i?!is(t,e.get(n,c)):!is(e.get(n,c),t))return a=!1,!1}));return a&&e.size===s}function Repeat(e,t){if(!(this instanceof Repeat))return new Repeat(e,t);if(this._value=e,this.size=void 0===t?1/0:Math.max(0,t),0===this.size){if(v)return v;v=this}}function invariant(e,t){if(!e)throw new Error(t)}function Range(e,t,r){if(!(this instanceof Range))return new Range(e,t,r);if(invariant(0!==r,"Cannot step a Range by 0"),e=e||0,void 0===t&&(t=1/0),r=void 0===r?1:Math.abs(r),tn?iteratorDone():iteratorValue(e,i,r[t?n-i++:i++])}))},createClass(ObjectSeq,KeyedSeq),ObjectSeq.prototype.get=function(e,t){return void 0===t||this.has(e)?this._object[e]:t},ObjectSeq.prototype.has=function(e){return this._object.hasOwnProperty(e)},ObjectSeq.prototype.__iterate=function(e,t){for(var r=this._object,n=this._keys,i=n.length-1,o=0;o<=i;o++){var a=n[t?i-o:o];if(!1===e(r[a],a,this))return o+1}return o},ObjectSeq.prototype.__iterator=function(e,t){var r=this._object,n=this._keys,i=n.length-1,o=0;return new Iterator((function(){var a=n[t?i-o:o];return o++>i?iteratorDone():iteratorValue(e,a,r[a])}))},ObjectSeq.prototype[i]=!0,createClass(IterableSeq,IndexedSeq),IterableSeq.prototype.__iterateUncached=function(e,t){if(t)return this.cacheResult().__iterate(e,t);var r=getIterator(this._iterable),n=0;if(isIterator(r))for(var i;!(i=r.next()).done&&!1!==e(i.value,n++,this););return n},IterableSeq.prototype.__iteratorUncached=function(e,t){if(t)return this.cacheResult().__iterator(e,t);var r=getIterator(this._iterable);if(!isIterator(r))return new Iterator(iteratorDone);var n=0;return new Iterator((function(){var t=r.next();return t.done?t:iteratorValue(e,n++,t.value)}))},createClass(IteratorSeq,IndexedSeq),IteratorSeq.prototype.__iterateUncached=function(e,t){if(t)return this.cacheResult().__iterate(e,t);for(var r,n=this._iterator,i=this._iteratorCache,o=0;o=n.length){var t=r.next();if(t.done)return t;n[i]=t.value}return iteratorValue(e,i,n[i++])}))},createClass(Repeat,IndexedSeq),Repeat.prototype.toString=function(){return 0===this.size?"Repeat []":"Repeat [ "+this._value+" "+this.size+" times ]"},Repeat.prototype.get=function(e,t){return this.has(e)?this._value:t},Repeat.prototype.includes=function(e){return is(this._value,e)},Repeat.prototype.slice=function(e,t){var r=this.size;return wholeSlice(e,t,r)?this:new Repeat(this._value,resolveEnd(t,r)-resolveBegin(e,r))},Repeat.prototype.reverse=function(){return this},Repeat.prototype.indexOf=function(e){return is(this._value,e)?0:-1},Repeat.prototype.lastIndexOf=function(e){return is(this._value,e)?this.size:-1},Repeat.prototype.__iterate=function(e,t){for(var r=0;r=0&&t=0&&rr?iteratorDone():iteratorValue(e,o++,a)}))},Range.prototype.equals=function(e){return e instanceof Range?this._start===e._start&&this._end===e._end&&this._step===e._step:deepEqual(this,e)},createClass(Collection,Iterable),createClass(KeyedCollection,Collection),createClass(IndexedCollection,Collection),createClass(SetCollection,Collection),Collection.Keyed=KeyedCollection,Collection.Indexed=IndexedCollection,Collection.Set=SetCollection;var I="function"==typeof Math.imul&&-2===Math.imul(4294967295,2)?Math.imul:function imul(e,t){var r=65535&(e|=0),n=65535&(t|=0);return r*n+((e>>>16)*n+r*(t>>>16)<<16>>>0)|0};function smi(e){return e>>>1&1073741824|3221225471&e}function hash(e){if(!1===e||null==e)return 0;if("function"==typeof e.valueOf&&(!1===(e=e.valueOf())||null==e))return 0;if(!0===e)return 1;var t=typeof e;if("number"===t){if(e!=e||e===1/0)return 0;var r=0|e;for(r!==e&&(r^=4294967295*e);e>4294967295;)r^=e/=4294967295;return smi(r)}if("string"===t)return e.length>j?cachedHashString(e):hashString(e);if("function"==typeof e.hashCode)return e.hashCode();if("object"===t)return hashJSObj(e);if("function"==typeof e.toString)return hashString(e.toString());throw new Error("Value type "+t+" cannot be hashed.")}function cachedHashString(e){var t=D[e];return void 0===t&&(t=hashString(e),P===z&&(P=0,D={}),P++,D[e]=t),t}function hashString(e){for(var t=0,r=0;r0)switch(e.nodeType){case 1:return e.uniqueID;case 9:return e.documentElement&&e.documentElement.uniqueID}}var k,C="function"==typeof WeakMap;C&&(k=new WeakMap);var q=0,L="__immutablehash__";"function"==typeof Symbol&&(L=Symbol(L));var j=16,z=255,P=0,D={};function assertNotInfinite(e){invariant(e!==1/0,"Cannot perform this action with an infinite size.")}function Map(e){return null==e?emptyMap():isMap(e)&&!isOrdered(e)?e:emptyMap().withMutations((function(t){var r=KeyedIterable(e);assertNotInfinite(r.size),r.forEach((function(e,r){return t.set(r,e)}))}))}function isMap(e){return!(!e||!e[W])}createClass(Map,KeyedCollection),Map.of=function(){var t=e.call(arguments,0);return emptyMap().withMutations((function(e){for(var r=0;r=t.length)throw new Error("Missing value for key: "+t[r]);e.set(t[r],t[r+1])}}))},Map.prototype.toString=function(){return this.__toString("Map {","}")},Map.prototype.get=function(e,t){return this._root?this._root.get(0,void 0,e,t):t},Map.prototype.set=function(e,t){return updateMap(this,e,t)},Map.prototype.setIn=function(e,t){return this.updateIn(e,c,(function(){return t}))},Map.prototype.remove=function(e){return updateMap(this,e,c)},Map.prototype.deleteIn=function(e){return this.updateIn(e,(function(){return c}))},Map.prototype.update=function(e,t,r){return 1===arguments.length?e(this):this.updateIn([e],t,r)},Map.prototype.updateIn=function(e,t,r){r||(r=t,t=void 0);var n=updateInDeepMap(this,forceIterator(e),t,r);return n===c?void 0:n},Map.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._root=null,this.__hash=void 0,this.__altered=!0,this):emptyMap()},Map.prototype.merge=function(){return mergeIntoMapWith(this,void 0,arguments)},Map.prototype.mergeWith=function(t){return mergeIntoMapWith(this,t,e.call(arguments,1))},Map.prototype.mergeIn=function(t){var r=e.call(arguments,1);return this.updateIn(t,emptyMap(),(function(e){return"function"==typeof e.merge?e.merge.apply(e,r):r[r.length-1]}))},Map.prototype.mergeDeep=function(){return mergeIntoMapWith(this,deepMerger,arguments)},Map.prototype.mergeDeepWith=function(t){var r=e.call(arguments,1);return mergeIntoMapWith(this,deepMergerWith(t),r)},Map.prototype.mergeDeepIn=function(t){var r=e.call(arguments,1);return this.updateIn(t,emptyMap(),(function(e){return"function"==typeof e.mergeDeep?e.mergeDeep.apply(e,r):r[r.length-1]}))},Map.prototype.sort=function(e){return OrderedMap(sortFactory(this,e))},Map.prototype.sortBy=function(e,t){return OrderedMap(sortFactory(this,t,e))},Map.prototype.withMutations=function(e){var t=this.asMutable();return e(t),t.wasAltered()?t.__ensureOwner(this.__ownerID):this},Map.prototype.asMutable=function(){return this.__ownerID?this:this.__ensureOwner(new OwnerID)},Map.prototype.asImmutable=function(){return this.__ensureOwner()},Map.prototype.wasAltered=function(){return this.__altered},Map.prototype.__iterator=function(e,t){return new MapIterator(this,e,t)},Map.prototype.__iterate=function(e,t){var r=this,n=0;return this._root&&this._root.iterate((function(t){return n++,e(t[1],t[0],r)}),t),n},Map.prototype.__ensureOwner=function(e){return e===this.__ownerID?this:e?makeMap(this.size,this._root,e,this.__hash):(this.__ownerID=e,this.__altered=!1,this)},Map.isMap=isMap;var U,W="@@__IMMUTABLE_MAP__@@",K=Map.prototype;function ArrayMapNode(e,t){this.ownerID=e,this.entries=t}function BitmapIndexedNode(e,t,r){this.ownerID=e,this.bitmap=t,this.nodes=r}function HashArrayMapNode(e,t,r){this.ownerID=e,this.count=t,this.nodes=r}function HashCollisionNode(e,t,r){this.ownerID=e,this.keyHash=t,this.entries=r}function ValueNode(e,t,r){this.ownerID=e,this.keyHash=t,this.entry=r}function MapIterator(e,t,r){this._type=t,this._reverse=r,this._stack=e._root&&mapIteratorFrame(e._root)}function mapIteratorValue(e,t){return iteratorValue(e,t[0],t[1])}function mapIteratorFrame(e,t){return{node:e,index:0,__prev:t}}function makeMap(e,t,r,n){var i=Object.create(K);return i.size=e,i._root=t,i.__ownerID=r,i.__hash=n,i.__altered=!1,i}function emptyMap(){return U||(U=makeMap(0))}function updateMap(e,t,r){var n,i;if(e._root){var o=MakeRef(f),a=MakeRef(l);if(n=updateNode(e._root,e.__ownerID,0,void 0,t,r,o,a),!a.value)return e;i=e.size+(o.value?r===c?-1:1:0)}else{if(r===c)return e;i=1,n=new ArrayMapNode(e.__ownerID,[[t,r]])}return e.__ownerID?(e.size=i,e._root=n,e.__hash=void 0,e.__altered=!0,e):n?makeMap(i,n):emptyMap()}function updateNode(e,t,r,n,i,o,a,s){return e?e.update(t,r,n,i,o,a,s):o===c?e:(SetRef(s),SetRef(a),new ValueNode(t,n,[i,o]))}function isLeafNode(e){return e.constructor===ValueNode||e.constructor===HashCollisionNode}function mergeIntoNode(e,t,r,n,i){if(e.keyHash===n)return new HashCollisionNode(t,n,[e.entry,i]);var o,s=(0===r?e.keyHash:e.keyHash>>>r)&u,c=(0===r?n:n>>>r)&u;return new BitmapIndexedNode(t,1<>>=1)a[u]=1&r?t[o++]:void 0;return a[n]=i,new HashArrayMapNode(e,o+1,a)}function mergeIntoMapWith(e,t,r){for(var n=[],i=0;i>1&1431655765))+(e>>2&858993459))+(e>>4)&252645135,e+=e>>8,127&(e+=e>>16)}function setIn(e,t,r,n){var i=n?e:arrCopy(e);return i[t]=r,i}function spliceIn(e,t,r,n){var i=e.length+1;if(n&&t+1===i)return e[t]=r,e;for(var o=new Array(i),a=0,s=0;s=V)return createNodes(e,u,n,i);var p=e&&e===this.ownerID,d=p?u:arrCopy(u);return h?s?f===l-1?d.pop():d[f]=d.pop():d[f]=[n,i]:d.push([n,i]),p?(this.entries=d,this):new ArrayMapNode(e,d)}},BitmapIndexedNode.prototype.get=function(e,t,r,n){void 0===t&&(t=hash(r));var i=1<<((0===e?t:t>>>e)&u),o=this.bitmap;return o&i?this.nodes[popCount(o&i-1)].get(e+a,t,r,n):n},BitmapIndexedNode.prototype.update=function(e,t,r,n,i,o,s){void 0===r&&(r=hash(n));var f=(0===t?r:r>>>t)&u,l=1<=$)return expandNodes(e,_,h,f,m);if(p&&!m&&2===_.length&&isLeafNode(_[1^d]))return _[1^d];if(p&&m&&1===_.length&&isLeafNode(m))return m;var g=e&&e===this.ownerID,v=p?m?h:h^l:h|l,b=p?m?setIn(_,d,m,g):spliceOut(_,d,g):spliceIn(_,d,m,g);return g?(this.bitmap=v,this.nodes=b,this):new BitmapIndexedNode(e,v,b)},HashArrayMapNode.prototype.get=function(e,t,r,n){void 0===t&&(t=hash(r));var i=(0===e?t:t>>>e)&u,o=this.nodes[i];return o?o.get(e+a,t,r,n):n},HashArrayMapNode.prototype.update=function(e,t,r,n,i,o,s){void 0===r&&(r=hash(n));var f=(0===t?r:r>>>t)&u,l=i===c,h=this.nodes,p=h[f];if(l&&!p)return this;var d=updateNode(p,e,t+a,r,n,i,o,s);if(d===p)return this;var _=this.count;if(p){if(!d&&--_0&&n=0&&e>>t&u;if(n>=this.array.length)return new VNode([],e);var i,o=0===n;if(t>0){var s=this.array[n];if((i=s&&s.removeBefore(e,t-a,r))===s&&o)return this}if(o&&!i)return this;var c=editableVNode(this,e);if(!o)for(var f=0;f>>t&u;if(i>=this.array.length)return this;if(t>0){var o=this.array[i];if((n=o&&o.removeAfter(e,t-a,r))===o&&i===this.array.length-1)return this}var s=editableVNode(this,e);return s.array.splice(i+1),n&&(s.array[i]=n),s};var J,ee,te={};function iterateList(e,t){var r=e._origin,n=e._capacity,i=getTailOffset(n),o=e._tail;return iterateNodeOrLeaf(e._root,e._level,0);function iterateNodeOrLeaf(e,t,r){return 0===t?iterateLeaf(e,r):iterateNode(e,t,r)}function iterateLeaf(e,a){var u=a===i?o&&o.array:e&&e.array,c=a>r?0:r-a,f=n-a;return f>s&&(f=s),function(){if(c===f)return te;var e=t?--f:c++;return u&&u[e]}}function iterateNode(e,i,o){var u,c=e&&e.array,f=o>r?0:r-o>>i,l=1+(n-o>>i);return l>s&&(l=s),function(){for(;;){if(u){var e=u();if(e!==te)return e;u=null}if(f===l)return te;var r=t?--l:f++;u=iterateNodeOrLeaf(c&&c[r],i-a,o+(r<=e.size||t<0)return e.withMutations((function(e){t<0?setListBounds(e,t).set(0,r):setListBounds(e,0,t+1).set(t,r)}));t+=e._origin;var n=e._tail,i=e._root,o=MakeRef(l);return t>=getTailOffset(e._capacity)?n=updateVNode(n,e.__ownerID,0,t,r,o):i=updateVNode(i,e.__ownerID,e._level,t,r,o),o.value?e.__ownerID?(e._root=i,e._tail=n,e.__hash=void 0,e.__altered=!0,e):makeList(e._origin,e._capacity,e._level,i,n):e}function updateVNode(e,t,r,n,i,o){var s,c=n>>>r&u,f=e&&c0){var l=e&&e.array[c],h=updateVNode(l,t,r-a,n,i,o);return h===l?e:((s=editableVNode(e,t)).array[c]=h,s)}return f&&e.array[c]===i?e:(SetRef(o),s=editableVNode(e,t),void 0===i&&c===s.array.length-1?s.array.pop():s.array[c]=i,s)}function editableVNode(e,t){return t&&e&&t===e.ownerID?e:new VNode(e?e.array.slice():[],t)}function listNodeFor(e,t){if(t>=getTailOffset(e._capacity))return e._tail;if(t<1<0;)r=r.array[t>>>n&u],n-=a;return r}}function setListBounds(e,t,r){void 0!==t&&(t|=0),void 0!==r&&(r|=0);var n=e.__ownerID||new OwnerID,i=e._origin,o=e._capacity,s=i+t,c=void 0===r?o:r<0?o+r:i+r;if(s===i&&c===o)return e;if(s>=c)return e.clear();for(var f=e._level,l=e._root,h=0;s+h<0;)l=new VNode(l&&l.array.length?[void 0,l]:[],n),h+=1<<(f+=a);h&&(s+=h,i+=h,c+=h,o+=h);for(var p=getTailOffset(o),d=getTailOffset(c);d>=1<p?new VNode([],n):_;if(_&&d>p&&sa;g-=a){var v=p>>>g&u;m=m.array[v]=editableVNode(m.array[v],n)}m.array[p>>>a&u]=_}if(c=d)s-=d,c-=d,f=a,l=null,y=y&&y.removeBefore(n,0,s);else if(s>i||d>>f&u;if(b!==d>>>f&u)break;b&&(h+=(1<i&&(l=l.removeBefore(n,f,s-h)),l&&di&&(i=s.size),isIterable(a)||(s=s.map((function(e){return fromJS(e)}))),n.push(s)}return i>e.size&&(e=e.setSize(i)),mergeIntoCollectionWith(e,t,n)}function getTailOffset(e){return e>>a<=s&&a.size>=2*o.size?(n=(i=a.filter((function(e,t){return void 0!==e&&u!==t}))).toKeyedSeq().map((function(e){return e[0]})).flip().toMap(),e.__ownerID&&(n.__ownerID=i.__ownerID=e.__ownerID)):(n=o.remove(t),i=u===a.size-1?a.pop():a.set(u,void 0))}else if(f){if(r===a.get(u)[1])return e;n=o,i=a.set(u,[t,r])}else n=o.set(t,a.size),i=a.set(a.size,[t,r]);return e.__ownerID?(e.size=n.size,e._map=n,e._list=i,e.__hash=void 0,e):makeOrderedMap(n,i)}function ToKeyedSequence(e,t){this._iter=e,this._useKeys=t,this.size=e.size}function ToIndexedSequence(e){this._iter=e,this.size=e.size}function ToSetSequence(e){this._iter=e,this.size=e.size}function FromEntriesSequence(e){this._iter=e,this.size=e.size}function flipFactory(e){var t=makeSequence(e);return t._iter=e,t.size=e.size,t.flip=function(){return e},t.reverse=function(){var t=e.reverse.apply(this);return t.flip=function(){return e.reverse()},t},t.has=function(t){return e.includes(t)},t.includes=function(t){return e.has(t)},t.cacheResult=cacheResultThrough,t.__iterateUncached=function(t,r){var n=this;return e.__iterate((function(e,r){return!1!==t(r,e,n)}),r)},t.__iteratorUncached=function(t,r){if(t===d){var n=e.__iterator(t,r);return new Iterator((function(){var e=n.next();if(!e.done){var t=e.value[0];e.value[0]=e.value[1],e.value[1]=t}return e}))}return e.__iterator(t===p?h:p,r)},t}function mapFactory(e,t,r){var n=makeSequence(e);return n.size=e.size,n.has=function(t){return e.has(t)},n.get=function(n,i){var o=e.get(n,c);return o===c?i:t.call(r,o,n,e)},n.__iterateUncached=function(n,i){var o=this;return e.__iterate((function(e,i,a){return!1!==n(t.call(r,e,i,a),i,o)}),i)},n.__iteratorUncached=function(n,i){var o=e.__iterator(d,i);return new Iterator((function(){var i=o.next();if(i.done)return i;var a=i.value,s=a[0];return iteratorValue(n,s,t.call(r,a[1],s,e),i)}))},n}function reverseFactory(e,t){var r=makeSequence(e);return r._iter=e,r.size=e.size,r.reverse=function(){return e},e.flip&&(r.flip=function(){var t=flipFactory(e);return t.reverse=function(){return e.flip()},t}),r.get=function(r,n){return e.get(t?r:-1-r,n)},r.has=function(r){return e.has(t?r:-1-r)},r.includes=function(t){return e.includes(t)},r.cacheResult=cacheResultThrough,r.__iterate=function(t,r){var n=this;return e.__iterate((function(e,r){return t(e,r,n)}),!r)},r.__iterator=function(t,r){return e.__iterator(t,!r)},r}function filterFactory(e,t,r,n){var i=makeSequence(e);return n&&(i.has=function(n){var i=e.get(n,c);return i!==c&&!!t.call(r,i,n,e)},i.get=function(n,i){var o=e.get(n,c);return o!==c&&t.call(r,o,n,e)?o:i}),i.__iterateUncached=function(i,o){var a=this,s=0;return e.__iterate((function(e,o,u){if(t.call(r,e,o,u))return s++,i(e,n?o:s-1,a)}),o),s},i.__iteratorUncached=function(i,o){var a=e.__iterator(d,o),s=0;return new Iterator((function(){for(;;){var o=a.next();if(o.done)return o;var u=o.value,c=u[0],f=u[1];if(t.call(r,f,c,e))return iteratorValue(i,n?c:s++,f,o)}}))},i}function countByFactory(e,t,r){var n=Map().asMutable();return e.__iterate((function(i,o){n.update(t.call(r,i,o,e),0,(function(e){return e+1}))})),n.asImmutable()}function groupByFactory(e,t,r){var n=isKeyed(e),i=(isOrdered(e)?OrderedMap():Map()).asMutable();e.__iterate((function(o,a){i.update(t.call(r,o,a,e),(function(e){return(e=e||[]).push(n?[a,o]:o),e}))}));var o=iterableClass(e);return i.map((function(t){return reify(e,o(t))}))}function sliceFactory(e,t,r,n){var i=e.size;if(void 0!==t&&(t|=0),void 0!==r&&(r===1/0?r=i:r|=0),wholeSlice(t,r,i))return e;var o=resolveBegin(t,i),a=resolveEnd(r,i);if(o!=o||a!=a)return sliceFactory(e.toSeq().cacheResult(),t,r,n);var s,u=a-o;u==u&&(s=u<0?0:u);var c=makeSequence(e);return c.size=0===s?s:e.size&&s||void 0,!n&&isSeq(e)&&s>=0&&(c.get=function(t,r){return(t=wrapIndex(this,t))>=0&&ts)return iteratorDone();var e=i.next();return n||t===p?e:iteratorValue(t,u-1,t===h?void 0:e.value[1],e)}))},c}function takeWhileFactory(e,t,r){var n=makeSequence(e);return n.__iterateUncached=function(n,i){var o=this;if(i)return this.cacheResult().__iterate(n,i);var a=0;return e.__iterate((function(e,i,s){return t.call(r,e,i,s)&&++a&&n(e,i,o)})),a},n.__iteratorUncached=function(n,i){var o=this;if(i)return this.cacheResult().__iterator(n,i);var a=e.__iterator(d,i),s=!0;return new Iterator((function(){if(!s)return iteratorDone();var e=a.next();if(e.done)return e;var i=e.value,u=i[0],c=i[1];return t.call(r,c,u,o)?n===d?e:iteratorValue(n,u,c,e):(s=!1,iteratorDone())}))},n}function skipWhileFactory(e,t,r,n){var i=makeSequence(e);return i.__iterateUncached=function(i,o){var a=this;if(o)return this.cacheResult().__iterate(i,o);var s=!0,u=0;return e.__iterate((function(e,o,c){if(!s||!(s=t.call(r,e,o,c)))return u++,i(e,n?o:u-1,a)})),u},i.__iteratorUncached=function(i,o){var a=this;if(o)return this.cacheResult().__iterator(i,o);var s=e.__iterator(d,o),u=!0,c=0;return new Iterator((function(){var e,o,f;do{if((e=s.next()).done)return n||i===p?e:iteratorValue(i,c++,i===h?void 0:e.value[1],e);var l=e.value;o=l[0],f=l[1],u&&(u=t.call(r,f,o,a))}while(u);return i===d?e:iteratorValue(i,o,f,e)}))},i}function concatFactory(e,t){var r=isKeyed(e),n=[e].concat(t).map((function(e){return isIterable(e)?r&&(e=KeyedIterable(e)):e=r?keyedSeqFromValue(e):indexedSeqFromValue(Array.isArray(e)?e:[e]),e})).filter((function(e){return 0!==e.size}));if(0===n.length)return e;if(1===n.length){var i=n[0];if(i===e||r&&isKeyed(i)||isIndexed(e)&&isIndexed(i))return i}var o=new ArraySeq(n);return r?o=o.toKeyedSeq():isIndexed(e)||(o=o.toSetSeq()),(o=o.flatten(!0)).size=n.reduce((function(e,t){if(void 0!==e){var r=t.size;if(void 0!==r)return e+r}}),0),o}function flattenFactory(e,t,r){var n=makeSequence(e);return n.__iterateUncached=function(n,i){var o=0,a=!1;function flatDeep(e,s){var u=this;e.__iterate((function(e,i){return(!t||s0}function zipWithFactory(e,t,r){var n=makeSequence(e);return n.size=new ArraySeq(r).map((function(e){return e.size})).min(),n.__iterate=function(e,t){for(var r,n=this.__iterator(p,t),i=0;!(r=n.next()).done&&!1!==e(r.value,i++,this););return i},n.__iteratorUncached=function(e,n){var i=r.map((function(e){return e=Iterable(e),getIterator(n?e.reverse():e)})),o=0,a=!1;return new Iterator((function(){var r;return a||(r=i.map((function(e){return e.next()})),a=r.some((function(e){return e.done}))),a?iteratorDone():iteratorValue(e,o++,t.apply(null,r.map((function(e){return e.value}))))}))},n}function reify(e,t){return isSeq(e)?t:e.constructor(t)}function validateEntry(e){if(e!==Object(e))throw new TypeError("Expected [K, V] tuple: "+e)}function resolveSize(e){return assertNotInfinite(e.size),ensureSize(e)}function iterableClass(e){return isKeyed(e)?KeyedIterable:isIndexed(e)?IndexedIterable:SetIterable}function makeSequence(e){return Object.create((isKeyed(e)?KeyedSeq:isIndexed(e)?IndexedSeq:SetSeq).prototype)}function cacheResultThrough(){return this._iter.cacheResult?(this._iter.cacheResult(),this.size=this._iter.size,this):Seq.prototype.cacheResult.call(this)}function defaultComparator(e,t){return e>t?1:e=0;r--)t={value:arguments[r],next:t};return this.__ownerID?(this.size=e,this._head=t,this.__hash=void 0,this.__altered=!0,this):makeStack(e,t)},Stack.prototype.pushAll=function(e){if(0===(e=IndexedIterable(e)).size)return this;assertNotInfinite(e.size);var t=this.size,r=this._head;return e.reverse().forEach((function(e){t++,r={value:e,next:r}})),this.__ownerID?(this.size=t,this._head=r,this.__hash=void 0,this.__altered=!0,this):makeStack(t,r)},Stack.prototype.pop=function(){return this.slice(1)},Stack.prototype.unshift=function(){return this.push.apply(this,arguments)},Stack.prototype.unshiftAll=function(e){return this.pushAll(e)},Stack.prototype.shift=function(){return this.pop.apply(this,arguments)},Stack.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._head=void 0,this.__hash=void 0,this.__altered=!0,this):emptyStack()},Stack.prototype.slice=function(e,t){if(wholeSlice(e,t,this.size))return this;var r=resolveBegin(e,this.size);if(resolveEnd(t,this.size)!==this.size)return IndexedCollection.prototype.slice.call(this,e,t);for(var n=this.size-r,i=this._head;r--;)i=i.next;return this.__ownerID?(this.size=n,this._head=i,this.__hash=void 0,this.__altered=!0,this):makeStack(n,i)},Stack.prototype.__ensureOwner=function(e){return e===this.__ownerID?this:e?makeStack(this.size,this._head,e,this.__hash):(this.__ownerID=e,this.__altered=!1,this)},Stack.prototype.__iterate=function(e,t){if(t)return this.reverse().__iterate(e);for(var r=0,n=this._head;n&&!1!==e(n.value,r++,this);)n=n.next;return r},Stack.prototype.__iterator=function(e,t){if(t)return this.reverse().__iterator(e);var r=0,n=this._head;return new Iterator((function(){if(n){var t=n.value;return n=n.next,iteratorValue(e,r++,t)}return iteratorDone()}))},Stack.isStack=isStack;var ue,ce="@@__IMMUTABLE_STACK__@@",fe=Stack.prototype;function makeStack(e,t,r,n){var i=Object.create(fe);return i.size=e,i._head=t,i.__ownerID=r,i.__hash=n,i.__altered=!1,i}function emptyStack(){return ue||(ue=makeStack(0))}function mixin(e,t){var keyCopier=function(r){e.prototype[r]=t[r]};return Object.keys(t).forEach(keyCopier),Object.getOwnPropertySymbols&&Object.getOwnPropertySymbols(t).forEach(keyCopier),e}fe[ce]=!0,fe.withMutations=K.withMutations,fe.asMutable=K.asMutable,fe.asImmutable=K.asImmutable,fe.wasAltered=K.wasAltered,Iterable.Iterator=Iterator,mixin(Iterable,{toArray:function(){assertNotInfinite(this.size);var e=new Array(this.size||0);return this.valueSeq().__iterate((function(t,r){e[r]=t})),e},toIndexedSeq:function(){return new ToIndexedSequence(this)},toJS:function(){return this.toSeq().map((function(e){return e&&"function"==typeof e.toJS?e.toJS():e})).__toJS()},toJSON:function(){return this.toSeq().map((function(e){return e&&"function"==typeof e.toJSON?e.toJSON():e})).__toJS()},toKeyedSeq:function(){return new ToKeyedSequence(this,!0)},toMap:function(){return Map(this.toKeyedSeq())},toObject:function(){assertNotInfinite(this.size);var e={};return this.__iterate((function(t,r){e[r]=t})),e},toOrderedMap:function(){return OrderedMap(this.toKeyedSeq())},toOrderedSet:function(){return OrderedSet(isKeyed(this)?this.valueSeq():this)},toSet:function(){return Set(isKeyed(this)?this.valueSeq():this)},toSetSeq:function(){return new ToSetSequence(this)},toSeq:function(){return isIndexed(this)?this.toIndexedSeq():isKeyed(this)?this.toKeyedSeq():this.toSetSeq()},toStack:function(){return Stack(isKeyed(this)?this.valueSeq():this)},toList:function(){return List(isKeyed(this)?this.valueSeq():this)},toString:function(){return"[Iterable]"},__toString:function(e,t){return 0===this.size?e+t:e+" "+this.toSeq().map(this.__toStringMapper).join(", ")+" "+t},concat:function(){return reify(this,concatFactory(this,e.call(arguments,0)))},includes:function(e){return this.some((function(t){return is(t,e)}))},entries:function(){return this.__iterator(d)},every:function(e,t){assertNotInfinite(this.size);var r=!0;return this.__iterate((function(n,i,o){if(!e.call(t,n,i,o))return r=!1,!1})),r},filter:function(e,t){return reify(this,filterFactory(this,e,t,!0))},find:function(e,t,r){var n=this.findEntry(e,t);return n?n[1]:r},forEach:function(e,t){return assertNotInfinite(this.size),this.__iterate(t?e.bind(t):e)},join:function(e){assertNotInfinite(this.size),e=void 0!==e?""+e:",";var t="",r=!0;return this.__iterate((function(n){r?r=!1:t+=e,t+=null!=n?n.toString():""})),t},keys:function(){return this.__iterator(h)},map:function(e,t){return reify(this,mapFactory(this,e,t))},reduce:function(e,t,r){var n,i;return assertNotInfinite(this.size),arguments.length<2?i=!0:n=t,this.__iterate((function(t,o,a){i?(i=!1,n=t):n=e.call(r,n,t,o,a)})),n},reduceRight:function(e,t,r){var n=this.toKeyedSeq().reverse();return n.reduce.apply(n,arguments)},reverse:function(){return reify(this,reverseFactory(this,!0))},slice:function(e,t){return reify(this,sliceFactory(this,e,t,!0))},some:function(e,t){return!this.every(not(e),t)},sort:function(e){return reify(this,sortFactory(this,e))},values:function(){return this.__iterator(p)},butLast:function(){return this.slice(0,-1)},isEmpty:function(){return void 0!==this.size?0===this.size:!this.some((function(){return!0}))},count:function(e,t){return ensureSize(e?this.toSeq().filter(e,t):this)},countBy:function(e,t){return countByFactory(this,e,t)},equals:function(e){return deepEqual(this,e)},entrySeq:function(){var e=this;if(e._cache)return new ArraySeq(e._cache);var t=e.toSeq().map(entryMapper).toIndexedSeq();return t.fromEntrySeq=function(){return e.toSeq()},t},filterNot:function(e,t){return this.filter(not(e),t)},findEntry:function(e,t,r){var n=r;return this.__iterate((function(r,i,o){if(e.call(t,r,i,o))return n=[i,r],!1})),n},findKey:function(e,t){var r=this.findEntry(e,t);return r&&r[0]},findLast:function(e,t,r){return this.toKeyedSeq().reverse().find(e,t,r)},findLastEntry:function(e,t,r){return this.toKeyedSeq().reverse().findEntry(e,t,r)},findLastKey:function(e,t){return this.toKeyedSeq().reverse().findKey(e,t)},first:function(){return this.find(returnTrue)},flatMap:function(e,t){return reify(this,flatMapFactory(this,e,t))},flatten:function(e){return reify(this,flattenFactory(this,e,!0))},fromEntrySeq:function(){return new FromEntriesSequence(this)},get:function(e,t){return this.find((function(t,r){return is(r,e)}),void 0,t)},getIn:function(e,t){for(var r,n=this,i=forceIterator(e);!(r=i.next()).done;){var o=r.value;if((n=n&&n.get?n.get(o,c):c)===c)return t}return n},groupBy:function(e,t){return groupByFactory(this,e,t)},has:function(e){return this.get(e,c)!==c},hasIn:function(e){return this.getIn(e,c)!==c},isSubset:function(e){return e="function"==typeof e.includes?e:Iterable(e),this.every((function(t){return e.includes(t)}))},isSuperset:function(e){return(e="function"==typeof e.isSubset?e:Iterable(e)).isSubset(this)},keyOf:function(e){return this.findKey((function(t){return is(t,e)}))},keySeq:function(){return this.toSeq().map(keyMapper).toIndexedSeq()},last:function(){return this.toSeq().reverse().first()},lastKeyOf:function(e){return this.toKeyedSeq().reverse().keyOf(e)},max:function(e){return maxFactory(this,e)},maxBy:function(e,t){return maxFactory(this,t,e)},min:function(e){return maxFactory(this,e?neg(e):defaultNegComparator)},minBy:function(e,t){return maxFactory(this,t?neg(t):defaultNegComparator,e)},rest:function(){return this.slice(1)},skip:function(e){return this.slice(Math.max(0,e))},skipLast:function(e){return reify(this,this.toSeq().reverse().skip(e).reverse())},skipWhile:function(e,t){return reify(this,skipWhileFactory(this,e,t,!0))},skipUntil:function(e,t){return this.skipWhile(not(e),t)},sortBy:function(e,t){return reify(this,sortFactory(this,t,e))},take:function(e){return this.slice(0,Math.max(0,e))},takeLast:function(e){return reify(this,this.toSeq().reverse().take(e).reverse())},takeWhile:function(e,t){return reify(this,takeWhileFactory(this,e,t))},takeUntil:function(e,t){return this.takeWhile(not(e),t)},valueSeq:function(){return this.toIndexedSeq()},hashCode:function(){return this.__hash||(this.__hash=hashIterable(this))}});var le=Iterable.prototype;le[t]=!0,le[m]=le.values,le.__toJS=le.toArray,le.__toStringMapper=quoteString,le.inspect=le.toSource=function(){return this.toString()},le.chain=le.flatMap,le.contains=le.includes,mixin(KeyedIterable,{flip:function(){return reify(this,flipFactory(this))},mapEntries:function(e,t){var r=this,n=0;return reify(this,this.toSeq().map((function(i,o){return e.call(t,[o,i],n++,r)})).fromEntrySeq())},mapKeys:function(e,t){var r=this;return reify(this,this.toSeq().flip().map((function(n,i){return e.call(t,n,i,r)})).flip())}});var he=KeyedIterable.prototype;function keyMapper(e,t){return t}function entryMapper(e,t){return[t,e]}function not(e){return function(){return!e.apply(this,arguments)}}function neg(e){return function(){return-e.apply(this,arguments)}}function quoteString(e){return"string"==typeof e?JSON.stringify(e):String(e)}function defaultZipper(){return arrCopy(arguments)}function defaultNegComparator(e,t){return et?-1:0}function hashIterable(e){if(e.size===1/0)return 0;var t=isOrdered(e),r=isKeyed(e),n=t?1:0;return murmurHashOfSize(e.__iterate(r?t?function(e,t){n=31*n+hashMerge(hash(e),hash(t))|0}:function(e,t){n=n+hashMerge(hash(e),hash(t))|0}:t?function(e){n=31*n+hash(e)|0}:function(e){n=n+hash(e)|0}),n)}function murmurHashOfSize(e,t){return t=I(t,3432918353),t=I(t<<15|t>>>-15,461845907),t=I(t<<13|t>>>-13,5),t=I((t=t+3864292196^e)^t>>>16,2246822507),t=smi((t=I(t^t>>>13,3266489909))^t>>>16)}function hashMerge(e,t){return e^t+2654435769+(e<<6)+(e>>2)}return he[r]=!0,he[m]=le.entries,he.__toJS=le.toObject,he.__toStringMapper=function(e,t){return JSON.stringify(t)+": "+quoteString(e)},mixin(IndexedIterable,{toKeyedSeq:function(){return new ToKeyedSequence(this,!1)},filter:function(e,t){return reify(this,filterFactory(this,e,t,!1))},findIndex:function(e,t){var r=this.findEntry(e,t);return r?r[0]:-1},indexOf:function(e){var t=this.keyOf(e);return void 0===t?-1:t},lastIndexOf:function(e){var t=this.lastKeyOf(e);return void 0===t?-1:t},reverse:function(){return reify(this,reverseFactory(this,!1))},slice:function(e,t){return reify(this,sliceFactory(this,e,t,!1))},splice:function(e,t){var r=arguments.length;if(t=Math.max(0|t,0),0===r||2===r&&!t)return this;e=resolveBegin(e,e<0?this.count():this.size);var n=this.slice(0,e);return reify(this,1===r?n:n.concat(arrCopy(arguments,2),this.slice(e+t)))},findLastIndex:function(e,t){var r=this.findLastEntry(e,t);return r?r[0]:-1},first:function(){return this.get(0)},flatten:function(e){return reify(this,flattenFactory(this,e,!1))},get:function(e,t){return(e=wrapIndex(this,e))<0||this.size===1/0||void 0!==this.size&&e>this.size?t:this.find((function(t,r){return r===e}),void 0,t)},has:function(e){return(e=wrapIndex(this,e))>=0&&(void 0!==this.size?this.size===1/0||e{"function"==typeof Object.create?e.exports=function inherits(e,t){t&&(e.super_=t,e.prototype=Object.create(t.prototype,{constructor:{value:e,enumerable:!1,writable:!0,configurable:!0}}))}:e.exports=function inherits(e,t){if(t){e.super_=t;var TempCtor=function(){};TempCtor.prototype=t.prototype,e.prototype=new TempCtor,e.prototype.constructor=e}}},5580:(e,t,r)=>{var n=r(6110)(r(9325),"DataView");e.exports=n},1549:(e,t,r)=>{var n=r(2032),i=r(3862),o=r(6721),a=r(2749),s=r(5749);function Hash(e){var t=-1,r=null==e?0:e.length;for(this.clear();++t{var n=r(3702),i=r(80),o=r(4739),a=r(8655),s=r(1175);function ListCache(e){var t=-1,r=null==e?0:e.length;for(this.clear();++t{var n=r(6110)(r(9325),"Map");e.exports=n},3661:(e,t,r)=>{var n=r(3040),i=r(7670),o=r(289),a=r(4509),s=r(2949);function MapCache(e){var t=-1,r=null==e?0:e.length;for(this.clear();++t{var n=r(6110)(r(9325),"Promise");e.exports=n},6545:(e,t,r)=>{var n=r(6110)(r(9325),"Set");e.exports=n},8859:(e,t,r)=>{var n=r(3661),i=r(1380),o=r(1459);function SetCache(e){var t=-1,r=null==e?0:e.length;for(this.__data__=new n;++t{var n=r(79),i=r(1420),o=r(938),a=r(3605),s=r(9817),u=r(945);function Stack(e){var t=this.__data__=new n(e);this.size=t.size}Stack.prototype.clear=i,Stack.prototype.delete=o,Stack.prototype.get=a,Stack.prototype.has=s,Stack.prototype.set=u,e.exports=Stack},1873:(e,t,r)=>{var n=r(9325).Symbol;e.exports=n},7828:(e,t,r)=>{var n=r(9325).Uint8Array;e.exports=n},8303:(e,t,r)=>{var n=r(6110)(r(9325),"WeakMap");e.exports=n},9770:e=>{e.exports=function arrayFilter(e,t){for(var r=-1,n=null==e?0:e.length,i=0,o=[];++r{var n=r(8096),i=r(2428),o=r(6449),a=r(3656),s=r(361),u=r(7167),c=Object.prototype.hasOwnProperty;e.exports=function arrayLikeKeys(e,t){var r=o(e),f=!r&&i(e),l=!r&&!f&&a(e),h=!r&&!f&&!l&&u(e),p=r||f||l||h,d=p?n(e.length,String):[],_=d.length;for(var y in e)!t&&!c.call(e,y)||p&&("length"==y||l&&("offset"==y||"parent"==y)||h&&("buffer"==y||"byteLength"==y||"byteOffset"==y)||s(y,_))||d.push(y);return d}},4932:e=>{e.exports=function arrayMap(e,t){for(var r=-1,n=null==e?0:e.length,i=Array(n);++r{e.exports=function arrayPush(e,t){for(var r=-1,n=t.length,i=e.length;++r{e.exports=function arrayReduce(e,t,r,n){var i=-1,o=null==e?0:e.length;for(n&&o&&(r=e[++i]);++i{e.exports=function arraySome(e,t){for(var r=-1,n=null==e?0:e.length;++r{e.exports=function asciiToArray(e){return e.split("")}},1733:e=>{var t=/[^\x00-\x2f\x3a-\x40\x5b-\x60\x7b-\x7f]+/g;e.exports=function asciiWords(e){return e.match(t)||[]}},6547:(e,t,r)=>{var n=r(3360),i=r(5288),o=Object.prototype.hasOwnProperty;e.exports=function assignValue(e,t,r){var a=e[t];o.call(e,t)&&i(a,r)&&(void 0!==r||t in e)||n(e,t,r)}},6025:(e,t,r)=>{var n=r(5288);e.exports=function assocIndexOf(e,t){for(var r=e.length;r--;)if(n(e[r][0],t))return r;return-1}},3360:(e,t,r)=>{var n=r(3243);e.exports=function baseAssignValue(e,t,r){"__proto__"==t&&n?n(e,t,{configurable:!0,enumerable:!0,value:r,writable:!0}):e[t]=r}},909:(e,t,r)=>{var n=r(641),i=r(8329)(n);e.exports=i},2523:e=>{e.exports=function baseFindIndex(e,t,r,n){for(var i=e.length,o=r+(n?1:-1);n?o--:++o{var n=r(3221)();e.exports=n},641:(e,t,r)=>{var n=r(6649),i=r(5950);e.exports=function baseForOwn(e,t){return e&&n(e,t,i)}},7422:(e,t,r)=>{var n=r(1769),i=r(7797);e.exports=function baseGet(e,t){for(var r=0,o=(t=n(t,e)).length;null!=e&&r{var n=r(4528),i=r(6449);e.exports=function baseGetAllKeys(e,t,r){var o=t(e);return i(e)?o:n(o,r(e))}},2552:(e,t,r)=>{var n=r(1873),i=r(659),o=r(9350),a=n?n.toStringTag:void 0;e.exports=function baseGetTag(e){return null==e?void 0===e?"[object Undefined]":"[object Null]":a&&a in Object(e)?i(e):o(e)}},8077:e=>{e.exports=function baseHasIn(e,t){return null!=e&&t in Object(e)}},7534:(e,t,r)=>{var n=r(2552),i=r(346);e.exports=function baseIsArguments(e){return i(e)&&"[object Arguments]"==n(e)}},270:(e,t,r)=>{var n=r(7068),i=r(346);e.exports=function baseIsEqual(e,t,r,o,a){return e===t||(null==e||null==t||!i(e)&&!i(t)?e!=e&&t!=t:n(e,t,r,o,baseIsEqual,a))}},7068:(e,t,r)=>{var n=r(7217),i=r(5911),o=r(1986),a=r(689),s=r(5861),u=r(6449),c=r(3656),f=r(7167),l="[object Arguments]",h="[object Array]",p="[object Object]",d=Object.prototype.hasOwnProperty;e.exports=function baseIsEqualDeep(e,t,r,_,y,m){var g=u(e),v=u(t),b=g?h:s(e),w=v?h:s(t),I=(b=b==l?p:b)==p,x=(w=w==l?p:w)==p,B=b==w;if(B&&c(e)){if(!c(t))return!1;g=!0,I=!1}if(B&&!I)return m||(m=new n),g||f(e)?i(e,t,r,_,y,m):o(e,t,b,r,_,y,m);if(!(1&r)){var k=I&&d.call(e,"__wrapped__"),C=x&&d.call(t,"__wrapped__");if(k||C){var q=k?e.value():e,L=C?t.value():t;return m||(m=new n),y(q,L,r,_,m)}}return!!B&&(m||(m=new n),a(e,t,r,_,y,m))}},1799:(e,t,r)=>{var n=r(7217),i=r(270);e.exports=function baseIsMatch(e,t,r,o){var a=r.length,s=a,u=!o;if(null==e)return!s;for(e=Object(e);a--;){var c=r[a];if(u&&c[2]?c[1]!==e[c[0]]:!(c[0]in e))return!1}for(;++a{var n=r(1882),i=r(7296),o=r(3805),a=r(7473),s=/^\[object .+?Constructor\]$/,u=Function.prototype,c=Object.prototype,f=u.toString,l=c.hasOwnProperty,h=RegExp("^"+f.call(l).replace(/[\\^$.*+?()[\]{}|]/g,"\\$&").replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g,"$1.*?")+"$");e.exports=function baseIsNative(e){return!(!o(e)||i(e))&&(n(e)?h:s).test(a(e))}},4901:(e,t,r)=>{var n=r(2552),i=r(294),o=r(346),a={};a["[object Float32Array]"]=a["[object Float64Array]"]=a["[object Int8Array]"]=a["[object Int16Array]"]=a["[object Int32Array]"]=a["[object Uint8Array]"]=a["[object Uint8ClampedArray]"]=a["[object Uint16Array]"]=a["[object Uint32Array]"]=!0,a["[object Arguments]"]=a["[object Array]"]=a["[object ArrayBuffer]"]=a["[object Boolean]"]=a["[object DataView]"]=a["[object Date]"]=a["[object Error]"]=a["[object Function]"]=a["[object Map]"]=a["[object Number]"]=a["[object Object]"]=a["[object RegExp]"]=a["[object Set]"]=a["[object String]"]=a["[object WeakMap]"]=!1,e.exports=function baseIsTypedArray(e){return o(e)&&i(e.length)&&!!a[n(e)]}},5389:(e,t,r)=>{var n=r(3663),i=r(7978),o=r(3488),a=r(6449),s=r(583);e.exports=function baseIteratee(e){return"function"==typeof e?e:null==e?o:"object"==typeof e?a(e)?i(e[0],e[1]):n(e):s(e)}},8984:(e,t,r)=>{var n=r(5527),i=r(3650),o=Object.prototype.hasOwnProperty;e.exports=function baseKeys(e){if(!n(e))return i(e);var t=[];for(var r in Object(e))o.call(e,r)&&"constructor"!=r&&t.push(r);return t}},3663:(e,t,r)=>{var n=r(1799),i=r(776),o=r(7197);e.exports=function baseMatches(e){var t=i(e);return 1==t.length&&t[0][2]?o(t[0][0],t[0][1]):function(r){return r===e||n(r,e,t)}}},7978:(e,t,r)=>{var n=r(270),i=r(8156),o=r(631),a=r(8586),s=r(756),u=r(7197),c=r(7797);e.exports=function baseMatchesProperty(e,t){return a(e)&&s(t)?u(c(e),t):function(r){var a=i(r,e);return void 0===a&&a===t?o(r,e):n(t,a,3)}}},7237:e=>{e.exports=function baseProperty(e){return function(t){return null==t?void 0:t[e]}}},7255:(e,t,r)=>{var n=r(7422);e.exports=function basePropertyDeep(e){return function(t){return n(t,e)}}},4552:e=>{e.exports=function basePropertyOf(e){return function(t){return null==e?void 0:e[t]}}},5160:e=>{e.exports=function baseSlice(e,t,r){var n=-1,i=e.length;t<0&&(t=-t>i?0:i+t),(r=r>i?i:r)<0&&(r+=i),i=t>r?0:r-t>>>0,t>>>=0;for(var o=Array(i);++n{var n=r(909);e.exports=function baseSome(e,t){var r;return n(e,(function(e,n,i){return!(r=t(e,n,i))})),!!r}},8096:e=>{e.exports=function baseTimes(e,t){for(var r=-1,n=Array(e);++r{var n=r(1873),i=r(4932),o=r(6449),a=r(4394),s=n?n.prototype:void 0,u=s?s.toString:void 0;e.exports=function baseToString(e){if("string"==typeof e)return e;if(o(e))return i(e,baseToString)+"";if(a(e))return u?u.call(e):"";var t=e+"";return"0"==t&&1/e==-1/0?"-0":t}},4128:(e,t,r)=>{var n=r(1800),i=/^\s+/;e.exports=function baseTrim(e){return e?e.slice(0,n(e)+1).replace(i,""):e}},7301:e=>{e.exports=function baseUnary(e){return function(t){return e(t)}}},1234:e=>{e.exports=function baseZipObject(e,t,r){for(var n=-1,i=e.length,o=t.length,a={};++n{e.exports=function cacheHas(e,t){return e.has(t)}},1769:(e,t,r)=>{var n=r(6449),i=r(8586),o=r(1802),a=r(3222);e.exports=function castPath(e,t){return n(e)?e:i(e,t)?[e]:o(a(e))}},8754:(e,t,r)=>{var n=r(5160);e.exports=function castSlice(e,t,r){var i=e.length;return r=void 0===r?i:r,!t&&r>=i?e:n(e,t,r)}},5481:(e,t,r)=>{var n=r(9325)["__core-js_shared__"];e.exports=n},8329:(e,t,r)=>{var n=r(4894);e.exports=function createBaseEach(e,t){return function(r,i){if(null==r)return r;if(!n(r))return e(r,i);for(var o=r.length,a=t?o:-1,s=Object(r);(t?a--:++a{e.exports=function createBaseFor(e){return function(t,r,n){for(var i=-1,o=Object(t),a=n(t),s=a.length;s--;){var u=a[e?s:++i];if(!1===r(o[u],u,o))break}return t}}},2507:(e,t,r)=>{var n=r(8754),i=r(9698),o=r(3912),a=r(3222);e.exports=function createCaseFirst(e){return function(t){t=a(t);var r=i(t)?o(t):void 0,s=r?r[0]:t.charAt(0),u=r?n(r,1).join(""):t.slice(1);return s[e]()+u}}},5539:(e,t,r)=>{var n=r(882),i=r(828),o=r(6645),a=RegExp("['’]","g");e.exports=function createCompounder(e){return function(t){return n(o(i(t).replace(a,"")),e,"")}}},2006:(e,t,r)=>{var n=r(5389),i=r(4894),o=r(5950);e.exports=function createFind(e){return function(t,r,a){var s=Object(t);if(!i(t)){var u=n(r,3);t=o(t),r=function(e){return u(s[e],e,s)}}var c=e(t,r,a);return c>-1?s[u?t[c]:c]:void 0}}},4647:(e,t,r)=>{var n=r(4552)({À:"A",Á:"A",Â:"A",Ã:"A",Ä:"A",Å:"A",à:"a",á:"a",â:"a",ã:"a",ä:"a",å:"a",Ç:"C",ç:"c",Ð:"D",ð:"d",È:"E",É:"E",Ê:"E",Ë:"E",è:"e",é:"e",ê:"e",ë:"e",Ì:"I",Í:"I",Î:"I",Ï:"I",ì:"i",í:"i",î:"i",ï:"i",Ñ:"N",ñ:"n",Ò:"O",Ó:"O",Ô:"O",Õ:"O",Ö:"O",Ø:"O",ò:"o",ó:"o",ô:"o",õ:"o",ö:"o",ø:"o",Ù:"U",Ú:"U",Û:"U",Ü:"U",ù:"u",ú:"u",û:"u",ü:"u",Ý:"Y",ý:"y",ÿ:"y",Æ:"Ae",æ:"ae",Þ:"Th",þ:"th",ß:"ss",Ā:"A",Ă:"A",Ą:"A",ā:"a",ă:"a",ą:"a",Ć:"C",Ĉ:"C",Ċ:"C",Č:"C",ć:"c",ĉ:"c",ċ:"c",č:"c",Ď:"D",Đ:"D",ď:"d",đ:"d",Ē:"E",Ĕ:"E",Ė:"E",Ę:"E",Ě:"E",ē:"e",ĕ:"e",ė:"e",ę:"e",ě:"e",Ĝ:"G",Ğ:"G",Ġ:"G",Ģ:"G",ĝ:"g",ğ:"g",ġ:"g",ģ:"g",Ĥ:"H",Ħ:"H",ĥ:"h",ħ:"h",Ĩ:"I",Ī:"I",Ĭ:"I",Į:"I",İ:"I",ĩ:"i",ī:"i",ĭ:"i",į:"i",ı:"i",Ĵ:"J",ĵ:"j",Ķ:"K",ķ:"k",ĸ:"k",Ĺ:"L",Ļ:"L",Ľ:"L",Ŀ:"L",Ł:"L",ĺ:"l",ļ:"l",ľ:"l",ŀ:"l",ł:"l",Ń:"N",Ņ:"N",Ň:"N",Ŋ:"N",ń:"n",ņ:"n",ň:"n",ŋ:"n",Ō:"O",Ŏ:"O",Ő:"O",ō:"o",ŏ:"o",ő:"o",Ŕ:"R",Ŗ:"R",Ř:"R",ŕ:"r",ŗ:"r",ř:"r",Ś:"S",Ŝ:"S",Ş:"S",Š:"S",ś:"s",ŝ:"s",ş:"s",š:"s",Ţ:"T",Ť:"T",Ŧ:"T",ţ:"t",ť:"t",ŧ:"t",Ũ:"U",Ū:"U",Ŭ:"U",Ů:"U",Ű:"U",Ų:"U",ũ:"u",ū:"u",ŭ:"u",ů:"u",ű:"u",ų:"u",Ŵ:"W",ŵ:"w",Ŷ:"Y",ŷ:"y",Ÿ:"Y",Ź:"Z",Ż:"Z",Ž:"Z",ź:"z",ż:"z",ž:"z",IJ:"IJ",ij:"ij",Œ:"Oe",œ:"oe",ʼn:"'n",ſ:"s"});e.exports=n},3243:(e,t,r)=>{var n=r(6110),i=function(){try{var e=n(Object,"defineProperty");return e({},"",{}),e}catch(e){}}();e.exports=i},5911:(e,t,r)=>{var n=r(8859),i=r(4248),o=r(9219);e.exports=function equalArrays(e,t,r,a,s,u){var c=1&r,f=e.length,l=t.length;if(f!=l&&!(c&&l>f))return!1;var h=u.get(e),p=u.get(t);if(h&&p)return h==t&&p==e;var d=-1,_=!0,y=2&r?new n:void 0;for(u.set(e,t),u.set(t,e);++d{var n=r(1873),i=r(7828),o=r(5288),a=r(5911),s=r(317),u=r(4247),c=n?n.prototype:void 0,f=c?c.valueOf:void 0;e.exports=function equalByTag(e,t,r,n,c,l,h){switch(r){case"[object DataView]":if(e.byteLength!=t.byteLength||e.byteOffset!=t.byteOffset)return!1;e=e.buffer,t=t.buffer;case"[object ArrayBuffer]":return!(e.byteLength!=t.byteLength||!l(new i(e),new i(t)));case"[object Boolean]":case"[object Date]":case"[object Number]":return o(+e,+t);case"[object Error]":return e.name==t.name&&e.message==t.message;case"[object RegExp]":case"[object String]":return e==t+"";case"[object Map]":var p=s;case"[object Set]":var d=1&n;if(p||(p=u),e.size!=t.size&&!d)return!1;var _=h.get(e);if(_)return _==t;n|=2,h.set(e,t);var y=a(p(e),p(t),n,c,l,h);return h.delete(e),y;case"[object Symbol]":if(f)return f.call(e)==f.call(t)}return!1}},689:(e,t,r)=>{var n=r(2),i=Object.prototype.hasOwnProperty;e.exports=function equalObjects(e,t,r,o,a,s){var u=1&r,c=n(e),f=c.length;if(f!=n(t).length&&!u)return!1;for(var l=f;l--;){var h=c[l];if(!(u?h in t:i.call(t,h)))return!1}var p=s.get(e),d=s.get(t);if(p&&d)return p==t&&d==e;var _=!0;s.set(e,t),s.set(t,e);for(var y=u;++l{var n="object"==typeof r.g&&r.g&&r.g.Object===Object&&r.g;e.exports=n},2:(e,t,r)=>{var n=r(2199),i=r(4664),o=r(5950);e.exports=function getAllKeys(e){return n(e,o,i)}},2651:(e,t,r)=>{var n=r(4218);e.exports=function getMapData(e,t){var r=e.__data__;return n(t)?r["string"==typeof t?"string":"hash"]:r.map}},776:(e,t,r)=>{var n=r(756),i=r(5950);e.exports=function getMatchData(e){for(var t=i(e),r=t.length;r--;){var o=t[r],a=e[o];t[r]=[o,a,n(a)]}return t}},6110:(e,t,r)=>{var n=r(5083),i=r(392);e.exports=function getNative(e,t){var r=i(e,t);return n(r)?r:void 0}},659:(e,t,r)=>{var n=r(1873),i=Object.prototype,o=i.hasOwnProperty,a=i.toString,s=n?n.toStringTag:void 0;e.exports=function getRawTag(e){var t=o.call(e,s),r=e[s];try{e[s]=void 0;var n=!0}catch(e){}var i=a.call(e);return n&&(t?e[s]=r:delete e[s]),i}},4664:(e,t,r)=>{var n=r(9770),i=r(3345),o=Object.prototype.propertyIsEnumerable,a=Object.getOwnPropertySymbols,s=a?function(e){return null==e?[]:(e=Object(e),n(a(e),(function(t){return o.call(e,t)})))}:i;e.exports=s},5861:(e,t,r)=>{var n=r(5580),i=r(8223),o=r(2804),a=r(6545),s=r(8303),u=r(2552),c=r(7473),f="[object Map]",l="[object Promise]",h="[object Set]",p="[object WeakMap]",d="[object DataView]",_=c(n),y=c(i),m=c(o),g=c(a),v=c(s),b=u;(n&&b(new n(new ArrayBuffer(1)))!=d||i&&b(new i)!=f||o&&b(o.resolve())!=l||a&&b(new a)!=h||s&&b(new s)!=p)&&(b=function(e){var t=u(e),r="[object Object]"==t?e.constructor:void 0,n=r?c(r):"";if(n)switch(n){case _:return d;case y:return f;case m:return l;case g:return h;case v:return p}return t}),e.exports=b},392:e=>{e.exports=function getValue(e,t){return null==e?void 0:e[t]}},9326:(e,t,r)=>{var n=r(1769),i=r(2428),o=r(6449),a=r(361),s=r(294),u=r(7797);e.exports=function hasPath(e,t,r){for(var c=-1,f=(t=n(t,e)).length,l=!1;++c{var t=RegExp("[\\u200d\\ud800-\\udfff\\u0300-\\u036f\\ufe20-\\ufe2f\\u20d0-\\u20ff\\ufe0e\\ufe0f]");e.exports=function hasUnicode(e){return t.test(e)}},5434:e=>{var t=/[a-z][A-Z]|[A-Z]{2}[a-z]|[0-9][a-zA-Z]|[a-zA-Z][0-9]|[^a-zA-Z0-9 ]/;e.exports=function hasUnicodeWord(e){return t.test(e)}},2032:(e,t,r)=>{var n=r(1042);e.exports=function hashClear(){this.__data__=n?n(null):{},this.size=0}},3862:e=>{e.exports=function hashDelete(e){var t=this.has(e)&&delete this.__data__[e];return this.size-=t?1:0,t}},6721:(e,t,r)=>{var n=r(1042),i=Object.prototype.hasOwnProperty;e.exports=function hashGet(e){var t=this.__data__;if(n){var r=t[e];return"__lodash_hash_undefined__"===r?void 0:r}return i.call(t,e)?t[e]:void 0}},2749:(e,t,r)=>{var n=r(1042),i=Object.prototype.hasOwnProperty;e.exports=function hashHas(e){var t=this.__data__;return n?void 0!==t[e]:i.call(t,e)}},5749:(e,t,r)=>{var n=r(1042);e.exports=function hashSet(e,t){var r=this.__data__;return this.size+=this.has(e)?0:1,r[e]=n&&void 0===t?"__lodash_hash_undefined__":t,this}},361:e=>{var t=/^(?:0|[1-9]\d*)$/;e.exports=function isIndex(e,r){var n=typeof e;return!!(r=null==r?9007199254740991:r)&&("number"==n||"symbol"!=n&&t.test(e))&&e>-1&&e%1==0&&e{var n=r(5288),i=r(4894),o=r(361),a=r(3805);e.exports=function isIterateeCall(e,t,r){if(!a(r))return!1;var s=typeof t;return!!("number"==s?i(r)&&o(t,r.length):"string"==s&&t in r)&&n(r[t],e)}},8586:(e,t,r)=>{var n=r(6449),i=r(4394),o=/\.|\[(?:[^[\]]*|(["'])(?:(?!\1)[^\\]|\\.)*?\1)\]/,a=/^\w*$/;e.exports=function isKey(e,t){if(n(e))return!1;var r=typeof e;return!("number"!=r&&"symbol"!=r&&"boolean"!=r&&null!=e&&!i(e))||(a.test(e)||!o.test(e)||null!=t&&e in Object(t))}},4218:e=>{e.exports=function isKeyable(e){var t=typeof e;return"string"==t||"number"==t||"symbol"==t||"boolean"==t?"__proto__"!==e:null===e}},7296:(e,t,r)=>{var n,i=r(5481),o=(n=/[^.]+$/.exec(i&&i.keys&&i.keys.IE_PROTO||""))?"Symbol(src)_1."+n:"";e.exports=function isMasked(e){return!!o&&o in e}},5527:e=>{var t=Object.prototype;e.exports=function isPrototype(e){var r=e&&e.constructor;return e===("function"==typeof r&&r.prototype||t)}},756:(e,t,r)=>{var n=r(3805);e.exports=function isStrictComparable(e){return e==e&&!n(e)}},3702:e=>{e.exports=function listCacheClear(){this.__data__=[],this.size=0}},80:(e,t,r)=>{var n=r(6025),i=Array.prototype.splice;e.exports=function listCacheDelete(e){var t=this.__data__,r=n(t,e);return!(r<0)&&(r==t.length-1?t.pop():i.call(t,r,1),--this.size,!0)}},4739:(e,t,r)=>{var n=r(6025);e.exports=function listCacheGet(e){var t=this.__data__,r=n(t,e);return r<0?void 0:t[r][1]}},8655:(e,t,r)=>{var n=r(6025);e.exports=function listCacheHas(e){return n(this.__data__,e)>-1}},1175:(e,t,r)=>{var n=r(6025);e.exports=function listCacheSet(e,t){var r=this.__data__,i=n(r,e);return i<0?(++this.size,r.push([e,t])):r[i][1]=t,this}},3040:(e,t,r)=>{var n=r(1549),i=r(79),o=r(8223);e.exports=function mapCacheClear(){this.size=0,this.__data__={hash:new n,map:new(o||i),string:new n}}},7670:(e,t,r)=>{var n=r(2651);e.exports=function mapCacheDelete(e){var t=n(this,e).delete(e);return this.size-=t?1:0,t}},289:(e,t,r)=>{var n=r(2651);e.exports=function mapCacheGet(e){return n(this,e).get(e)}},4509:(e,t,r)=>{var n=r(2651);e.exports=function mapCacheHas(e){return n(this,e).has(e)}},2949:(e,t,r)=>{var n=r(2651);e.exports=function mapCacheSet(e,t){var r=n(this,e),i=r.size;return r.set(e,t),this.size+=r.size==i?0:1,this}},317:e=>{e.exports=function mapToArray(e){var t=-1,r=Array(e.size);return e.forEach((function(e,n){r[++t]=[n,e]})),r}},7197:e=>{e.exports=function matchesStrictComparable(e,t){return function(r){return null!=r&&(r[e]===t&&(void 0!==t||e in Object(r)))}}},2224:(e,t,r)=>{var n=r(104);e.exports=function memoizeCapped(e){var t=n(e,(function(e){return 500===r.size&&r.clear(),e})),r=t.cache;return t}},1042:(e,t,r)=>{var n=r(6110)(Object,"create");e.exports=n},3650:(e,t,r)=>{var n=r(4335)(Object.keys,Object);e.exports=n},6009:(e,t,r)=>{e=r.nmd(e);var n=r(4840),i=t&&!t.nodeType&&t,o=i&&e&&!e.nodeType&&e,a=o&&o.exports===i&&n.process,s=function(){try{var e=o&&o.require&&o.require("util").types;return e||a&&a.binding&&a.binding("util")}catch(e){}}();e.exports=s},9350:e=>{var t=Object.prototype.toString;e.exports=function objectToString(e){return t.call(e)}},4335:e=>{e.exports=function overArg(e,t){return function(r){return e(t(r))}}},9325:(e,t,r)=>{var n=r(4840),i="object"==typeof self&&self&&self.Object===Object&&self,o=n||i||Function("return this")();e.exports=o},1380:e=>{e.exports=function setCacheAdd(e){return this.__data__.set(e,"__lodash_hash_undefined__"),this}},1459:e=>{e.exports=function setCacheHas(e){return this.__data__.has(e)}},4247:e=>{e.exports=function setToArray(e){var t=-1,r=Array(e.size);return e.forEach((function(e){r[++t]=e})),r}},1420:(e,t,r)=>{var n=r(79);e.exports=function stackClear(){this.__data__=new n,this.size=0}},938:e=>{e.exports=function stackDelete(e){var t=this.__data__,r=t.delete(e);return this.size=t.size,r}},3605:e=>{e.exports=function stackGet(e){return this.__data__.get(e)}},9817:e=>{e.exports=function stackHas(e){return this.__data__.has(e)}},945:(e,t,r)=>{var n=r(79),i=r(8223),o=r(3661);e.exports=function stackSet(e,t){var r=this.__data__;if(r instanceof n){var a=r.__data__;if(!i||a.length<199)return a.push([e,t]),this.size=++r.size,this;r=this.__data__=new o(a)}return r.set(e,t),this.size=r.size,this}},3912:(e,t,r)=>{var n=r(1074),i=r(9698),o=r(2054);e.exports=function stringToArray(e){return i(e)?o(e):n(e)}},1802:(e,t,r)=>{var n=r(2224),i=/[^.[\]]+|\[(?:(-?\d+(?:\.\d+)?)|(["'])((?:(?!\2)[^\\]|\\.)*?)\2)\]|(?=(?:\.|\[\])(?:\.|\[\]|$))/g,o=/\\(\\)?/g,a=n((function(e){var t=[];return 46===e.charCodeAt(0)&&t.push(""),e.replace(i,(function(e,r,n,i){t.push(n?i.replace(o,"$1"):r||e)})),t}));e.exports=a},7797:(e,t,r)=>{var n=r(4394);e.exports=function toKey(e){if("string"==typeof e||n(e))return e;var t=e+"";return"0"==t&&1/e==-1/0?"-0":t}},7473:e=>{var t=Function.prototype.toString;e.exports=function toSource(e){if(null!=e){try{return t.call(e)}catch(e){}try{return e+""}catch(e){}}return""}},1800:e=>{var t=/\s/;e.exports=function trimmedEndIndex(e){for(var r=e.length;r--&&t.test(e.charAt(r)););return r}},2054:e=>{var t="\\ud800-\\udfff",r="["+t+"]",n="[\\u0300-\\u036f\\ufe20-\\ufe2f\\u20d0-\\u20ff]",i="\\ud83c[\\udffb-\\udfff]",o="[^"+t+"]",a="(?:\\ud83c[\\udde6-\\uddff]){2}",s="[\\ud800-\\udbff][\\udc00-\\udfff]",u="(?:"+n+"|"+i+")"+"?",c="[\\ufe0e\\ufe0f]?",f=c+u+("(?:\\u200d(?:"+[o,a,s].join("|")+")"+c+u+")*"),l="(?:"+[o+n+"?",n,a,s,r].join("|")+")",h=RegExp(i+"(?="+i+")|"+l+f,"g");e.exports=function unicodeToArray(e){return e.match(h)||[]}},2225:e=>{var t="\\ud800-\\udfff",r="\\u2700-\\u27bf",n="a-z\\xdf-\\xf6\\xf8-\\xff",i="A-Z\\xc0-\\xd6\\xd8-\\xde",o="\\xac\\xb1\\xd7\\xf7\\x00-\\x2f\\x3a-\\x40\\x5b-\\x60\\x7b-\\xbf\\u2000-\\u206f \\t\\x0b\\f\\xa0\\ufeff\\n\\r\\u2028\\u2029\\u1680\\u180e\\u2000\\u2001\\u2002\\u2003\\u2004\\u2005\\u2006\\u2007\\u2008\\u2009\\u200a\\u202f\\u205f\\u3000",a="["+o+"]",s="\\d+",u="["+r+"]",c="["+n+"]",f="[^"+t+o+s+r+n+i+"]",l="(?:\\ud83c[\\udde6-\\uddff]){2}",h="[\\ud800-\\udbff][\\udc00-\\udfff]",p="["+i+"]",d="(?:"+c+"|"+f+")",_="(?:"+p+"|"+f+")",y="(?:['’](?:d|ll|m|re|s|t|ve))?",m="(?:['’](?:D|LL|M|RE|S|T|VE))?",g="(?:[\\u0300-\\u036f\\ufe20-\\ufe2f\\u20d0-\\u20ff]|\\ud83c[\\udffb-\\udfff])?",v="[\\ufe0e\\ufe0f]?",b=v+g+("(?:\\u200d(?:"+["[^"+t+"]",l,h].join("|")+")"+v+g+")*"),w="(?:"+[u,l,h].join("|")+")"+b,I=RegExp([p+"?"+c+"+"+y+"(?="+[a,p,"$"].join("|")+")",_+"+"+m+"(?="+[a,p+d,"$"].join("|")+")",p+"?"+d+"+"+y,p+"+"+m,"\\d*(?:1ST|2ND|3RD|(?![123])\\dTH)(?=\\b|[a-z_])","\\d*(?:1st|2nd|3rd|(?![123])\\dth)(?=\\b|[A-Z_])",s,w].join("|"),"g");e.exports=function unicodeWords(e){return e.match(I)||[]}},4058:(e,t,r)=>{var n=r(4792),i=r(5539)((function(e,t,r){return t=t.toLowerCase(),e+(r?n(t):t)}));e.exports=i},4792:(e,t,r)=>{var n=r(3222),i=r(5808);e.exports=function capitalize(e){return i(n(e).toLowerCase())}},828:(e,t,r)=>{var n=r(4647),i=r(3222),o=/[\xc0-\xd6\xd8-\xf6\xf8-\xff\u0100-\u017f]/g,a=RegExp("[\\u0300-\\u036f\\ufe20-\\ufe2f\\u20d0-\\u20ff]","g");e.exports=function deburr(e){return(e=i(e))&&e.replace(o,n).replace(a,"")}},5288:e=>{e.exports=function eq(e,t){return e===t||e!=e&&t!=t}},7309:(e,t,r)=>{var n=r(2006)(r(4713));e.exports=n},4713:(e,t,r)=>{var n=r(2523),i=r(5389),o=r(1489),a=Math.max;e.exports=function findIndex(e,t,r){var s=null==e?0:e.length;if(!s)return-1;var u=null==r?0:o(r);return u<0&&(u=a(s+u,0)),n(e,i(t,3),u)}},8156:(e,t,r)=>{var n=r(7422);e.exports=function get(e,t,r){var i=null==e?void 0:n(e,t);return void 0===i?r:i}},631:(e,t,r)=>{var n=r(8077),i=r(9326);e.exports=function hasIn(e,t){return null!=e&&i(e,t,n)}},3488:e=>{e.exports=function identity(e){return e}},2428:(e,t,r)=>{var n=r(7534),i=r(346),o=Object.prototype,a=o.hasOwnProperty,s=o.propertyIsEnumerable,u=n(function(){return arguments}())?n:function(e){return i(e)&&a.call(e,"callee")&&!s.call(e,"callee")};e.exports=u},6449:e=>{var t=Array.isArray;e.exports=t},4894:(e,t,r)=>{var n=r(1882),i=r(294);e.exports=function isArrayLike(e){return null!=e&&i(e.length)&&!n(e)}},3656:(e,t,r)=>{e=r.nmd(e);var n=r(9325),i=r(9935),o=t&&!t.nodeType&&t,a=o&&e&&!e.nodeType&&e,s=a&&a.exports===o?n.Buffer:void 0,u=(s?s.isBuffer:void 0)||i;e.exports=u},1882:(e,t,r)=>{var n=r(2552),i=r(3805);e.exports=function isFunction(e){if(!i(e))return!1;var t=n(e);return"[object Function]"==t||"[object GeneratorFunction]"==t||"[object AsyncFunction]"==t||"[object Proxy]"==t}},294:e=>{e.exports=function isLength(e){return"number"==typeof e&&e>-1&&e%1==0&&e<=9007199254740991}},3805:e=>{e.exports=function isObject(e){var t=typeof e;return null!=e&&("object"==t||"function"==t)}},346:e=>{e.exports=function isObjectLike(e){return null!=e&&"object"==typeof e}},4394:(e,t,r)=>{var n=r(2552),i=r(346);e.exports=function isSymbol(e){return"symbol"==typeof e||i(e)&&"[object Symbol]"==n(e)}},7167:(e,t,r)=>{var n=r(4901),i=r(7301),o=r(6009),a=o&&o.isTypedArray,s=a?i(a):n;e.exports=s},5950:(e,t,r)=>{var n=r(695),i=r(8984),o=r(4894);e.exports=function keys(e){return o(e)?n(e):i(e)}},104:(e,t,r)=>{var n=r(3661);function memoize(e,t){if("function"!=typeof e||null!=t&&"function"!=typeof t)throw new TypeError("Expected a function");var memoized=function(){var r=arguments,n=t?t.apply(this,r):r[0],i=memoized.cache;if(i.has(n))return i.get(n);var o=e.apply(this,r);return memoized.cache=i.set(n,o)||i,o};return memoized.cache=new(memoize.Cache||n),memoized}memoize.Cache=n,e.exports=memoize},583:(e,t,r)=>{var n=r(7237),i=r(7255),o=r(8586),a=r(7797);e.exports=function property(e){return o(e)?n(a(e)):i(e)}},2426:(e,t,r)=>{var n=r(4248),i=r(5389),o=r(916),a=r(6449),s=r(6800);e.exports=function some(e,t,r){var u=a(e)?n:o;return r&&s(e,t,r)&&(t=void 0),u(e,i(t,3))}},3345:e=>{e.exports=function stubArray(){return[]}},9935:e=>{e.exports=function stubFalse(){return!1}},7400:(e,t,r)=>{var n=r(9374),i=1/0;e.exports=function toFinite(e){return e?(e=n(e))===i||e===-1/0?17976931348623157e292*(e<0?-1:1):e==e?e:0:0===e?e:0}},1489:(e,t,r)=>{var n=r(7400);e.exports=function toInteger(e){var t=n(e),r=t%1;return t==t?r?t-r:t:0}},9374:(e,t,r)=>{var n=r(4128),i=r(3805),o=r(4394),a=/^[-+]0x[0-9a-f]+$/i,s=/^0b[01]+$/i,u=/^0o[0-7]+$/i,c=parseInt;e.exports=function toNumber(e){if("number"==typeof e)return e;if(o(e))return NaN;if(i(e)){var t="function"==typeof e.valueOf?e.valueOf():e;e=i(t)?t+"":t}if("string"!=typeof e)return 0===e?e:+e;e=n(e);var r=s.test(e);return r||u.test(e)?c(e.slice(2),r?2:8):a.test(e)?NaN:+e}},3222:(e,t,r)=>{var n=r(7556);e.exports=function toString(e){return null==e?"":n(e)}},5808:(e,t,r)=>{var n=r(2507)("toUpperCase");e.exports=n},6645:(e,t,r)=>{var n=r(1733),i=r(5434),o=r(3222),a=r(2225);e.exports=function words(e,t,r){return e=o(e),void 0===(t=r?void 0:t)?i(e)?a(e):n(e):e.match(t)||[]}},7248:(e,t,r)=>{var n=r(6547),i=r(1234);e.exports=function zipObject(e,t){return i(e||[],t||[],n)}},5606:e=>{var t,r,n=e.exports={};function defaultSetTimout(){throw new Error("setTimeout has not been defined")}function defaultClearTimeout(){throw new Error("clearTimeout has not been defined")}function runTimeout(e){if(t===setTimeout)return setTimeout(e,0);if((t===defaultSetTimout||!t)&&setTimeout)return t=setTimeout,setTimeout(e,0);try{return t(e,0)}catch(r){try{return t.call(null,e,0)}catch(r){return t.call(this,e,0)}}}!function(){try{t="function"==typeof setTimeout?setTimeout:defaultSetTimout}catch(e){t=defaultSetTimout}try{r="function"==typeof clearTimeout?clearTimeout:defaultClearTimeout}catch(e){r=defaultClearTimeout}}();var i,o=[],a=!1,s=-1;function cleanUpNextTick(){a&&i&&(a=!1,i.length?o=i.concat(o):s=-1,o.length&&drainQueue())}function drainQueue(){if(!a){var e=runTimeout(cleanUpNextTick);a=!0;for(var t=o.length;t;){for(i=o,o=[];++s1)for(var r=1;r{"use strict";var n=r(5606),i=65536,o=4294967295;var a=r(2861).Buffer,s=r.g.crypto||r.g.msCrypto;s&&s.getRandomValues?e.exports=function randomBytes(e,t){if(e>o)throw new RangeError("requested too many random bytes");var r=a.allocUnsafe(e);if(e>0)if(e>i)for(var u=0;u{"use strict";var r=Symbol.for("react.element"),n=Symbol.for("react.portal"),i=Symbol.for("react.fragment"),o=Symbol.for("react.strict_mode"),a=Symbol.for("react.profiler"),s=Symbol.for("react.provider"),u=Symbol.for("react.context"),c=Symbol.for("react.forward_ref"),f=Symbol.for("react.suspense"),l=Symbol.for("react.memo"),h=Symbol.for("react.lazy"),p=Symbol.iterator;var d={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},_=Object.assign,y={};function E(e,t,r){this.props=e,this.context=t,this.refs=y,this.updater=r||d}function F(){}function G(e,t,r){this.props=e,this.context=t,this.refs=y,this.updater=r||d}E.prototype.isReactComponent={},E.prototype.setState=function(e,t){if("object"!=typeof e&&"function"!=typeof e&&null!=e)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")},E.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")},F.prototype=E.prototype;var m=G.prototype=new F;m.constructor=G,_(m,E.prototype),m.isPureReactComponent=!0;var g=Array.isArray,v=Object.prototype.hasOwnProperty,b={current:null},w={key:!0,ref:!0,__self:!0,__source:!0};function M(e,t,n){var i,o={},a=null,s=null;if(null!=t)for(i in void 0!==t.ref&&(s=t.ref),void 0!==t.key&&(a=""+t.key),t)v.call(t,i)&&!w.hasOwnProperty(i)&&(o[i]=t[i]);var u=arguments.length-2;if(1===u)o.children=n;else if(1{"use strict";e.exports=r(5287)},2861:(e,t,r)=>{var n=r(8287),i=n.Buffer;function copyProps(e,t){for(var r in e)t[r]=e[r]}function SafeBuffer(e,t,r){return i(e,t,r)}i.from&&i.alloc&&i.allocUnsafe&&i.allocUnsafeSlow?e.exports=n:(copyProps(n,t),t.Buffer=SafeBuffer),SafeBuffer.prototype=Object.create(i.prototype),copyProps(i,SafeBuffer),SafeBuffer.from=function(e,t,r){if("number"==typeof e)throw new TypeError("Argument must not be a number");return i(e,t,r)},SafeBuffer.alloc=function(e,t,r){if("number"!=typeof e)throw new TypeError("Argument must be a number");var n=i(e);return void 0!==t?"string"==typeof r?n.fill(t,r):n.fill(t):n.fill(0),n},SafeBuffer.allocUnsafe=function(e){if("number"!=typeof e)throw new TypeError("Argument must be a number");return i(e)},SafeBuffer.allocUnsafeSlow=function(e){if("number"!=typeof e)throw new TypeError("Argument must be a number");return n.SlowBuffer(e)}},8011:(e,t,r)=>{var n=r(2861).Buffer;function Hash(e,t){this._block=n.alloc(e),this._finalSize=t,this._blockSize=e,this._len=0}Hash.prototype.update=function(e,t){"string"==typeof e&&(t=t||"utf8",e=n.from(e,t));for(var r=this._block,i=this._blockSize,o=e.length,a=this._len,s=0;s=this._finalSize&&(this._update(this._block),this._block.fill(0));var r=8*this._len;if(r<=4294967295)this._block.writeUInt32BE(r,this._blockSize-4);else{var n=(4294967295&r)>>>0,i=(r-n)/4294967296;this._block.writeUInt32BE(i,this._blockSize-8),this._block.writeUInt32BE(n,this._blockSize-4)}this._update(this._block);var o=this._hash();return e?o.toString(e):o},Hash.prototype._update=function(){throw new Error("_update must be implemented by subclass")},e.exports=Hash},2802:(e,t,r)=>{var n=e.exports=function SHA(e){e=e.toLowerCase();var t=n[e];if(!t)throw new Error(e+" is not supported (we accept pull requests)");return new t};n.sha=r(7816),n.sha1=r(3737),n.sha224=r(6710),n.sha256=r(4107),n.sha384=r(2827),n.sha512=r(2890)},7816:(e,t,r)=>{var n=r(6698),i=r(8011),o=r(2861).Buffer,a=[1518500249,1859775393,-1894007588,-899497514],s=new Array(80);function Sha(){this.init(),this._w=s,i.call(this,64,56)}function rotl30(e){return e<<30|e>>>2}function ft(e,t,r,n){return 0===e?t&r|~t&n:2===e?t&r|t&n|r&n:t^r^n}n(Sha,i),Sha.prototype.init=function(){return this._a=1732584193,this._b=4023233417,this._c=2562383102,this._d=271733878,this._e=3285377520,this},Sha.prototype._update=function(e){for(var t,r=this._w,n=0|this._a,i=0|this._b,o=0|this._c,s=0|this._d,u=0|this._e,c=0;c<16;++c)r[c]=e.readInt32BE(4*c);for(;c<80;++c)r[c]=r[c-3]^r[c-8]^r[c-14]^r[c-16];for(var f=0;f<80;++f){var l=~~(f/20),h=0|((t=n)<<5|t>>>27)+ft(l,i,o,s)+u+r[f]+a[l];u=s,s=o,o=rotl30(i),i=n,n=h}this._a=n+this._a|0,this._b=i+this._b|0,this._c=o+this._c|0,this._d=s+this._d|0,this._e=u+this._e|0},Sha.prototype._hash=function(){var e=o.allocUnsafe(20);return e.writeInt32BE(0|this._a,0),e.writeInt32BE(0|this._b,4),e.writeInt32BE(0|this._c,8),e.writeInt32BE(0|this._d,12),e.writeInt32BE(0|this._e,16),e},e.exports=Sha},3737:(e,t,r)=>{var n=r(6698),i=r(8011),o=r(2861).Buffer,a=[1518500249,1859775393,-1894007588,-899497514],s=new Array(80);function Sha1(){this.init(),this._w=s,i.call(this,64,56)}function rotl5(e){return e<<5|e>>>27}function rotl30(e){return e<<30|e>>>2}function ft(e,t,r,n){return 0===e?t&r|~t&n:2===e?t&r|t&n|r&n:t^r^n}n(Sha1,i),Sha1.prototype.init=function(){return this._a=1732584193,this._b=4023233417,this._c=2562383102,this._d=271733878,this._e=3285377520,this},Sha1.prototype._update=function(e){for(var t,r=this._w,n=0|this._a,i=0|this._b,o=0|this._c,s=0|this._d,u=0|this._e,c=0;c<16;++c)r[c]=e.readInt32BE(4*c);for(;c<80;++c)r[c]=(t=r[c-3]^r[c-8]^r[c-14]^r[c-16])<<1|t>>>31;for(var f=0;f<80;++f){var l=~~(f/20),h=rotl5(n)+ft(l,i,o,s)+u+r[f]+a[l]|0;u=s,s=o,o=rotl30(i),i=n,n=h}this._a=n+this._a|0,this._b=i+this._b|0,this._c=o+this._c|0,this._d=s+this._d|0,this._e=u+this._e|0},Sha1.prototype._hash=function(){var e=o.allocUnsafe(20);return e.writeInt32BE(0|this._a,0),e.writeInt32BE(0|this._b,4),e.writeInt32BE(0|this._c,8),e.writeInt32BE(0|this._d,12),e.writeInt32BE(0|this._e,16),e},e.exports=Sha1},6710:(e,t,r)=>{var n=r(6698),i=r(4107),o=r(8011),a=r(2861).Buffer,s=new Array(64);function Sha224(){this.init(),this._w=s,o.call(this,64,56)}n(Sha224,i),Sha224.prototype.init=function(){return this._a=3238371032,this._b=914150663,this._c=812702999,this._d=4144912697,this._e=4290775857,this._f=1750603025,this._g=1694076839,this._h=3204075428,this},Sha224.prototype._hash=function(){var e=a.allocUnsafe(28);return e.writeInt32BE(this._a,0),e.writeInt32BE(this._b,4),e.writeInt32BE(this._c,8),e.writeInt32BE(this._d,12),e.writeInt32BE(this._e,16),e.writeInt32BE(this._f,20),e.writeInt32BE(this._g,24),e},e.exports=Sha224},4107:(e,t,r)=>{var n=r(6698),i=r(8011),o=r(2861).Buffer,a=[1116352408,1899447441,3049323471,3921009573,961987163,1508970993,2453635748,2870763221,3624381080,310598401,607225278,1426881987,1925078388,2162078206,2614888103,3248222580,3835390401,4022224774,264347078,604807628,770255983,1249150122,1555081692,1996064986,2554220882,2821834349,2952996808,3210313671,3336571891,3584528711,113926993,338241895,666307205,773529912,1294757372,1396182291,1695183700,1986661051,2177026350,2456956037,2730485921,2820302411,3259730800,3345764771,3516065817,3600352804,4094571909,275423344,430227734,506948616,659060556,883997877,958139571,1322822218,1537002063,1747873779,1955562222,2024104815,2227730452,2361852424,2428436474,2756734187,3204031479,3329325298],s=new Array(64);function Sha256(){this.init(),this._w=s,i.call(this,64,56)}function ch(e,t,r){return r^e&(t^r)}function maj(e,t,r){return e&t|r&(e|t)}function sigma0(e){return(e>>>2|e<<30)^(e>>>13|e<<19)^(e>>>22|e<<10)}function sigma1(e){return(e>>>6|e<<26)^(e>>>11|e<<21)^(e>>>25|e<<7)}function gamma0(e){return(e>>>7|e<<25)^(e>>>18|e<<14)^e>>>3}n(Sha256,i),Sha256.prototype.init=function(){return this._a=1779033703,this._b=3144134277,this._c=1013904242,this._d=2773480762,this._e=1359893119,this._f=2600822924,this._g=528734635,this._h=1541459225,this},Sha256.prototype._update=function(e){for(var t,r=this._w,n=0|this._a,i=0|this._b,o=0|this._c,s=0|this._d,u=0|this._e,c=0|this._f,f=0|this._g,l=0|this._h,h=0;h<16;++h)r[h]=e.readInt32BE(4*h);for(;h<64;++h)r[h]=0|(((t=r[h-2])>>>17|t<<15)^(t>>>19|t<<13)^t>>>10)+r[h-7]+gamma0(r[h-15])+r[h-16];for(var p=0;p<64;++p){var d=l+sigma1(u)+ch(u,c,f)+a[p]+r[p]|0,_=sigma0(n)+maj(n,i,o)|0;l=f,f=c,c=u,u=s+d|0,s=o,o=i,i=n,n=d+_|0}this._a=n+this._a|0,this._b=i+this._b|0,this._c=o+this._c|0,this._d=s+this._d|0,this._e=u+this._e|0,this._f=c+this._f|0,this._g=f+this._g|0,this._h=l+this._h|0},Sha256.prototype._hash=function(){var e=o.allocUnsafe(32);return e.writeInt32BE(this._a,0),e.writeInt32BE(this._b,4),e.writeInt32BE(this._c,8),e.writeInt32BE(this._d,12),e.writeInt32BE(this._e,16),e.writeInt32BE(this._f,20),e.writeInt32BE(this._g,24),e.writeInt32BE(this._h,28),e},e.exports=Sha256},2827:(e,t,r)=>{var n=r(6698),i=r(2890),o=r(8011),a=r(2861).Buffer,s=new Array(160);function Sha384(){this.init(),this._w=s,o.call(this,128,112)}n(Sha384,i),Sha384.prototype.init=function(){return this._ah=3418070365,this._bh=1654270250,this._ch=2438529370,this._dh=355462360,this._eh=1731405415,this._fh=2394180231,this._gh=3675008525,this._hh=1203062813,this._al=3238371032,this._bl=914150663,this._cl=812702999,this._dl=4144912697,this._el=4290775857,this._fl=1750603025,this._gl=1694076839,this._hl=3204075428,this},Sha384.prototype._hash=function(){var e=a.allocUnsafe(48);function writeInt64BE(t,r,n){e.writeInt32BE(t,n),e.writeInt32BE(r,n+4)}return writeInt64BE(this._ah,this._al,0),writeInt64BE(this._bh,this._bl,8),writeInt64BE(this._ch,this._cl,16),writeInt64BE(this._dh,this._dl,24),writeInt64BE(this._eh,this._el,32),writeInt64BE(this._fh,this._fl,40),e},e.exports=Sha384},2890:(e,t,r)=>{var n=r(6698),i=r(8011),o=r(2861).Buffer,a=[1116352408,3609767458,1899447441,602891725,3049323471,3964484399,3921009573,2173295548,961987163,4081628472,1508970993,3053834265,2453635748,2937671579,2870763221,3664609560,3624381080,2734883394,310598401,1164996542,607225278,1323610764,1426881987,3590304994,1925078388,4068182383,2162078206,991336113,2614888103,633803317,3248222580,3479774868,3835390401,2666613458,4022224774,944711139,264347078,2341262773,604807628,2007800933,770255983,1495990901,1249150122,1856431235,1555081692,3175218132,1996064986,2198950837,2554220882,3999719339,2821834349,766784016,2952996808,2566594879,3210313671,3203337956,3336571891,1034457026,3584528711,2466948901,113926993,3758326383,338241895,168717936,666307205,1188179964,773529912,1546045734,1294757372,1522805485,1396182291,2643833823,1695183700,2343527390,1986661051,1014477480,2177026350,1206759142,2456956037,344077627,2730485921,1290863460,2820302411,3158454273,3259730800,3505952657,3345764771,106217008,3516065817,3606008344,3600352804,1432725776,4094571909,1467031594,275423344,851169720,430227734,3100823752,506948616,1363258195,659060556,3750685593,883997877,3785050280,958139571,3318307427,1322822218,3812723403,1537002063,2003034995,1747873779,3602036899,1955562222,1575990012,2024104815,1125592928,2227730452,2716904306,2361852424,442776044,2428436474,593698344,2756734187,3733110249,3204031479,2999351573,3329325298,3815920427,3391569614,3928383900,3515267271,566280711,3940187606,3454069534,4118630271,4000239992,116418474,1914138554,174292421,2731055270,289380356,3203993006,460393269,320620315,685471733,587496836,852142971,1086792851,1017036298,365543100,1126000580,2618297676,1288033470,3409855158,1501505948,4234509866,1607167915,987167468,1816402316,1246189591],s=new Array(160);function Sha512(){this.init(),this._w=s,i.call(this,128,112)}function Ch(e,t,r){return r^e&(t^r)}function maj(e,t,r){return e&t|r&(e|t)}function sigma0(e,t){return(e>>>28|t<<4)^(t>>>2|e<<30)^(t>>>7|e<<25)}function sigma1(e,t){return(e>>>14|t<<18)^(e>>>18|t<<14)^(t>>>9|e<<23)}function Gamma0(e,t){return(e>>>1|t<<31)^(e>>>8|t<<24)^e>>>7}function Gamma0l(e,t){return(e>>>1|t<<31)^(e>>>8|t<<24)^(e>>>7|t<<25)}function Gamma1(e,t){return(e>>>19|t<<13)^(t>>>29|e<<3)^e>>>6}function Gamma1l(e,t){return(e>>>19|t<<13)^(t>>>29|e<<3)^(e>>>6|t<<26)}function getCarry(e,t){return e>>>0>>0?1:0}n(Sha512,i),Sha512.prototype.init=function(){return this._ah=1779033703,this._bh=3144134277,this._ch=1013904242,this._dh=2773480762,this._eh=1359893119,this._fh=2600822924,this._gh=528734635,this._hh=1541459225,this._al=4089235720,this._bl=2227873595,this._cl=4271175723,this._dl=1595750129,this._el=2917565137,this._fl=725511199,this._gl=4215389547,this._hl=327033209,this},Sha512.prototype._update=function(e){for(var t=this._w,r=0|this._ah,n=0|this._bh,i=0|this._ch,o=0|this._dh,s=0|this._eh,u=0|this._fh,c=0|this._gh,f=0|this._hh,l=0|this._al,h=0|this._bl,p=0|this._cl,d=0|this._dl,_=0|this._el,y=0|this._fl,m=0|this._gl,g=0|this._hl,v=0;v<32;v+=2)t[v]=e.readInt32BE(4*v),t[v+1]=e.readInt32BE(4*v+4);for(;v<160;v+=2){var b=t[v-30],w=t[v-30+1],I=Gamma0(b,w),x=Gamma0l(w,b),B=Gamma1(b=t[v-4],w=t[v-4+1]),k=Gamma1l(w,b),C=t[v-14],q=t[v-14+1],L=t[v-32],j=t[v-32+1],z=x+q|0,P=I+C+getCarry(z,x)|0;P=(P=P+B+getCarry(z=z+k|0,k)|0)+L+getCarry(z=z+j|0,j)|0,t[v]=P,t[v+1]=z}for(var D=0;D<160;D+=2){P=t[D],z=t[D+1];var U=maj(r,n,i),W=maj(l,h,p),K=sigma0(r,l),V=sigma0(l,r),$=sigma1(s,_),H=sigma1(_,s),Y=a[D],Z=a[D+1],J=Ch(s,u,c),ee=Ch(_,y,m),te=g+H|0,re=f+$+getCarry(te,g)|0;re=(re=(re=re+J+getCarry(te=te+ee|0,ee)|0)+Y+getCarry(te=te+Z|0,Z)|0)+P+getCarry(te=te+z|0,z)|0;var ne=V+W|0,ie=K+U+getCarry(ne,V)|0;f=c,g=m,c=u,m=y,u=s,y=_,s=o+re+getCarry(_=d+te|0,d)|0,o=i,d=p,i=n,p=h,n=r,h=l,r=re+ie+getCarry(l=te+ne|0,te)|0}this._al=this._al+l|0,this._bl=this._bl+h|0,this._cl=this._cl+p|0,this._dl=this._dl+d|0,this._el=this._el+_|0,this._fl=this._fl+y|0,this._gl=this._gl+m|0,this._hl=this._hl+g|0,this._ah=this._ah+r+getCarry(this._al,l)|0,this._bh=this._bh+n+getCarry(this._bl,h)|0,this._ch=this._ch+i+getCarry(this._cl,p)|0,this._dh=this._dh+o+getCarry(this._dl,d)|0,this._eh=this._eh+s+getCarry(this._el,_)|0,this._fh=this._fh+u+getCarry(this._fl,y)|0,this._gh=this._gh+c+getCarry(this._gl,m)|0,this._hh=this._hh+f+getCarry(this._hl,g)|0},Sha512.prototype._hash=function(){var e=o.allocUnsafe(64);function writeInt64BE(t,r,n){e.writeInt32BE(t,n),e.writeInt32BE(r,n+4)}return writeInt64BE(this._ah,this._al,0),writeInt64BE(this._bh,this._bl,8),writeInt64BE(this._ch,this._cl,16),writeInt64BE(this._dh,this._dl,24),writeInt64BE(this._eh,this._el,32),writeInt64BE(this._fh,this._fl,40),writeInt64BE(this._gh,this._gl,48),writeInt64BE(this._hh,this._hl,56),e},e.exports=Sha512},7666:(e,t,r)=>{var n=r(4851),i=r(953);function _extends(){var t;return e.exports=_extends=n?i(t=n).call(t):function(e){for(var t=1;t{"use strict";var n=r(9709);e.exports=n},462:(e,t,r)=>{"use strict";var n=r(975);e.exports=n},2567:(e,t,r)=>{"use strict";r(9307);var n=r(1747);e.exports=n("Function","bind")},3034:(e,t,r)=>{"use strict";var n=r(8280),i=r(2567),o=Function.prototype;e.exports=function(e){var t=e.bind;return e===o||n(o,e)&&t===o.bind?i:t}},9748:(e,t,r)=>{"use strict";r(1340);var n=r(2046);e.exports=n.Object.assign},953:(e,t,r)=>{"use strict";e.exports=r(3375)},4851:(e,t,r)=>{"use strict";e.exports=r(5401)},3375:(e,t,r)=>{"use strict";var n=r(3700);e.exports=n},5401:(e,t,r)=>{"use strict";var n=r(462);e.exports=n},2159:(e,t,r)=>{"use strict";var n=r(2250),i=r(4640),o=TypeError;e.exports=function(e){if(n(e))return e;throw new o(i(e)+" is not a function")}},6624:(e,t,r)=>{"use strict";var n=r(6285),i=String,o=TypeError;e.exports=function(e){if(n(e))return e;throw new o(i(e)+" is not an object")}},4436:(e,t,r)=>{"use strict";var n=r(7374),i=r(4849),o=r(575),createMethod=function(e){return function(t,r,a){var s=n(t),u=o(s);if(0===u)return!e&&-1;var c,f=i(a,u);if(e&&r!=r){for(;u>f;)if((c=s[f++])!=c)return!0}else for(;u>f;f++)if((e||f in s)&&s[f]===r)return e||f||0;return!e&&-1}};e.exports={includes:createMethod(!0),indexOf:createMethod(!1)}},3427:(e,t,r)=>{"use strict";var n=r(1907);e.exports=n([].slice)},5807:(e,t,r)=>{"use strict";var n=r(1907),i=n({}.toString),o=n("".slice);e.exports=function(e){return o(i(e),8,-1)}},1626:(e,t,r)=>{"use strict";var n=r(9447),i=r(4284),o=r(5817);e.exports=n?function(e,t,r){return i.f(e,t,o(1,r))}:function(e,t,r){return e[t]=r,e}},5817:e=>{"use strict";e.exports=function(e,t){return{enumerable:!(1&e),configurable:!(2&e),writable:!(4&e),value:t}}},2532:(e,t,r)=>{"use strict";var n=r(5951),i=Object.defineProperty;e.exports=function(e,t){try{i(n,e,{value:t,configurable:!0,writable:!0})}catch(r){n[e]=t}return t}},9447:(e,t,r)=>{"use strict";var n=r(8828);e.exports=!n((function(){return 7!==Object.defineProperty({},1,{get:function(){return 7}})[1]}))},9552:(e,t,r)=>{"use strict";var n=r(5951),i=r(6285),o=n.document,a=i(o)&&i(o.createElement);e.exports=function(e){return a?o.createElement(e):{}}},376:e=>{"use strict";e.exports=["constructor","hasOwnProperty","isPrototypeOf","propertyIsEnumerable","toLocaleString","toString","valueOf"]},6794:(e,t,r)=>{"use strict";var n=r(5951).navigator,i=n&&n.userAgent;e.exports=i?String(i):""},798:(e,t,r)=>{"use strict";var n,i,o=r(5951),a=r(6794),s=o.process,u=o.Deno,c=s&&s.versions||u&&u.version,f=c&&c.v8;f&&(i=(n=f.split("."))[0]>0&&n[0]<4?1:+(n[0]+n[1])),!i&&a&&(!(n=a.match(/Edge\/(\d+)/))||n[1]>=74)&&(n=a.match(/Chrome\/(\d+)/))&&(i=+n[1]),e.exports=i},1091:(e,t,r)=>{"use strict";var n=r(5951),i=r(6024),o=r(2361),a=r(2250),s=r(3846).f,u=r(7463),c=r(2046),f=r(8311),l=r(1626),h=r(9724);r(6128);var wrapConstructor=function(e){var Wrapper=function(t,r,n){if(this instanceof Wrapper){switch(arguments.length){case 0:return new e;case 1:return new e(t);case 2:return new e(t,r)}return new e(t,r,n)}return i(e,this,arguments)};return Wrapper.prototype=e.prototype,Wrapper};e.exports=function(e,t){var r,i,p,d,_,y,m,g,v,b=e.target,w=e.global,I=e.stat,x=e.proto,B=w?n:I?n[b]:n[b]&&n[b].prototype,k=w?c:c[b]||l(c,b,{})[b],C=k.prototype;for(d in t)i=!(r=u(w?d:b+(I?".":"#")+d,e.forced))&&B&&h(B,d),y=k[d],i&&(m=e.dontCallGetSet?(v=s(B,d))&&v.value:B[d]),_=i&&m?m:t[d],(r||x||typeof y!=typeof _)&&(g=e.bind&&i?f(_,n):e.wrap&&i?wrapConstructor(_):x&&a(_)?o(_):_,(e.sham||_&&_.sham||y&&y.sham)&&l(g,"sham",!0),l(k,d,g),x&&(h(c,p=b+"Prototype")||l(c,p,{}),l(c[p],d,_),e.real&&C&&(r||!C[d])&&l(C,d,_)))}},8828:e=>{"use strict";e.exports=function(e){try{return!!e()}catch(e){return!0}}},6024:(e,t,r)=>{"use strict";var n=r(1505),i=Function.prototype,o=i.apply,a=i.call;e.exports="object"==typeof Reflect&&Reflect.apply||(n?a.bind(o):function(){return a.apply(o,arguments)})},8311:(e,t,r)=>{"use strict";var n=r(2361),i=r(2159),o=r(1505),a=n(n.bind);e.exports=function(e,t){return i(e),void 0===t?e:o?a(e,t):function(){return e.apply(t,arguments)}}},1505:(e,t,r)=>{"use strict";var n=r(8828);e.exports=!n((function(){var e=function(){}.bind();return"function"!=typeof e||e.hasOwnProperty("prototype")}))},4673:(e,t,r)=>{"use strict";var n=r(1907),i=r(2159),o=r(6285),a=r(9724),s=r(3427),u=r(1505),c=Function,f=n([].concat),l=n([].join),h={};e.exports=u?c.bind:function bind(e){var t=i(this),r=t.prototype,n=s(arguments,1),u=function bound(){var r=f(n,s(arguments));return this instanceof u?function(e,t,r){if(!a(h,t)){for(var n=[],i=0;i{"use strict";var n=r(1505),i=Function.prototype.call;e.exports=n?i.bind(i):function(){return i.apply(i,arguments)}},2361:(e,t,r)=>{"use strict";var n=r(5807),i=r(1907);e.exports=function(e){if("Function"===n(e))return i(e)}},1907:(e,t,r)=>{"use strict";var n=r(1505),i=Function.prototype,o=i.call,a=n&&i.bind.bind(o,o);e.exports=n?a:function(e){return function(){return o.apply(e,arguments)}}},1747:(e,t,r)=>{"use strict";var n=r(5951),i=r(2046);e.exports=function(e,t){var r=i[e+"Prototype"],o=r&&r[t];if(o)return o;var a=n[e],s=a&&a.prototype;return s&&s[t]}},5582:(e,t,r)=>{"use strict";var n=r(2046),i=r(5951),o=r(2250),aFunction=function(e){return o(e)?e:void 0};e.exports=function(e,t){return arguments.length<2?aFunction(n[e])||aFunction(i[e]):n[e]&&n[e][t]||i[e]&&i[e][t]}},9367:(e,t,r)=>{"use strict";var n=r(2159),i=r(7136);e.exports=function(e,t){var r=e[t];return i(r)?void 0:n(r)}},5951:function(e,t,r){"use strict";var check=function(e){return e&&e.Math===Math&&e};e.exports=check("object"==typeof globalThis&&globalThis)||check("object"==typeof window&&window)||check("object"==typeof self&&self)||check("object"==typeof r.g&&r.g)||check("object"==typeof this&&this)||function(){return this}()||Function("return this")()},9724:(e,t,r)=>{"use strict";var n=r(1907),i=r(9298),o=n({}.hasOwnProperty);e.exports=Object.hasOwn||function hasOwn(e,t){return o(i(e),t)}},8530:e=>{"use strict";e.exports={}},3648:(e,t,r)=>{"use strict";var n=r(9447),i=r(8828),o=r(9552);e.exports=!n&&!i((function(){return 7!==Object.defineProperty(o("div"),"a",{get:function(){return 7}}).a}))},6946:(e,t,r)=>{"use strict";var n=r(1907),i=r(8828),o=r(5807),a=Object,s=n("".split);e.exports=i((function(){return!a("z").propertyIsEnumerable(0)}))?function(e){return"String"===o(e)?s(e,""):a(e)}:a},2250:e=>{"use strict";var t="object"==typeof document&&document.all;e.exports=void 0===t&&void 0!==t?function(e){return"function"==typeof e||e===t}:function(e){return"function"==typeof e}},7463:(e,t,r)=>{"use strict";var n=r(8828),i=r(2250),o=/#|\.prototype\./,isForced=function(e,t){var r=s[a(e)];return r===c||r!==u&&(i(t)?n(t):!!t)},a=isForced.normalize=function(e){return String(e).replace(o,".").toLowerCase()},s=isForced.data={},u=isForced.NATIVE="N",c=isForced.POLYFILL="P";e.exports=isForced},7136:e=>{"use strict";e.exports=function(e){return null==e}},6285:(e,t,r)=>{"use strict";var n=r(2250);e.exports=function(e){return"object"==typeof e?null!==e:n(e)}},7376:e=>{"use strict";e.exports=!0},5594:(e,t,r)=>{"use strict";var n=r(5582),i=r(2250),o=r(8280),a=r(3556),s=Object;e.exports=a?function(e){return"symbol"==typeof e}:function(e){var t=n("Symbol");return i(t)&&o(t.prototype,s(e))}},575:(e,t,r)=>{"use strict";var n=r(3121);e.exports=function(e){return n(e.length)}},1176:e=>{"use strict";var t=Math.ceil,r=Math.floor;e.exports=Math.trunc||function trunc(e){var n=+e;return(n>0?r:t)(n)}},9538:(e,t,r)=>{"use strict";var n=r(9447),i=r(1907),o=r(3930),a=r(8828),s=r(2875),u=r(7170),c=r(2574),f=r(9298),l=r(6946),h=Object.assign,p=Object.defineProperty,d=i([].concat);e.exports=!h||a((function(){if(n&&1!==h({b:1},h(p({},"a",{enumerable:!0,get:function(){p(this,"b",{value:3,enumerable:!1})}}),{b:2})).b)return!0;var e={},t={},r=Symbol("assign detection"),i="abcdefghijklmnopqrst";return e[r]=7,i.split("").forEach((function(e){t[e]=e})),7!==h({},e)[r]||s(h({},t)).join("")!==i}))?function assign(e,t){for(var r=f(e),i=arguments.length,a=1,h=u.f,p=c.f;i>a;)for(var _,y=l(arguments[a++]),m=h?d(s(y),h(y)):s(y),g=m.length,v=0;g>v;)_=m[v++],n&&!o(p,y,_)||(r[_]=y[_]);return r}:h},4284:(e,t,r)=>{"use strict";var n=r(9447),i=r(3648),o=r(8661),a=r(6624),s=r(470),u=TypeError,c=Object.defineProperty,f=Object.getOwnPropertyDescriptor,l="enumerable",h="configurable",p="writable";t.f=n?o?function defineProperty(e,t,r){if(a(e),t=s(t),a(r),"function"==typeof e&&"prototype"===t&&"value"in r&&p in r&&!r[p]){var n=f(e,t);n&&n[p]&&(e[t]=r.value,r={configurable:h in r?r[h]:n[h],enumerable:l in r?r[l]:n[l],writable:!1})}return c(e,t,r)}:c:function defineProperty(e,t,r){if(a(e),t=s(t),a(r),i)try{return c(e,t,r)}catch(e){}if("get"in r||"set"in r)throw new u("Accessors not supported");return"value"in r&&(e[t]=r.value),e}},3846:(e,t,r)=>{"use strict";var n=r(9447),i=r(3930),o=r(2574),a=r(5817),s=r(7374),u=r(470),c=r(9724),f=r(3648),l=Object.getOwnPropertyDescriptor;t.f=n?l:function getOwnPropertyDescriptor(e,t){if(e=s(e),t=u(t),f)try{return l(e,t)}catch(e){}if(c(e,t))return a(!i(o.f,e,t),e[t])}},7170:(e,t)=>{"use strict";t.f=Object.getOwnPropertySymbols},8280:(e,t,r)=>{"use strict";var n=r(1907);e.exports=n({}.isPrototypeOf)},3045:(e,t,r)=>{"use strict";var n=r(1907),i=r(9724),o=r(7374),a=r(4436).indexOf,s=r(8530),u=n([].push);e.exports=function(e,t){var r,n=o(e),c=0,f=[];for(r in n)!i(s,r)&&i(n,r)&&u(f,r);for(;t.length>c;)i(n,r=t[c++])&&(~a(f,r)||u(f,r));return f}},2875:(e,t,r)=>{"use strict";var n=r(3045),i=r(376);e.exports=Object.keys||function keys(e){return n(e,i)}},2574:(e,t)=>{"use strict";var r={}.propertyIsEnumerable,n=Object.getOwnPropertyDescriptor,i=n&&!r.call({1:2},1);t.f=i?function propertyIsEnumerable(e){var t=n(this,e);return!!t&&t.enumerable}:r},581:(e,t,r)=>{"use strict";var n=r(3930),i=r(2250),o=r(6285),a=TypeError;e.exports=function(e,t){var r,s;if("string"===t&&i(r=e.toString)&&!o(s=n(r,e)))return s;if(i(r=e.valueOf)&&!o(s=n(r,e)))return s;if("string"!==t&&i(r=e.toString)&&!o(s=n(r,e)))return s;throw new a("Can't convert object to primitive value")}},2046:e=>{"use strict";e.exports={}},4239:(e,t,r)=>{"use strict";var n=r(7136),i=TypeError;e.exports=function(e){if(n(e))throw new i("Can't call method on "+e);return e}},6128:(e,t,r)=>{"use strict";var n=r(7376),i=r(5951),o=r(2532),a="__core-js_shared__",s=e.exports=i[a]||o(a,{});(s.versions||(s.versions=[])).push({version:"3.39.0",mode:n?"pure":"global",copyright:"© 2014-2024 Denis Pushkarev (zloirock.ru)",license:"https://github.com/zloirock/core-js/blob/v3.39.0/LICENSE",source:"https://github.com/zloirock/core-js"})},5816:(e,t,r)=>{"use strict";var n=r(6128);e.exports=function(e,t){return n[e]||(n[e]=t||{})}},9846:(e,t,r)=>{"use strict";var n=r(798),i=r(8828),o=r(5951).String;e.exports=!!Object.getOwnPropertySymbols&&!i((function(){var e=Symbol("symbol detection");return!o(e)||!(Object(e)instanceof Symbol)||!Symbol.sham&&n&&n<41}))},4849:(e,t,r)=>{"use strict";var n=r(5482),i=Math.max,o=Math.min;e.exports=function(e,t){var r=n(e);return r<0?i(r+t,0):o(r,t)}},7374:(e,t,r)=>{"use strict";var n=r(6946),i=r(4239);e.exports=function(e){return n(i(e))}},5482:(e,t,r)=>{"use strict";var n=r(1176);e.exports=function(e){var t=+e;return t!=t||0===t?0:n(t)}},3121:(e,t,r)=>{"use strict";var n=r(5482),i=Math.min;e.exports=function(e){var t=n(e);return t>0?i(t,9007199254740991):0}},9298:(e,t,r)=>{"use strict";var n=r(4239),i=Object;e.exports=function(e){return i(n(e))}},6028:(e,t,r)=>{"use strict";var n=r(3930),i=r(6285),o=r(5594),a=r(9367),s=r(581),u=r(6264),c=TypeError,f=u("toPrimitive");e.exports=function(e,t){if(!i(e)||o(e))return e;var r,u=a(e,f);if(u){if(void 0===t&&(t="default"),r=n(u,e,t),!i(r)||o(r))return r;throw new c("Can't convert object to primitive value")}return void 0===t&&(t="number"),s(e,t)}},470:(e,t,r)=>{"use strict";var n=r(6028),i=r(5594);e.exports=function(e){var t=n(e,"string");return i(t)?t:t+""}},4640:e=>{"use strict";var t=String;e.exports=function(e){try{return t(e)}catch(e){return"Object"}}},6499:(e,t,r)=>{"use strict";var n=r(1907),i=0,o=Math.random(),a=n(1..toString);e.exports=function(e){return"Symbol("+(void 0===e?"":e)+")_"+a(++i+o,36)}},3556:(e,t,r)=>{"use strict";var n=r(9846);e.exports=n&&!Symbol.sham&&"symbol"==typeof Symbol.iterator},8661:(e,t,r)=>{"use strict";var n=r(9447),i=r(8828);e.exports=n&&i((function(){return 42!==Object.defineProperty((function(){}),"prototype",{value:42,writable:!1}).prototype}))},6264:(e,t,r)=>{"use strict";var n=r(5951),i=r(5816),o=r(9724),a=r(6499),s=r(9846),u=r(3556),c=n.Symbol,f=i("wks"),l=u?c.for||c:c&&c.withoutSetter||a;e.exports=function(e){return o(f,e)||(f[e]=s&&o(c,e)?c[e]:l("Symbol."+e)),f[e]}},9307:(e,t,r)=>{"use strict";var n=r(1091),i=r(4673);n({target:"Function",proto:!0,forced:Function.bind!==i},{bind:i})},1340:(e,t,r)=>{"use strict";var n=r(1091),i=r(9538);n({target:"Object",stat:!0,arity:2,forced:Object.assign!==i},{assign:i})},9709:(e,t,r)=>{"use strict";var n=r(3034);e.exports=n},975:(e,t,r)=>{"use strict";var n=r(9748);e.exports=n}},t={};function __webpack_require__(r){var n=t[r];if(void 0!==n)return n.exports;var i=t[r]={id:r,loaded:!1,exports:{}};return e[r].call(i.exports,i,i.exports,__webpack_require__),i.loaded=!0,i.exports}__webpack_require__.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return __webpack_require__.d(t,{a:t}),t},__webpack_require__.d=(e,t)=>{for(var r in t)__webpack_require__.o(t,r)&&!__webpack_require__.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},__webpack_require__.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),__webpack_require__.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),__webpack_require__.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},__webpack_require__.nmd=e=>(e.paths=[],e.children||(e.children=[]),e);var r={};return(()=>{"use strict";__webpack_require__.d(r,{default:()=>ot});var e={};__webpack_require__.r(e),__webpack_require__.d(e,{TOGGLE_CONFIGS:()=>Je,UPDATE_CONFIGS:()=>Ge,downloadConfig:()=>downloadConfig,getConfigByUrl:()=>getConfigByUrl,loaded:()=>loaded,toggle:()=>toggle,update:()=>update});var t={};__webpack_require__.r(t),__webpack_require__.d(t,{get:()=>get});var n=__webpack_require__(6540);class StandaloneLayout extends n.Component{render(){const{getComponent:e}=this.props,t=e("Container"),r=e("Row"),i=e("Col"),o=e("Topbar",!0),a=e("BaseLayout",!0),s=e("onlineValidatorBadge",!0);return n.createElement(t,{className:"swagger-ui"},o?n.createElement(o,null):null,n.createElement(a,null),n.createElement(r,null,n.createElement(i,null,n.createElement(s,null))))}}const i=StandaloneLayout,stadalone_layout=()=>({components:{StandaloneLayout:i}});var o=__webpack_require__(9404),a=__webpack_require__.n(o);__webpack_require__(6750),__webpack_require__(4058),__webpack_require__(5808),__webpack_require__(104),__webpack_require__(7309),__webpack_require__(2426),__webpack_require__(5288),__webpack_require__(1882),__webpack_require__(2205),__webpack_require__(3209),__webpack_require__(2802);const s=function makeWindow(){var e={location:{},history:{},open:()=>{},close:()=>{},File:function(){},FormData:function(){}};if("undefined"==typeof window)return e;try{e=window;for(var t of["File","Blob","FormData"])t in window&&(e[t]=window[t])}catch(e){console.error(e)}return e}();a().Set.of("type","format","items","default","maximum","exclusiveMaximum","minimum","exclusiveMinimum","maxLength","minLength","pattern","maxItems","minItems","uniqueItems","enum","multipleOf");__webpack_require__(8287).Buffer;const parseSearch=()=>{const e=new URLSearchParams(s.location.search);return Object.fromEntries(e)};class TopBar extends n.Component{constructor(e,t){super(e,t),this.state={url:e.specSelectors.url(),selectedIndex:0}}UNSAFE_componentWillReceiveProps(e){this.setState({url:e.specSelectors.url()})}onUrlChange=e=>{let{target:{value:t}}=e;this.setState({url:t})};flushAuthData(){const{persistAuthorization:e}=this.props.getConfigs();e||this.props.authActions.restoreAuthorization({authorized:{}})}loadSpec=e=>{this.flushAuthData(),this.props.specActions.updateUrl(e),this.props.specActions.download(e)};onUrlSelect=e=>{let t=e.target.value||e.target.href;this.loadSpec(t),this.setSelectedUrl(t),e.preventDefault()};downloadUrl=e=>{this.loadSpec(this.state.url),e.preventDefault()};setSearch=e=>{let t=parseSearch();t["urls.primaryName"]=e.name;const r=`${window.location.protocol}//${window.location.host}${window.location.pathname}`;window&&window.history&&window.history.pushState&&window.history.replaceState(null,"",`${r}?${(e=>{const t=new URLSearchParams(Object.entries(e));return String(t)})(t)}`)};setSelectedUrl=e=>{const t=this.props.getConfigs().urls||[];t&&t.length&&e&&t.forEach(((t,r)=>{t.url===e&&(this.setState({selectedIndex:r}),this.setSearch(t))}))};componentDidMount(){const e=this.props.getConfigs(),t=e.urls||[];if(t&&t.length){var r=this.state.selectedIndex;let n=parseSearch()["urls.primaryName"]||e.urls.primaryName;n&&t.forEach(((e,t)=>{e.name===n&&(this.setState({selectedIndex:t}),r=t)})),this.loadSpec(t[r].url)}}onFilterChange=e=>{let{target:{value:t}}=e;this.props.layoutActions.updateFilter(t)};render(){let{getComponent:e,specSelectors:t,getConfigs:r}=this.props;const i=e("Button"),o=e("Link"),a=e("Logo");let s="loading"===t.loadingStatus();const u=["download-url-input"];"failed"===t.loadingStatus()&&u.push("failed"),s&&u.push("loading");const{urls:c}=r();let f=[],l=null;if(c){let e=[];c.forEach(((t,r)=>{e.push(n.createElement("option",{key:r,value:t.url},t.name))})),f.push(n.createElement("label",{className:"select-label",htmlFor:"select"},n.createElement("span",null,"Select a definition"),n.createElement("select",{id:"select",disabled:s,onChange:this.onUrlSelect,value:c[this.state.selectedIndex].url},e)))}else l=this.downloadUrl,f.push(n.createElement("input",{className:u.join(" "),type:"text",onChange:this.onUrlChange,value:this.state.url,disabled:s,id:"download-url-input"})),f.push(n.createElement(i,{className:"download-url-button",onClick:this.downloadUrl},"Explore"));return n.createElement("div",{className:"topbar"},n.createElement("div",{className:"wrapper"},n.createElement("div",{className:"topbar-wrapper"},n.createElement(o,null,n.createElement(a,null)),n.createElement("form",{className:"download-url-wrapper",onSubmit:l},f.map(((e,t)=>(0,n.cloneElement)(e,{key:t})))))))}}const u=TopBar;var c,f,l,h,p,d,_,y,m,g,v,b,w,I,x,B,k,C,q,L,j,z,P,D,U,W,K,V,$,H,Y,Z;function _extends(){return _extends=Object.assign?Object.assign.bind():function(e){for(var t=1;tn.createElement("svg",_extends({xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 407 116"},e),c||(c=n.createElement("defs",null,n.createElement("clipPath",{id:"logo_small_svg__clip-SW_TM-logo-on-dark"},n.createElement("path",{d:"M0 0h407v116H0z"})),n.createElement("style",null,".logo_small_svg__cls-2{fill:#fff}.logo_small_svg__cls-3{fill:#85ea2d}"))),n.createElement("g",{id:"logo_small_svg__SW_TM-logo-on-dark",style:{clipPath:"url(#logo_small_svg__clip-SW_TM-logo-on-dark)"}},n.createElement("g",{id:"logo_small_svg__SW_In-Product",transform:"translate(-.301)"},f||(f=n.createElement("path",{id:"logo_small_svg__Path_2936",d:"M359.15 70.674h-.7v-3.682h-1.26v-.6h3.219v.6h-1.259Z",className:"logo_small_svg__cls-2","data-name":"Path 2936"})),l||(l=n.createElement("path",{id:"logo_small_svg__Path_2937",d:"m363.217 70.674-1.242-3.574h-.023q.05.8.05 1.494v2.083h-.636v-4.286h.987l1.19 3.407h.017l1.225-3.407h.99v4.283h-.675v-2.118a30 30 0 0 1 .044-1.453h-.023l-1.286 3.571Z",className:"logo_small_svg__cls-2","data-name":"Path 2937"})),h||(h=n.createElement("path",{id:"logo_small_svg__Path_2938",d:"M50.328 97.669a47.642 47.642 0 1 1 47.643-47.642 47.64 47.64 0 0 1-47.643 47.642",className:"logo_small_svg__cls-3","data-name":"Path 2938"})),p||(p=n.createElement("path",{id:"logo_small_svg__Path_2939",d:"M50.328 4.769A45.258 45.258 0 1 1 5.07 50.027 45.26 45.26 0 0 1 50.328 4.769m0-4.769a50.027 50.027 0 1 0 50.027 50.027A50.027 50.027 0 0 0 50.328 0",className:"logo_small_svg__cls-3","data-name":"Path 2939"})),n.createElement("path",{id:"logo_small_svg__Path_2940",d:"M31.8 33.854c-.154 1.712.058 3.482-.057 5.213a43 43 0 0 1-.693 5.156 9.53 9.53 0 0 1-4.1 5.829c4.079 2.654 4.54 6.771 4.81 10.946.135 2.25.077 4.52.308 6.752.173 1.731.846 2.174 2.636 2.231.73.02 1.48 0 2.327 0v5.349c-5.29.9-9.657-.6-10.734-5.079a31 31 0 0 1-.654-5c-.117-1.789.076-3.578-.058-5.367-.386-4.906-1.02-6.56-5.713-6.791v-6.1a9 9 0 0 1 1.028-.173c2.577-.135 3.674-.924 4.231-3.463a29 29 0 0 0 .481-4.329 82 82 0 0 1 .6-8.406c.673-3.982 3.136-5.906 7.234-6.137 1.154-.057 2.327 0 3.655 0v5.464c-.558.038-1.039.115-1.539.115-3.336-.115-3.51 1.02-3.762 3.79m6.406 12.658h-.077a3.515 3.515 0 1 0-.346 7.021h.231a3.46 3.46 0 0 0 3.655-3.251v-.192a3.523 3.523 0 0 0-3.461-3.578Zm12.062 0a3.373 3.373 0 0 0-3.482 3.251 2 2 0 0 0 .02.327 3.3 3.3 0 0 0 3.578 3.443 3.263 3.263 0 0 0 3.443-3.558 3.308 3.308 0 0 0-3.557-3.463Zm12.351 0a3.59 3.59 0 0 0-3.655 3.482 3.53 3.53 0 0 0 3.536 3.539h.039c1.769.309 3.559-1.4 3.674-3.462a3.57 3.57 0 0 0-3.6-3.559Zm16.948.288c-2.232-.1-3.348-.846-3.9-2.962a21.5 21.5 0 0 1-.635-4.136c-.154-2.578-.135-5.175-.308-7.753-.4-6.117-4.828-8.252-11.254-7.195v5.31c1.019 0 1.808 0 2.6.019 1.366.019 2.4.539 2.539 2.059.135 1.385.135 2.789.27 4.193.269 2.79.422 5.618.9 8.369a8.72 8.72 0 0 0 3.921 5.348c-3.4 2.289-4.406 5.559-4.578 9.234-.1 2.52-.154 5.059-.289 7.6-.115 2.308-.923 3.058-3.251 3.116-.654.019-1.289.077-2.019.115v5.445c1.365 0 2.616.077 3.866 0 3.886-.231 6.233-2.117 7-5.887A49 49 0 0 0 75 63.4c.135-1.923.116-3.866.308-5.771.289-2.982 1.655-4.213 4.636-4.4a4 4 0 0 0 .828-.192v-6.1c-.5-.058-.843-.115-1.208-.135Z","data-name":"Path 2940",style:{fill:"#173647"}}),d||(d=n.createElement("path",{id:"logo_small_svg__Path_2941",d:"M152.273 58.122a11.23 11.23 0 0 1-4.384 9.424q-4.383 3.382-11.9 3.382-8.14 0-12.524-2.1V63.7a33 33 0 0 0 6.137 1.879 32.3 32.3 0 0 0 6.575.689q5.322 0 8.015-2.02a6.63 6.63 0 0 0 2.692-5.62 7.2 7.2 0 0 0-.954-3.9 8.9 8.9 0 0 0-3.194-2.8 44.6 44.6 0 0 0-6.81-2.911q-6.387-2.286-9.126-5.417a11.96 11.96 0 0 1-2.74-8.172A10.16 10.16 0 0 1 128.039 27q3.977-3.131 10.52-3.131a31 31 0 0 1 12.555 2.5L149.455 31a28.4 28.4 0 0 0-11.021-2.38 10.67 10.67 0 0 0-6.606 1.816 5.98 5.98 0 0 0-2.38 5.041 7.7 7.7 0 0 0 .877 3.9 8.24 8.24 0 0 0 2.959 2.786 36.7 36.7 0 0 0 6.371 2.8q7.2 2.566 9.91 5.51a10.84 10.84 0 0 1 2.708 7.649",className:"logo_small_svg__cls-2","data-name":"Path 2941"})),_||(_=n.createElement("path",{id:"logo_small_svg__Path_2942",d:"M185.288 70.3 179 50.17q-.594-1.848-2.222-8.391h-.251q-1.252 5.479-2.192 8.453L167.849 70.3h-6.011l-9.361-34.315h5.447q3.318 12.931 5.057 19.693a80 80 0 0 1 1.988 9.111h.25q.345-1.785 1.112-4.618t1.33-4.493l6.294-19.693h5.635l6.137 19.693a66 66 0 0 1 2.379 9.048h.251a33 33 0 0 1 .673-3.475q.548-2.347 6.528-25.266h5.385L191.456 70.3Z",className:"logo_small_svg__cls-2","data-name":"Path 2942"})),y||(y=n.createElement("path",{id:"logo_small_svg__Path_2943",d:"m225.115 70.3-1.033-4.885h-.25a14.45 14.45 0 0 1-5.119 4.368 15.6 15.6 0 0 1-6.372 1.143q-5.1 0-8-2.63t-2.9-7.483q0-10.4 16.626-10.9l5.823-.188V47.6q0-4.038-1.738-5.964t-5.552-1.923a22.6 22.6 0 0 0-9.706 2.63l-1.6-3.977a24.4 24.4 0 0 1 5.557-2.16 24 24 0 0 1 6.058-.783q6.136 0 9.1 2.724t2.959 8.735V70.3Zm-11.741-3.663a10.55 10.55 0 0 0 7.626-2.66 9.85 9.85 0 0 0 2.771-7.451v-3.1l-5.2.219q-6.2.219-8.939 1.926a5.8 5.8 0 0 0-2.74 5.306 5.35 5.35 0 0 0 1.707 4.29 7.08 7.08 0 0 0 4.775 1.472Z",className:"logo_small_svg__cls-2","data-name":"Path 2943"})),m||(m=n.createElement("path",{id:"logo_small_svg__Path_2944",d:"M264.6 35.987v3.287l-6.356.752a11.16 11.16 0 0 1 2.255 6.856 10.15 10.15 0 0 1-3.444 8.047q-3.444 3-9.456 3a15.7 15.7 0 0 1-2.88-.25Q241.4 59.438 241.4 62.1a2.24 2.24 0 0 0 1.159 2.082 8.46 8.46 0 0 0 3.976.673h6.074q5.573 0 8.563 2.348a8.16 8.16 0 0 1 2.99 6.825 9.74 9.74 0 0 1-4.571 8.688q-4.572 2.989-13.338 2.99-6.732 0-10.379-2.5a8.09 8.09 0 0 1-3.647-7.076 7.95 7.95 0 0 1 2-5.417 10.2 10.2 0 0 1 5.636-3.1 5.43 5.43 0 0 1-2.207-1.847 4.9 4.9 0 0 1-.893-2.912 5.53 5.53 0 0 1 1-3.288 10.5 10.5 0 0 1 3.162-2.723 9.28 9.28 0 0 1-4.336-3.726 10.95 10.95 0 0 1-1.675-6.012q0-5.634 3.382-8.688t9.58-3.052a17.4 17.4 0 0 1 4.853.626Zm-27.367 40.075a4.66 4.66 0 0 0 2.348 4.227 12.97 12.97 0 0 0 6.732 1.44q6.543 0 9.69-1.956a5.99 5.99 0 0 0 3.147-5.307q0-2.787-1.723-3.867t-6.481-1.08h-6.23a8.2 8.2 0 0 0-5.51 1.69 6.04 6.04 0 0 0-1.973 4.853m2.818-29.086a6.98 6.98 0 0 0 2.035 5.448 8.12 8.12 0 0 0 5.667 1.847q7.608 0 7.608-7.389 0-7.733-7.7-7.733a7.63 7.63 0 0 0-5.635 1.972q-1.976 1.973-1.975 5.855",className:"logo_small_svg__cls-2","data-name":"Path 2944"})),g||(g=n.createElement("path",{id:"logo_small_svg__Path_2945",d:"M299.136 35.987v3.287l-6.356.752a11.17 11.17 0 0 1 2.254 6.856 10.15 10.15 0 0 1-3.444 8.047q-3.444 3-9.455 3a15.7 15.7 0 0 1-2.88-.25q-3.32 1.754-3.319 4.415a2.24 2.24 0 0 0 1.158 2.082 8.46 8.46 0 0 0 3.976.673h6.074q5.574 0 8.563 2.348a8.16 8.16 0 0 1 2.99 6.825 9.74 9.74 0 0 1-4.571 8.688q-4.57 2.989-13.337 2.99-6.732 0-10.379-2.5a8.09 8.09 0 0 1-3.648-7.076 7.95 7.95 0 0 1 2-5.417 10.2 10.2 0 0 1 5.636-3.1 5.43 5.43 0 0 1-2.208-1.847 4.9 4.9 0 0 1-.892-2.912 5.53 5.53 0 0 1 1-3.288 10.5 10.5 0 0 1 3.162-2.723 9.27 9.27 0 0 1-4.336-3.726 10.95 10.95 0 0 1-1.675-6.012q0-5.634 3.381-8.688t9.581-3.052a17.4 17.4 0 0 1 4.853.626Zm-27.364 40.075a4.66 4.66 0 0 0 2.348 4.227 12.97 12.97 0 0 0 6.731 1.44q6.544 0 9.691-1.956a5.99 5.99 0 0 0 3.146-5.307q0-2.787-1.722-3.867t-6.481-1.08h-6.23a8.2 8.2 0 0 0-5.511 1.69 6.04 6.04 0 0 0-1.972 4.853m2.818-29.086a6.98 6.98 0 0 0 2.035 5.448 8.12 8.12 0 0 0 5.667 1.847q7.607 0 7.608-7.389 0-7.733-7.7-7.733a7.63 7.63 0 0 0-5.635 1.972q-1.975 1.973-1.975 5.855",className:"logo_small_svg__cls-2","data-name":"Path 2945"})),v||(v=n.createElement("path",{id:"logo_small_svg__Path_2946",d:"M316.778 70.928q-7.608 0-12.007-4.634t-4.4-12.868q0-8.3 4.086-13.181a13.57 13.57 0 0 1 10.974-4.884 12.94 12.94 0 0 1 10.207 4.239q3.762 4.247 3.762 11.2v3.287h-23.643q.156 6.044 3.053 9.174t8.156 3.131a27.6 27.6 0 0 0 10.958-2.317v4.634a27.5 27.5 0 0 1-5.213 1.706 29.3 29.3 0 0 1-5.933.513m-1.409-31.215a8.49 8.49 0 0 0-6.591 2.692 12.4 12.4 0 0 0-2.9 7.452h17.94q0-4.916-2.191-7.53a7.71 7.71 0 0 0-6.258-2.614",className:"logo_small_svg__cls-2","data-name":"Path 2946"})),b||(b=n.createElement("path",{id:"logo_small_svg__Path_2947",d:"M350.9 35.361a20.4 20.4 0 0 1 4.1.375l-.721 4.822a17.7 17.7 0 0 0-3.757-.47 9.14 9.14 0 0 0-7.122 3.382 12.33 12.33 0 0 0-2.959 8.422V70.3h-5.2V35.987h4.29l.6 6.356h.25a15.1 15.1 0 0 1 4.6-5.166 10.36 10.36 0 0 1 5.919-1.816",className:"logo_small_svg__cls-2","data-name":"Path 2947"})),w||(w=n.createElement("path",{id:"logo_small_svg__Path_2948",d:"M255.857 96.638s-3.43-.391-4.85-.391c-2.058 0-3.111.735-3.111 2.18 0 1.568.882 1.935 3.748 2.719 3.527.98 4.8 1.911 4.8 4.777 0 3.675-2.3 5.267-5.61 5.267a36 36 0 0 1-5.487-.662l.27-2.18s3.306.441 5.046.441c2.082 0 3.037-.931 3.037-2.7 0-1.421-.759-1.91-3.331-2.523-3.626-.93-5.193-2.033-5.193-4.948 0-3.381 2.229-4.776 5.585-4.776a37 37 0 0 1 5.315.587Z",className:"logo_small_svg__cls-2","data-name":"Path 2948"})),I||(I=n.createElement("path",{id:"logo_small_svg__Path_2949",d:"M262.967 94.14h4.733l3.748 13.106L275.2 94.14h4.752v16.78H277.2v-14.5h-.145l-4.191 13.816h-2.842l-4.191-13.816h-.145v14.5h-2.719Z",className:"logo_small_svg__cls-2","data-name":"Path 2949"})),x||(x=n.createElement("path",{id:"logo_small_svg__Path_2950",d:"M322.057 94.14H334.3v2.425h-4.728v14.355h-2.743V96.565h-4.777Z",className:"logo_small_svg__cls-2","data-name":"Path 2950"})),B||(B=n.createElement("path",{id:"logo_small_svg__Path_2951",d:"M346.137 94.14c3.332 0 5.12 1.249 5.12 4.361 0 2.033-.637 3.037-1.984 3.772 1.445.563 2.4 1.592 2.4 3.9 0 3.43-2.081 4.752-5.339 4.752h-6.566V94.14Zm-3.65 2.352v4.8h3.6c1.666 0 2.4-.832 2.4-2.474 0-1.617-.833-2.327-2.5-2.327Zm0 7.1v4.973h3.7c1.689 0 2.694-.539 2.694-2.548 0-1.911-1.421-2.425-2.744-2.425Z",className:"logo_small_svg__cls-2","data-name":"Path 2951"})),k||(k=n.createElement("path",{id:"logo_small_svg__Path_2952",d:"M358.414 94.14H369v2.377h-7.864v4.751h6.394v2.332h-6.394v4.924H369v2.4h-10.586Z",className:"logo_small_svg__cls-2","data-name":"Path 2952"})),C||(C=n.createElement("path",{id:"logo_small_svg__Path_2953",d:"M378.747 94.14h5.414l4.164 16.78h-2.744l-1.239-4.92h-5.777l-1.239 4.923h-2.719Zm.361 9.456h4.708l-1.737-7.178h-1.225Z",className:"logo_small_svg__cls-2","data-name":"Path 2953"})),q||(q=n.createElement("path",{id:"logo_small_svg__Path_2954",d:"M397.1 105.947v4.973h-2.719V94.14h6.37c3.7 0 5.683 2.12 5.683 5.843 0 2.376-.956 4.519-2.744 5.352l2.769 5.585h-2.989l-2.426-4.973Zm3.651-9.455H397.1v7.1h3.7c2.057 0 2.841-1.85 2.841-3.589 0-1.9-.934-3.511-2.894-3.511Z",className:"logo_small_svg__cls-2","data-name":"Path 2954"})),L||(L=n.createElement("path",{id:"logo_small_svg__Path_2955",d:"M290.013 94.14h5.413l4.164 16.78h-2.743l-1.239-4.92h-5.777l-1.239 4.923h-2.719Zm.361 9.456h4.707l-1.737-7.178h-1.225Z",className:"logo_small_svg__cls-2","data-name":"Path 2955"})),j||(j=n.createElement("path",{id:"logo_small_svg__Path_2956",d:"M308.362 105.947v4.973h-2.719V94.14h6.369c3.7 0 5.683 2.12 5.683 5.843 0 2.376-.955 4.519-2.743 5.352l2.768 5.585h-2.989l-2.425-4.973Zm3.65-9.455h-3.65v7.1h3.7c2.058 0 2.841-1.85 2.841-3.589-.003-1.903-.931-3.511-2.891-3.511",className:"logo_small_svg__cls-2","data-name":"Path 2956"})),z||(z=n.createElement("path",{id:"logo_small_svg__Path_2957",d:"M130.606 107.643a3.02 3.02 0 0 1-1.18 2.537 5.1 5.1 0 0 1-3.2.91 8 8 0 0 1-3.371-.564v-1.383a9 9 0 0 0 1.652.506 8.7 8.7 0 0 0 1.77.186 3.57 3.57 0 0 0 2.157-.544 1.78 1.78 0 0 0 .725-1.512 1.95 1.95 0 0 0-.257-1.05 2.4 2.4 0 0 0-.86-.754 12 12 0 0 0-1.833-.784 5.84 5.84 0 0 1-2.456-1.458 3.2 3.2 0 0 1-.738-2.2 2.74 2.74 0 0 1 1.071-2.267 4.44 4.44 0 0 1 2.831-.843 8.3 8.3 0 0 1 3.38.675l-.447 1.247a7.6 7.6 0 0 0-2.966-.641 2.88 2.88 0 0 0-1.779.489 1.61 1.61 0 0 0-.64 1.357 2.1 2.1 0 0 0 .236 1.049 2.2 2.2 0 0 0 .8.75 10 10 0 0 0 1.715.754 6.8 6.8 0 0 1 2.667 1.483 2.92 2.92 0 0 1 .723 2.057",className:"logo_small_svg__cls-2","data-name":"Path 2957"})),P||(P=n.createElement("path",{id:"logo_small_svg__Path_2958",d:"M134.447 101.686v5.991a2.4 2.4 0 0 0 .515 1.686 2.1 2.1 0 0 0 1.609.556 2.63 2.63 0 0 0 2.12-.792 4 4 0 0 0 .67-2.587v-4.854h1.4v9.236H139.6l-.2-1.239h-.075a2.8 2.8 0 0 1-1.193 1.045 4 4 0 0 1-1.74.362 3.53 3.53 0 0 1-2.524-.8 3.4 3.4 0 0 1-.839-2.562v-6.042Z",className:"logo_small_svg__cls-2","data-name":"Path 2958"})),D||(D=n.createElement("path",{id:"logo_small_svg__Path_2959",d:"M148.206 111.09a4 4 0 0 1-1.647-.333 3.1 3.1 0 0 1-1.252-1.023h-.1a12 12 0 0 1 .1 1.533v3.8h-1.4v-13.381h1.137l.194 1.264h.067a3.26 3.26 0 0 1 1.256-1.1 3.8 3.8 0 0 1 1.643-.337 3.41 3.41 0 0 1 2.836 1.256 6.68 6.68 0 0 1-.017 7.057 3.42 3.42 0 0 1-2.817 1.264m-.2-8.385a2.48 2.48 0 0 0-2.048.784 4.04 4.04 0 0 0-.649 2.494v.312a4.63 4.63 0 0 0 .649 2.785 2.47 2.47 0 0 0 2.082.839 2.16 2.16 0 0 0 1.875-.969 4.6 4.6 0 0 0 .678-2.671 4.43 4.43 0 0 0-.678-2.651 2.23 2.23 0 0 0-1.915-.923Z",className:"logo_small_svg__cls-2","data-name":"Path 2959"})),U||(U=n.createElement("path",{id:"logo_small_svg__Path_2960",d:"M159.039 111.09a4 4 0 0 1-1.647-.333 3.1 3.1 0 0 1-1.252-1.023h-.1a12 12 0 0 1 .1 1.533v3.8h-1.4v-13.381h1.137l.194 1.264h.067a3.26 3.26 0 0 1 1.256-1.1 3.8 3.8 0 0 1 1.643-.337 3.41 3.41 0 0 1 2.836 1.256 6.68 6.68 0 0 1-.017 7.057 3.42 3.42 0 0 1-2.817 1.264m-.2-8.385a2.48 2.48 0 0 0-2.048.784 4.04 4.04 0 0 0-.649 2.494v.312a4.63 4.63 0 0 0 .649 2.785 2.47 2.47 0 0 0 2.082.839 2.16 2.16 0 0 0 1.875-.969 4.6 4.6 0 0 0 .678-2.671 4.43 4.43 0 0 0-.678-2.651 2.23 2.23 0 0 0-1.911-.923Z",className:"logo_small_svg__cls-2","data-name":"Path 2960"})),W||(W=n.createElement("path",{id:"logo_small_svg__Path_2961",d:"M173.612 106.3a5.1 5.1 0 0 1-1.137 3.527 4 4 0 0 1-3.143 1.268 4.17 4.17 0 0 1-2.2-.581 3.84 3.84 0 0 1-1.483-1.669 5.8 5.8 0 0 1-.522-2.545 5.1 5.1 0 0 1 1.129-3.518 4 4 0 0 1 3.135-1.26 3.9 3.9 0 0 1 3.08 1.29 5.07 5.07 0 0 1 1.141 3.488m-7.036 0a4.4 4.4 0 0 0 .708 2.7 2.81 2.81 0 0 0 4.167 0 4.37 4.37 0 0 0 .712-2.7 4.3 4.3 0 0 0-.712-2.675 2.5 2.5 0 0 0-2.1-.915 2.46 2.46 0 0 0-2.072.9 4.33 4.33 0 0 0-.7 2.69Z",className:"logo_small_svg__cls-2","data-name":"Path 2961"})),K||(K=n.createElement("path",{id:"logo_small_svg__Path_2962",d:"M180.525 101.517a5.5 5.5 0 0 1 1.1.1l-.194 1.3a4.8 4.8 0 0 0-1.011-.127 2.46 2.46 0 0 0-1.917.911 3.32 3.32 0 0 0-.8 2.267v4.955h-1.4v-9.236h1.154l.16 1.71h.068a4.05 4.05 0 0 1 1.238-1.39 2.8 2.8 0 0 1 1.6-.49Z",className:"logo_small_svg__cls-2","data-name":"Path 2962"})),V||(V=n.createElement("path",{id:"logo_small_svg__Path_2963",d:"M187.363 109.936a4.5 4.5 0 0 0 .716-.055 4 4 0 0 0 .548-.114v1.07a2.5 2.5 0 0 1-.67.181 5 5 0 0 1-.8.072q-2.68 0-2.68-2.823v-5.494h-1.323v-.673l1.323-.582.59-1.972h.809v2.141h2.68v1.087h-2.68v5.435a1.87 1.87 0 0 0 .4 1.281 1.38 1.38 0 0 0 1.087.446",className:"logo_small_svg__cls-2","data-name":"Path 2963"})),$||($=n.createElement("path",{id:"logo_small_svg__Path_2964",d:"M194.538 111.09a4.24 4.24 0 0 1-3.231-1.247 4.82 4.82 0 0 1-1.184-3.463 5.36 5.36 0 0 1 1.1-3.548 3.65 3.65 0 0 1 2.954-1.315 3.48 3.48 0 0 1 2.747 1.142 4.38 4.38 0 0 1 1.011 3.013v.885h-6.362a3.66 3.66 0 0 0 .822 2.469 2.84 2.84 0 0 0 2.2.843 7.4 7.4 0 0 0 2.949-.624v1.247a7.4 7.4 0 0 1-1.4.459 8 8 0 0 1-1.6.139Zm-.379-8.4a2.29 2.29 0 0 0-1.774.725 3.34 3.34 0 0 0-.779 2.006h4.828a3.07 3.07 0 0 0-.59-2.027 2.08 2.08 0 0 0-1.685-.706Z",className:"logo_small_svg__cls-2","data-name":"Path 2964"})),H||(H=n.createElement("path",{id:"logo_small_svg__Path_2965",d:"M206.951 109.683h-.076a3.29 3.29 0 0 1-2.9 1.407 3.43 3.43 0 0 1-2.819-1.239 5.45 5.45 0 0 1-1.006-3.522 5.54 5.54 0 0 1 1.011-3.548 3.4 3.4 0 0 1 2.814-1.264 3.36 3.36 0 0 1 2.883 1.365h.109l-.059-.665-.034-.649v-3.759h1.4v13.113h-1.138Zm-2.8.236a2.55 2.55 0 0 0 2.078-.779 3.95 3.95 0 0 0 .644-2.516v-.3a4.64 4.64 0 0 0-.653-2.8 2.48 2.48 0 0 0-2.086-.839 2.14 2.14 0 0 0-1.883.957 4.76 4.76 0 0 0-.653 2.7 4.55 4.55 0 0 0 .649 2.671 2.2 2.2 0 0 0 1.906.906Z",className:"logo_small_svg__cls-2","data-name":"Path 2965"})),Y||(Y=n.createElement("path",{id:"logo_small_svg__Path_2966",d:"M220.712 101.534a3.44 3.44 0 0 1 2.827 1.243 6.65 6.65 0 0 1-.009 7.053 3.42 3.42 0 0 1-2.818 1.26 4 4 0 0 1-1.648-.333 3.1 3.1 0 0 1-1.251-1.023h-.1l-.295 1.188h-1V97.809h1.4V101q0 1.069-.068 1.921h.068a3.32 3.32 0 0 1 2.894-1.387m-.2 1.171a2.44 2.44 0 0 0-2.064.822 6.34 6.34 0 0 0 .017 5.553 2.46 2.46 0 0 0 2.081.839 2.16 2.16 0 0 0 1.922-.94 4.83 4.83 0 0 0 .632-2.7 4.64 4.64 0 0 0-.632-2.689 2.24 2.24 0 0 0-1.959-.885Z",className:"logo_small_svg__cls-2","data-name":"Path 2966"})),Z||(Z=n.createElement("path",{id:"logo_small_svg__Path_2967",d:"M225.758 101.686h1.5l2.023 5.267a20 20 0 0 1 .826 2.6h.067q.109-.431.459-1.471t2.288-6.4h1.5l-3.969 10.518a5.25 5.25 0 0 1-1.378 2.212 2.93 2.93 0 0 1-1.934.653 5.7 5.7 0 0 1-1.264-.143V113.8a5 5 0 0 0 1.037.1 2.136 2.136 0 0 0 2.056-1.618l.514-1.314Z",className:"logo_small_svg__cls-2","data-name":"Path 2967"}))))),components_Logo=()=>n.createElement(logo_small,{height:"40"}),top_bar=()=>({components:{Topbar:u,Logo:components_Logo}});function isNothing(e){return null==e}var J={isNothing,isObject:function js_yaml_isObject(e){return"object"==typeof e&&null!==e},toArray:function toArray(e){return Array.isArray(e)?e:isNothing(e)?[]:[e]},repeat:function repeat(e,t){var r,n="";for(r=0;rs&&(t=n-s+(o=" ... ").length),r-n>s&&(r=n+s-(a=" ...").length),{str:o+e.slice(t,r).replace(/\t/g,"→")+a,pos:n-t+o.length}}function padStart(e,t){return J.repeat(" ",t-e.length)+e}var te=function makeSnippet(e,t){if(t=Object.create(t||null),!e.buffer)return null;t.maxLength||(t.maxLength=79),"number"!=typeof t.indent&&(t.indent=1),"number"!=typeof t.linesBefore&&(t.linesBefore=3),"number"!=typeof t.linesAfter&&(t.linesAfter=2);for(var r,n=/\r?\n|\r|\0/g,i=[0],o=[],a=-1;r=n.exec(e.buffer);)o.push(r.index),i.push(r.index+r[0].length),e.position<=r.index&&a<0&&(a=i.length-2);a<0&&(a=i.length-1);var s,u,c="",f=Math.min(e.line+t.linesAfter,o.length).toString().length,l=t.maxLength-(t.indent+f+3);for(s=1;s<=t.linesBefore&&!(a-s<0);s++)u=getLine(e.buffer,i[a-s],o[a-s],e.position-(i[a]-i[a-s]),l),c=J.repeat(" ",t.indent)+padStart((e.line-s+1).toString(),f)+" | "+u.str+"\n"+c;for(u=getLine(e.buffer,i[a],o[a],e.position,l),c+=J.repeat(" ",t.indent)+padStart((e.line+1).toString(),f)+" | "+u.str+"\n",c+=J.repeat("-",t.indent+f+3+u.pos)+"^\n",s=1;s<=t.linesAfter&&!(a+s>=o.length);s++)u=getLine(e.buffer,i[a+s],o[a+s],e.position-(i[a]-i[a+s]),l),c+=J.repeat(" ",t.indent)+padStart((e.line+s+1).toString(),f)+" | "+u.str+"\n";return c.replace(/\n$/,"")},re=["kind","multi","resolve","construct","instanceOf","predicate","represent","representName","defaultStyle","styleAliases"],ne=["scalar","sequence","mapping"];var ie=function Type$1(e,t){if(t=t||{},Object.keys(t).forEach((function(t){if(-1===re.indexOf(t))throw new ee('Unknown option "'+t+'" is met in definition of "'+e+'" YAML type.')})),this.options=t,this.tag=e,this.kind=t.kind||null,this.resolve=t.resolve||function(){return!0},this.construct=t.construct||function(e){return e},this.instanceOf=t.instanceOf||null,this.predicate=t.predicate||null,this.represent=t.represent||null,this.representName=t.representName||null,this.defaultStyle=t.defaultStyle||null,this.multi=t.multi||!1,this.styleAliases=function compileStyleAliases(e){var t={};return null!==e&&Object.keys(e).forEach((function(r){e[r].forEach((function(e){t[String(e)]=r}))})),t}(t.styleAliases||null),-1===ne.indexOf(this.kind))throw new ee('Unknown kind "'+this.kind+'" is specified for "'+e+'" YAML type.')};function compileList(e,t){var r=[];return e[t].forEach((function(e){var t=r.length;r.forEach((function(r,n){r.tag===e.tag&&r.kind===e.kind&&r.multi===e.multi&&(t=n)})),r[t]=e})),r}function Schema$1(e){return this.extend(e)}Schema$1.prototype.extend=function extend(e){var t=[],r=[];if(e instanceof ie)r.push(e);else if(Array.isArray(e))r=r.concat(e);else{if(!e||!Array.isArray(e.implicit)&&!Array.isArray(e.explicit))throw new ee("Schema.extend argument should be a Type, [ Type ], or a schema definition ({ implicit: [...], explicit: [...] })");e.implicit&&(t=t.concat(e.implicit)),e.explicit&&(r=r.concat(e.explicit))}t.forEach((function(e){if(!(e instanceof ie))throw new ee("Specified list of YAML types (or a single Type object) contains a non-Type object.");if(e.loadKind&&"scalar"!==e.loadKind)throw new ee("There is a non-scalar type in the implicit list of a schema. Implicit resolving of such types is not supported.");if(e.multi)throw new ee("There is a multi type in the implicit list of a schema. Multi tags can only be listed as explicit.")})),r.forEach((function(e){if(!(e instanceof ie))throw new ee("Specified list of YAML types (or a single Type object) contains a non-Type object.")}));var n=Object.create(Schema$1.prototype);return n.implicit=(this.implicit||[]).concat(t),n.explicit=(this.explicit||[]).concat(r),n.compiledImplicit=compileList(n,"implicit"),n.compiledExplicit=compileList(n,"explicit"),n.compiledTypeMap=function compileMap(){var e,t,r={scalar:{},sequence:{},mapping:{},fallback:{},multi:{scalar:[],sequence:[],mapping:[],fallback:[]}};function collectType(e){e.multi?(r.multi[e.kind].push(e),r.multi.fallback.push(e)):r[e.kind][e.tag]=r.fallback[e.tag]=e}for(e=0,t=arguments.length;e=0?"0b"+e.toString(2):"-0b"+e.toString(2).slice(1)},octal:function(e){return e>=0?"0o"+e.toString(8):"-0o"+e.toString(8).slice(1)},decimal:function(e){return e.toString(10)},hexadecimal:function(e){return e>=0?"0x"+e.toString(16).toUpperCase():"-0x"+e.toString(16).toUpperCase().slice(1)}},defaultStyle:"decimal",styleAliases:{binary:[2,"bin"],octal:[8,"oct"],decimal:[10,"dec"],hexadecimal:[16,"hex"]}}),pe=new RegExp("^(?:[-+]?(?:[0-9][0-9_]*)(?:\\.[0-9_]*)?(?:[eE][-+]?[0-9]+)?|\\.[0-9_]+(?:[eE][-+]?[0-9]+)?|[-+]?\\.(?:inf|Inf|INF)|\\.(?:nan|NaN|NAN))$");var de=/^[-+]?[0-9]+e/;var _e=new ie("tag:yaml.org,2002:float",{kind:"scalar",resolve:function resolveYamlFloat(e){return null!==e&&!(!pe.test(e)||"_"===e[e.length-1])},construct:function constructYamlFloat(e){var t,r;return r="-"===(t=e.replace(/_/g,"").toLowerCase())[0]?-1:1,"+-".indexOf(t[0])>=0&&(t=t.slice(1)),".inf"===t?1===r?Number.POSITIVE_INFINITY:Number.NEGATIVE_INFINITY:".nan"===t?NaN:r*parseFloat(t,10)},predicate:function isFloat(e){return"[object Number]"===Object.prototype.toString.call(e)&&(e%1!=0||J.isNegativeZero(e))},represent:function representYamlFloat(e,t){var r;if(isNaN(e))switch(t){case"lowercase":return".nan";case"uppercase":return".NAN";case"camelcase":return".NaN"}else if(Number.POSITIVE_INFINITY===e)switch(t){case"lowercase":return".inf";case"uppercase":return".INF";case"camelcase":return".Inf"}else if(Number.NEGATIVE_INFINITY===e)switch(t){case"lowercase":return"-.inf";case"uppercase":return"-.INF";case"camelcase":return"-.Inf"}else if(J.isNegativeZero(e))return"-0.0";return r=e.toString(10),de.test(r)?r.replace("e",".e"):r},defaultStyle:"lowercase"}),ye=ce.extend({implicit:[fe,le,he,_e]}),me=ye,ge=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9])-([0-9][0-9])$"),ve=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9]?)-([0-9][0-9]?)(?:[Tt]|[ \\t]+)([0-9][0-9]?):([0-9][0-9]):([0-9][0-9])(?:\\.([0-9]*))?(?:[ \\t]*(Z|([-+])([0-9][0-9]?)(?::([0-9][0-9]))?))?$");var be=new ie("tag:yaml.org,2002:timestamp",{kind:"scalar",resolve:function resolveYamlTimestamp(e){return null!==e&&(null!==ge.exec(e)||null!==ve.exec(e))},construct:function constructYamlTimestamp(e){var t,r,n,i,o,a,s,u,c=0,f=null;if(null===(t=ge.exec(e))&&(t=ve.exec(e)),null===t)throw new Error("Date resolve error");if(r=+t[1],n=+t[2]-1,i=+t[3],!t[4])return new Date(Date.UTC(r,n,i));if(o=+t[4],a=+t[5],s=+t[6],t[7]){for(c=t[7].slice(0,3);c.length<3;)c+="0";c=+c}return t[9]&&(f=6e4*(60*+t[10]+ +(t[11]||0)),"-"===t[9]&&(f=-f)),u=new Date(Date.UTC(r,n,i,o,a,s,c)),f&&u.setTime(u.getTime()-f),u},instanceOf:Date,represent:function representYamlTimestamp(e){return e.toISOString()}});var Se=new ie("tag:yaml.org,2002:merge",{kind:"scalar",resolve:function resolveYamlMerge(e){return"<<"===e||null===e}}),we="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=\n\r";var Ie=new ie("tag:yaml.org,2002:binary",{kind:"scalar",resolve:function resolveYamlBinary(e){if(null===e)return!1;var t,r,n=0,i=e.length,o=we;for(r=0;r64)){if(t<0)return!1;n+=6}return n%8==0},construct:function constructYamlBinary(e){var t,r,n=e.replace(/[\r\n=]/g,""),i=n.length,o=we,a=0,s=[];for(t=0;t>16&255),s.push(a>>8&255),s.push(255&a)),a=a<<6|o.indexOf(n.charAt(t));return 0===(r=i%4*6)?(s.push(a>>16&255),s.push(a>>8&255),s.push(255&a)):18===r?(s.push(a>>10&255),s.push(a>>2&255)):12===r&&s.push(a>>4&255),new Uint8Array(s)},predicate:function isBinary(e){return"[object Uint8Array]"===Object.prototype.toString.call(e)},represent:function representYamlBinary(e){var t,r,n="",i=0,o=e.length,a=we;for(t=0;t>18&63],n+=a[i>>12&63],n+=a[i>>6&63],n+=a[63&i]),i=(i<<8)+e[t];return 0===(r=o%3)?(n+=a[i>>18&63],n+=a[i>>12&63],n+=a[i>>6&63],n+=a[63&i]):2===r?(n+=a[i>>10&63],n+=a[i>>4&63],n+=a[i<<2&63],n+=a[64]):1===r&&(n+=a[i>>2&63],n+=a[i<<4&63],n+=a[64],n+=a[64]),n}}),xe=Object.prototype.hasOwnProperty,Ee=Object.prototype.toString;var Oe=new ie("tag:yaml.org,2002:omap",{kind:"sequence",resolve:function resolveYamlOmap(e){if(null===e)return!0;var t,r,n,i,o,a=[],s=e;for(t=0,r=s.length;t>10),56320+(e-65536&1023))}for(var ze=new Array(256),Pe=new Array(256),Fe=0;Fe<256;Fe++)ze[Fe]=simpleEscapeSequence(Fe)?1:0,Pe[Fe]=simpleEscapeSequence(Fe);function State$1(e,t){this.input=e,this.filename=t.filename||null,this.schema=t.schema||Me,this.onWarning=t.onWarning||null,this.legacy=t.legacy||!1,this.json=t.json||!1,this.listener=t.listener||null,this.implicitTypes=this.schema.compiledImplicit,this.typeMap=this.schema.compiledTypeMap,this.length=e.length,this.position=0,this.line=0,this.lineStart=0,this.lineIndent=0,this.firstTabInLine=-1,this.documents=[]}function generateError(e,t){var r={name:e.filename,buffer:e.input.slice(0,-1),position:e.position,line:e.line,column:e.position-e.lineStart};return r.snippet=te(r),new ee(t,r)}function throwError(e,t){throw generateError(e,t)}function throwWarning(e,t){e.onWarning&&e.onWarning.call(null,generateError(e,t))}var De={YAML:function handleYamlDirective(e,t,r){var n,i,o;null!==e.version&&throwError(e,"duplication of %YAML directive"),1!==r.length&&throwError(e,"YAML directive accepts exactly one argument"),null===(n=/^([0-9]+)\.([0-9]+)$/.exec(r[0]))&&throwError(e,"ill-formed argument of the YAML directive"),i=parseInt(n[1],10),o=parseInt(n[2],10),1!==i&&throwError(e,"unacceptable YAML version of the document"),e.version=r[0],e.checkLineBreaks=o<2,1!==o&&2!==o&&throwWarning(e,"unsupported YAML version of the document")},TAG:function handleTagDirective(e,t,r){var n,i;2!==r.length&&throwError(e,"TAG directive accepts exactly two arguments"),n=r[0],i=r[1],Te.test(n)||throwError(e,"ill-formed tag handle (first argument) of the TAG directive"),qe.call(e.tagMap,n)&&throwError(e,'there is a previously declared suffix for "'+n+'" tag handle'),Re.test(i)||throwError(e,"ill-formed tag prefix (second argument) of the TAG directive");try{i=decodeURIComponent(i)}catch(t){throwError(e,"tag prefix is malformed: "+i)}e.tagMap[n]=i}};function captureSegment(e,t,r,n){var i,o,a,s;if(t1&&(e.result+=J.repeat("\n",t-1))}function readBlockSequence(e,t){var r,n,i=e.tag,o=e.anchor,a=[],s=!1;if(-1!==e.firstTabInLine)return!1;for(null!==e.anchor&&(e.anchorMap[e.anchor]=a),n=e.input.charCodeAt(e.position);0!==n&&(-1!==e.firstTabInLine&&(e.position=e.firstTabInLine,throwError(e,"tab characters must not be used in indentation")),45===n)&&is_WS_OR_EOL(e.input.charCodeAt(e.position+1));)if(s=!0,e.position++,skipSeparationSpace(e,!0,-1)&&e.lineIndent<=t)a.push(null),n=e.input.charCodeAt(e.position);else if(r=e.line,composeNode(e,t,3,!1,!0),a.push(e.result),skipSeparationSpace(e,!0,-1),n=e.input.charCodeAt(e.position),(e.line===r||e.lineIndent>t)&&0!==n)throwError(e,"bad indentation of a sequence entry");else if(e.lineIndentt?d=1:e.lineIndent===t?d=0:e.lineIndentt?d=1:e.lineIndent===t?d=0:e.lineIndentt)&&(m&&(a=e.line,s=e.lineStart,u=e.position),composeNode(e,t,4,!0,i)&&(m?_=e.result:y=e.result),m||(storeMappingPair(e,h,p,d,_,y,a,s,u),d=_=y=null),skipSeparationSpace(e,!0,-1),c=e.input.charCodeAt(e.position)),(e.line===o||e.lineIndent>t)&&0!==c)throwError(e,"bad indentation of a mapping entry");else if(e.lineIndent=0))break;0===i?throwError(e,"bad explicit indentation width of a block scalar; it cannot be less than one"):c?throwError(e,"repeat of an indentation width identifier"):(f=t+i-1,c=!0)}if(is_WHITE_SPACE(o)){do{o=e.input.charCodeAt(++e.position)}while(is_WHITE_SPACE(o));if(35===o)do{o=e.input.charCodeAt(++e.position)}while(!is_EOL(o)&&0!==o)}for(;0!==o;){for(readLineBreak(e),e.lineIndent=0,o=e.input.charCodeAt(e.position);(!c||e.lineIndentf&&(f=e.lineIndent),is_EOL(o))l++;else{if(e.lineIndent0){for(i=a,o=0;i>0;i--)(a=fromHexCode(s=e.input.charCodeAt(++e.position)))>=0?o=(o<<4)+a:throwError(e,"expected hexadecimal character");e.result+=charFromCodepoint(o),e.position++}else throwError(e,"unknown escape sequence");r=n=e.position}else is_EOL(s)?(captureSegment(e,r,n,!0),writeFoldedLines(e,skipSeparationSpace(e,!1,t)),r=n=e.position):e.position===e.lineStart&&testDocumentSeparator(e)?throwError(e,"unexpected end of the document within a double quoted scalar"):(e.position++,n=e.position)}throwError(e,"unexpected end of the stream within a double quoted scalar")}(e,h)?y=!0:!function readAlias(e){var t,r,n;if(42!==(n=e.input.charCodeAt(e.position)))return!1;for(n=e.input.charCodeAt(++e.position),t=e.position;0!==n&&!is_WS_OR_EOL(n)&&!is_FLOW_INDICATOR(n);)n=e.input.charCodeAt(++e.position);return e.position===t&&throwError(e,"name of an alias node must contain at least one character"),r=e.input.slice(t,e.position),qe.call(e.anchorMap,r)||throwError(e,'unidentified alias "'+r+'"'),e.result=e.anchorMap[r],skipSeparationSpace(e,!0,-1),!0}(e)?function readPlainScalar(e,t,r){var n,i,o,a,s,u,c,f,l=e.kind,h=e.result;if(is_WS_OR_EOL(f=e.input.charCodeAt(e.position))||is_FLOW_INDICATOR(f)||35===f||38===f||42===f||33===f||124===f||62===f||39===f||34===f||37===f||64===f||96===f)return!1;if((63===f||45===f)&&(is_WS_OR_EOL(n=e.input.charCodeAt(e.position+1))||r&&is_FLOW_INDICATOR(n)))return!1;for(e.kind="scalar",e.result="",i=o=e.position,a=!1;0!==f;){if(58===f){if(is_WS_OR_EOL(n=e.input.charCodeAt(e.position+1))||r&&is_FLOW_INDICATOR(n))break}else if(35===f){if(is_WS_OR_EOL(e.input.charCodeAt(e.position-1)))break}else{if(e.position===e.lineStart&&testDocumentSeparator(e)||r&&is_FLOW_INDICATOR(f))break;if(is_EOL(f)){if(s=e.line,u=e.lineStart,c=e.lineIndent,skipSeparationSpace(e,!1,-1),e.lineIndent>=t){a=!0,f=e.input.charCodeAt(e.position);continue}e.position=o,e.line=s,e.lineStart=u,e.lineIndent=c;break}}a&&(captureSegment(e,i,o,!1),writeFoldedLines(e,e.line-s),i=o=e.position,a=!1),is_WHITE_SPACE(f)||(o=e.position+1),f=e.input.charCodeAt(++e.position)}return captureSegment(e,i,o,!1),!!e.result||(e.kind=l,e.result=h,!1)}(e,h,1===r)&&(y=!0,null===e.tag&&(e.tag="?")):(y=!0,null===e.tag&&null===e.anchor||throwError(e,"alias node should not have any properties")),null!==e.anchor&&(e.anchorMap[e.anchor]=e.result)):0===d&&(y=s&&readBlockSequence(e,p))),null===e.tag)null!==e.anchor&&(e.anchorMap[e.anchor]=e.result);else if("?"===e.tag){for(null!==e.result&&"scalar"!==e.kind&&throwError(e,'unacceptable node kind for ! tag; it should be "scalar", not "'+e.kind+'"'),u=0,c=e.implicitTypes.length;u"),null!==e.result&&l.kind!==e.kind&&throwError(e,"unacceptable node kind for !<"+e.tag+'> tag; it should be "'+l.kind+'", not "'+e.kind+'"'),l.resolve(e.result,e.tag)?(e.result=l.construct(e.result,e.tag),null!==e.anchor&&(e.anchorMap[e.anchor]=e.result)):throwError(e,"cannot resolve a node with !<"+e.tag+"> explicit tag")}return null!==e.listener&&e.listener("close",e),null!==e.tag||null!==e.anchor||y}function readDocument(e){var t,r,n,i,o=e.position,a=!1;for(e.version=null,e.checkLineBreaks=e.legacy,e.tagMap=Object.create(null),e.anchorMap=Object.create(null);0!==(i=e.input.charCodeAt(e.position))&&(skipSeparationSpace(e,!0,-1),i=e.input.charCodeAt(e.position),!(e.lineIndent>0||37!==i));){for(a=!0,i=e.input.charCodeAt(++e.position),t=e.position;0!==i&&!is_WS_OR_EOL(i);)i=e.input.charCodeAt(++e.position);for(n=[],(r=e.input.slice(t,e.position)).length<1&&throwError(e,"directive name must not be less than one character in length");0!==i;){for(;is_WHITE_SPACE(i);)i=e.input.charCodeAt(++e.position);if(35===i){do{i=e.input.charCodeAt(++e.position)}while(0!==i&&!is_EOL(i));break}if(is_EOL(i))break;for(t=e.position;0!==i&&!is_WS_OR_EOL(i);)i=e.input.charCodeAt(++e.position);n.push(e.input.slice(t,e.position))}0!==i&&readLineBreak(e),qe.call(De,r)?De[r](e,r,n):throwWarning(e,'unknown document directive "'+r+'"')}skipSeparationSpace(e,!0,-1),0===e.lineIndent&&45===e.input.charCodeAt(e.position)&&45===e.input.charCodeAt(e.position+1)&&45===e.input.charCodeAt(e.position+2)?(e.position+=3,skipSeparationSpace(e,!0,-1)):a&&throwError(e,"directives end mark is expected"),composeNode(e,e.lineIndent-1,4,!1,!0),skipSeparationSpace(e,!0,-1),e.checkLineBreaks&&Ne.test(e.input.slice(o,e.position))&&throwWarning(e,"non-ASCII line breaks are interpreted as content"),e.documents.push(e.result),e.position===e.lineStart&&testDocumentSeparator(e)?46===e.input.charCodeAt(e.position)&&(e.position+=3,skipSeparationSpace(e,!0,-1)):e.position=55296&&n<=56319&&t+1=56320&&r<=57343?1024*(n-55296)+r-56320+65536:n}function needIndentIndicator(e){return/^\n* /.test(e)}function chooseScalarStyle(e,t,r,n,i,o,a,s){var u,c=0,f=null,l=!1,h=!1,p=-1!==n,d=-1,_=function isPlainSafeFirst(e){return isPrintable(e)&&e!==Ve&&!isWhitespace(e)&&45!==e&&63!==e&&58!==e&&44!==e&&91!==e&&93!==e&&123!==e&&125!==e&&35!==e&&38!==e&&42!==e&&33!==e&&124!==e&&61!==e&&62!==e&&39!==e&&34!==e&&37!==e&&64!==e&&96!==e}(codePointAt(e,0))&&function isPlainSafeLast(e){return!isWhitespace(e)&&58!==e}(codePointAt(e,e.length-1));if(t||a)for(u=0;u=65536?u+=2:u++){if(!isPrintable(c=codePointAt(e,u)))return 5;_=_&&isPlainSafe(c,f,s),f=c}else{for(u=0;u=65536?u+=2:u++){if(10===(c=codePointAt(e,u)))l=!0,p&&(h=h||u-d-1>n&&" "!==e[d+1],d=u);else if(!isPrintable(c))return 5;_=_&&isPlainSafe(c,f,s),f=c}h=h||p&&u-d-1>n&&" "!==e[d+1]}return l||h?r>9&&needIndentIndicator(e)?5:a?2===o?5:2:h?4:3:!_||a||i(e)?2===o?5:2:1}function writeScalar(e,t,r,n,i){e.dump=function(){if(0===t.length)return 2===e.quotingType?'""':"''";if(!e.noCompatMode&&(-1!==He.indexOf(t)||Ye.test(t)))return 2===e.quotingType?'"'+t+'"':"'"+t+"'";var o=e.indent*Math.max(1,r),a=-1===e.lineWidth?-1:Math.max(Math.min(e.lineWidth,40),e.lineWidth-o),s=n||e.flowLevel>-1&&r>=e.flowLevel;switch(chooseScalarStyle(t,s,e.indent,a,(function testAmbiguity(t){return function testImplicitResolving(e,t){var r,n;for(r=0,n=e.implicitTypes.length;r"+blockHeader(t,e.indent)+dropEndingNewline(indentString(function foldString(e,t){var r,n,i=/(\n+)([^\n]*)/g,o=(s=e.indexOf("\n"),s=-1!==s?s:e.length,i.lastIndex=s,foldLine(e.slice(0,s),t)),a="\n"===e[0]||" "===e[0];var s;for(;n=i.exec(e);){var u=n[1],c=n[2];r=" "===c[0],o+=u+(a||r||""===c?"":"\n")+foldLine(c,t),a=r}return o}(t,a),o));case 5:return'"'+function escapeString(e){for(var t,r="",n=0,i=0;i=65536?i+=2:i++)n=codePointAt(e,i),!(t=$e[n])&&isPrintable(n)?(r+=e[i],n>=65536&&(r+=e[i+1])):r+=t||encodeHex(n);return r}(t)+'"';default:throw new ee("impossible error: invalid scalar style")}}()}function blockHeader(e,t){var r=needIndentIndicator(e)?String(t):"",n="\n"===e[e.length-1];return r+(n&&("\n"===e[e.length-2]||"\n"===e)?"+":n?"":"-")+"\n"}function dropEndingNewline(e){return"\n"===e[e.length-1]?e.slice(0,-1):e}function foldLine(e,t){if(""===e||" "===e[0])return e;for(var r,n,i=/ [^ ]/g,o=0,a=0,s=0,u="";r=i.exec(e);)(s=r.index)-o>t&&(n=a>o?a:s,u+="\n"+e.slice(o,n),o=n+1),a=s;return u+="\n",e.length-o>t&&a>o?u+=e.slice(o,a)+"\n"+e.slice(a+1):u+=e.slice(o),u.slice(1)}function writeBlockSequence(e,t,r,n){var i,o,a,s="",u=e.tag;for(i=0,o=r.length;i tag resolver accepts not "'+u+'" style');n=s.represent[u](t,u)}e.dump=n}return!0}return!1}function writeNode(e,t,r,n,i,o,a){e.tag=null,e.dump=r,detectType(e,r,!1)||detectType(e,r,!0);var s,u=We.call(e.dump),c=n;n&&(n=e.flowLevel<0||e.flowLevel>t);var f,l,h="[object Object]"===u||"[object Array]"===u;if(h&&(l=-1!==(f=e.duplicates.indexOf(r))),(null!==e.tag&&"?"!==e.tag||l||2!==e.indent&&t>0)&&(i=!1),l&&e.usedDuplicates[f])e.dump="*ref_"+f;else{if(h&&l&&!e.usedDuplicates[f]&&(e.usedDuplicates[f]=!0),"[object Object]"===u)n&&0!==Object.keys(e.dump).length?(!function writeBlockMapping(e,t,r,n){var i,o,a,s,u,c,f="",l=e.tag,h=Object.keys(r);if(!0===e.sortKeys)h.sort();else if("function"==typeof e.sortKeys)h.sort(e.sortKeys);else if(e.sortKeys)throw new ee("sortKeys must be a boolean or a function");for(i=0,o=h.length;i1024)&&(e.dump&&10===e.dump.charCodeAt(0)?c+="?":c+="? "),c+=e.dump,u&&(c+=generateNextLine(e,t)),writeNode(e,t+1,s,!0,u)&&(e.dump&&10===e.dump.charCodeAt(0)?c+=":":c+=": ",f+=c+=e.dump));e.tag=l,e.dump=f||"{}"}(e,t,e.dump,i),l&&(e.dump="&ref_"+f+e.dump)):(!function writeFlowMapping(e,t,r){var n,i,o,a,s,u="",c=e.tag,f=Object.keys(r);for(n=0,i=f.length;n1024&&(s+="? "),s+=e.dump+(e.condenseFlow?'"':"")+":"+(e.condenseFlow?"":" "),writeNode(e,t,a,!1,!1)&&(u+=s+=e.dump));e.tag=c,e.dump="{"+u+"}"}(e,t,e.dump),l&&(e.dump="&ref_"+f+" "+e.dump));else if("[object Array]"===u)n&&0!==e.dump.length?(e.noArrayIndent&&!a&&t>0?writeBlockSequence(e,t-1,e.dump,i):writeBlockSequence(e,t,e.dump,i),l&&(e.dump="&ref_"+f+e.dump)):(!function writeFlowSequence(e,t,r){var n,i,o,a="",s=e.tag;for(n=0,i=r.length;n",e.dump=s+" "+e.dump)}return!0}function getDuplicateReferences(e,t){var r,n,i=[],o=[];for(inspectNode(e,i,o),r=0,n=o.length;r()=>{},downloadConfig=e=>t=>{const{fn:{fetch:r}}=t;return r(e)},getConfigByUrl=(e,t)=>r=>{const{specActions:n,configsActions:i}=r;if(e)return i.downloadConfig(e).then(next,next);function next(i){i instanceof Error||i.status>=400?(n.updateLoadingStatus("failedConfig"),n.updateLoadingStatus("failedConfig"),n.updateUrl(""),console.error(i.statusText+" "+e.url),t(null)):t(((e,t)=>{try{return Ze.load(e)}catch(e){return t&&t.errActions.newThrownErr(new Error(e)),{}}})(i.text,r))}},get=(e,t)=>e.getIn(Array.isArray(t)?t:[t]),Qe={[Ge]:(e,t)=>e.merge((0,o.fromJS)(t.payload)),[Je]:(e,t)=>{const r=t.payload,n=e.get(r);return e.set(r,!n)}};var Xe=__webpack_require__(7248),et=__webpack_require__.n(Xe),tt=__webpack_require__(7666),rt=__webpack_require__.n(tt);const nt=console.error,withErrorBoundary=e=>t=>{const{getComponent:r,fn:i}=e(),o=r("ErrorBoundary"),a=i.getDisplayName(t);class WithErrorBoundary extends n.Component{render(){return n.createElement(o,{targetName:a,getComponent:r,fn:i},n.createElement(t,rt()({},this.props,this.context)))}}var s;return WithErrorBoundary.displayName=`WithErrorBoundary(${a})`,(s=t).prototype&&s.prototype.isReactComponent&&(WithErrorBoundary.prototype.mapStateToProps=t.prototype.mapStateToProps),WithErrorBoundary},fallback=({name:e})=>n.createElement("div",{className:"fallback"},"😱 ",n.createElement("i",null,"Could not render ","t"===e?"this component":e,", see the console."));class ErrorBoundary extends n.Component{static defaultProps={targetName:"this component",getComponent:()=>fallback,fn:{componentDidCatch:nt},children:null};static getDerivedStateFromError(e){return{hasError:!0,error:e}}constructor(...e){super(...e),this.state={hasError:!1,error:null}}componentDidCatch(e,t){this.props.fn.componentDidCatch(e,t)}render(){const{getComponent:e,targetName:t,children:r}=this.props;if(this.state.hasError){const r=e("Fallback");return n.createElement(r,{name:t})}return r}}const it=ErrorBoundary,ot=[top_bar,function configsPlugin(){return{statePlugins:{configs:{reducers:Qe,actions:e,selectors:t}}}},stadalone_layout,(({componentList:e=[],fullOverride:t=!1}={})=>({getSystem:r})=>{const n=t?e:["App","BaseLayout","VersionPragmaFilter","InfoContainer","ServersContainer","SchemesContainer","AuthorizeBtnContainer","FilterContainer","Operations","OperationContainer","parameters","responses","OperationServers","Models","ModelWrapper",...e],i=et()(n,Array(n.length).fill(((e,{fn:t})=>t.withErrorBoundary(e))));return{fn:{componentDidCatch:nt,withErrorBoundary:withErrorBoundary(r)},components:{ErrorBoundary:it,Fallback:fallback},wrapComponents:i}})({fullOverride:!0,componentList:["Topbar","StandaloneLayout","onlineValidatorBadge"]})]})(),r=r.default})())); \ No newline at end of file diff --git a/web/vendor/swagger-ui/swagger-ui.css b/web/vendor/swagger-ui/swagger-ui.css new file mode 100644 index 0000000..27ffa53 --- /dev/null +++ b/web/vendor/swagger-ui/swagger-ui.css @@ -0,0 +1,3 @@ +.swagger-ui{color:#3b4151;font-family:sans-serif/*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */}.swagger-ui html{line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}.swagger-ui body{margin:0}.swagger-ui article,.swagger-ui aside,.swagger-ui footer,.swagger-ui header,.swagger-ui nav,.swagger-ui section{display:block}.swagger-ui h1{font-size:2em;margin:.67em 0}.swagger-ui figcaption,.swagger-ui figure,.swagger-ui main{display:block}.swagger-ui figure{margin:1em 40px}.swagger-ui hr{box-sizing:content-box;height:0;overflow:visible}.swagger-ui pre{font-family:monospace,monospace;font-size:1em}.swagger-ui a{background-color:transparent;-webkit-text-decoration-skip:objects}.swagger-ui abbr[title]{border-bottom:none;text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}.swagger-ui b,.swagger-ui strong{font-weight:inherit;font-weight:bolder}.swagger-ui code,.swagger-ui kbd,.swagger-ui samp{font-family:monospace,monospace;font-size:1em}.swagger-ui dfn{font-style:italic}.swagger-ui mark{background-color:#ff0;color:#000}.swagger-ui small{font-size:80%}.swagger-ui sub,.swagger-ui sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}.swagger-ui sub{bottom:-.25em}.swagger-ui sup{top:-.5em}.swagger-ui audio,.swagger-ui video{display:inline-block}.swagger-ui audio:not([controls]){display:none;height:0}.swagger-ui img{border-style:none}.swagger-ui svg:not(:root){overflow:hidden}.swagger-ui button,.swagger-ui input,.swagger-ui optgroup,.swagger-ui select,.swagger-ui textarea{font-family:sans-serif;font-size:100%;line-height:1.15;margin:0}.swagger-ui button,.swagger-ui input{overflow:visible}.swagger-ui button,.swagger-ui select{text-transform:none}.swagger-ui [type=reset],.swagger-ui [type=submit],.swagger-ui button,.swagger-ui html [type=button]{-webkit-appearance:button}.swagger-ui [type=button]::-moz-focus-inner,.swagger-ui [type=reset]::-moz-focus-inner,.swagger-ui [type=submit]::-moz-focus-inner,.swagger-ui button::-moz-focus-inner{border-style:none;padding:0}.swagger-ui [type=button]:-moz-focusring,.swagger-ui [type=reset]:-moz-focusring,.swagger-ui [type=submit]:-moz-focusring,.swagger-ui button:-moz-focusring{outline:1px dotted ButtonText}.swagger-ui fieldset{padding:.35em .75em .625em}.swagger-ui legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}.swagger-ui progress{display:inline-block;vertical-align:baseline}.swagger-ui textarea{overflow:auto}.swagger-ui [type=checkbox],.swagger-ui [type=radio]{box-sizing:border-box;padding:0}.swagger-ui [type=number]::-webkit-inner-spin-button,.swagger-ui [type=number]::-webkit-outer-spin-button{height:auto}.swagger-ui [type=search]{-webkit-appearance:textfield;outline-offset:-2px}.swagger-ui [type=search]::-webkit-search-cancel-button,.swagger-ui [type=search]::-webkit-search-decoration{-webkit-appearance:none}.swagger-ui ::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}.swagger-ui details,.swagger-ui menu{display:block}.swagger-ui summary{display:list-item}.swagger-ui canvas{display:inline-block}.swagger-ui [hidden],.swagger-ui template{display:none}.swagger-ui .debug *{outline:1px solid gold}.swagger-ui .debug-white *{outline:1px solid #fff}.swagger-ui .debug-black *{outline:1px solid #000}.swagger-ui .debug-grid{background:transparent url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6MTRDOTY4N0U2N0VFMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MTRDOTY4N0Q2N0VFMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3NjY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3NzY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PsBS+GMAAAAjSURBVHjaYvz//z8DLsD4gcGXiYEAGBIKGBne//fFpwAgwAB98AaF2pjlUQAAAABJRU5ErkJggg==) repeat 0 0}.swagger-ui .debug-grid-16{background:transparent url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6ODYyRjhERDU2N0YyMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6ODYyRjhERDQ2N0YyMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3QTY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3QjY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PvCS01IAAABMSURBVHjaYmR4/5+BFPBfAMFm/MBgx8RAGWCn1AAmSg34Q6kBDKMGMDCwICeMIemF/5QawEipAWwUhwEjMDvbAWlWkvVBwu8vQIABAEwBCph8U6c0AAAAAElFTkSuQmCC) repeat 0 0}.swagger-ui .debug-grid-8-solid{background:#fff url(data:image/jpeg;base64,/9j/4QAYRXhpZgAASUkqAAgAAAAAAAAAAAAAAP/sABFEdWNreQABAAQAAAAAAAD/4QMxaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLwA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/PiA8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJBZG9iZSBYTVAgQ29yZSA1LjYtYzExMSA3OS4xNTgzMjUsIDIwMTUvMDkvMTAtMDE6MTA6MjAgICAgICAgICI+IDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+IDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bXA6Q3JlYXRvclRvb2w9IkFkb2JlIFBob3Rvc2hvcCBDQyAyMDE1IChNYWNpbnRvc2gpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOkIxMjI0OTczNjdCMzExRTZCMkJDRTI0MDgxMDAyMTcxIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOkIxMjI0OTc0NjdCMzExRTZCMkJDRTI0MDgxMDAyMTcxIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6QjEyMjQ5NzE2N0IzMTFFNkIyQkNFMjQwODEwMDIxNzEiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6QjEyMjQ5NzI2N0IzMTFFNkIyQkNFMjQwODEwMDIxNzEiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz7/7gAOQWRvYmUAZMAAAAAB/9sAhAAbGhopHSlBJiZBQi8vL0JHPz4+P0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHAR0pKTQmND8oKD9HPzU/R0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0f/wAARCAAIAAgDASIAAhEBAxEB/8QAWQABAQAAAAAAAAAAAAAAAAAAAAYBAQEAAAAAAAAAAAAAAAAAAAIEEAEBAAMBAAAAAAAAAAAAAAABADECA0ERAAEDBQAAAAAAAAAAAAAAAAARITFBUWESIv/aAAwDAQACEQMRAD8AoOnTV1QTD7JJshP3vSM3P//Z) repeat 0 0}.swagger-ui .debug-grid-16-solid{background:#fff url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NzY3MkJEN0U2N0M1MTFFNkIyQkNFMjQwODEwMDIxNzEiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NzY3MkJEN0Y2N0M1MTFFNkIyQkNFMjQwODEwMDIxNzEiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3QzY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3RDY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/Pve6J3kAAAAzSURBVHjaYvz//z8D0UDsMwMjSRoYP5Gq4SPNbRjVMEQ1fCRDg+in/6+J1AJUxsgAEGAA31BAJMS0GYEAAAAASUVORK5CYII=) repeat 0 0}.swagger-ui .border-box,.swagger-ui a,.swagger-ui article,.swagger-ui body,.swagger-ui code,.swagger-ui dd,.swagger-ui div,.swagger-ui dl,.swagger-ui dt,.swagger-ui fieldset,.swagger-ui footer,.swagger-ui form,.swagger-ui h1,.swagger-ui h2,.swagger-ui h3,.swagger-ui h4,.swagger-ui h5,.swagger-ui h6,.swagger-ui header,.swagger-ui html,.swagger-ui input[type=email],.swagger-ui input[type=number],.swagger-ui input[type=password],.swagger-ui input[type=tel],.swagger-ui input[type=text],.swagger-ui input[type=url],.swagger-ui legend,.swagger-ui li,.swagger-ui main,.swagger-ui ol,.swagger-ui p,.swagger-ui pre,.swagger-ui section,.swagger-ui table,.swagger-ui td,.swagger-ui textarea,.swagger-ui th,.swagger-ui tr,.swagger-ui ul{box-sizing:border-box}.swagger-ui .aspect-ratio{height:0;position:relative}.swagger-ui .aspect-ratio--16x9{padding-bottom:56.25%}.swagger-ui .aspect-ratio--9x16{padding-bottom:177.77%}.swagger-ui .aspect-ratio--4x3{padding-bottom:75%}.swagger-ui .aspect-ratio--3x4{padding-bottom:133.33%}.swagger-ui .aspect-ratio--6x4{padding-bottom:66.6%}.swagger-ui .aspect-ratio--4x6{padding-bottom:150%}.swagger-ui .aspect-ratio--8x5{padding-bottom:62.5%}.swagger-ui .aspect-ratio--5x8{padding-bottom:160%}.swagger-ui .aspect-ratio--7x5{padding-bottom:71.42%}.swagger-ui .aspect-ratio--5x7{padding-bottom:140%}.swagger-ui .aspect-ratio--1x1{padding-bottom:100%}.swagger-ui .aspect-ratio--object{bottom:0;height:100%;left:0;position:absolute;right:0;top:0;width:100%;z-index:100}@media screen and (min-width:30em){.swagger-ui .aspect-ratio-ns{height:0;position:relative}.swagger-ui .aspect-ratio--16x9-ns{padding-bottom:56.25%}.swagger-ui .aspect-ratio--9x16-ns{padding-bottom:177.77%}.swagger-ui .aspect-ratio--4x3-ns{padding-bottom:75%}.swagger-ui .aspect-ratio--3x4-ns{padding-bottom:133.33%}.swagger-ui .aspect-ratio--6x4-ns{padding-bottom:66.6%}.swagger-ui .aspect-ratio--4x6-ns{padding-bottom:150%}.swagger-ui .aspect-ratio--8x5-ns{padding-bottom:62.5%}.swagger-ui .aspect-ratio--5x8-ns{padding-bottom:160%}.swagger-ui .aspect-ratio--7x5-ns{padding-bottom:71.42%}.swagger-ui .aspect-ratio--5x7-ns{padding-bottom:140%}.swagger-ui .aspect-ratio--1x1-ns{padding-bottom:100%}.swagger-ui .aspect-ratio--object-ns{bottom:0;height:100%;left:0;position:absolute;right:0;top:0;width:100%;z-index:100}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .aspect-ratio-m{height:0;position:relative}.swagger-ui .aspect-ratio--16x9-m{padding-bottom:56.25%}.swagger-ui .aspect-ratio--9x16-m{padding-bottom:177.77%}.swagger-ui .aspect-ratio--4x3-m{padding-bottom:75%}.swagger-ui .aspect-ratio--3x4-m{padding-bottom:133.33%}.swagger-ui .aspect-ratio--6x4-m{padding-bottom:66.6%}.swagger-ui .aspect-ratio--4x6-m{padding-bottom:150%}.swagger-ui .aspect-ratio--8x5-m{padding-bottom:62.5%}.swagger-ui .aspect-ratio--5x8-m{padding-bottom:160%}.swagger-ui .aspect-ratio--7x5-m{padding-bottom:71.42%}.swagger-ui .aspect-ratio--5x7-m{padding-bottom:140%}.swagger-ui .aspect-ratio--1x1-m{padding-bottom:100%}.swagger-ui .aspect-ratio--object-m{bottom:0;height:100%;left:0;position:absolute;right:0;top:0;width:100%;z-index:100}}@media screen and (min-width:60em){.swagger-ui .aspect-ratio-l{height:0;position:relative}.swagger-ui .aspect-ratio--16x9-l{padding-bottom:56.25%}.swagger-ui .aspect-ratio--9x16-l{padding-bottom:177.77%}.swagger-ui .aspect-ratio--4x3-l{padding-bottom:75%}.swagger-ui .aspect-ratio--3x4-l{padding-bottom:133.33%}.swagger-ui .aspect-ratio--6x4-l{padding-bottom:66.6%}.swagger-ui .aspect-ratio--4x6-l{padding-bottom:150%}.swagger-ui .aspect-ratio--8x5-l{padding-bottom:62.5%}.swagger-ui .aspect-ratio--5x8-l{padding-bottom:160%}.swagger-ui .aspect-ratio--7x5-l{padding-bottom:71.42%}.swagger-ui .aspect-ratio--5x7-l{padding-bottom:140%}.swagger-ui .aspect-ratio--1x1-l{padding-bottom:100%}.swagger-ui .aspect-ratio--object-l{bottom:0;height:100%;left:0;position:absolute;right:0;top:0;width:100%;z-index:100}}.swagger-ui img{max-width:100%}.swagger-ui .cover{background-size:cover!important}.swagger-ui .contain{background-size:contain!important}@media screen and (min-width:30em){.swagger-ui .cover-ns{background-size:cover!important}.swagger-ui .contain-ns{background-size:contain!important}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .cover-m{background-size:cover!important}.swagger-ui .contain-m{background-size:contain!important}}@media screen and (min-width:60em){.swagger-ui .cover-l{background-size:cover!important}.swagger-ui .contain-l{background-size:contain!important}}.swagger-ui .bg-center{background-position:50%;background-repeat:no-repeat}.swagger-ui .bg-top{background-position:top;background-repeat:no-repeat}.swagger-ui .bg-right{background-position:100%;background-repeat:no-repeat}.swagger-ui .bg-bottom{background-position:bottom;background-repeat:no-repeat}.swagger-ui .bg-left{background-position:0;background-repeat:no-repeat}@media screen and (min-width:30em){.swagger-ui .bg-center-ns{background-position:50%;background-repeat:no-repeat}.swagger-ui .bg-top-ns{background-position:top;background-repeat:no-repeat}.swagger-ui .bg-right-ns{background-position:100%;background-repeat:no-repeat}.swagger-ui .bg-bottom-ns{background-position:bottom;background-repeat:no-repeat}.swagger-ui .bg-left-ns{background-position:0;background-repeat:no-repeat}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .bg-center-m{background-position:50%;background-repeat:no-repeat}.swagger-ui .bg-top-m{background-position:top;background-repeat:no-repeat}.swagger-ui .bg-right-m{background-position:100%;background-repeat:no-repeat}.swagger-ui .bg-bottom-m{background-position:bottom;background-repeat:no-repeat}.swagger-ui .bg-left-m{background-position:0;background-repeat:no-repeat}}@media screen and (min-width:60em){.swagger-ui .bg-center-l{background-position:50%;background-repeat:no-repeat}.swagger-ui .bg-top-l{background-position:top;background-repeat:no-repeat}.swagger-ui .bg-right-l{background-position:100%;background-repeat:no-repeat}.swagger-ui .bg-bottom-l{background-position:bottom;background-repeat:no-repeat}.swagger-ui .bg-left-l{background-position:0;background-repeat:no-repeat}}.swagger-ui .outline{outline:1px solid}.swagger-ui .outline-transparent{outline:1px solid transparent}.swagger-ui .outline-0{outline:0}@media screen and (min-width:30em){.swagger-ui .outline-ns{outline:1px solid}.swagger-ui .outline-transparent-ns{outline:1px solid transparent}.swagger-ui .outline-0-ns{outline:0}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .outline-m{outline:1px solid}.swagger-ui .outline-transparent-m{outline:1px solid transparent}.swagger-ui .outline-0-m{outline:0}}@media screen and (min-width:60em){.swagger-ui .outline-l{outline:1px solid}.swagger-ui .outline-transparent-l{outline:1px solid transparent}.swagger-ui .outline-0-l{outline:0}}.swagger-ui .ba{border-style:solid;border-width:1px}.swagger-ui .bt{border-top-style:solid;border-top-width:1px}.swagger-ui .br{border-right-style:solid;border-right-width:1px}.swagger-ui .bb{border-bottom-style:solid;border-bottom-width:1px}.swagger-ui .bl{border-left-style:solid;border-left-width:1px}.swagger-ui .bn{border-style:none;border-width:0}@media screen and (min-width:30em){.swagger-ui .ba-ns{border-style:solid;border-width:1px}.swagger-ui .bt-ns{border-top-style:solid;border-top-width:1px}.swagger-ui .br-ns{border-right-style:solid;border-right-width:1px}.swagger-ui .bb-ns{border-bottom-style:solid;border-bottom-width:1px}.swagger-ui .bl-ns{border-left-style:solid;border-left-width:1px}.swagger-ui .bn-ns{border-style:none;border-width:0}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .ba-m{border-style:solid;border-width:1px}.swagger-ui .bt-m{border-top-style:solid;border-top-width:1px}.swagger-ui .br-m{border-right-style:solid;border-right-width:1px}.swagger-ui .bb-m{border-bottom-style:solid;border-bottom-width:1px}.swagger-ui .bl-m{border-left-style:solid;border-left-width:1px}.swagger-ui .bn-m{border-style:none;border-width:0}}@media screen and (min-width:60em){.swagger-ui .ba-l{border-style:solid;border-width:1px}.swagger-ui .bt-l{border-top-style:solid;border-top-width:1px}.swagger-ui .br-l{border-right-style:solid;border-right-width:1px}.swagger-ui .bb-l{border-bottom-style:solid;border-bottom-width:1px}.swagger-ui .bl-l{border-left-style:solid;border-left-width:1px}.swagger-ui .bn-l{border-style:none;border-width:0}}.swagger-ui .b--black{border-color:#000}.swagger-ui .b--near-black{border-color:#111}.swagger-ui .b--dark-gray{border-color:#333}.swagger-ui .b--mid-gray{border-color:#555}.swagger-ui .b--gray{border-color:#777}.swagger-ui .b--silver{border-color:#999}.swagger-ui .b--light-silver{border-color:#aaa}.swagger-ui .b--moon-gray{border-color:#ccc}.swagger-ui .b--light-gray{border-color:#eee}.swagger-ui .b--near-white{border-color:#f4f4f4}.swagger-ui .b--white{border-color:#fff}.swagger-ui .b--white-90{border-color:hsla(0,0%,100%,.9)}.swagger-ui .b--white-80{border-color:hsla(0,0%,100%,.8)}.swagger-ui .b--white-70{border-color:hsla(0,0%,100%,.7)}.swagger-ui .b--white-60{border-color:hsla(0,0%,100%,.6)}.swagger-ui .b--white-50{border-color:hsla(0,0%,100%,.5)}.swagger-ui .b--white-40{border-color:hsla(0,0%,100%,.4)}.swagger-ui .b--white-30{border-color:hsla(0,0%,100%,.3)}.swagger-ui .b--white-20{border-color:hsla(0,0%,100%,.2)}.swagger-ui .b--white-10{border-color:hsla(0,0%,100%,.1)}.swagger-ui .b--white-05{border-color:hsla(0,0%,100%,.05)}.swagger-ui .b--white-025{border-color:hsla(0,0%,100%,.025)}.swagger-ui .b--white-0125{border-color:hsla(0,0%,100%,.013)}.swagger-ui .b--black-90{border-color:rgba(0,0,0,.9)}.swagger-ui .b--black-80{border-color:rgba(0,0,0,.8)}.swagger-ui .b--black-70{border-color:rgba(0,0,0,.7)}.swagger-ui .b--black-60{border-color:rgba(0,0,0,.6)}.swagger-ui .b--black-50{border-color:rgba(0,0,0,.5)}.swagger-ui .b--black-40{border-color:rgba(0,0,0,.4)}.swagger-ui .b--black-30{border-color:rgba(0,0,0,.3)}.swagger-ui .b--black-20{border-color:rgba(0,0,0,.2)}.swagger-ui .b--black-10{border-color:rgba(0,0,0,.1)}.swagger-ui .b--black-05{border-color:rgba(0,0,0,.05)}.swagger-ui .b--black-025{border-color:rgba(0,0,0,.025)}.swagger-ui .b--black-0125{border-color:rgba(0,0,0,.013)}.swagger-ui .b--dark-red{border-color:#e7040f}.swagger-ui .b--red{border-color:#ff4136}.swagger-ui .b--light-red{border-color:#ff725c}.swagger-ui .b--orange{border-color:#ff6300}.swagger-ui .b--gold{border-color:#ffb700}.swagger-ui .b--yellow{border-color:gold}.swagger-ui .b--light-yellow{border-color:#fbf1a9}.swagger-ui .b--purple{border-color:#5e2ca5}.swagger-ui .b--light-purple{border-color:#a463f2}.swagger-ui .b--dark-pink{border-color:#d5008f}.swagger-ui .b--hot-pink{border-color:#ff41b4}.swagger-ui .b--pink{border-color:#ff80cc}.swagger-ui .b--light-pink{border-color:#ffa3d7}.swagger-ui .b--dark-green{border-color:#137752}.swagger-ui .b--green{border-color:#19a974}.swagger-ui .b--light-green{border-color:#9eebcf}.swagger-ui .b--navy{border-color:#001b44}.swagger-ui .b--dark-blue{border-color:#00449e}.swagger-ui .b--blue{border-color:#357edd}.swagger-ui .b--light-blue{border-color:#96ccff}.swagger-ui .b--lightest-blue{border-color:#cdecff}.swagger-ui .b--washed-blue{border-color:#f6fffe}.swagger-ui .b--washed-green{border-color:#e8fdf5}.swagger-ui .b--washed-yellow{border-color:#fffceb}.swagger-ui .b--washed-red{border-color:#ffdfdf}.swagger-ui .b--transparent{border-color:transparent}.swagger-ui .b--inherit{border-color:inherit}.swagger-ui .br0{border-radius:0}.swagger-ui .br1{border-radius:.125rem}.swagger-ui .br2{border-radius:.25rem}.swagger-ui .br3{border-radius:.5rem}.swagger-ui .br4{border-radius:1rem}.swagger-ui .br-100{border-radius:100%}.swagger-ui .br-pill{border-radius:9999px}.swagger-ui .br--bottom{border-top-left-radius:0;border-top-right-radius:0}.swagger-ui .br--top{border-bottom-left-radius:0;border-bottom-right-radius:0}.swagger-ui .br--right{border-bottom-left-radius:0;border-top-left-radius:0}.swagger-ui .br--left{border-bottom-right-radius:0;border-top-right-radius:0}@media screen and (min-width:30em){.swagger-ui .br0-ns{border-radius:0}.swagger-ui .br1-ns{border-radius:.125rem}.swagger-ui .br2-ns{border-radius:.25rem}.swagger-ui .br3-ns{border-radius:.5rem}.swagger-ui .br4-ns{border-radius:1rem}.swagger-ui .br-100-ns{border-radius:100%}.swagger-ui .br-pill-ns{border-radius:9999px}.swagger-ui .br--bottom-ns{border-top-left-radius:0;border-top-right-radius:0}.swagger-ui .br--top-ns{border-bottom-left-radius:0;border-bottom-right-radius:0}.swagger-ui .br--right-ns{border-bottom-left-radius:0;border-top-left-radius:0}.swagger-ui .br--left-ns{border-bottom-right-radius:0;border-top-right-radius:0}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .br0-m{border-radius:0}.swagger-ui .br1-m{border-radius:.125rem}.swagger-ui .br2-m{border-radius:.25rem}.swagger-ui .br3-m{border-radius:.5rem}.swagger-ui .br4-m{border-radius:1rem}.swagger-ui .br-100-m{border-radius:100%}.swagger-ui .br-pill-m{border-radius:9999px}.swagger-ui .br--bottom-m{border-top-left-radius:0;border-top-right-radius:0}.swagger-ui .br--top-m{border-bottom-left-radius:0;border-bottom-right-radius:0}.swagger-ui .br--right-m{border-bottom-left-radius:0;border-top-left-radius:0}.swagger-ui .br--left-m{border-bottom-right-radius:0;border-top-right-radius:0}}@media screen and (min-width:60em){.swagger-ui .br0-l{border-radius:0}.swagger-ui .br1-l{border-radius:.125rem}.swagger-ui .br2-l{border-radius:.25rem}.swagger-ui .br3-l{border-radius:.5rem}.swagger-ui .br4-l{border-radius:1rem}.swagger-ui .br-100-l{border-radius:100%}.swagger-ui .br-pill-l{border-radius:9999px}.swagger-ui .br--bottom-l{border-top-left-radius:0;border-top-right-radius:0}.swagger-ui .br--top-l{border-bottom-left-radius:0;border-bottom-right-radius:0}.swagger-ui .br--right-l{border-bottom-left-radius:0;border-top-left-radius:0}.swagger-ui .br--left-l{border-bottom-right-radius:0;border-top-right-radius:0}}.swagger-ui .b--dotted{border-style:dotted}.swagger-ui .b--dashed{border-style:dashed}.swagger-ui .b--solid{border-style:solid}.swagger-ui .b--none{border-style:none}@media screen and (min-width:30em){.swagger-ui .b--dotted-ns{border-style:dotted}.swagger-ui .b--dashed-ns{border-style:dashed}.swagger-ui .b--solid-ns{border-style:solid}.swagger-ui .b--none-ns{border-style:none}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .b--dotted-m{border-style:dotted}.swagger-ui .b--dashed-m{border-style:dashed}.swagger-ui .b--solid-m{border-style:solid}.swagger-ui .b--none-m{border-style:none}}@media screen and (min-width:60em){.swagger-ui .b--dotted-l{border-style:dotted}.swagger-ui .b--dashed-l{border-style:dashed}.swagger-ui .b--solid-l{border-style:solid}.swagger-ui .b--none-l{border-style:none}}.swagger-ui .bw0{border-width:0}.swagger-ui .bw1{border-width:.125rem}.swagger-ui .bw2{border-width:.25rem}.swagger-ui .bw3{border-width:.5rem}.swagger-ui .bw4{border-width:1rem}.swagger-ui .bw5{border-width:2rem}.swagger-ui .bt-0{border-top-width:0}.swagger-ui .br-0{border-right-width:0}.swagger-ui .bb-0{border-bottom-width:0}.swagger-ui .bl-0{border-left-width:0}@media screen and (min-width:30em){.swagger-ui .bw0-ns{border-width:0}.swagger-ui .bw1-ns{border-width:.125rem}.swagger-ui .bw2-ns{border-width:.25rem}.swagger-ui .bw3-ns{border-width:.5rem}.swagger-ui .bw4-ns{border-width:1rem}.swagger-ui .bw5-ns{border-width:2rem}.swagger-ui .bt-0-ns{border-top-width:0}.swagger-ui .br-0-ns{border-right-width:0}.swagger-ui .bb-0-ns{border-bottom-width:0}.swagger-ui .bl-0-ns{border-left-width:0}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .bw0-m{border-width:0}.swagger-ui .bw1-m{border-width:.125rem}.swagger-ui .bw2-m{border-width:.25rem}.swagger-ui .bw3-m{border-width:.5rem}.swagger-ui .bw4-m{border-width:1rem}.swagger-ui .bw5-m{border-width:2rem}.swagger-ui .bt-0-m{border-top-width:0}.swagger-ui .br-0-m{border-right-width:0}.swagger-ui .bb-0-m{border-bottom-width:0}.swagger-ui .bl-0-m{border-left-width:0}}@media screen and (min-width:60em){.swagger-ui .bw0-l{border-width:0}.swagger-ui .bw1-l{border-width:.125rem}.swagger-ui .bw2-l{border-width:.25rem}.swagger-ui .bw3-l{border-width:.5rem}.swagger-ui .bw4-l{border-width:1rem}.swagger-ui .bw5-l{border-width:2rem}.swagger-ui .bt-0-l{border-top-width:0}.swagger-ui .br-0-l{border-right-width:0}.swagger-ui .bb-0-l{border-bottom-width:0}.swagger-ui .bl-0-l{border-left-width:0}}.swagger-ui .shadow-1{box-shadow:0 0 4px 2px rgba(0,0,0,.2)}.swagger-ui .shadow-2{box-shadow:0 0 8px 2px rgba(0,0,0,.2)}.swagger-ui .shadow-3{box-shadow:2px 2px 4px 2px rgba(0,0,0,.2)}.swagger-ui .shadow-4{box-shadow:2px 2px 8px 0 rgba(0,0,0,.2)}.swagger-ui .shadow-5{box-shadow:4px 4px 8px 0 rgba(0,0,0,.2)}@media screen and (min-width:30em){.swagger-ui .shadow-1-ns{box-shadow:0 0 4px 2px rgba(0,0,0,.2)}.swagger-ui .shadow-2-ns{box-shadow:0 0 8px 2px rgba(0,0,0,.2)}.swagger-ui .shadow-3-ns{box-shadow:2px 2px 4px 2px rgba(0,0,0,.2)}.swagger-ui .shadow-4-ns{box-shadow:2px 2px 8px 0 rgba(0,0,0,.2)}.swagger-ui .shadow-5-ns{box-shadow:4px 4px 8px 0 rgba(0,0,0,.2)}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .shadow-1-m{box-shadow:0 0 4px 2px rgba(0,0,0,.2)}.swagger-ui .shadow-2-m{box-shadow:0 0 8px 2px rgba(0,0,0,.2)}.swagger-ui .shadow-3-m{box-shadow:2px 2px 4px 2px rgba(0,0,0,.2)}.swagger-ui .shadow-4-m{box-shadow:2px 2px 8px 0 rgba(0,0,0,.2)}.swagger-ui .shadow-5-m{box-shadow:4px 4px 8px 0 rgba(0,0,0,.2)}}@media screen and (min-width:60em){.swagger-ui .shadow-1-l{box-shadow:0 0 4px 2px rgba(0,0,0,.2)}.swagger-ui .shadow-2-l{box-shadow:0 0 8px 2px rgba(0,0,0,.2)}.swagger-ui .shadow-3-l{box-shadow:2px 2px 4px 2px rgba(0,0,0,.2)}.swagger-ui .shadow-4-l{box-shadow:2px 2px 8px 0 rgba(0,0,0,.2)}.swagger-ui .shadow-5-l{box-shadow:4px 4px 8px 0 rgba(0,0,0,.2)}}.swagger-ui .pre{overflow-x:auto;overflow-y:hidden;overflow:scroll}.swagger-ui .top-0{top:0}.swagger-ui .right-0{right:0}.swagger-ui .bottom-0{bottom:0}.swagger-ui .left-0{left:0}.swagger-ui .top-1{top:1rem}.swagger-ui .right-1{right:1rem}.swagger-ui .bottom-1{bottom:1rem}.swagger-ui .left-1{left:1rem}.swagger-ui .top-2{top:2rem}.swagger-ui .right-2{right:2rem}.swagger-ui .bottom-2{bottom:2rem}.swagger-ui .left-2{left:2rem}.swagger-ui .top--1{top:-1rem}.swagger-ui .right--1{right:-1rem}.swagger-ui .bottom--1{bottom:-1rem}.swagger-ui .left--1{left:-1rem}.swagger-ui .top--2{top:-2rem}.swagger-ui .right--2{right:-2rem}.swagger-ui .bottom--2{bottom:-2rem}.swagger-ui .left--2{left:-2rem}.swagger-ui .absolute--fill{bottom:0;left:0;right:0;top:0}@media screen and (min-width:30em){.swagger-ui .top-0-ns{top:0}.swagger-ui .left-0-ns{left:0}.swagger-ui .right-0-ns{right:0}.swagger-ui .bottom-0-ns{bottom:0}.swagger-ui .top-1-ns{top:1rem}.swagger-ui .left-1-ns{left:1rem}.swagger-ui .right-1-ns{right:1rem}.swagger-ui .bottom-1-ns{bottom:1rem}.swagger-ui .top-2-ns{top:2rem}.swagger-ui .left-2-ns{left:2rem}.swagger-ui .right-2-ns{right:2rem}.swagger-ui .bottom-2-ns{bottom:2rem}.swagger-ui .top--1-ns{top:-1rem}.swagger-ui .right--1-ns{right:-1rem}.swagger-ui .bottom--1-ns{bottom:-1rem}.swagger-ui .left--1-ns{left:-1rem}.swagger-ui .top--2-ns{top:-2rem}.swagger-ui .right--2-ns{right:-2rem}.swagger-ui .bottom--2-ns{bottom:-2rem}.swagger-ui .left--2-ns{left:-2rem}.swagger-ui .absolute--fill-ns{bottom:0;left:0;right:0;top:0}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .top-0-m{top:0}.swagger-ui .left-0-m{left:0}.swagger-ui .right-0-m{right:0}.swagger-ui .bottom-0-m{bottom:0}.swagger-ui .top-1-m{top:1rem}.swagger-ui .left-1-m{left:1rem}.swagger-ui .right-1-m{right:1rem}.swagger-ui .bottom-1-m{bottom:1rem}.swagger-ui .top-2-m{top:2rem}.swagger-ui .left-2-m{left:2rem}.swagger-ui .right-2-m{right:2rem}.swagger-ui .bottom-2-m{bottom:2rem}.swagger-ui .top--1-m{top:-1rem}.swagger-ui .right--1-m{right:-1rem}.swagger-ui .bottom--1-m{bottom:-1rem}.swagger-ui .left--1-m{left:-1rem}.swagger-ui .top--2-m{top:-2rem}.swagger-ui .right--2-m{right:-2rem}.swagger-ui .bottom--2-m{bottom:-2rem}.swagger-ui .left--2-m{left:-2rem}.swagger-ui .absolute--fill-m{bottom:0;left:0;right:0;top:0}}@media screen and (min-width:60em){.swagger-ui .top-0-l{top:0}.swagger-ui .left-0-l{left:0}.swagger-ui .right-0-l{right:0}.swagger-ui .bottom-0-l{bottom:0}.swagger-ui .top-1-l{top:1rem}.swagger-ui .left-1-l{left:1rem}.swagger-ui .right-1-l{right:1rem}.swagger-ui .bottom-1-l{bottom:1rem}.swagger-ui .top-2-l{top:2rem}.swagger-ui .left-2-l{left:2rem}.swagger-ui .right-2-l{right:2rem}.swagger-ui .bottom-2-l{bottom:2rem}.swagger-ui .top--1-l{top:-1rem}.swagger-ui .right--1-l{right:-1rem}.swagger-ui .bottom--1-l{bottom:-1rem}.swagger-ui .left--1-l{left:-1rem}.swagger-ui .top--2-l{top:-2rem}.swagger-ui .right--2-l{right:-2rem}.swagger-ui .bottom--2-l{bottom:-2rem}.swagger-ui .left--2-l{left:-2rem}.swagger-ui .absolute--fill-l{bottom:0;left:0;right:0;top:0}}.swagger-ui .cf:after,.swagger-ui .cf:before{content:" ";display:table}.swagger-ui .cf:after{clear:both}.swagger-ui .cf{zoom:1}.swagger-ui .cl{clear:left}.swagger-ui .cr{clear:right}.swagger-ui .cb{clear:both}.swagger-ui .cn{clear:none}@media screen and (min-width:30em){.swagger-ui .cl-ns{clear:left}.swagger-ui .cr-ns{clear:right}.swagger-ui .cb-ns{clear:both}.swagger-ui .cn-ns{clear:none}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .cl-m{clear:left}.swagger-ui .cr-m{clear:right}.swagger-ui .cb-m{clear:both}.swagger-ui .cn-m{clear:none}}@media screen and (min-width:60em){.swagger-ui .cl-l{clear:left}.swagger-ui .cr-l{clear:right}.swagger-ui .cb-l{clear:both}.swagger-ui .cn-l{clear:none}}.swagger-ui .flex{display:flex}.swagger-ui .inline-flex{display:inline-flex}.swagger-ui .flex-auto{flex:1 1 auto;min-height:0;min-width:0}.swagger-ui .flex-none{flex:none}.swagger-ui .flex-column{flex-direction:column}.swagger-ui .flex-row{flex-direction:row}.swagger-ui .flex-wrap{flex-wrap:wrap}.swagger-ui .flex-nowrap{flex-wrap:nowrap}.swagger-ui .flex-wrap-reverse{flex-wrap:wrap-reverse}.swagger-ui .flex-column-reverse{flex-direction:column-reverse}.swagger-ui .flex-row-reverse{flex-direction:row-reverse}.swagger-ui .items-start{align-items:flex-start}.swagger-ui .items-end{align-items:flex-end}.swagger-ui .items-center{align-items:center}.swagger-ui .items-baseline{align-items:baseline}.swagger-ui .items-stretch{align-items:stretch}.swagger-ui .self-start{align-self:flex-start}.swagger-ui .self-end{align-self:flex-end}.swagger-ui .self-center{align-self:center}.swagger-ui .self-baseline{align-self:baseline}.swagger-ui .self-stretch{align-self:stretch}.swagger-ui .justify-start{justify-content:flex-start}.swagger-ui .justify-end{justify-content:flex-end}.swagger-ui .justify-center{justify-content:center}.swagger-ui .justify-between{justify-content:space-between}.swagger-ui .justify-around{justify-content:space-around}.swagger-ui .content-start{align-content:flex-start}.swagger-ui .content-end{align-content:flex-end}.swagger-ui .content-center{align-content:center}.swagger-ui .content-between{align-content:space-between}.swagger-ui .content-around{align-content:space-around}.swagger-ui .content-stretch{align-content:stretch}.swagger-ui .order-0{order:0}.swagger-ui .order-1{order:1}.swagger-ui .order-2{order:2}.swagger-ui .order-3{order:3}.swagger-ui .order-4{order:4}.swagger-ui .order-5{order:5}.swagger-ui .order-6{order:6}.swagger-ui .order-7{order:7}.swagger-ui .order-8{order:8}.swagger-ui .order-last{order:99999}.swagger-ui .flex-grow-0{flex-grow:0}.swagger-ui .flex-grow-1{flex-grow:1}.swagger-ui .flex-shrink-0{flex-shrink:0}.swagger-ui .flex-shrink-1{flex-shrink:1}@media screen and (min-width:30em){.swagger-ui .flex-ns{display:flex}.swagger-ui .inline-flex-ns{display:inline-flex}.swagger-ui .flex-auto-ns{flex:1 1 auto;min-height:0;min-width:0}.swagger-ui .flex-none-ns{flex:none}.swagger-ui .flex-column-ns{flex-direction:column}.swagger-ui .flex-row-ns{flex-direction:row}.swagger-ui .flex-wrap-ns{flex-wrap:wrap}.swagger-ui .flex-nowrap-ns{flex-wrap:nowrap}.swagger-ui .flex-wrap-reverse-ns{flex-wrap:wrap-reverse}.swagger-ui .flex-column-reverse-ns{flex-direction:column-reverse}.swagger-ui .flex-row-reverse-ns{flex-direction:row-reverse}.swagger-ui .items-start-ns{align-items:flex-start}.swagger-ui .items-end-ns{align-items:flex-end}.swagger-ui .items-center-ns{align-items:center}.swagger-ui .items-baseline-ns{align-items:baseline}.swagger-ui .items-stretch-ns{align-items:stretch}.swagger-ui .self-start-ns{align-self:flex-start}.swagger-ui .self-end-ns{align-self:flex-end}.swagger-ui .self-center-ns{align-self:center}.swagger-ui .self-baseline-ns{align-self:baseline}.swagger-ui .self-stretch-ns{align-self:stretch}.swagger-ui .justify-start-ns{justify-content:flex-start}.swagger-ui .justify-end-ns{justify-content:flex-end}.swagger-ui .justify-center-ns{justify-content:center}.swagger-ui .justify-between-ns{justify-content:space-between}.swagger-ui .justify-around-ns{justify-content:space-around}.swagger-ui .content-start-ns{align-content:flex-start}.swagger-ui .content-end-ns{align-content:flex-end}.swagger-ui .content-center-ns{align-content:center}.swagger-ui .content-between-ns{align-content:space-between}.swagger-ui .content-around-ns{align-content:space-around}.swagger-ui .content-stretch-ns{align-content:stretch}.swagger-ui .order-0-ns{order:0}.swagger-ui .order-1-ns{order:1}.swagger-ui .order-2-ns{order:2}.swagger-ui .order-3-ns{order:3}.swagger-ui .order-4-ns{order:4}.swagger-ui .order-5-ns{order:5}.swagger-ui .order-6-ns{order:6}.swagger-ui .order-7-ns{order:7}.swagger-ui .order-8-ns{order:8}.swagger-ui .order-last-ns{order:99999}.swagger-ui .flex-grow-0-ns{flex-grow:0}.swagger-ui .flex-grow-1-ns{flex-grow:1}.swagger-ui .flex-shrink-0-ns{flex-shrink:0}.swagger-ui .flex-shrink-1-ns{flex-shrink:1}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .flex-m{display:flex}.swagger-ui .inline-flex-m{display:inline-flex}.swagger-ui .flex-auto-m{flex:1 1 auto;min-height:0;min-width:0}.swagger-ui .flex-none-m{flex:none}.swagger-ui .flex-column-m{flex-direction:column}.swagger-ui .flex-row-m{flex-direction:row}.swagger-ui .flex-wrap-m{flex-wrap:wrap}.swagger-ui .flex-nowrap-m{flex-wrap:nowrap}.swagger-ui .flex-wrap-reverse-m{flex-wrap:wrap-reverse}.swagger-ui .flex-column-reverse-m{flex-direction:column-reverse}.swagger-ui .flex-row-reverse-m{flex-direction:row-reverse}.swagger-ui .items-start-m{align-items:flex-start}.swagger-ui .items-end-m{align-items:flex-end}.swagger-ui .items-center-m{align-items:center}.swagger-ui .items-baseline-m{align-items:baseline}.swagger-ui .items-stretch-m{align-items:stretch}.swagger-ui .self-start-m{align-self:flex-start}.swagger-ui .self-end-m{align-self:flex-end}.swagger-ui .self-center-m{align-self:center}.swagger-ui .self-baseline-m{align-self:baseline}.swagger-ui .self-stretch-m{align-self:stretch}.swagger-ui .justify-start-m{justify-content:flex-start}.swagger-ui .justify-end-m{justify-content:flex-end}.swagger-ui .justify-center-m{justify-content:center}.swagger-ui .justify-between-m{justify-content:space-between}.swagger-ui .justify-around-m{justify-content:space-around}.swagger-ui .content-start-m{align-content:flex-start}.swagger-ui .content-end-m{align-content:flex-end}.swagger-ui .content-center-m{align-content:center}.swagger-ui .content-between-m{align-content:space-between}.swagger-ui .content-around-m{align-content:space-around}.swagger-ui .content-stretch-m{align-content:stretch}.swagger-ui .order-0-m{order:0}.swagger-ui .order-1-m{order:1}.swagger-ui .order-2-m{order:2}.swagger-ui .order-3-m{order:3}.swagger-ui .order-4-m{order:4}.swagger-ui .order-5-m{order:5}.swagger-ui .order-6-m{order:6}.swagger-ui .order-7-m{order:7}.swagger-ui .order-8-m{order:8}.swagger-ui .order-last-m{order:99999}.swagger-ui .flex-grow-0-m{flex-grow:0}.swagger-ui .flex-grow-1-m{flex-grow:1}.swagger-ui .flex-shrink-0-m{flex-shrink:0}.swagger-ui .flex-shrink-1-m{flex-shrink:1}}@media screen and (min-width:60em){.swagger-ui .flex-l{display:flex}.swagger-ui .inline-flex-l{display:inline-flex}.swagger-ui .flex-auto-l{flex:1 1 auto;min-height:0;min-width:0}.swagger-ui .flex-none-l{flex:none}.swagger-ui .flex-column-l{flex-direction:column}.swagger-ui .flex-row-l{flex-direction:row}.swagger-ui .flex-wrap-l{flex-wrap:wrap}.swagger-ui .flex-nowrap-l{flex-wrap:nowrap}.swagger-ui .flex-wrap-reverse-l{flex-wrap:wrap-reverse}.swagger-ui .flex-column-reverse-l{flex-direction:column-reverse}.swagger-ui .flex-row-reverse-l{flex-direction:row-reverse}.swagger-ui .items-start-l{align-items:flex-start}.swagger-ui .items-end-l{align-items:flex-end}.swagger-ui .items-center-l{align-items:center}.swagger-ui .items-baseline-l{align-items:baseline}.swagger-ui .items-stretch-l{align-items:stretch}.swagger-ui .self-start-l{align-self:flex-start}.swagger-ui .self-end-l{align-self:flex-end}.swagger-ui .self-center-l{align-self:center}.swagger-ui .self-baseline-l{align-self:baseline}.swagger-ui .self-stretch-l{align-self:stretch}.swagger-ui .justify-start-l{justify-content:flex-start}.swagger-ui .justify-end-l{justify-content:flex-end}.swagger-ui .justify-center-l{justify-content:center}.swagger-ui .justify-between-l{justify-content:space-between}.swagger-ui .justify-around-l{justify-content:space-around}.swagger-ui .content-start-l{align-content:flex-start}.swagger-ui .content-end-l{align-content:flex-end}.swagger-ui .content-center-l{align-content:center}.swagger-ui .content-between-l{align-content:space-between}.swagger-ui .content-around-l{align-content:space-around}.swagger-ui .content-stretch-l{align-content:stretch}.swagger-ui .order-0-l{order:0}.swagger-ui .order-1-l{order:1}.swagger-ui .order-2-l{order:2}.swagger-ui .order-3-l{order:3}.swagger-ui .order-4-l{order:4}.swagger-ui .order-5-l{order:5}.swagger-ui .order-6-l{order:6}.swagger-ui .order-7-l{order:7}.swagger-ui .order-8-l{order:8}.swagger-ui .order-last-l{order:99999}.swagger-ui .flex-grow-0-l{flex-grow:0}.swagger-ui .flex-grow-1-l{flex-grow:1}.swagger-ui .flex-shrink-0-l{flex-shrink:0}.swagger-ui .flex-shrink-1-l{flex-shrink:1}}.swagger-ui .dn{display:none}.swagger-ui .di{display:inline}.swagger-ui .db{display:block}.swagger-ui .dib{display:inline-block}.swagger-ui .dit{display:inline-table}.swagger-ui .dt{display:table}.swagger-ui .dtc{display:table-cell}.swagger-ui .dt-row{display:table-row}.swagger-ui .dt-row-group{display:table-row-group}.swagger-ui .dt-column{display:table-column}.swagger-ui .dt-column-group{display:table-column-group}.swagger-ui .dt--fixed{table-layout:fixed;width:100%}@media screen and (min-width:30em){.swagger-ui .dn-ns{display:none}.swagger-ui .di-ns{display:inline}.swagger-ui .db-ns{display:block}.swagger-ui .dib-ns{display:inline-block}.swagger-ui .dit-ns{display:inline-table}.swagger-ui .dt-ns{display:table}.swagger-ui .dtc-ns{display:table-cell}.swagger-ui .dt-row-ns{display:table-row}.swagger-ui .dt-row-group-ns{display:table-row-group}.swagger-ui .dt-column-ns{display:table-column}.swagger-ui .dt-column-group-ns{display:table-column-group}.swagger-ui .dt--fixed-ns{table-layout:fixed;width:100%}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .dn-m{display:none}.swagger-ui .di-m{display:inline}.swagger-ui .db-m{display:block}.swagger-ui .dib-m{display:inline-block}.swagger-ui .dit-m{display:inline-table}.swagger-ui .dt-m{display:table}.swagger-ui .dtc-m{display:table-cell}.swagger-ui .dt-row-m{display:table-row}.swagger-ui .dt-row-group-m{display:table-row-group}.swagger-ui .dt-column-m{display:table-column}.swagger-ui .dt-column-group-m{display:table-column-group}.swagger-ui .dt--fixed-m{table-layout:fixed;width:100%}}@media screen and (min-width:60em){.swagger-ui .dn-l{display:none}.swagger-ui .di-l{display:inline}.swagger-ui .db-l{display:block}.swagger-ui .dib-l{display:inline-block}.swagger-ui .dit-l{display:inline-table}.swagger-ui .dt-l{display:table}.swagger-ui .dtc-l{display:table-cell}.swagger-ui .dt-row-l{display:table-row}.swagger-ui .dt-row-group-l{display:table-row-group}.swagger-ui .dt-column-l{display:table-column}.swagger-ui .dt-column-group-l{display:table-column-group}.swagger-ui .dt--fixed-l{table-layout:fixed;width:100%}}.swagger-ui .fl{_display:inline;float:left}.swagger-ui .fr{_display:inline;float:right}.swagger-ui .fn{float:none}@media screen and (min-width:30em){.swagger-ui .fl-ns{_display:inline;float:left}.swagger-ui .fr-ns{_display:inline;float:right}.swagger-ui .fn-ns{float:none}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .fl-m{_display:inline;float:left}.swagger-ui .fr-m{_display:inline;float:right}.swagger-ui .fn-m{float:none}}@media screen and (min-width:60em){.swagger-ui .fl-l{_display:inline;float:left}.swagger-ui .fr-l{_display:inline;float:right}.swagger-ui .fn-l{float:none}}.swagger-ui .sans-serif{font-family:-apple-system,BlinkMacSystemFont,avenir next,avenir,helvetica,helvetica neue,ubuntu,roboto,noto,segoe ui,arial,sans-serif}.swagger-ui .serif{font-family:georgia,serif}.swagger-ui .system-sans-serif{font-family:sans-serif}.swagger-ui .system-serif{font-family:serif}.swagger-ui .code,.swagger-ui code{font-family:Consolas,monaco,monospace}.swagger-ui .courier{font-family:Courier Next,courier,monospace}.swagger-ui .helvetica{font-family:helvetica neue,helvetica,sans-serif}.swagger-ui .avenir{font-family:avenir next,avenir,sans-serif}.swagger-ui .athelas{font-family:athelas,georgia,serif}.swagger-ui .georgia{font-family:georgia,serif}.swagger-ui .times{font-family:times,serif}.swagger-ui .bodoni{font-family:Bodoni MT,serif}.swagger-ui .calisto{font-family:Calisto MT,serif}.swagger-ui .garamond{font-family:garamond,serif}.swagger-ui .baskerville{font-family:baskerville,serif}.swagger-ui .i{font-style:italic}.swagger-ui .fs-normal{font-style:normal}@media screen and (min-width:30em){.swagger-ui .i-ns{font-style:italic}.swagger-ui .fs-normal-ns{font-style:normal}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .i-m{font-style:italic}.swagger-ui .fs-normal-m{font-style:normal}}@media screen and (min-width:60em){.swagger-ui .i-l{font-style:italic}.swagger-ui .fs-normal-l{font-style:normal}}.swagger-ui .normal{font-weight:400}.swagger-ui .b{font-weight:700}.swagger-ui .fw1{font-weight:100}.swagger-ui .fw2{font-weight:200}.swagger-ui .fw3{font-weight:300}.swagger-ui .fw4{font-weight:400}.swagger-ui .fw5{font-weight:500}.swagger-ui .fw6{font-weight:600}.swagger-ui .fw7{font-weight:700}.swagger-ui .fw8{font-weight:800}.swagger-ui .fw9{font-weight:900}@media screen and (min-width:30em){.swagger-ui .normal-ns{font-weight:400}.swagger-ui .b-ns{font-weight:700}.swagger-ui .fw1-ns{font-weight:100}.swagger-ui .fw2-ns{font-weight:200}.swagger-ui .fw3-ns{font-weight:300}.swagger-ui .fw4-ns{font-weight:400}.swagger-ui .fw5-ns{font-weight:500}.swagger-ui .fw6-ns{font-weight:600}.swagger-ui .fw7-ns{font-weight:700}.swagger-ui .fw8-ns{font-weight:800}.swagger-ui .fw9-ns{font-weight:900}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .normal-m{font-weight:400}.swagger-ui .b-m{font-weight:700}.swagger-ui .fw1-m{font-weight:100}.swagger-ui .fw2-m{font-weight:200}.swagger-ui .fw3-m{font-weight:300}.swagger-ui .fw4-m{font-weight:400}.swagger-ui .fw5-m{font-weight:500}.swagger-ui .fw6-m{font-weight:600}.swagger-ui .fw7-m{font-weight:700}.swagger-ui .fw8-m{font-weight:800}.swagger-ui .fw9-m{font-weight:900}}@media screen and (min-width:60em){.swagger-ui .normal-l{font-weight:400}.swagger-ui .b-l{font-weight:700}.swagger-ui .fw1-l{font-weight:100}.swagger-ui .fw2-l{font-weight:200}.swagger-ui .fw3-l{font-weight:300}.swagger-ui .fw4-l{font-weight:400}.swagger-ui .fw5-l{font-weight:500}.swagger-ui .fw6-l{font-weight:600}.swagger-ui .fw7-l{font-weight:700}.swagger-ui .fw8-l{font-weight:800}.swagger-ui .fw9-l{font-weight:900}}.swagger-ui .input-reset{-webkit-appearance:none;-moz-appearance:none}.swagger-ui .button-reset::-moz-focus-inner,.swagger-ui .input-reset::-moz-focus-inner{border:0;padding:0}.swagger-ui .h1{height:1rem}.swagger-ui .h2{height:2rem}.swagger-ui .h3{height:4rem}.swagger-ui .h4{height:8rem}.swagger-ui .h5{height:16rem}.swagger-ui .h-25{height:25%}.swagger-ui .h-50{height:50%}.swagger-ui .h-75{height:75%}.swagger-ui .h-100{height:100%}.swagger-ui .min-h-100{min-height:100%}.swagger-ui .vh-25{height:25vh}.swagger-ui .vh-50{height:50vh}.swagger-ui .vh-75{height:75vh}.swagger-ui .vh-100{height:100vh}.swagger-ui .min-vh-100{min-height:100vh}.swagger-ui .h-auto{height:auto}.swagger-ui .h-inherit{height:inherit}@media screen and (min-width:30em){.swagger-ui .h1-ns{height:1rem}.swagger-ui .h2-ns{height:2rem}.swagger-ui .h3-ns{height:4rem}.swagger-ui .h4-ns{height:8rem}.swagger-ui .h5-ns{height:16rem}.swagger-ui .h-25-ns{height:25%}.swagger-ui .h-50-ns{height:50%}.swagger-ui .h-75-ns{height:75%}.swagger-ui .h-100-ns{height:100%}.swagger-ui .min-h-100-ns{min-height:100%}.swagger-ui .vh-25-ns{height:25vh}.swagger-ui .vh-50-ns{height:50vh}.swagger-ui .vh-75-ns{height:75vh}.swagger-ui .vh-100-ns{height:100vh}.swagger-ui .min-vh-100-ns{min-height:100vh}.swagger-ui .h-auto-ns{height:auto}.swagger-ui .h-inherit-ns{height:inherit}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .h1-m{height:1rem}.swagger-ui .h2-m{height:2rem}.swagger-ui .h3-m{height:4rem}.swagger-ui .h4-m{height:8rem}.swagger-ui .h5-m{height:16rem}.swagger-ui .h-25-m{height:25%}.swagger-ui .h-50-m{height:50%}.swagger-ui .h-75-m{height:75%}.swagger-ui .h-100-m{height:100%}.swagger-ui .min-h-100-m{min-height:100%}.swagger-ui .vh-25-m{height:25vh}.swagger-ui .vh-50-m{height:50vh}.swagger-ui .vh-75-m{height:75vh}.swagger-ui .vh-100-m{height:100vh}.swagger-ui .min-vh-100-m{min-height:100vh}.swagger-ui .h-auto-m{height:auto}.swagger-ui .h-inherit-m{height:inherit}}@media screen and (min-width:60em){.swagger-ui .h1-l{height:1rem}.swagger-ui .h2-l{height:2rem}.swagger-ui .h3-l{height:4rem}.swagger-ui .h4-l{height:8rem}.swagger-ui .h5-l{height:16rem}.swagger-ui .h-25-l{height:25%}.swagger-ui .h-50-l{height:50%}.swagger-ui .h-75-l{height:75%}.swagger-ui .h-100-l{height:100%}.swagger-ui .min-h-100-l{min-height:100%}.swagger-ui .vh-25-l{height:25vh}.swagger-ui .vh-50-l{height:50vh}.swagger-ui .vh-75-l{height:75vh}.swagger-ui .vh-100-l{height:100vh}.swagger-ui .min-vh-100-l{min-height:100vh}.swagger-ui .h-auto-l{height:auto}.swagger-ui .h-inherit-l{height:inherit}}.swagger-ui .tracked{letter-spacing:.1em}.swagger-ui .tracked-tight{letter-spacing:-.05em}.swagger-ui .tracked-mega{letter-spacing:.25em}@media screen and (min-width:30em){.swagger-ui .tracked-ns{letter-spacing:.1em}.swagger-ui .tracked-tight-ns{letter-spacing:-.05em}.swagger-ui .tracked-mega-ns{letter-spacing:.25em}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .tracked-m{letter-spacing:.1em}.swagger-ui .tracked-tight-m{letter-spacing:-.05em}.swagger-ui .tracked-mega-m{letter-spacing:.25em}}@media screen and (min-width:60em){.swagger-ui .tracked-l{letter-spacing:.1em}.swagger-ui .tracked-tight-l{letter-spacing:-.05em}.swagger-ui .tracked-mega-l{letter-spacing:.25em}}.swagger-ui .lh-solid{line-height:1}.swagger-ui .lh-title{line-height:1.25}.swagger-ui .lh-copy{line-height:1.5}@media screen and (min-width:30em){.swagger-ui .lh-solid-ns{line-height:1}.swagger-ui .lh-title-ns{line-height:1.25}.swagger-ui .lh-copy-ns{line-height:1.5}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .lh-solid-m{line-height:1}.swagger-ui .lh-title-m{line-height:1.25}.swagger-ui .lh-copy-m{line-height:1.5}}@media screen and (min-width:60em){.swagger-ui .lh-solid-l{line-height:1}.swagger-ui .lh-title-l{line-height:1.25}.swagger-ui .lh-copy-l{line-height:1.5}}.swagger-ui .link{-webkit-text-decoration:none;text-decoration:none}.swagger-ui .link,.swagger-ui .link:active,.swagger-ui .link:focus,.swagger-ui .link:hover,.swagger-ui .link:link,.swagger-ui .link:visited{transition:color .15s ease-in}.swagger-ui .link:focus{outline:1px dotted currentColor}.swagger-ui .list{list-style-type:none}.swagger-ui .mw-100{max-width:100%}.swagger-ui .mw1{max-width:1rem}.swagger-ui .mw2{max-width:2rem}.swagger-ui .mw3{max-width:4rem}.swagger-ui .mw4{max-width:8rem}.swagger-ui .mw5{max-width:16rem}.swagger-ui .mw6{max-width:32rem}.swagger-ui .mw7{max-width:48rem}.swagger-ui .mw8{max-width:64rem}.swagger-ui .mw9{max-width:96rem}.swagger-ui .mw-none{max-width:none}@media screen and (min-width:30em){.swagger-ui .mw-100-ns{max-width:100%}.swagger-ui .mw1-ns{max-width:1rem}.swagger-ui .mw2-ns{max-width:2rem}.swagger-ui .mw3-ns{max-width:4rem}.swagger-ui .mw4-ns{max-width:8rem}.swagger-ui .mw5-ns{max-width:16rem}.swagger-ui .mw6-ns{max-width:32rem}.swagger-ui .mw7-ns{max-width:48rem}.swagger-ui .mw8-ns{max-width:64rem}.swagger-ui .mw9-ns{max-width:96rem}.swagger-ui .mw-none-ns{max-width:none}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .mw-100-m{max-width:100%}.swagger-ui .mw1-m{max-width:1rem}.swagger-ui .mw2-m{max-width:2rem}.swagger-ui .mw3-m{max-width:4rem}.swagger-ui .mw4-m{max-width:8rem}.swagger-ui .mw5-m{max-width:16rem}.swagger-ui .mw6-m{max-width:32rem}.swagger-ui .mw7-m{max-width:48rem}.swagger-ui .mw8-m{max-width:64rem}.swagger-ui .mw9-m{max-width:96rem}.swagger-ui .mw-none-m{max-width:none}}@media screen and (min-width:60em){.swagger-ui .mw-100-l{max-width:100%}.swagger-ui .mw1-l{max-width:1rem}.swagger-ui .mw2-l{max-width:2rem}.swagger-ui .mw3-l{max-width:4rem}.swagger-ui .mw4-l{max-width:8rem}.swagger-ui .mw5-l{max-width:16rem}.swagger-ui .mw6-l{max-width:32rem}.swagger-ui .mw7-l{max-width:48rem}.swagger-ui .mw8-l{max-width:64rem}.swagger-ui .mw9-l{max-width:96rem}.swagger-ui .mw-none-l{max-width:none}}.swagger-ui .w1{width:1rem}.swagger-ui .w2{width:2rem}.swagger-ui .w3{width:4rem}.swagger-ui .w4{width:8rem}.swagger-ui .w5{width:16rem}.swagger-ui .w-10{width:10%}.swagger-ui .w-20{width:20%}.swagger-ui .w-25{width:25%}.swagger-ui .w-30{width:30%}.swagger-ui .w-33{width:33%}.swagger-ui .w-34{width:34%}.swagger-ui .w-40{width:40%}.swagger-ui .w-50{width:50%}.swagger-ui .w-60{width:60%}.swagger-ui .w-70{width:70%}.swagger-ui .w-75{width:75%}.swagger-ui .w-80{width:80%}.swagger-ui .w-90{width:90%}.swagger-ui .w-100{width:100%}.swagger-ui .w-third{width:33.3333333333%}.swagger-ui .w-two-thirds{width:66.6666666667%}.swagger-ui .w-auto{width:auto}@media screen and (min-width:30em){.swagger-ui .w1-ns{width:1rem}.swagger-ui .w2-ns{width:2rem}.swagger-ui .w3-ns{width:4rem}.swagger-ui .w4-ns{width:8rem}.swagger-ui .w5-ns{width:16rem}.swagger-ui .w-10-ns{width:10%}.swagger-ui .w-20-ns{width:20%}.swagger-ui .w-25-ns{width:25%}.swagger-ui .w-30-ns{width:30%}.swagger-ui .w-33-ns{width:33%}.swagger-ui .w-34-ns{width:34%}.swagger-ui .w-40-ns{width:40%}.swagger-ui .w-50-ns{width:50%}.swagger-ui .w-60-ns{width:60%}.swagger-ui .w-70-ns{width:70%}.swagger-ui .w-75-ns{width:75%}.swagger-ui .w-80-ns{width:80%}.swagger-ui .w-90-ns{width:90%}.swagger-ui .w-100-ns{width:100%}.swagger-ui .w-third-ns{width:33.3333333333%}.swagger-ui .w-two-thirds-ns{width:66.6666666667%}.swagger-ui .w-auto-ns{width:auto}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .w1-m{width:1rem}.swagger-ui .w2-m{width:2rem}.swagger-ui .w3-m{width:4rem}.swagger-ui .w4-m{width:8rem}.swagger-ui .w5-m{width:16rem}.swagger-ui .w-10-m{width:10%}.swagger-ui .w-20-m{width:20%}.swagger-ui .w-25-m{width:25%}.swagger-ui .w-30-m{width:30%}.swagger-ui .w-33-m{width:33%}.swagger-ui .w-34-m{width:34%}.swagger-ui .w-40-m{width:40%}.swagger-ui .w-50-m{width:50%}.swagger-ui .w-60-m{width:60%}.swagger-ui .w-70-m{width:70%}.swagger-ui .w-75-m{width:75%}.swagger-ui .w-80-m{width:80%}.swagger-ui .w-90-m{width:90%}.swagger-ui .w-100-m{width:100%}.swagger-ui .w-third-m{width:33.3333333333%}.swagger-ui .w-two-thirds-m{width:66.6666666667%}.swagger-ui .w-auto-m{width:auto}}@media screen and (min-width:60em){.swagger-ui .w1-l{width:1rem}.swagger-ui .w2-l{width:2rem}.swagger-ui .w3-l{width:4rem}.swagger-ui .w4-l{width:8rem}.swagger-ui .w5-l{width:16rem}.swagger-ui .w-10-l{width:10%}.swagger-ui .w-20-l{width:20%}.swagger-ui .w-25-l{width:25%}.swagger-ui .w-30-l{width:30%}.swagger-ui .w-33-l{width:33%}.swagger-ui .w-34-l{width:34%}.swagger-ui .w-40-l{width:40%}.swagger-ui .w-50-l{width:50%}.swagger-ui .w-60-l{width:60%}.swagger-ui .w-70-l{width:70%}.swagger-ui .w-75-l{width:75%}.swagger-ui .w-80-l{width:80%}.swagger-ui .w-90-l{width:90%}.swagger-ui .w-100-l{width:100%}.swagger-ui .w-third-l{width:33.3333333333%}.swagger-ui .w-two-thirds-l{width:66.6666666667%}.swagger-ui .w-auto-l{width:auto}}.swagger-ui .overflow-visible{overflow:visible}.swagger-ui .overflow-hidden{overflow:hidden}.swagger-ui .overflow-scroll{overflow:scroll}.swagger-ui .overflow-auto{overflow:auto}.swagger-ui .overflow-x-visible{overflow-x:visible}.swagger-ui .overflow-x-hidden{overflow-x:hidden}.swagger-ui .overflow-x-scroll{overflow-x:scroll}.swagger-ui .overflow-x-auto{overflow-x:auto}.swagger-ui .overflow-y-visible{overflow-y:visible}.swagger-ui .overflow-y-hidden{overflow-y:hidden}.swagger-ui .overflow-y-scroll{overflow-y:scroll}.swagger-ui .overflow-y-auto{overflow-y:auto}@media screen and (min-width:30em){.swagger-ui .overflow-visible-ns{overflow:visible}.swagger-ui .overflow-hidden-ns{overflow:hidden}.swagger-ui .overflow-scroll-ns{overflow:scroll}.swagger-ui .overflow-auto-ns{overflow:auto}.swagger-ui .overflow-x-visible-ns{overflow-x:visible}.swagger-ui .overflow-x-hidden-ns{overflow-x:hidden}.swagger-ui .overflow-x-scroll-ns{overflow-x:scroll}.swagger-ui .overflow-x-auto-ns{overflow-x:auto}.swagger-ui .overflow-y-visible-ns{overflow-y:visible}.swagger-ui .overflow-y-hidden-ns{overflow-y:hidden}.swagger-ui .overflow-y-scroll-ns{overflow-y:scroll}.swagger-ui .overflow-y-auto-ns{overflow-y:auto}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .overflow-visible-m{overflow:visible}.swagger-ui .overflow-hidden-m{overflow:hidden}.swagger-ui .overflow-scroll-m{overflow:scroll}.swagger-ui .overflow-auto-m{overflow:auto}.swagger-ui .overflow-x-visible-m{overflow-x:visible}.swagger-ui .overflow-x-hidden-m{overflow-x:hidden}.swagger-ui .overflow-x-scroll-m{overflow-x:scroll}.swagger-ui .overflow-x-auto-m{overflow-x:auto}.swagger-ui .overflow-y-visible-m{overflow-y:visible}.swagger-ui .overflow-y-hidden-m{overflow-y:hidden}.swagger-ui .overflow-y-scroll-m{overflow-y:scroll}.swagger-ui .overflow-y-auto-m{overflow-y:auto}}@media screen and (min-width:60em){.swagger-ui .overflow-visible-l{overflow:visible}.swagger-ui .overflow-hidden-l{overflow:hidden}.swagger-ui .overflow-scroll-l{overflow:scroll}.swagger-ui .overflow-auto-l{overflow:auto}.swagger-ui .overflow-x-visible-l{overflow-x:visible}.swagger-ui .overflow-x-hidden-l{overflow-x:hidden}.swagger-ui .overflow-x-scroll-l{overflow-x:scroll}.swagger-ui .overflow-x-auto-l{overflow-x:auto}.swagger-ui .overflow-y-visible-l{overflow-y:visible}.swagger-ui .overflow-y-hidden-l{overflow-y:hidden}.swagger-ui .overflow-y-scroll-l{overflow-y:scroll}.swagger-ui .overflow-y-auto-l{overflow-y:auto}}.swagger-ui .static{position:static}.swagger-ui .relative{position:relative}.swagger-ui .absolute{position:absolute}.swagger-ui .fixed{position:fixed}@media screen and (min-width:30em){.swagger-ui .static-ns{position:static}.swagger-ui .relative-ns{position:relative}.swagger-ui .absolute-ns{position:absolute}.swagger-ui .fixed-ns{position:fixed}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .static-m{position:static}.swagger-ui .relative-m{position:relative}.swagger-ui .absolute-m{position:absolute}.swagger-ui .fixed-m{position:fixed}}@media screen and (min-width:60em){.swagger-ui .static-l{position:static}.swagger-ui .relative-l{position:relative}.swagger-ui .absolute-l{position:absolute}.swagger-ui .fixed-l{position:fixed}}.swagger-ui .o-100{opacity:1}.swagger-ui .o-90{opacity:.9}.swagger-ui .o-80{opacity:.8}.swagger-ui .o-70{opacity:.7}.swagger-ui .o-60{opacity:.6}.swagger-ui .o-50{opacity:.5}.swagger-ui .o-40{opacity:.4}.swagger-ui .o-30{opacity:.3}.swagger-ui .o-20{opacity:.2}.swagger-ui .o-10{opacity:.1}.swagger-ui .o-05{opacity:.05}.swagger-ui .o-025{opacity:.025}.swagger-ui .o-0{opacity:0}.swagger-ui .rotate-45{transform:rotate(45deg)}.swagger-ui .rotate-90{transform:rotate(90deg)}.swagger-ui .rotate-135{transform:rotate(135deg)}.swagger-ui .rotate-180{transform:rotate(180deg)}.swagger-ui .rotate-225{transform:rotate(225deg)}.swagger-ui .rotate-270{transform:rotate(270deg)}.swagger-ui .rotate-315{transform:rotate(315deg)}@media screen and (min-width:30em){.swagger-ui .rotate-45-ns{transform:rotate(45deg)}.swagger-ui .rotate-90-ns{transform:rotate(90deg)}.swagger-ui .rotate-135-ns{transform:rotate(135deg)}.swagger-ui .rotate-180-ns{transform:rotate(180deg)}.swagger-ui .rotate-225-ns{transform:rotate(225deg)}.swagger-ui .rotate-270-ns{transform:rotate(270deg)}.swagger-ui .rotate-315-ns{transform:rotate(315deg)}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .rotate-45-m{transform:rotate(45deg)}.swagger-ui .rotate-90-m{transform:rotate(90deg)}.swagger-ui .rotate-135-m{transform:rotate(135deg)}.swagger-ui .rotate-180-m{transform:rotate(180deg)}.swagger-ui .rotate-225-m{transform:rotate(225deg)}.swagger-ui .rotate-270-m{transform:rotate(270deg)}.swagger-ui .rotate-315-m{transform:rotate(315deg)}}@media screen and (min-width:60em){.swagger-ui .rotate-45-l{transform:rotate(45deg)}.swagger-ui .rotate-90-l{transform:rotate(90deg)}.swagger-ui .rotate-135-l{transform:rotate(135deg)}.swagger-ui .rotate-180-l{transform:rotate(180deg)}.swagger-ui .rotate-225-l{transform:rotate(225deg)}.swagger-ui .rotate-270-l{transform:rotate(270deg)}.swagger-ui .rotate-315-l{transform:rotate(315deg)}}.swagger-ui .black-90{color:rgba(0,0,0,.9)}.swagger-ui .black-80{color:rgba(0,0,0,.8)}.swagger-ui .black-70{color:rgba(0,0,0,.7)}.swagger-ui .black-60{color:rgba(0,0,0,.6)}.swagger-ui .black-50{color:rgba(0,0,0,.5)}.swagger-ui .black-40{color:rgba(0,0,0,.4)}.swagger-ui .black-30{color:rgba(0,0,0,.3)}.swagger-ui .black-20{color:rgba(0,0,0,.2)}.swagger-ui .black-10{color:rgba(0,0,0,.1)}.swagger-ui .black-05{color:rgba(0,0,0,.05)}.swagger-ui .white-90{color:hsla(0,0%,100%,.9)}.swagger-ui .white-80{color:hsla(0,0%,100%,.8)}.swagger-ui .white-70{color:hsla(0,0%,100%,.7)}.swagger-ui .white-60{color:hsla(0,0%,100%,.6)}.swagger-ui .white-50{color:hsla(0,0%,100%,.5)}.swagger-ui .white-40{color:hsla(0,0%,100%,.4)}.swagger-ui .white-30{color:hsla(0,0%,100%,.3)}.swagger-ui .white-20{color:hsla(0,0%,100%,.2)}.swagger-ui .white-10{color:hsla(0,0%,100%,.1)}.swagger-ui .black{color:#000}.swagger-ui .near-black{color:#111}.swagger-ui .dark-gray{color:#333}.swagger-ui .mid-gray{color:#555}.swagger-ui .gray{color:#777}.swagger-ui .silver{color:#999}.swagger-ui .light-silver{color:#aaa}.swagger-ui .moon-gray{color:#ccc}.swagger-ui .light-gray{color:#eee}.swagger-ui .near-white{color:#f4f4f4}.swagger-ui .white{color:#fff}.swagger-ui .dark-red{color:#e7040f}.swagger-ui .red{color:#ff4136}.swagger-ui .light-red{color:#ff725c}.swagger-ui .orange{color:#ff6300}.swagger-ui .gold{color:#ffb700}.swagger-ui .yellow{color:gold}.swagger-ui .light-yellow{color:#fbf1a9}.swagger-ui .purple{color:#5e2ca5}.swagger-ui .light-purple{color:#a463f2}.swagger-ui .dark-pink{color:#d5008f}.swagger-ui .hot-pink{color:#ff41b4}.swagger-ui .pink{color:#ff80cc}.swagger-ui .light-pink{color:#ffa3d7}.swagger-ui .dark-green{color:#137752}.swagger-ui .green{color:#19a974}.swagger-ui .light-green{color:#9eebcf}.swagger-ui .navy{color:#001b44}.swagger-ui .dark-blue{color:#00449e}.swagger-ui .blue{color:#357edd}.swagger-ui .light-blue{color:#96ccff}.swagger-ui .lightest-blue{color:#cdecff}.swagger-ui .washed-blue{color:#f6fffe}.swagger-ui .washed-green{color:#e8fdf5}.swagger-ui .washed-yellow{color:#fffceb}.swagger-ui .washed-red{color:#ffdfdf}.swagger-ui .color-inherit{color:inherit}.swagger-ui .bg-black-90{background-color:rgba(0,0,0,.9)}.swagger-ui .bg-black-80{background-color:rgba(0,0,0,.8)}.swagger-ui .bg-black-70{background-color:rgba(0,0,0,.7)}.swagger-ui .bg-black-60{background-color:rgba(0,0,0,.6)}.swagger-ui .bg-black-50{background-color:rgba(0,0,0,.5)}.swagger-ui .bg-black-40{background-color:rgba(0,0,0,.4)}.swagger-ui .bg-black-30{background-color:rgba(0,0,0,.3)}.swagger-ui .bg-black-20{background-color:rgba(0,0,0,.2)}.swagger-ui .bg-black-10{background-color:rgba(0,0,0,.1)}.swagger-ui .bg-black-05{background-color:rgba(0,0,0,.05)}.swagger-ui .bg-white-90{background-color:hsla(0,0%,100%,.9)}.swagger-ui .bg-white-80{background-color:hsla(0,0%,100%,.8)}.swagger-ui .bg-white-70{background-color:hsla(0,0%,100%,.7)}.swagger-ui .bg-white-60{background-color:hsla(0,0%,100%,.6)}.swagger-ui .bg-white-50{background-color:hsla(0,0%,100%,.5)}.swagger-ui .bg-white-40{background-color:hsla(0,0%,100%,.4)}.swagger-ui .bg-white-30{background-color:hsla(0,0%,100%,.3)}.swagger-ui .bg-white-20{background-color:hsla(0,0%,100%,.2)}.swagger-ui .bg-white-10{background-color:hsla(0,0%,100%,.1)}.swagger-ui .bg-black{background-color:#000}.swagger-ui .bg-near-black{background-color:#111}.swagger-ui .bg-dark-gray{background-color:#333}.swagger-ui .bg-mid-gray{background-color:#555}.swagger-ui .bg-gray{background-color:#777}.swagger-ui .bg-silver{background-color:#999}.swagger-ui .bg-light-silver{background-color:#aaa}.swagger-ui .bg-moon-gray{background-color:#ccc}.swagger-ui .bg-light-gray{background-color:#eee}.swagger-ui .bg-near-white{background-color:#f4f4f4}.swagger-ui .bg-white{background-color:#fff}.swagger-ui .bg-transparent{background-color:transparent}.swagger-ui .bg-dark-red{background-color:#e7040f}.swagger-ui .bg-red{background-color:#ff4136}.swagger-ui .bg-light-red{background-color:#ff725c}.swagger-ui .bg-orange{background-color:#ff6300}.swagger-ui .bg-gold{background-color:#ffb700}.swagger-ui .bg-yellow{background-color:gold}.swagger-ui .bg-light-yellow{background-color:#fbf1a9}.swagger-ui .bg-purple{background-color:#5e2ca5}.swagger-ui .bg-light-purple{background-color:#a463f2}.swagger-ui .bg-dark-pink{background-color:#d5008f}.swagger-ui .bg-hot-pink{background-color:#ff41b4}.swagger-ui .bg-pink{background-color:#ff80cc}.swagger-ui .bg-light-pink{background-color:#ffa3d7}.swagger-ui .bg-dark-green{background-color:#137752}.swagger-ui .bg-green{background-color:#19a974}.swagger-ui .bg-light-green{background-color:#9eebcf}.swagger-ui .bg-navy{background-color:#001b44}.swagger-ui .bg-dark-blue{background-color:#00449e}.swagger-ui .bg-blue{background-color:#357edd}.swagger-ui .bg-light-blue{background-color:#96ccff}.swagger-ui .bg-lightest-blue{background-color:#cdecff}.swagger-ui .bg-washed-blue{background-color:#f6fffe}.swagger-ui .bg-washed-green{background-color:#e8fdf5}.swagger-ui .bg-washed-yellow{background-color:#fffceb}.swagger-ui .bg-washed-red{background-color:#ffdfdf}.swagger-ui .bg-inherit{background-color:inherit}.swagger-ui .hover-black:focus,.swagger-ui .hover-black:hover{color:#000}.swagger-ui .hover-near-black:focus,.swagger-ui .hover-near-black:hover{color:#111}.swagger-ui .hover-dark-gray:focus,.swagger-ui .hover-dark-gray:hover{color:#333}.swagger-ui .hover-mid-gray:focus,.swagger-ui .hover-mid-gray:hover{color:#555}.swagger-ui .hover-gray:focus,.swagger-ui .hover-gray:hover{color:#777}.swagger-ui .hover-silver:focus,.swagger-ui .hover-silver:hover{color:#999}.swagger-ui .hover-light-silver:focus,.swagger-ui .hover-light-silver:hover{color:#aaa}.swagger-ui .hover-moon-gray:focus,.swagger-ui .hover-moon-gray:hover{color:#ccc}.swagger-ui .hover-light-gray:focus,.swagger-ui .hover-light-gray:hover{color:#eee}.swagger-ui .hover-near-white:focus,.swagger-ui .hover-near-white:hover{color:#f4f4f4}.swagger-ui .hover-white:focus,.swagger-ui .hover-white:hover{color:#fff}.swagger-ui .hover-black-90:focus,.swagger-ui .hover-black-90:hover{color:rgba(0,0,0,.9)}.swagger-ui .hover-black-80:focus,.swagger-ui .hover-black-80:hover{color:rgba(0,0,0,.8)}.swagger-ui .hover-black-70:focus,.swagger-ui .hover-black-70:hover{color:rgba(0,0,0,.7)}.swagger-ui .hover-black-60:focus,.swagger-ui .hover-black-60:hover{color:rgba(0,0,0,.6)}.swagger-ui .hover-black-50:focus,.swagger-ui .hover-black-50:hover{color:rgba(0,0,0,.5)}.swagger-ui .hover-black-40:focus,.swagger-ui .hover-black-40:hover{color:rgba(0,0,0,.4)}.swagger-ui .hover-black-30:focus,.swagger-ui .hover-black-30:hover{color:rgba(0,0,0,.3)}.swagger-ui .hover-black-20:focus,.swagger-ui .hover-black-20:hover{color:rgba(0,0,0,.2)}.swagger-ui .hover-black-10:focus,.swagger-ui .hover-black-10:hover{color:rgba(0,0,0,.1)}.swagger-ui .hover-white-90:focus,.swagger-ui .hover-white-90:hover{color:hsla(0,0%,100%,.9)}.swagger-ui .hover-white-80:focus,.swagger-ui .hover-white-80:hover{color:hsla(0,0%,100%,.8)}.swagger-ui .hover-white-70:focus,.swagger-ui .hover-white-70:hover{color:hsla(0,0%,100%,.7)}.swagger-ui .hover-white-60:focus,.swagger-ui .hover-white-60:hover{color:hsla(0,0%,100%,.6)}.swagger-ui .hover-white-50:focus,.swagger-ui .hover-white-50:hover{color:hsla(0,0%,100%,.5)}.swagger-ui .hover-white-40:focus,.swagger-ui .hover-white-40:hover{color:hsla(0,0%,100%,.4)}.swagger-ui .hover-white-30:focus,.swagger-ui .hover-white-30:hover{color:hsla(0,0%,100%,.3)}.swagger-ui .hover-white-20:focus,.swagger-ui .hover-white-20:hover{color:hsla(0,0%,100%,.2)}.swagger-ui .hover-white-10:focus,.swagger-ui .hover-white-10:hover{color:hsla(0,0%,100%,.1)}.swagger-ui .hover-inherit:focus,.swagger-ui .hover-inherit:hover{color:inherit}.swagger-ui .hover-bg-black:focus,.swagger-ui .hover-bg-black:hover{background-color:#000}.swagger-ui .hover-bg-near-black:focus,.swagger-ui .hover-bg-near-black:hover{background-color:#111}.swagger-ui .hover-bg-dark-gray:focus,.swagger-ui .hover-bg-dark-gray:hover{background-color:#333}.swagger-ui .hover-bg-mid-gray:focus,.swagger-ui .hover-bg-mid-gray:hover{background-color:#555}.swagger-ui .hover-bg-gray:focus,.swagger-ui .hover-bg-gray:hover{background-color:#777}.swagger-ui .hover-bg-silver:focus,.swagger-ui .hover-bg-silver:hover{background-color:#999}.swagger-ui .hover-bg-light-silver:focus,.swagger-ui .hover-bg-light-silver:hover{background-color:#aaa}.swagger-ui .hover-bg-moon-gray:focus,.swagger-ui .hover-bg-moon-gray:hover{background-color:#ccc}.swagger-ui .hover-bg-light-gray:focus,.swagger-ui .hover-bg-light-gray:hover{background-color:#eee}.swagger-ui .hover-bg-near-white:focus,.swagger-ui .hover-bg-near-white:hover{background-color:#f4f4f4}.swagger-ui .hover-bg-white:focus,.swagger-ui .hover-bg-white:hover{background-color:#fff}.swagger-ui .hover-bg-transparent:focus,.swagger-ui .hover-bg-transparent:hover{background-color:transparent}.swagger-ui .hover-bg-black-90:focus,.swagger-ui .hover-bg-black-90:hover{background-color:rgba(0,0,0,.9)}.swagger-ui .hover-bg-black-80:focus,.swagger-ui .hover-bg-black-80:hover{background-color:rgba(0,0,0,.8)}.swagger-ui .hover-bg-black-70:focus,.swagger-ui .hover-bg-black-70:hover{background-color:rgba(0,0,0,.7)}.swagger-ui .hover-bg-black-60:focus,.swagger-ui .hover-bg-black-60:hover{background-color:rgba(0,0,0,.6)}.swagger-ui .hover-bg-black-50:focus,.swagger-ui .hover-bg-black-50:hover{background-color:rgba(0,0,0,.5)}.swagger-ui .hover-bg-black-40:focus,.swagger-ui .hover-bg-black-40:hover{background-color:rgba(0,0,0,.4)}.swagger-ui .hover-bg-black-30:focus,.swagger-ui .hover-bg-black-30:hover{background-color:rgba(0,0,0,.3)}.swagger-ui .hover-bg-black-20:focus,.swagger-ui .hover-bg-black-20:hover{background-color:rgba(0,0,0,.2)}.swagger-ui .hover-bg-black-10:focus,.swagger-ui .hover-bg-black-10:hover{background-color:rgba(0,0,0,.1)}.swagger-ui .hover-bg-white-90:focus,.swagger-ui .hover-bg-white-90:hover{background-color:hsla(0,0%,100%,.9)}.swagger-ui .hover-bg-white-80:focus,.swagger-ui .hover-bg-white-80:hover{background-color:hsla(0,0%,100%,.8)}.swagger-ui .hover-bg-white-70:focus,.swagger-ui .hover-bg-white-70:hover{background-color:hsla(0,0%,100%,.7)}.swagger-ui .hover-bg-white-60:focus,.swagger-ui .hover-bg-white-60:hover{background-color:hsla(0,0%,100%,.6)}.swagger-ui .hover-bg-white-50:focus,.swagger-ui .hover-bg-white-50:hover{background-color:hsla(0,0%,100%,.5)}.swagger-ui .hover-bg-white-40:focus,.swagger-ui .hover-bg-white-40:hover{background-color:hsla(0,0%,100%,.4)}.swagger-ui .hover-bg-white-30:focus,.swagger-ui .hover-bg-white-30:hover{background-color:hsla(0,0%,100%,.3)}.swagger-ui .hover-bg-white-20:focus,.swagger-ui .hover-bg-white-20:hover{background-color:hsla(0,0%,100%,.2)}.swagger-ui .hover-bg-white-10:focus,.swagger-ui .hover-bg-white-10:hover{background-color:hsla(0,0%,100%,.1)}.swagger-ui .hover-dark-red:focus,.swagger-ui .hover-dark-red:hover{color:#e7040f}.swagger-ui .hover-red:focus,.swagger-ui .hover-red:hover{color:#ff4136}.swagger-ui .hover-light-red:focus,.swagger-ui .hover-light-red:hover{color:#ff725c}.swagger-ui .hover-orange:focus,.swagger-ui .hover-orange:hover{color:#ff6300}.swagger-ui .hover-gold:focus,.swagger-ui .hover-gold:hover{color:#ffb700}.swagger-ui .hover-yellow:focus,.swagger-ui .hover-yellow:hover{color:gold}.swagger-ui .hover-light-yellow:focus,.swagger-ui .hover-light-yellow:hover{color:#fbf1a9}.swagger-ui .hover-purple:focus,.swagger-ui .hover-purple:hover{color:#5e2ca5}.swagger-ui .hover-light-purple:focus,.swagger-ui .hover-light-purple:hover{color:#a463f2}.swagger-ui .hover-dark-pink:focus,.swagger-ui .hover-dark-pink:hover{color:#d5008f}.swagger-ui .hover-hot-pink:focus,.swagger-ui .hover-hot-pink:hover{color:#ff41b4}.swagger-ui .hover-pink:focus,.swagger-ui .hover-pink:hover{color:#ff80cc}.swagger-ui .hover-light-pink:focus,.swagger-ui .hover-light-pink:hover{color:#ffa3d7}.swagger-ui .hover-dark-green:focus,.swagger-ui .hover-dark-green:hover{color:#137752}.swagger-ui .hover-green:focus,.swagger-ui .hover-green:hover{color:#19a974}.swagger-ui .hover-light-green:focus,.swagger-ui .hover-light-green:hover{color:#9eebcf}.swagger-ui .hover-navy:focus,.swagger-ui .hover-navy:hover{color:#001b44}.swagger-ui .hover-dark-blue:focus,.swagger-ui .hover-dark-blue:hover{color:#00449e}.swagger-ui .hover-blue:focus,.swagger-ui .hover-blue:hover{color:#357edd}.swagger-ui .hover-light-blue:focus,.swagger-ui .hover-light-blue:hover{color:#96ccff}.swagger-ui .hover-lightest-blue:focus,.swagger-ui .hover-lightest-blue:hover{color:#cdecff}.swagger-ui .hover-washed-blue:focus,.swagger-ui .hover-washed-blue:hover{color:#f6fffe}.swagger-ui .hover-washed-green:focus,.swagger-ui .hover-washed-green:hover{color:#e8fdf5}.swagger-ui .hover-washed-yellow:focus,.swagger-ui .hover-washed-yellow:hover{color:#fffceb}.swagger-ui .hover-washed-red:focus,.swagger-ui .hover-washed-red:hover{color:#ffdfdf}.swagger-ui .hover-bg-dark-red:focus,.swagger-ui .hover-bg-dark-red:hover{background-color:#e7040f}.swagger-ui .hover-bg-red:focus,.swagger-ui .hover-bg-red:hover{background-color:#ff4136}.swagger-ui .hover-bg-light-red:focus,.swagger-ui .hover-bg-light-red:hover{background-color:#ff725c}.swagger-ui .hover-bg-orange:focus,.swagger-ui .hover-bg-orange:hover{background-color:#ff6300}.swagger-ui .hover-bg-gold:focus,.swagger-ui .hover-bg-gold:hover{background-color:#ffb700}.swagger-ui .hover-bg-yellow:focus,.swagger-ui .hover-bg-yellow:hover{background-color:gold}.swagger-ui .hover-bg-light-yellow:focus,.swagger-ui .hover-bg-light-yellow:hover{background-color:#fbf1a9}.swagger-ui .hover-bg-purple:focus,.swagger-ui .hover-bg-purple:hover{background-color:#5e2ca5}.swagger-ui .hover-bg-light-purple:focus,.swagger-ui .hover-bg-light-purple:hover{background-color:#a463f2}.swagger-ui .hover-bg-dark-pink:focus,.swagger-ui .hover-bg-dark-pink:hover{background-color:#d5008f}.swagger-ui .hover-bg-hot-pink:focus,.swagger-ui .hover-bg-hot-pink:hover{background-color:#ff41b4}.swagger-ui .hover-bg-pink:focus,.swagger-ui .hover-bg-pink:hover{background-color:#ff80cc}.swagger-ui .hover-bg-light-pink:focus,.swagger-ui .hover-bg-light-pink:hover{background-color:#ffa3d7}.swagger-ui .hover-bg-dark-green:focus,.swagger-ui .hover-bg-dark-green:hover{background-color:#137752}.swagger-ui .hover-bg-green:focus,.swagger-ui .hover-bg-green:hover{background-color:#19a974}.swagger-ui .hover-bg-light-green:focus,.swagger-ui .hover-bg-light-green:hover{background-color:#9eebcf}.swagger-ui .hover-bg-navy:focus,.swagger-ui .hover-bg-navy:hover{background-color:#001b44}.swagger-ui .hover-bg-dark-blue:focus,.swagger-ui .hover-bg-dark-blue:hover{background-color:#00449e}.swagger-ui .hover-bg-blue:focus,.swagger-ui .hover-bg-blue:hover{background-color:#357edd}.swagger-ui .hover-bg-light-blue:focus,.swagger-ui .hover-bg-light-blue:hover{background-color:#96ccff}.swagger-ui .hover-bg-lightest-blue:focus,.swagger-ui .hover-bg-lightest-blue:hover{background-color:#cdecff}.swagger-ui .hover-bg-washed-blue:focus,.swagger-ui .hover-bg-washed-blue:hover{background-color:#f6fffe}.swagger-ui .hover-bg-washed-green:focus,.swagger-ui .hover-bg-washed-green:hover{background-color:#e8fdf5}.swagger-ui .hover-bg-washed-yellow:focus,.swagger-ui .hover-bg-washed-yellow:hover{background-color:#fffceb}.swagger-ui .hover-bg-washed-red:focus,.swagger-ui .hover-bg-washed-red:hover{background-color:#ffdfdf}.swagger-ui .hover-bg-inherit:focus,.swagger-ui .hover-bg-inherit:hover{background-color:inherit}.swagger-ui .pa0{padding:0}.swagger-ui .pa1{padding:.25rem}.swagger-ui .pa2{padding:.5rem}.swagger-ui .pa3{padding:1rem}.swagger-ui .pa4{padding:2rem}.swagger-ui .pa5{padding:4rem}.swagger-ui .pa6{padding:8rem}.swagger-ui .pa7{padding:16rem}.swagger-ui .pl0{padding-left:0}.swagger-ui .pl1{padding-left:.25rem}.swagger-ui .pl2{padding-left:.5rem}.swagger-ui .pl3{padding-left:1rem}.swagger-ui .pl4{padding-left:2rem}.swagger-ui .pl5{padding-left:4rem}.swagger-ui .pl6{padding-left:8rem}.swagger-ui .pl7{padding-left:16rem}.swagger-ui .pr0{padding-right:0}.swagger-ui .pr1{padding-right:.25rem}.swagger-ui .pr2{padding-right:.5rem}.swagger-ui .pr3{padding-right:1rem}.swagger-ui .pr4{padding-right:2rem}.swagger-ui .pr5{padding-right:4rem}.swagger-ui .pr6{padding-right:8rem}.swagger-ui .pr7{padding-right:16rem}.swagger-ui .pb0{padding-bottom:0}.swagger-ui .pb1{padding-bottom:.25rem}.swagger-ui .pb2{padding-bottom:.5rem}.swagger-ui .pb3{padding-bottom:1rem}.swagger-ui .pb4{padding-bottom:2rem}.swagger-ui .pb5{padding-bottom:4rem}.swagger-ui .pb6{padding-bottom:8rem}.swagger-ui .pb7{padding-bottom:16rem}.swagger-ui .pt0{padding-top:0}.swagger-ui .pt1{padding-top:.25rem}.swagger-ui .pt2{padding-top:.5rem}.swagger-ui .pt3{padding-top:1rem}.swagger-ui .pt4{padding-top:2rem}.swagger-ui .pt5{padding-top:4rem}.swagger-ui .pt6{padding-top:8rem}.swagger-ui .pt7{padding-top:16rem}.swagger-ui .pv0{padding-bottom:0;padding-top:0}.swagger-ui .pv1{padding-bottom:.25rem;padding-top:.25rem}.swagger-ui .pv2{padding-bottom:.5rem;padding-top:.5rem}.swagger-ui .pv3{padding-bottom:1rem;padding-top:1rem}.swagger-ui .pv4{padding-bottom:2rem;padding-top:2rem}.swagger-ui .pv5{padding-bottom:4rem;padding-top:4rem}.swagger-ui .pv6{padding-bottom:8rem;padding-top:8rem}.swagger-ui .pv7{padding-bottom:16rem;padding-top:16rem}.swagger-ui .ph0{padding-left:0;padding-right:0}.swagger-ui .ph1{padding-left:.25rem;padding-right:.25rem}.swagger-ui .ph2{padding-left:.5rem;padding-right:.5rem}.swagger-ui .ph3{padding-left:1rem;padding-right:1rem}.swagger-ui .ph4{padding-left:2rem;padding-right:2rem}.swagger-ui .ph5{padding-left:4rem;padding-right:4rem}.swagger-ui .ph6{padding-left:8rem;padding-right:8rem}.swagger-ui .ph7{padding-left:16rem;padding-right:16rem}.swagger-ui .ma0{margin:0}.swagger-ui .ma1{margin:.25rem}.swagger-ui .ma2{margin:.5rem}.swagger-ui .ma3{margin:1rem}.swagger-ui .ma4{margin:2rem}.swagger-ui .ma5{margin:4rem}.swagger-ui .ma6{margin:8rem}.swagger-ui .ma7{margin:16rem}.swagger-ui .ml0{margin-left:0}.swagger-ui .ml1{margin-left:.25rem}.swagger-ui .ml2{margin-left:.5rem}.swagger-ui .ml3{margin-left:1rem}.swagger-ui .ml4{margin-left:2rem}.swagger-ui .ml5{margin-left:4rem}.swagger-ui .ml6{margin-left:8rem}.swagger-ui .ml7{margin-left:16rem}.swagger-ui .mr0{margin-right:0}.swagger-ui .mr1{margin-right:.25rem}.swagger-ui .mr2{margin-right:.5rem}.swagger-ui .mr3{margin-right:1rem}.swagger-ui .mr4{margin-right:2rem}.swagger-ui .mr5{margin-right:4rem}.swagger-ui .mr6{margin-right:8rem}.swagger-ui .mr7{margin-right:16rem}.swagger-ui .mb0{margin-bottom:0}.swagger-ui .mb1{margin-bottom:.25rem}.swagger-ui .mb2{margin-bottom:.5rem}.swagger-ui .mb3{margin-bottom:1rem}.swagger-ui .mb4{margin-bottom:2rem}.swagger-ui .mb5{margin-bottom:4rem}.swagger-ui .mb6{margin-bottom:8rem}.swagger-ui .mb7{margin-bottom:16rem}.swagger-ui .mt0{margin-top:0}.swagger-ui .mt1{margin-top:.25rem}.swagger-ui .mt2{margin-top:.5rem}.swagger-ui .mt3{margin-top:1rem}.swagger-ui .mt4{margin-top:2rem}.swagger-ui .mt5{margin-top:4rem}.swagger-ui .mt6{margin-top:8rem}.swagger-ui .mt7{margin-top:16rem}.swagger-ui .mv0{margin-bottom:0;margin-top:0}.swagger-ui .mv1{margin-bottom:.25rem;margin-top:.25rem}.swagger-ui .mv2{margin-bottom:.5rem;margin-top:.5rem}.swagger-ui .mv3{margin-bottom:1rem;margin-top:1rem}.swagger-ui .mv4{margin-bottom:2rem;margin-top:2rem}.swagger-ui .mv5{margin-bottom:4rem;margin-top:4rem}.swagger-ui .mv6{margin-bottom:8rem;margin-top:8rem}.swagger-ui .mv7{margin-bottom:16rem;margin-top:16rem}.swagger-ui .mh0{margin-left:0;margin-right:0}.swagger-ui .mh1{margin-left:.25rem;margin-right:.25rem}.swagger-ui .mh2{margin-left:.5rem;margin-right:.5rem}.swagger-ui .mh3{margin-left:1rem;margin-right:1rem}.swagger-ui .mh4{margin-left:2rem;margin-right:2rem}.swagger-ui .mh5{margin-left:4rem;margin-right:4rem}.swagger-ui .mh6{margin-left:8rem;margin-right:8rem}.swagger-ui .mh7{margin-left:16rem;margin-right:16rem}@media screen and (min-width:30em){.swagger-ui .pa0-ns{padding:0}.swagger-ui .pa1-ns{padding:.25rem}.swagger-ui .pa2-ns{padding:.5rem}.swagger-ui .pa3-ns{padding:1rem}.swagger-ui .pa4-ns{padding:2rem}.swagger-ui .pa5-ns{padding:4rem}.swagger-ui .pa6-ns{padding:8rem}.swagger-ui .pa7-ns{padding:16rem}.swagger-ui .pl0-ns{padding-left:0}.swagger-ui .pl1-ns{padding-left:.25rem}.swagger-ui .pl2-ns{padding-left:.5rem}.swagger-ui .pl3-ns{padding-left:1rem}.swagger-ui .pl4-ns{padding-left:2rem}.swagger-ui .pl5-ns{padding-left:4rem}.swagger-ui .pl6-ns{padding-left:8rem}.swagger-ui .pl7-ns{padding-left:16rem}.swagger-ui .pr0-ns{padding-right:0}.swagger-ui .pr1-ns{padding-right:.25rem}.swagger-ui .pr2-ns{padding-right:.5rem}.swagger-ui .pr3-ns{padding-right:1rem}.swagger-ui .pr4-ns{padding-right:2rem}.swagger-ui .pr5-ns{padding-right:4rem}.swagger-ui .pr6-ns{padding-right:8rem}.swagger-ui .pr7-ns{padding-right:16rem}.swagger-ui .pb0-ns{padding-bottom:0}.swagger-ui .pb1-ns{padding-bottom:.25rem}.swagger-ui .pb2-ns{padding-bottom:.5rem}.swagger-ui .pb3-ns{padding-bottom:1rem}.swagger-ui .pb4-ns{padding-bottom:2rem}.swagger-ui .pb5-ns{padding-bottom:4rem}.swagger-ui .pb6-ns{padding-bottom:8rem}.swagger-ui .pb7-ns{padding-bottom:16rem}.swagger-ui .pt0-ns{padding-top:0}.swagger-ui .pt1-ns{padding-top:.25rem}.swagger-ui .pt2-ns{padding-top:.5rem}.swagger-ui .pt3-ns{padding-top:1rem}.swagger-ui .pt4-ns{padding-top:2rem}.swagger-ui .pt5-ns{padding-top:4rem}.swagger-ui .pt6-ns{padding-top:8rem}.swagger-ui .pt7-ns{padding-top:16rem}.swagger-ui .pv0-ns{padding-bottom:0;padding-top:0}.swagger-ui .pv1-ns{padding-bottom:.25rem;padding-top:.25rem}.swagger-ui .pv2-ns{padding-bottom:.5rem;padding-top:.5rem}.swagger-ui .pv3-ns{padding-bottom:1rem;padding-top:1rem}.swagger-ui .pv4-ns{padding-bottom:2rem;padding-top:2rem}.swagger-ui .pv5-ns{padding-bottom:4rem;padding-top:4rem}.swagger-ui .pv6-ns{padding-bottom:8rem;padding-top:8rem}.swagger-ui .pv7-ns{padding-bottom:16rem;padding-top:16rem}.swagger-ui .ph0-ns{padding-left:0;padding-right:0}.swagger-ui .ph1-ns{padding-left:.25rem;padding-right:.25rem}.swagger-ui .ph2-ns{padding-left:.5rem;padding-right:.5rem}.swagger-ui .ph3-ns{padding-left:1rem;padding-right:1rem}.swagger-ui .ph4-ns{padding-left:2rem;padding-right:2rem}.swagger-ui .ph5-ns{padding-left:4rem;padding-right:4rem}.swagger-ui .ph6-ns{padding-left:8rem;padding-right:8rem}.swagger-ui .ph7-ns{padding-left:16rem;padding-right:16rem}.swagger-ui .ma0-ns{margin:0}.swagger-ui .ma1-ns{margin:.25rem}.swagger-ui .ma2-ns{margin:.5rem}.swagger-ui .ma3-ns{margin:1rem}.swagger-ui .ma4-ns{margin:2rem}.swagger-ui .ma5-ns{margin:4rem}.swagger-ui .ma6-ns{margin:8rem}.swagger-ui .ma7-ns{margin:16rem}.swagger-ui .ml0-ns{margin-left:0}.swagger-ui .ml1-ns{margin-left:.25rem}.swagger-ui .ml2-ns{margin-left:.5rem}.swagger-ui .ml3-ns{margin-left:1rem}.swagger-ui .ml4-ns{margin-left:2rem}.swagger-ui .ml5-ns{margin-left:4rem}.swagger-ui .ml6-ns{margin-left:8rem}.swagger-ui .ml7-ns{margin-left:16rem}.swagger-ui .mr0-ns{margin-right:0}.swagger-ui .mr1-ns{margin-right:.25rem}.swagger-ui .mr2-ns{margin-right:.5rem}.swagger-ui .mr3-ns{margin-right:1rem}.swagger-ui .mr4-ns{margin-right:2rem}.swagger-ui .mr5-ns{margin-right:4rem}.swagger-ui .mr6-ns{margin-right:8rem}.swagger-ui .mr7-ns{margin-right:16rem}.swagger-ui .mb0-ns{margin-bottom:0}.swagger-ui .mb1-ns{margin-bottom:.25rem}.swagger-ui .mb2-ns{margin-bottom:.5rem}.swagger-ui .mb3-ns{margin-bottom:1rem}.swagger-ui .mb4-ns{margin-bottom:2rem}.swagger-ui .mb5-ns{margin-bottom:4rem}.swagger-ui .mb6-ns{margin-bottom:8rem}.swagger-ui .mb7-ns{margin-bottom:16rem}.swagger-ui .mt0-ns{margin-top:0}.swagger-ui .mt1-ns{margin-top:.25rem}.swagger-ui .mt2-ns{margin-top:.5rem}.swagger-ui .mt3-ns{margin-top:1rem}.swagger-ui .mt4-ns{margin-top:2rem}.swagger-ui .mt5-ns{margin-top:4rem}.swagger-ui .mt6-ns{margin-top:8rem}.swagger-ui .mt7-ns{margin-top:16rem}.swagger-ui .mv0-ns{margin-bottom:0;margin-top:0}.swagger-ui .mv1-ns{margin-bottom:.25rem;margin-top:.25rem}.swagger-ui .mv2-ns{margin-bottom:.5rem;margin-top:.5rem}.swagger-ui .mv3-ns{margin-bottom:1rem;margin-top:1rem}.swagger-ui .mv4-ns{margin-bottom:2rem;margin-top:2rem}.swagger-ui .mv5-ns{margin-bottom:4rem;margin-top:4rem}.swagger-ui .mv6-ns{margin-bottom:8rem;margin-top:8rem}.swagger-ui .mv7-ns{margin-bottom:16rem;margin-top:16rem}.swagger-ui .mh0-ns{margin-left:0;margin-right:0}.swagger-ui .mh1-ns{margin-left:.25rem;margin-right:.25rem}.swagger-ui .mh2-ns{margin-left:.5rem;margin-right:.5rem}.swagger-ui .mh3-ns{margin-left:1rem;margin-right:1rem}.swagger-ui .mh4-ns{margin-left:2rem;margin-right:2rem}.swagger-ui .mh5-ns{margin-left:4rem;margin-right:4rem}.swagger-ui .mh6-ns{margin-left:8rem;margin-right:8rem}.swagger-ui .mh7-ns{margin-left:16rem;margin-right:16rem}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .pa0-m{padding:0}.swagger-ui .pa1-m{padding:.25rem}.swagger-ui .pa2-m{padding:.5rem}.swagger-ui .pa3-m{padding:1rem}.swagger-ui .pa4-m{padding:2rem}.swagger-ui .pa5-m{padding:4rem}.swagger-ui .pa6-m{padding:8rem}.swagger-ui .pa7-m{padding:16rem}.swagger-ui .pl0-m{padding-left:0}.swagger-ui .pl1-m{padding-left:.25rem}.swagger-ui .pl2-m{padding-left:.5rem}.swagger-ui .pl3-m{padding-left:1rem}.swagger-ui .pl4-m{padding-left:2rem}.swagger-ui .pl5-m{padding-left:4rem}.swagger-ui .pl6-m{padding-left:8rem}.swagger-ui .pl7-m{padding-left:16rem}.swagger-ui .pr0-m{padding-right:0}.swagger-ui .pr1-m{padding-right:.25rem}.swagger-ui .pr2-m{padding-right:.5rem}.swagger-ui .pr3-m{padding-right:1rem}.swagger-ui .pr4-m{padding-right:2rem}.swagger-ui .pr5-m{padding-right:4rem}.swagger-ui .pr6-m{padding-right:8rem}.swagger-ui .pr7-m{padding-right:16rem}.swagger-ui .pb0-m{padding-bottom:0}.swagger-ui .pb1-m{padding-bottom:.25rem}.swagger-ui .pb2-m{padding-bottom:.5rem}.swagger-ui .pb3-m{padding-bottom:1rem}.swagger-ui .pb4-m{padding-bottom:2rem}.swagger-ui .pb5-m{padding-bottom:4rem}.swagger-ui .pb6-m{padding-bottom:8rem}.swagger-ui .pb7-m{padding-bottom:16rem}.swagger-ui .pt0-m{padding-top:0}.swagger-ui .pt1-m{padding-top:.25rem}.swagger-ui .pt2-m{padding-top:.5rem}.swagger-ui .pt3-m{padding-top:1rem}.swagger-ui .pt4-m{padding-top:2rem}.swagger-ui .pt5-m{padding-top:4rem}.swagger-ui .pt6-m{padding-top:8rem}.swagger-ui .pt7-m{padding-top:16rem}.swagger-ui .pv0-m{padding-bottom:0;padding-top:0}.swagger-ui .pv1-m{padding-bottom:.25rem;padding-top:.25rem}.swagger-ui .pv2-m{padding-bottom:.5rem;padding-top:.5rem}.swagger-ui .pv3-m{padding-bottom:1rem;padding-top:1rem}.swagger-ui .pv4-m{padding-bottom:2rem;padding-top:2rem}.swagger-ui .pv5-m{padding-bottom:4rem;padding-top:4rem}.swagger-ui .pv6-m{padding-bottom:8rem;padding-top:8rem}.swagger-ui .pv7-m{padding-bottom:16rem;padding-top:16rem}.swagger-ui .ph0-m{padding-left:0;padding-right:0}.swagger-ui .ph1-m{padding-left:.25rem;padding-right:.25rem}.swagger-ui .ph2-m{padding-left:.5rem;padding-right:.5rem}.swagger-ui .ph3-m{padding-left:1rem;padding-right:1rem}.swagger-ui .ph4-m{padding-left:2rem;padding-right:2rem}.swagger-ui .ph5-m{padding-left:4rem;padding-right:4rem}.swagger-ui .ph6-m{padding-left:8rem;padding-right:8rem}.swagger-ui .ph7-m{padding-left:16rem;padding-right:16rem}.swagger-ui .ma0-m{margin:0}.swagger-ui .ma1-m{margin:.25rem}.swagger-ui .ma2-m{margin:.5rem}.swagger-ui .ma3-m{margin:1rem}.swagger-ui .ma4-m{margin:2rem}.swagger-ui .ma5-m{margin:4rem}.swagger-ui .ma6-m{margin:8rem}.swagger-ui .ma7-m{margin:16rem}.swagger-ui .ml0-m{margin-left:0}.swagger-ui .ml1-m{margin-left:.25rem}.swagger-ui .ml2-m{margin-left:.5rem}.swagger-ui .ml3-m{margin-left:1rem}.swagger-ui .ml4-m{margin-left:2rem}.swagger-ui .ml5-m{margin-left:4rem}.swagger-ui .ml6-m{margin-left:8rem}.swagger-ui .ml7-m{margin-left:16rem}.swagger-ui .mr0-m{margin-right:0}.swagger-ui .mr1-m{margin-right:.25rem}.swagger-ui .mr2-m{margin-right:.5rem}.swagger-ui .mr3-m{margin-right:1rem}.swagger-ui .mr4-m{margin-right:2rem}.swagger-ui .mr5-m{margin-right:4rem}.swagger-ui .mr6-m{margin-right:8rem}.swagger-ui .mr7-m{margin-right:16rem}.swagger-ui .mb0-m{margin-bottom:0}.swagger-ui .mb1-m{margin-bottom:.25rem}.swagger-ui .mb2-m{margin-bottom:.5rem}.swagger-ui .mb3-m{margin-bottom:1rem}.swagger-ui .mb4-m{margin-bottom:2rem}.swagger-ui .mb5-m{margin-bottom:4rem}.swagger-ui .mb6-m{margin-bottom:8rem}.swagger-ui .mb7-m{margin-bottom:16rem}.swagger-ui .mt0-m{margin-top:0}.swagger-ui .mt1-m{margin-top:.25rem}.swagger-ui .mt2-m{margin-top:.5rem}.swagger-ui .mt3-m{margin-top:1rem}.swagger-ui .mt4-m{margin-top:2rem}.swagger-ui .mt5-m{margin-top:4rem}.swagger-ui .mt6-m{margin-top:8rem}.swagger-ui .mt7-m{margin-top:16rem}.swagger-ui .mv0-m{margin-bottom:0;margin-top:0}.swagger-ui .mv1-m{margin-bottom:.25rem;margin-top:.25rem}.swagger-ui .mv2-m{margin-bottom:.5rem;margin-top:.5rem}.swagger-ui .mv3-m{margin-bottom:1rem;margin-top:1rem}.swagger-ui .mv4-m{margin-bottom:2rem;margin-top:2rem}.swagger-ui .mv5-m{margin-bottom:4rem;margin-top:4rem}.swagger-ui .mv6-m{margin-bottom:8rem;margin-top:8rem}.swagger-ui .mv7-m{margin-bottom:16rem;margin-top:16rem}.swagger-ui .mh0-m{margin-left:0;margin-right:0}.swagger-ui .mh1-m{margin-left:.25rem;margin-right:.25rem}.swagger-ui .mh2-m{margin-left:.5rem;margin-right:.5rem}.swagger-ui .mh3-m{margin-left:1rem;margin-right:1rem}.swagger-ui .mh4-m{margin-left:2rem;margin-right:2rem}.swagger-ui .mh5-m{margin-left:4rem;margin-right:4rem}.swagger-ui .mh6-m{margin-left:8rem;margin-right:8rem}.swagger-ui .mh7-m{margin-left:16rem;margin-right:16rem}}@media screen and (min-width:60em){.swagger-ui .pa0-l{padding:0}.swagger-ui .pa1-l{padding:.25rem}.swagger-ui .pa2-l{padding:.5rem}.swagger-ui .pa3-l{padding:1rem}.swagger-ui .pa4-l{padding:2rem}.swagger-ui .pa5-l{padding:4rem}.swagger-ui .pa6-l{padding:8rem}.swagger-ui .pa7-l{padding:16rem}.swagger-ui .pl0-l{padding-left:0}.swagger-ui .pl1-l{padding-left:.25rem}.swagger-ui .pl2-l{padding-left:.5rem}.swagger-ui .pl3-l{padding-left:1rem}.swagger-ui .pl4-l{padding-left:2rem}.swagger-ui .pl5-l{padding-left:4rem}.swagger-ui .pl6-l{padding-left:8rem}.swagger-ui .pl7-l{padding-left:16rem}.swagger-ui .pr0-l{padding-right:0}.swagger-ui .pr1-l{padding-right:.25rem}.swagger-ui .pr2-l{padding-right:.5rem}.swagger-ui .pr3-l{padding-right:1rem}.swagger-ui .pr4-l{padding-right:2rem}.swagger-ui .pr5-l{padding-right:4rem}.swagger-ui .pr6-l{padding-right:8rem}.swagger-ui .pr7-l{padding-right:16rem}.swagger-ui .pb0-l{padding-bottom:0}.swagger-ui .pb1-l{padding-bottom:.25rem}.swagger-ui .pb2-l{padding-bottom:.5rem}.swagger-ui .pb3-l{padding-bottom:1rem}.swagger-ui .pb4-l{padding-bottom:2rem}.swagger-ui .pb5-l{padding-bottom:4rem}.swagger-ui .pb6-l{padding-bottom:8rem}.swagger-ui .pb7-l{padding-bottom:16rem}.swagger-ui .pt0-l{padding-top:0}.swagger-ui .pt1-l{padding-top:.25rem}.swagger-ui .pt2-l{padding-top:.5rem}.swagger-ui .pt3-l{padding-top:1rem}.swagger-ui .pt4-l{padding-top:2rem}.swagger-ui .pt5-l{padding-top:4rem}.swagger-ui .pt6-l{padding-top:8rem}.swagger-ui .pt7-l{padding-top:16rem}.swagger-ui .pv0-l{padding-bottom:0;padding-top:0}.swagger-ui .pv1-l{padding-bottom:.25rem;padding-top:.25rem}.swagger-ui .pv2-l{padding-bottom:.5rem;padding-top:.5rem}.swagger-ui .pv3-l{padding-bottom:1rem;padding-top:1rem}.swagger-ui .pv4-l{padding-bottom:2rem;padding-top:2rem}.swagger-ui .pv5-l{padding-bottom:4rem;padding-top:4rem}.swagger-ui .pv6-l{padding-bottom:8rem;padding-top:8rem}.swagger-ui .pv7-l{padding-bottom:16rem;padding-top:16rem}.swagger-ui .ph0-l{padding-left:0;padding-right:0}.swagger-ui .ph1-l{padding-left:.25rem;padding-right:.25rem}.swagger-ui .ph2-l{padding-left:.5rem;padding-right:.5rem}.swagger-ui .ph3-l{padding-left:1rem;padding-right:1rem}.swagger-ui .ph4-l{padding-left:2rem;padding-right:2rem}.swagger-ui .ph5-l{padding-left:4rem;padding-right:4rem}.swagger-ui .ph6-l{padding-left:8rem;padding-right:8rem}.swagger-ui .ph7-l{padding-left:16rem;padding-right:16rem}.swagger-ui .ma0-l{margin:0}.swagger-ui .ma1-l{margin:.25rem}.swagger-ui .ma2-l{margin:.5rem}.swagger-ui .ma3-l{margin:1rem}.swagger-ui .ma4-l{margin:2rem}.swagger-ui .ma5-l{margin:4rem}.swagger-ui .ma6-l{margin:8rem}.swagger-ui .ma7-l{margin:16rem}.swagger-ui .ml0-l{margin-left:0}.swagger-ui .ml1-l{margin-left:.25rem}.swagger-ui .ml2-l{margin-left:.5rem}.swagger-ui .ml3-l{margin-left:1rem}.swagger-ui .ml4-l{margin-left:2rem}.swagger-ui .ml5-l{margin-left:4rem}.swagger-ui .ml6-l{margin-left:8rem}.swagger-ui .ml7-l{margin-left:16rem}.swagger-ui .mr0-l{margin-right:0}.swagger-ui .mr1-l{margin-right:.25rem}.swagger-ui .mr2-l{margin-right:.5rem}.swagger-ui .mr3-l{margin-right:1rem}.swagger-ui .mr4-l{margin-right:2rem}.swagger-ui .mr5-l{margin-right:4rem}.swagger-ui .mr6-l{margin-right:8rem}.swagger-ui .mr7-l{margin-right:16rem}.swagger-ui .mb0-l{margin-bottom:0}.swagger-ui .mb1-l{margin-bottom:.25rem}.swagger-ui .mb2-l{margin-bottom:.5rem}.swagger-ui .mb3-l{margin-bottom:1rem}.swagger-ui .mb4-l{margin-bottom:2rem}.swagger-ui .mb5-l{margin-bottom:4rem}.swagger-ui .mb6-l{margin-bottom:8rem}.swagger-ui .mb7-l{margin-bottom:16rem}.swagger-ui .mt0-l{margin-top:0}.swagger-ui .mt1-l{margin-top:.25rem}.swagger-ui .mt2-l{margin-top:.5rem}.swagger-ui .mt3-l{margin-top:1rem}.swagger-ui .mt4-l{margin-top:2rem}.swagger-ui .mt5-l{margin-top:4rem}.swagger-ui .mt6-l{margin-top:8rem}.swagger-ui .mt7-l{margin-top:16rem}.swagger-ui .mv0-l{margin-bottom:0;margin-top:0}.swagger-ui .mv1-l{margin-bottom:.25rem;margin-top:.25rem}.swagger-ui .mv2-l{margin-bottom:.5rem;margin-top:.5rem}.swagger-ui .mv3-l{margin-bottom:1rem;margin-top:1rem}.swagger-ui .mv4-l{margin-bottom:2rem;margin-top:2rem}.swagger-ui .mv5-l{margin-bottom:4rem;margin-top:4rem}.swagger-ui .mv6-l{margin-bottom:8rem;margin-top:8rem}.swagger-ui .mv7-l{margin-bottom:16rem;margin-top:16rem}.swagger-ui .mh0-l{margin-left:0;margin-right:0}.swagger-ui .mh1-l{margin-left:.25rem;margin-right:.25rem}.swagger-ui .mh2-l{margin-left:.5rem;margin-right:.5rem}.swagger-ui .mh3-l{margin-left:1rem;margin-right:1rem}.swagger-ui .mh4-l{margin-left:2rem;margin-right:2rem}.swagger-ui .mh5-l{margin-left:4rem;margin-right:4rem}.swagger-ui .mh6-l{margin-left:8rem;margin-right:8rem}.swagger-ui .mh7-l{margin-left:16rem;margin-right:16rem}}.swagger-ui .na1{margin:-.25rem}.swagger-ui .na2{margin:-.5rem}.swagger-ui .na3{margin:-1rem}.swagger-ui .na4{margin:-2rem}.swagger-ui .na5{margin:-4rem}.swagger-ui .na6{margin:-8rem}.swagger-ui .na7{margin:-16rem}.swagger-ui .nl1{margin-left:-.25rem}.swagger-ui .nl2{margin-left:-.5rem}.swagger-ui .nl3{margin-left:-1rem}.swagger-ui .nl4{margin-left:-2rem}.swagger-ui .nl5{margin-left:-4rem}.swagger-ui .nl6{margin-left:-8rem}.swagger-ui .nl7{margin-left:-16rem}.swagger-ui .nr1{margin-right:-.25rem}.swagger-ui .nr2{margin-right:-.5rem}.swagger-ui .nr3{margin-right:-1rem}.swagger-ui .nr4{margin-right:-2rem}.swagger-ui .nr5{margin-right:-4rem}.swagger-ui .nr6{margin-right:-8rem}.swagger-ui .nr7{margin-right:-16rem}.swagger-ui .nb1{margin-bottom:-.25rem}.swagger-ui .nb2{margin-bottom:-.5rem}.swagger-ui .nb3{margin-bottom:-1rem}.swagger-ui .nb4{margin-bottom:-2rem}.swagger-ui .nb5{margin-bottom:-4rem}.swagger-ui .nb6{margin-bottom:-8rem}.swagger-ui .nb7{margin-bottom:-16rem}.swagger-ui .nt1{margin-top:-.25rem}.swagger-ui .nt2{margin-top:-.5rem}.swagger-ui .nt3{margin-top:-1rem}.swagger-ui .nt4{margin-top:-2rem}.swagger-ui .nt5{margin-top:-4rem}.swagger-ui .nt6{margin-top:-8rem}.swagger-ui .nt7{margin-top:-16rem}@media screen and (min-width:30em){.swagger-ui .na1-ns{margin:-.25rem}.swagger-ui .na2-ns{margin:-.5rem}.swagger-ui .na3-ns{margin:-1rem}.swagger-ui .na4-ns{margin:-2rem}.swagger-ui .na5-ns{margin:-4rem}.swagger-ui .na6-ns{margin:-8rem}.swagger-ui .na7-ns{margin:-16rem}.swagger-ui .nl1-ns{margin-left:-.25rem}.swagger-ui .nl2-ns{margin-left:-.5rem}.swagger-ui .nl3-ns{margin-left:-1rem}.swagger-ui .nl4-ns{margin-left:-2rem}.swagger-ui .nl5-ns{margin-left:-4rem}.swagger-ui .nl6-ns{margin-left:-8rem}.swagger-ui .nl7-ns{margin-left:-16rem}.swagger-ui .nr1-ns{margin-right:-.25rem}.swagger-ui .nr2-ns{margin-right:-.5rem}.swagger-ui .nr3-ns{margin-right:-1rem}.swagger-ui .nr4-ns{margin-right:-2rem}.swagger-ui .nr5-ns{margin-right:-4rem}.swagger-ui .nr6-ns{margin-right:-8rem}.swagger-ui .nr7-ns{margin-right:-16rem}.swagger-ui .nb1-ns{margin-bottom:-.25rem}.swagger-ui .nb2-ns{margin-bottom:-.5rem}.swagger-ui .nb3-ns{margin-bottom:-1rem}.swagger-ui .nb4-ns{margin-bottom:-2rem}.swagger-ui .nb5-ns{margin-bottom:-4rem}.swagger-ui .nb6-ns{margin-bottom:-8rem}.swagger-ui .nb7-ns{margin-bottom:-16rem}.swagger-ui .nt1-ns{margin-top:-.25rem}.swagger-ui .nt2-ns{margin-top:-.5rem}.swagger-ui .nt3-ns{margin-top:-1rem}.swagger-ui .nt4-ns{margin-top:-2rem}.swagger-ui .nt5-ns{margin-top:-4rem}.swagger-ui .nt6-ns{margin-top:-8rem}.swagger-ui .nt7-ns{margin-top:-16rem}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .na1-m{margin:-.25rem}.swagger-ui .na2-m{margin:-.5rem}.swagger-ui .na3-m{margin:-1rem}.swagger-ui .na4-m{margin:-2rem}.swagger-ui .na5-m{margin:-4rem}.swagger-ui .na6-m{margin:-8rem}.swagger-ui .na7-m{margin:-16rem}.swagger-ui .nl1-m{margin-left:-.25rem}.swagger-ui .nl2-m{margin-left:-.5rem}.swagger-ui .nl3-m{margin-left:-1rem}.swagger-ui .nl4-m{margin-left:-2rem}.swagger-ui .nl5-m{margin-left:-4rem}.swagger-ui .nl6-m{margin-left:-8rem}.swagger-ui .nl7-m{margin-left:-16rem}.swagger-ui .nr1-m{margin-right:-.25rem}.swagger-ui .nr2-m{margin-right:-.5rem}.swagger-ui .nr3-m{margin-right:-1rem}.swagger-ui .nr4-m{margin-right:-2rem}.swagger-ui .nr5-m{margin-right:-4rem}.swagger-ui .nr6-m{margin-right:-8rem}.swagger-ui .nr7-m{margin-right:-16rem}.swagger-ui .nb1-m{margin-bottom:-.25rem}.swagger-ui .nb2-m{margin-bottom:-.5rem}.swagger-ui .nb3-m{margin-bottom:-1rem}.swagger-ui .nb4-m{margin-bottom:-2rem}.swagger-ui .nb5-m{margin-bottom:-4rem}.swagger-ui .nb6-m{margin-bottom:-8rem}.swagger-ui .nb7-m{margin-bottom:-16rem}.swagger-ui .nt1-m{margin-top:-.25rem}.swagger-ui .nt2-m{margin-top:-.5rem}.swagger-ui .nt3-m{margin-top:-1rem}.swagger-ui .nt4-m{margin-top:-2rem}.swagger-ui .nt5-m{margin-top:-4rem}.swagger-ui .nt6-m{margin-top:-8rem}.swagger-ui .nt7-m{margin-top:-16rem}}@media screen and (min-width:60em){.swagger-ui .na1-l{margin:-.25rem}.swagger-ui .na2-l{margin:-.5rem}.swagger-ui .na3-l{margin:-1rem}.swagger-ui .na4-l{margin:-2rem}.swagger-ui .na5-l{margin:-4rem}.swagger-ui .na6-l{margin:-8rem}.swagger-ui .na7-l{margin:-16rem}.swagger-ui .nl1-l{margin-left:-.25rem}.swagger-ui .nl2-l{margin-left:-.5rem}.swagger-ui .nl3-l{margin-left:-1rem}.swagger-ui .nl4-l{margin-left:-2rem}.swagger-ui .nl5-l{margin-left:-4rem}.swagger-ui .nl6-l{margin-left:-8rem}.swagger-ui .nl7-l{margin-left:-16rem}.swagger-ui .nr1-l{margin-right:-.25rem}.swagger-ui .nr2-l{margin-right:-.5rem}.swagger-ui .nr3-l{margin-right:-1rem}.swagger-ui .nr4-l{margin-right:-2rem}.swagger-ui .nr5-l{margin-right:-4rem}.swagger-ui .nr6-l{margin-right:-8rem}.swagger-ui .nr7-l{margin-right:-16rem}.swagger-ui .nb1-l{margin-bottom:-.25rem}.swagger-ui .nb2-l{margin-bottom:-.5rem}.swagger-ui .nb3-l{margin-bottom:-1rem}.swagger-ui .nb4-l{margin-bottom:-2rem}.swagger-ui .nb5-l{margin-bottom:-4rem}.swagger-ui .nb6-l{margin-bottom:-8rem}.swagger-ui .nb7-l{margin-bottom:-16rem}.swagger-ui .nt1-l{margin-top:-.25rem}.swagger-ui .nt2-l{margin-top:-.5rem}.swagger-ui .nt3-l{margin-top:-1rem}.swagger-ui .nt4-l{margin-top:-2rem}.swagger-ui .nt5-l{margin-top:-4rem}.swagger-ui .nt6-l{margin-top:-8rem}.swagger-ui .nt7-l{margin-top:-16rem}}.swagger-ui .collapse{border-collapse:collapse;border-spacing:0}.swagger-ui .striped--light-silver:nth-child(odd){background-color:#aaa}.swagger-ui .striped--moon-gray:nth-child(odd){background-color:#ccc}.swagger-ui .striped--light-gray:nth-child(odd){background-color:#eee}.swagger-ui .striped--near-white:nth-child(odd){background-color:#f4f4f4}.swagger-ui .stripe-light:nth-child(odd){background-color:hsla(0,0%,100%,.1)}.swagger-ui .stripe-dark:nth-child(odd){background-color:rgba(0,0,0,.1)}.swagger-ui .strike{-webkit-text-decoration:line-through;text-decoration:line-through}.swagger-ui .underline{-webkit-text-decoration:underline;text-decoration:underline}.swagger-ui .no-underline{-webkit-text-decoration:none;text-decoration:none}@media screen and (min-width:30em){.swagger-ui .strike-ns{-webkit-text-decoration:line-through;text-decoration:line-through}.swagger-ui .underline-ns{-webkit-text-decoration:underline;text-decoration:underline}.swagger-ui .no-underline-ns{-webkit-text-decoration:none;text-decoration:none}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .strike-m{-webkit-text-decoration:line-through;text-decoration:line-through}.swagger-ui .underline-m{-webkit-text-decoration:underline;text-decoration:underline}.swagger-ui .no-underline-m{-webkit-text-decoration:none;text-decoration:none}}@media screen and (min-width:60em){.swagger-ui .strike-l{-webkit-text-decoration:line-through;text-decoration:line-through}.swagger-ui .underline-l{-webkit-text-decoration:underline;text-decoration:underline}.swagger-ui .no-underline-l{-webkit-text-decoration:none;text-decoration:none}}.swagger-ui .tl{text-align:left}.swagger-ui .tr{text-align:right}.swagger-ui .tc{text-align:center}.swagger-ui .tj{text-align:justify}@media screen and (min-width:30em){.swagger-ui .tl-ns{text-align:left}.swagger-ui .tr-ns{text-align:right}.swagger-ui .tc-ns{text-align:center}.swagger-ui .tj-ns{text-align:justify}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .tl-m{text-align:left}.swagger-ui .tr-m{text-align:right}.swagger-ui .tc-m{text-align:center}.swagger-ui .tj-m{text-align:justify}}@media screen and (min-width:60em){.swagger-ui .tl-l{text-align:left}.swagger-ui .tr-l{text-align:right}.swagger-ui .tc-l{text-align:center}.swagger-ui .tj-l{text-align:justify}}.swagger-ui .ttc{text-transform:capitalize}.swagger-ui .ttl{text-transform:lowercase}.swagger-ui .ttu{text-transform:uppercase}.swagger-ui .ttn{text-transform:none}@media screen and (min-width:30em){.swagger-ui .ttc-ns{text-transform:capitalize}.swagger-ui .ttl-ns{text-transform:lowercase}.swagger-ui .ttu-ns{text-transform:uppercase}.swagger-ui .ttn-ns{text-transform:none}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .ttc-m{text-transform:capitalize}.swagger-ui .ttl-m{text-transform:lowercase}.swagger-ui .ttu-m{text-transform:uppercase}.swagger-ui .ttn-m{text-transform:none}}@media screen and (min-width:60em){.swagger-ui .ttc-l{text-transform:capitalize}.swagger-ui .ttl-l{text-transform:lowercase}.swagger-ui .ttu-l{text-transform:uppercase}.swagger-ui .ttn-l{text-transform:none}}.swagger-ui .f-6,.swagger-ui .f-headline{font-size:6rem}.swagger-ui .f-5,.swagger-ui .f-subheadline{font-size:5rem}.swagger-ui .f1{font-size:3rem}.swagger-ui .f2{font-size:2.25rem}.swagger-ui .f3{font-size:1.5rem}.swagger-ui .f4{font-size:1.25rem}.swagger-ui .f5{font-size:1rem}.swagger-ui .f6{font-size:.875rem}.swagger-ui .f7{font-size:.75rem}@media screen and (min-width:30em){.swagger-ui .f-6-ns,.swagger-ui .f-headline-ns{font-size:6rem}.swagger-ui .f-5-ns,.swagger-ui .f-subheadline-ns{font-size:5rem}.swagger-ui .f1-ns{font-size:3rem}.swagger-ui .f2-ns{font-size:2.25rem}.swagger-ui .f3-ns{font-size:1.5rem}.swagger-ui .f4-ns{font-size:1.25rem}.swagger-ui .f5-ns{font-size:1rem}.swagger-ui .f6-ns{font-size:.875rem}.swagger-ui .f7-ns{font-size:.75rem}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .f-6-m,.swagger-ui .f-headline-m{font-size:6rem}.swagger-ui .f-5-m,.swagger-ui .f-subheadline-m{font-size:5rem}.swagger-ui .f1-m{font-size:3rem}.swagger-ui .f2-m{font-size:2.25rem}.swagger-ui .f3-m{font-size:1.5rem}.swagger-ui .f4-m{font-size:1.25rem}.swagger-ui .f5-m{font-size:1rem}.swagger-ui .f6-m{font-size:.875rem}.swagger-ui .f7-m{font-size:.75rem}}@media screen and (min-width:60em){.swagger-ui .f-6-l,.swagger-ui .f-headline-l{font-size:6rem}.swagger-ui .f-5-l,.swagger-ui .f-subheadline-l{font-size:5rem}.swagger-ui .f1-l{font-size:3rem}.swagger-ui .f2-l{font-size:2.25rem}.swagger-ui .f3-l{font-size:1.5rem}.swagger-ui .f4-l{font-size:1.25rem}.swagger-ui .f5-l{font-size:1rem}.swagger-ui .f6-l{font-size:.875rem}.swagger-ui .f7-l{font-size:.75rem}}.swagger-ui .measure{max-width:30em}.swagger-ui .measure-wide{max-width:34em}.swagger-ui .measure-narrow{max-width:20em}.swagger-ui .indent{margin-bottom:0;margin-top:0;text-indent:1em}.swagger-ui .small-caps{font-feature-settings:"smcp";font-variant:small-caps}.swagger-ui .truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}@media screen and (min-width:30em){.swagger-ui .measure-ns{max-width:30em}.swagger-ui .measure-wide-ns{max-width:34em}.swagger-ui .measure-narrow-ns{max-width:20em}.swagger-ui .indent-ns{margin-bottom:0;margin-top:0;text-indent:1em}.swagger-ui .small-caps-ns{font-feature-settings:"smcp";font-variant:small-caps}.swagger-ui .truncate-ns{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .measure-m{max-width:30em}.swagger-ui .measure-wide-m{max-width:34em}.swagger-ui .measure-narrow-m{max-width:20em}.swagger-ui .indent-m{margin-bottom:0;margin-top:0;text-indent:1em}.swagger-ui .small-caps-m{font-feature-settings:"smcp";font-variant:small-caps}.swagger-ui .truncate-m{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}@media screen and (min-width:60em){.swagger-ui .measure-l{max-width:30em}.swagger-ui .measure-wide-l{max-width:34em}.swagger-ui .measure-narrow-l{max-width:20em}.swagger-ui .indent-l{margin-bottom:0;margin-top:0;text-indent:1em}.swagger-ui .small-caps-l{font-feature-settings:"smcp";font-variant:small-caps}.swagger-ui .truncate-l{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}.swagger-ui .overflow-container{overflow-y:scroll}.swagger-ui .center{margin-left:auto;margin-right:auto}.swagger-ui .mr-auto{margin-right:auto}.swagger-ui .ml-auto{margin-left:auto}@media screen and (min-width:30em){.swagger-ui .center-ns{margin-left:auto;margin-right:auto}.swagger-ui .mr-auto-ns{margin-right:auto}.swagger-ui .ml-auto-ns{margin-left:auto}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .center-m{margin-left:auto;margin-right:auto}.swagger-ui .mr-auto-m{margin-right:auto}.swagger-ui .ml-auto-m{margin-left:auto}}@media screen and (min-width:60em){.swagger-ui .center-l{margin-left:auto;margin-right:auto}.swagger-ui .mr-auto-l{margin-right:auto}.swagger-ui .ml-auto-l{margin-left:auto}}.swagger-ui .clip{position:fixed!important;_position:absolute!important;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px)}@media screen and (min-width:30em){.swagger-ui .clip-ns{position:fixed!important;_position:absolute!important;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px)}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .clip-m{position:fixed!important;_position:absolute!important;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px)}}@media screen and (min-width:60em){.swagger-ui .clip-l{position:fixed!important;_position:absolute!important;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px)}}.swagger-ui .ws-normal{white-space:normal}.swagger-ui .nowrap{white-space:nowrap}.swagger-ui .pre{white-space:pre}@media screen and (min-width:30em){.swagger-ui .ws-normal-ns{white-space:normal}.swagger-ui .nowrap-ns{white-space:nowrap}.swagger-ui .pre-ns{white-space:pre}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .ws-normal-m{white-space:normal}.swagger-ui .nowrap-m{white-space:nowrap}.swagger-ui .pre-m{white-space:pre}}@media screen and (min-width:60em){.swagger-ui .ws-normal-l{white-space:normal}.swagger-ui .nowrap-l{white-space:nowrap}.swagger-ui .pre-l{white-space:pre}}.swagger-ui .v-base{vertical-align:baseline}.swagger-ui .v-mid{vertical-align:middle}.swagger-ui .v-top{vertical-align:top}.swagger-ui .v-btm{vertical-align:bottom}@media screen and (min-width:30em){.swagger-ui .v-base-ns{vertical-align:baseline}.swagger-ui .v-mid-ns{vertical-align:middle}.swagger-ui .v-top-ns{vertical-align:top}.swagger-ui .v-btm-ns{vertical-align:bottom}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .v-base-m{vertical-align:baseline}.swagger-ui .v-mid-m{vertical-align:middle}.swagger-ui .v-top-m{vertical-align:top}.swagger-ui .v-btm-m{vertical-align:bottom}}@media screen and (min-width:60em){.swagger-ui .v-base-l{vertical-align:baseline}.swagger-ui .v-mid-l{vertical-align:middle}.swagger-ui .v-top-l{vertical-align:top}.swagger-ui .v-btm-l{vertical-align:bottom}}.swagger-ui .dim{opacity:1;transition:opacity .15s ease-in}.swagger-ui .dim:focus,.swagger-ui .dim:hover{opacity:.5;transition:opacity .15s ease-in}.swagger-ui .dim:active{opacity:.8;transition:opacity .15s ease-out}.swagger-ui .glow{transition:opacity .15s ease-in}.swagger-ui .glow:focus,.swagger-ui .glow:hover{opacity:1;transition:opacity .15s ease-in}.swagger-ui .hide-child .child{opacity:0;transition:opacity .15s ease-in}.swagger-ui .hide-child:active .child,.swagger-ui .hide-child:focus .child,.swagger-ui .hide-child:hover .child{opacity:1;transition:opacity .15s ease-in}.swagger-ui .underline-hover:focus,.swagger-ui .underline-hover:hover{-webkit-text-decoration:underline;text-decoration:underline}.swagger-ui .grow{-moz-osx-font-smoothing:grayscale;backface-visibility:hidden;transform:translateZ(0);transition:transform .25s ease-out}.swagger-ui .grow:focus,.swagger-ui .grow:hover{transform:scale(1.05)}.swagger-ui .grow:active{transform:scale(.9)}.swagger-ui .grow-large{-moz-osx-font-smoothing:grayscale;backface-visibility:hidden;transform:translateZ(0);transition:transform .25s ease-in-out}.swagger-ui .grow-large:focus,.swagger-ui .grow-large:hover{transform:scale(1.2)}.swagger-ui .grow-large:active{transform:scale(.95)}.swagger-ui .pointer:hover{cursor:pointer}.swagger-ui .shadow-hover{cursor:pointer;position:relative;transition:all .5s cubic-bezier(.165,.84,.44,1)}.swagger-ui .shadow-hover:after{border-radius:inherit;box-shadow:0 0 16px 2px rgba(0,0,0,.2);content:"";height:100%;left:0;opacity:0;position:absolute;top:0;transition:opacity .5s cubic-bezier(.165,.84,.44,1);width:100%;z-index:-1}.swagger-ui .shadow-hover:focus:after,.swagger-ui .shadow-hover:hover:after{opacity:1}.swagger-ui .bg-animate,.swagger-ui .bg-animate:focus,.swagger-ui .bg-animate:hover{transition:background-color .15s ease-in-out}.swagger-ui .z-0{z-index:0}.swagger-ui .z-1{z-index:1}.swagger-ui .z-2{z-index:2}.swagger-ui .z-3{z-index:3}.swagger-ui .z-4{z-index:4}.swagger-ui .z-5{z-index:5}.swagger-ui .z-999{z-index:999}.swagger-ui .z-9999{z-index:9999}.swagger-ui .z-max{z-index:2147483647}.swagger-ui .z-inherit{z-index:inherit}.swagger-ui .z-initial,.swagger-ui .z-unset{z-index:auto}.swagger-ui .nested-copy-line-height ol,.swagger-ui .nested-copy-line-height p,.swagger-ui .nested-copy-line-height ul{line-height:1.5}.swagger-ui .nested-headline-line-height h1,.swagger-ui .nested-headline-line-height h2,.swagger-ui .nested-headline-line-height h3,.swagger-ui .nested-headline-line-height h4,.swagger-ui .nested-headline-line-height h5,.swagger-ui .nested-headline-line-height h6{line-height:1.25}.swagger-ui .nested-list-reset ol,.swagger-ui .nested-list-reset ul{list-style-type:none;margin-left:0;padding-left:0}.swagger-ui .nested-copy-indent p+p{margin-bottom:0;margin-top:0;text-indent:.1em}.swagger-ui .nested-copy-seperator p+p{margin-top:1.5em}.swagger-ui .nested-img img{display:block;max-width:100%;width:100%}.swagger-ui .nested-links a{color:#357edd;transition:color .15s ease-in}.swagger-ui .nested-links a:focus,.swagger-ui .nested-links a:hover{color:#96ccff;transition:color .15s ease-in}.swagger-ui .wrapper{box-sizing:border-box;margin:0 auto;max-width:1460px;padding:0 20px;width:100%}.swagger-ui .opblock-tag-section{display:flex;flex-direction:column}.swagger-ui .try-out.btn-group{display:flex;flex:.1 2 auto;padding:0}.swagger-ui .try-out__btn{margin-left:1.25rem}.swagger-ui .opblock-tag{align-items:center;border-bottom:1px solid rgba(59,65,81,.3);cursor:pointer;display:flex;padding:10px 20px 10px 10px;transition:all .2s}.swagger-ui .opblock-tag:hover{background:rgba(0,0,0,.02)}.swagger-ui .opblock-tag{color:#3b4151;font-family:sans-serif;font-size:24px;margin:0 0 5px}.swagger-ui .opblock-tag.no-desc span{flex:1}.swagger-ui .opblock-tag svg{transition:all .4s}.swagger-ui .opblock-tag small{color:#3b4151;flex:2;font-family:sans-serif;font-size:14px;font-weight:400;padding:0 10px}.swagger-ui .opblock-tag>div{flex:1 1 150px;font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}@media(max-width:640px){.swagger-ui .opblock-tag small,.swagger-ui .opblock-tag>div{flex:1}}.swagger-ui .opblock-tag .info__externaldocs{text-align:right}.swagger-ui .parameter__type{color:#3b4151;font-family:monospace;font-size:12px;font-weight:600;padding:5px 0}.swagger-ui .parameter-controls{margin-top:.75em}.swagger-ui .examples__title{display:block;font-size:1.1em;font-weight:700;margin-bottom:.75em}.swagger-ui .examples__section{margin-top:1.5em}.swagger-ui .examples__section-header{font-size:.9rem;font-weight:700;margin-bottom:.5rem}.swagger-ui .examples-select{display:inline-block;margin-bottom:.75em}.swagger-ui .examples-select .examples-select-element{width:100%}.swagger-ui .examples-select__section-label{font-size:.9rem;font-weight:700;margin-right:.5rem}.swagger-ui .example__section{margin-top:1.5em}.swagger-ui .example__section-header{font-size:.9rem;font-weight:700;margin-bottom:.5rem}.swagger-ui .view-line-link{cursor:pointer;margin:0 5px;position:relative;top:3px;transition:all .5s;width:20px}.swagger-ui .opblock{border:1px solid #000;border-radius:4px;box-shadow:0 0 3px rgba(0,0,0,.19);margin:0 0 15px}.swagger-ui .opblock .tab-header{display:flex;flex:1}.swagger-ui .opblock .tab-header .tab-item{cursor:pointer;padding:0 40px}.swagger-ui .opblock .tab-header .tab-item:first-of-type{padding:0 40px 0 0}.swagger-ui .opblock .tab-header .tab-item.active h4 span{position:relative}.swagger-ui .opblock .tab-header .tab-item.active h4 span:after{background:grey;bottom:-15px;content:"";height:4px;left:50%;position:absolute;transform:translateX(-50%);width:120%}.swagger-ui .opblock.is-open .opblock-summary{border-bottom:1px solid #000}.swagger-ui .opblock .opblock-section-header{align-items:center;background:hsla(0,0%,100%,.8);box-shadow:0 1px 2px rgba(0,0,0,.1);display:flex;min-height:50px;padding:8px 20px}.swagger-ui .opblock .opblock-section-header>label{align-items:center;color:#3b4151;display:flex;font-family:sans-serif;font-size:12px;font-weight:700;margin:0 0 0 auto}.swagger-ui .opblock .opblock-section-header>label>span{padding:0 10px 0 0}.swagger-ui .opblock .opblock-section-header h4{color:#3b4151;flex:1;font-family:sans-serif;font-size:14px;margin:0}.swagger-ui .opblock .opblock-summary-method{background:#000;border-radius:3px;color:#fff;font-family:sans-serif;font-size:14px;font-weight:700;min-width:80px;padding:6px 0;text-align:center;text-shadow:0 1px 0 rgba(0,0,0,.1)}@media(max-width:768px){.swagger-ui .opblock .opblock-summary-method{font-size:12px}}.swagger-ui .opblock .opblock-summary-operation-id,.swagger-ui .opblock .opblock-summary-path,.swagger-ui .opblock .opblock-summary-path__deprecated{align-items:center;color:#3b4151;display:flex;font-family:monospace;font-size:16px;font-weight:600;word-break:break-word}@media(max-width:768px){.swagger-ui .opblock .opblock-summary-operation-id,.swagger-ui .opblock .opblock-summary-path,.swagger-ui .opblock .opblock-summary-path__deprecated{font-size:12px}}.swagger-ui .opblock .opblock-summary-path{flex-shrink:1}@media(max-width:640px){.swagger-ui .opblock .opblock-summary-path{max-width:100%}}.swagger-ui .opblock .opblock-summary-path__deprecated{-webkit-text-decoration:line-through;text-decoration:line-through}.swagger-ui .opblock .opblock-summary-operation-id{font-size:14px}.swagger-ui .opblock .opblock-summary-description{color:#3b4151;font-family:sans-serif;font-size:13px;word-break:break-word}.swagger-ui .opblock .opblock-summary-path-description-wrapper{align-items:center;display:flex;flex-direction:row;flex-wrap:wrap;gap:0 10px;padding:0 10px;width:100%}@media(max-width:550px){.swagger-ui .opblock .opblock-summary-path-description-wrapper{align-items:flex-start;flex-direction:column}}.swagger-ui .opblock .opblock-summary{align-items:center;cursor:pointer;display:flex;padding:5px}.swagger-ui .opblock .opblock-summary .view-line-link{cursor:pointer;margin:0;position:relative;top:2px;transition:all .5s;width:0}.swagger-ui .opblock .opblock-summary:hover .view-line-link{margin:0 5px;width:18px}.swagger-ui .opblock .opblock-summary:hover .view-line-link.copy-to-clipboard{width:24px}.swagger-ui .opblock.opblock-post{background:rgba(73,204,144,.1);border-color:#49cc90}.swagger-ui .opblock.opblock-post .opblock-summary-method{background:#49cc90}.swagger-ui .opblock.opblock-post .opblock-summary{border-color:#49cc90}.swagger-ui .opblock.opblock-post .tab-header .tab-item.active h4 span:after{background:#49cc90}.swagger-ui .opblock.opblock-put{background:rgba(252,161,48,.1);border-color:#fca130}.swagger-ui .opblock.opblock-put .opblock-summary-method{background:#fca130}.swagger-ui .opblock.opblock-put .opblock-summary{border-color:#fca130}.swagger-ui .opblock.opblock-put .tab-header .tab-item.active h4 span:after{background:#fca130}.swagger-ui .opblock.opblock-delete{background:rgba(249,62,62,.1);border-color:#f93e3e}.swagger-ui .opblock.opblock-delete .opblock-summary-method{background:#f93e3e}.swagger-ui .opblock.opblock-delete .opblock-summary{border-color:#f93e3e}.swagger-ui .opblock.opblock-delete .tab-header .tab-item.active h4 span:after{background:#f93e3e}.swagger-ui .opblock.opblock-get{background:rgba(97,175,254,.1);border-color:#61affe}.swagger-ui .opblock.opblock-get .opblock-summary-method{background:#61affe}.swagger-ui .opblock.opblock-get .opblock-summary{border-color:#61affe}.swagger-ui .opblock.opblock-get .tab-header .tab-item.active h4 span:after{background:#61affe}.swagger-ui .opblock.opblock-patch{background:rgba(80,227,194,.1);border-color:#50e3c2}.swagger-ui .opblock.opblock-patch .opblock-summary-method{background:#50e3c2}.swagger-ui .opblock.opblock-patch .opblock-summary{border-color:#50e3c2}.swagger-ui .opblock.opblock-patch .tab-header .tab-item.active h4 span:after{background:#50e3c2}.swagger-ui .opblock.opblock-head{background:rgba(144,18,254,.1);border-color:#9012fe}.swagger-ui .opblock.opblock-head .opblock-summary-method{background:#9012fe}.swagger-ui .opblock.opblock-head .opblock-summary{border-color:#9012fe}.swagger-ui .opblock.opblock-head .tab-header .tab-item.active h4 span:after{background:#9012fe}.swagger-ui .opblock.opblock-options{background:rgba(13,90,167,.1);border-color:#0d5aa7}.swagger-ui .opblock.opblock-options .opblock-summary-method{background:#0d5aa7}.swagger-ui .opblock.opblock-options .opblock-summary{border-color:#0d5aa7}.swagger-ui .opblock.opblock-options .tab-header .tab-item.active h4 span:after{background:#0d5aa7}.swagger-ui .opblock.opblock-deprecated{background:hsla(0,0%,92%,.1);border-color:#ebebeb;opacity:.6}.swagger-ui .opblock.opblock-deprecated .opblock-summary-method{background:#ebebeb}.swagger-ui .opblock.opblock-deprecated .opblock-summary{border-color:#ebebeb}.swagger-ui .opblock.opblock-deprecated .tab-header .tab-item.active h4 span:after{background:#ebebeb}.swagger-ui .opblock .opblock-schemes{padding:8px 20px}.swagger-ui .opblock .opblock-schemes .schemes-title{padding:0 10px 0 0}.swagger-ui .filter .operation-filter-input{border:2px solid #d8dde7;margin:20px 0;padding:10px;width:100%}.swagger-ui .download-url-wrapper .failed,.swagger-ui .filter .failed{color:red}.swagger-ui .download-url-wrapper .loading,.swagger-ui .filter .loading{color:#aaa}.swagger-ui .model-example{margin-top:1em}.swagger-ui .tab{display:flex;list-style:none;padding:0}.swagger-ui .tab li{color:#3b4151;cursor:pointer;font-family:sans-serif;font-size:12px;min-width:60px;padding:0}.swagger-ui .tab li:first-of-type{padding-left:0;padding-right:12px;position:relative}.swagger-ui .tab li:first-of-type:after{background:rgba(0,0,0,.2);content:"";height:100%;position:absolute;right:6px;top:0;width:1px}.swagger-ui .tab li.active{font-weight:700}.swagger-ui .tab li button.tablinks{background:none;border:0;color:inherit;font-family:inherit;font-weight:inherit;padding:0}.swagger-ui .opblock-description-wrapper,.swagger-ui .opblock-external-docs-wrapper,.swagger-ui .opblock-title_normal{color:#3b4151;font-family:sans-serif;font-size:12px;margin:0 0 5px;padding:15px 20px}.swagger-ui .opblock-description-wrapper h4,.swagger-ui .opblock-external-docs-wrapper h4,.swagger-ui .opblock-title_normal h4{color:#3b4151;font-family:sans-serif;font-size:12px;margin:0 0 5px}.swagger-ui .opblock-description-wrapper p,.swagger-ui .opblock-external-docs-wrapper p,.swagger-ui .opblock-title_normal p{color:#3b4151;font-family:sans-serif;font-size:14px;margin:0}.swagger-ui .opblock-external-docs-wrapper h4{padding-left:0}.swagger-ui .execute-wrapper{padding:20px;text-align:right}.swagger-ui .execute-wrapper .btn{padding:8px 40px;width:100%}.swagger-ui .body-param-options{display:flex;flex-direction:column}.swagger-ui .body-param-options .body-param-edit{padding:10px 0}.swagger-ui .body-param-options label{padding:8px 0}.swagger-ui .body-param-options label select{margin:3px 0 0}.swagger-ui .responses-inner{padding:20px}.swagger-ui .responses-inner h4,.swagger-ui .responses-inner h5{color:#3b4151;font-family:sans-serif;font-size:12px;margin:10px 0 5px}.swagger-ui .responses-inner .curl{max-height:400px;min-height:6em;overflow-y:auto}.swagger-ui .response-col_status{color:#3b4151;font-family:sans-serif;font-size:14px}.swagger-ui .response-col_status .response-undocumented{color:#909090;font-family:monospace;font-size:11px;font-weight:600}.swagger-ui .response-col_links{color:#3b4151;font-family:sans-serif;font-size:14px;max-width:40em;padding-left:2em}.swagger-ui .response-col_links .response-undocumented{color:#909090;font-family:monospace;font-size:11px;font-weight:600}.swagger-ui .response-col_links .operation-link{margin-bottom:1.5em}.swagger-ui .response-col_links .operation-link .description{margin-bottom:.5em}.swagger-ui .opblock-body .opblock-loading-animation{display:block;margin:3em auto}.swagger-ui .opblock-body pre.microlight{background:#333;border-radius:4px;font-size:12px;hyphens:auto;margin:0;padding:10px;white-space:pre-wrap;word-break:break-all;word-break:break-word;word-wrap:break-word;color:#fff;font-family:monospace;font-weight:600}.swagger-ui .opblock-body pre.microlight .headerline{display:block}.swagger-ui .highlight-code{position:relative}.swagger-ui .highlight-code>.microlight{max-height:400px;min-height:6em;overflow-y:auto}.swagger-ui .highlight-code>.microlight code{white-space:pre-wrap!important;word-break:break-all}.swagger-ui .curl-command{position:relative}.swagger-ui .download-contents{align-items:center;background:#7d8293;border:none;border-radius:4px;bottom:10px;color:#fff;display:flex;font-family:sans-serif;font-size:14px;font-weight:600;height:30px;justify-content:center;padding:5px;position:absolute;right:10px;text-align:center}.swagger-ui .scheme-container{background:#fff;box-shadow:0 1px 2px 0 rgba(0,0,0,.15);margin:0 0 20px;padding:30px 0}.swagger-ui .scheme-container .schemes{align-items:flex-end;display:flex;flex-wrap:wrap;gap:10px;justify-content:space-between}.swagger-ui .scheme-container .schemes>.schemes-server-container{display:flex;flex-wrap:wrap;gap:10px}.swagger-ui .scheme-container .schemes>.schemes-server-container>label{color:#3b4151;display:flex;flex-direction:column;font-family:sans-serif;font-size:12px;font-weight:700;margin:-20px 15px 0 0}.swagger-ui .scheme-container .schemes>.schemes-server-container>label select{min-width:130px;text-transform:uppercase}.swagger-ui .scheme-container .schemes:not(:has(.schemes-server-container)){justify-content:flex-end}.swagger-ui .scheme-container .schemes .auth-wrapper{flex:none;justify-content:start}.swagger-ui .scheme-container .schemes .auth-wrapper .authorize{display:flex;flex-wrap:nowrap;margin:0;padding-right:20px}.swagger-ui .loading-container{align-items:center;display:flex;flex-direction:column;justify-content:center;margin-top:1em;min-height:1px;padding:40px 0 60px}.swagger-ui .loading-container .loading{position:relative}.swagger-ui .loading-container .loading:after{color:#3b4151;content:"loading";font-family:sans-serif;font-size:10px;font-weight:700;left:50%;position:absolute;text-transform:uppercase;top:50%;transform:translate(-50%,-50%)}.swagger-ui .loading-container .loading:before{animation:rotation 1s linear infinite,opacity .5s;backface-visibility:hidden;border:2px solid rgba(85,85,85,.1);border-radius:100%;border-top-color:rgba(0,0,0,.6);content:"";display:block;height:60px;left:50%;margin:-30px;opacity:1;position:absolute;top:50%;width:60px}@keyframes rotation{to{transform:rotate(1turn)}}.swagger-ui .response-controls{display:flex;padding-top:1em}.swagger-ui .response-control-media-type{margin-right:1em}.swagger-ui .response-control-media-type--accept-controller select{border-color:green}.swagger-ui .response-control-media-type__accept-message{color:green;font-size:.7em}.swagger-ui .response-control-examples__title,.swagger-ui .response-control-media-type__title{display:block;font-size:.7em;margin-bottom:.2em}@keyframes blinker{50%{opacity:0}}.swagger-ui .hidden{display:none}.swagger-ui .no-margin{border:none;height:auto;margin:0;padding:0}.swagger-ui .float-right{float:right}.swagger-ui .svg-assets{height:0;position:absolute;width:0}.swagger-ui section h3{color:#3b4151;font-family:sans-serif}.swagger-ui a.nostyle{display:inline}.swagger-ui a.nostyle,.swagger-ui a.nostyle:visited{color:inherit;cursor:pointer;text-decoration:inherit}.swagger-ui .fallback{color:#aaa;padding:1em}.swagger-ui .version-pragma{height:100%;padding:5em 0}.swagger-ui .version-pragma__message{display:flex;font-size:1.2em;height:100%;justify-content:center;line-height:1.5em;padding:0 .6em;text-align:center}.swagger-ui .version-pragma__message>div{flex:1;max-width:55ch}.swagger-ui .version-pragma__message code{background-color:#dedede;padding:4px 4px 2px;white-space:pre}.swagger-ui .opblock-link{font-weight:400}.swagger-ui .opblock-link.shown{font-weight:700}.swagger-ui span.token-string{color:#555}.swagger-ui span.token-not-formatted{color:#555;font-weight:700}.swagger-ui .btn{background:transparent;border:2px solid grey;border-radius:4px;box-shadow:0 1px 2px rgba(0,0,0,.1);color:#3b4151;font-family:sans-serif;font-size:14px;font-weight:700;padding:5px 23px;transition:all .3s}.swagger-ui .btn.btn-sm{font-size:12px;padding:4px 23px}.swagger-ui .btn[disabled]{cursor:not-allowed;opacity:.3}.swagger-ui .btn:hover{box-shadow:0 0 5px rgba(0,0,0,.3)}.swagger-ui .btn.cancel{background-color:transparent;border-color:#ff6060;color:#ff6060;font-family:sans-serif}.swagger-ui .btn.authorize{background-color:transparent;border-color:#49cc90;color:#49cc90;display:inline;line-height:1}.swagger-ui .btn.authorize span{float:left;padding:4px 20px 0 0}.swagger-ui .btn.authorize svg{fill:#49cc90}.swagger-ui .btn.execute{background-color:#4990e2;border-color:#4990e2;color:#fff}.swagger-ui .btn-group{display:flex;padding:30px}.swagger-ui .btn-group .btn{flex:1}.swagger-ui .btn-group .btn:first-child{border-radius:4px 0 0 4px}.swagger-ui .btn-group .btn:last-child{border-radius:0 4px 4px 0}.swagger-ui .authorization__btn{background:none;border:none;padding:0 0 0 10px}.swagger-ui .authorization__btn .locked{opacity:1}.swagger-ui .authorization__btn .unlocked{opacity:.4}.swagger-ui .model-box-control,.swagger-ui .models-control,.swagger-ui .opblock-summary-control{all:inherit;border-bottom:0;cursor:pointer;flex:1;padding:0}.swagger-ui .model-box-control:focus,.swagger-ui .models-control:focus,.swagger-ui .opblock-summary-control:focus{outline:auto}.swagger-ui .expand-methods,.swagger-ui .expand-operation{background:none;border:none}.swagger-ui .expand-methods svg,.swagger-ui .expand-operation svg{height:20px;width:20px}.swagger-ui .expand-methods{padding:0 10px}.swagger-ui .expand-methods:hover svg{fill:#404040}.swagger-ui .expand-methods svg{transition:all .3s;fill:#707070}.swagger-ui button{cursor:pointer}.swagger-ui button.invalid{animation:shake .4s 1;background:#feebeb;border-color:#f93e3e}.swagger-ui .copy-to-clipboard{align-items:center;background:#7d8293;border:none;border-radius:4px;bottom:10px;display:flex;height:30px;justify-content:center;position:absolute;right:100px;width:30px}.swagger-ui .copy-to-clipboard button{background:url("data:image/svg+xml;charset=utf-8,") 50% no-repeat;border:none;flex-grow:1;flex-shrink:1;height:25px}.swagger-ui .copy-to-clipboard:active{background:#5e626f}.swagger-ui .opblock-control-arrow{background:none;border:none;text-align:center}.swagger-ui .curl-command .copy-to-clipboard{bottom:5px;height:20px;right:10px;width:20px}.swagger-ui .curl-command .copy-to-clipboard button{height:18px}.swagger-ui .opblock .opblock-summary .view-line-link.copy-to-clipboard{height:26px;position:static}.swagger-ui select{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:#f7f7f7 url("data:image/svg+xml;charset=utf-8,") right 10px center no-repeat;background-size:20px;border:2px solid #41444e;border-radius:4px;box-shadow:0 1px 2px 0 rgba(0,0,0,.25);color:#3b4151;font-family:sans-serif;font-size:14px;font-weight:700;padding:5px 40px 5px 10px}.swagger-ui select[multiple]{background:#f7f7f7;margin:5px 0;padding:5px}.swagger-ui select.invalid{animation:shake .4s 1;background:#feebeb;border-color:#f93e3e}.swagger-ui .opblock-body select{min-width:230px}@media(max-width:768px){.swagger-ui .opblock-body select{min-width:180px}}@media(max-width:640px){.swagger-ui .opblock-body select{min-width:100%;width:100%}}.swagger-ui label{color:#3b4151;font-family:sans-serif;font-size:12px;font-weight:700;margin:0 0 5px}.swagger-ui input[type=email],.swagger-ui input[type=file],.swagger-ui input[type=password],.swagger-ui input[type=search],.swagger-ui input[type=text]{line-height:1}@media(max-width:768px){.swagger-ui input[type=email],.swagger-ui input[type=file],.swagger-ui input[type=password],.swagger-ui input[type=search],.swagger-ui input[type=text]{max-width:175px}}.swagger-ui input[type=email],.swagger-ui input[type=file],.swagger-ui input[type=password],.swagger-ui input[type=search],.swagger-ui input[type=text],.swagger-ui textarea{background:#fff;border:1px solid #d9d9d9;border-radius:4px;margin:5px 0;min-width:100px;padding:8px 10px}.swagger-ui input[type=email].invalid,.swagger-ui input[type=file].invalid,.swagger-ui input[type=password].invalid,.swagger-ui input[type=search].invalid,.swagger-ui input[type=text].invalid,.swagger-ui textarea.invalid{animation:shake .4s 1;background:#feebeb;border-color:#f93e3e}.swagger-ui input[disabled],.swagger-ui select[disabled],.swagger-ui textarea[disabled]{background-color:#fafafa;color:#888;cursor:not-allowed}.swagger-ui select[disabled]{border-color:#888}.swagger-ui textarea[disabled]{background-color:#41444e;color:#fff}@keyframes shake{10%,90%{transform:translate3d(-1px,0,0)}20%,80%{transform:translate3d(2px,0,0)}30%,50%,70%{transform:translate3d(-4px,0,0)}40%,60%{transform:translate3d(4px,0,0)}}.swagger-ui textarea{background:hsla(0,0%,100%,.8);border:none;border-radius:4px;color:#3b4151;font-family:monospace;font-size:12px;font-weight:600;min-height:280px;outline:none;padding:10px;width:100%}.swagger-ui textarea:focus{border:2px solid #61affe}.swagger-ui textarea.curl{background:#41444e;border-radius:4px;color:#fff;font-family:monospace;font-size:12px;font-weight:600;margin:0;min-height:100px;padding:10px;resize:none}.swagger-ui .checkbox{color:#303030;padding:5px 0 10px;transition:opacity .5s}.swagger-ui .checkbox label{display:flex}.swagger-ui .checkbox p{color:#3b4151;font-family:monospace;font-style:italic;font-weight:400!important;font-weight:600;margin:0!important}.swagger-ui .checkbox input[type=checkbox]{display:none}.swagger-ui .checkbox input[type=checkbox]+label>.item{background:#e8e8e8;border-radius:1px;box-shadow:0 0 0 2px #e8e8e8;cursor:pointer;display:inline-block;flex:none;height:16px;margin:0 8px 0 0;padding:5px;position:relative;top:3px;width:16px}.swagger-ui .checkbox input[type=checkbox]+label>.item:active{transform:scale(.9)}.swagger-ui .checkbox input[type=checkbox]:checked+label>.item{background:#e8e8e8 url("data:image/svg+xml;charset=utf-8,") 50% no-repeat}.swagger-ui .dialog-ux{bottom:0;left:0;position:fixed;right:0;top:0;z-index:9999}.swagger-ui .dialog-ux .backdrop-ux{background:rgba(0,0,0,.8);bottom:0;left:0;position:fixed;right:0;top:0}.swagger-ui .dialog-ux .modal-ux{background:#fff;border:1px solid #ebebeb;border-radius:4px;box-shadow:0 10px 30px 0 rgba(0,0,0,.2);left:50%;max-width:650px;min-width:300px;position:absolute;top:50%;transform:translate(-50%,-50%);width:100%;z-index:9999}.swagger-ui .dialog-ux .modal-ux-content{max-height:540px;overflow-y:auto;padding:20px}.swagger-ui .dialog-ux .modal-ux-content p{color:#41444e;color:#3b4151;font-family:sans-serif;font-size:12px;margin:0 0 5px}.swagger-ui .dialog-ux .modal-ux-content h4{color:#3b4151;font-family:sans-serif;font-size:18px;font-weight:600;margin:15px 0 0}.swagger-ui .dialog-ux .modal-ux-header{align-items:center;border-bottom:1px solid #ebebeb;display:flex;padding:12px 0}.swagger-ui .dialog-ux .modal-ux-header .close-modal{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:none;border:none;padding:0 10px}.swagger-ui .dialog-ux .modal-ux-header h3{color:#3b4151;flex:1;font-family:sans-serif;font-size:20px;font-weight:600;margin:0;padding:0 20px}.swagger-ui .model{color:#3b4151;font-family:monospace;font-size:12px;font-weight:300;font-weight:600}.swagger-ui .model .deprecated span,.swagger-ui .model .deprecated td{color:#a0a0a0!important}.swagger-ui .model .deprecated>td:first-of-type{-webkit-text-decoration:line-through;text-decoration:line-through}.swagger-ui .model-toggle{cursor:pointer;display:inline-block;font-size:10px;margin:auto .3em;position:relative;top:6px;transform:rotate(90deg);transform-origin:50% 50%;transition:transform .15s ease-in}.swagger-ui .model-toggle.collapsed{transform:rotate(0deg)}.swagger-ui .model-toggle:after{background:url("data:image/svg+xml;charset=utf-8,") 50% no-repeat;background-size:100%;content:"";display:block;height:20px;width:20px}.swagger-ui .model-jump-to-path{cursor:pointer;position:relative}.swagger-ui .model-jump-to-path .view-line-link{cursor:pointer;position:absolute;top:-.4em}.swagger-ui .model-title{position:relative}.swagger-ui .model-title:hover .model-hint{visibility:visible}.swagger-ui .model-hint{background:rgba(0,0,0,.7);border-radius:4px;color:#ebebeb;padding:.1em .5em;position:absolute;top:-1.8em;visibility:hidden;white-space:nowrap}.swagger-ui .model p{margin:0 0 1em}.swagger-ui .model .property{color:#999;font-style:italic}.swagger-ui .model .property.primitive{color:#6b6b6b}.swagger-ui .model .external-docs,.swagger-ui table.model tr.description{color:#666;font-weight:400}.swagger-ui table.model tr.description td:first-child,.swagger-ui table.model tr.property-row.required td:first-child{font-weight:700}.swagger-ui table.model tr.property-row td{vertical-align:top}.swagger-ui table.model tr.property-row td:first-child{padding-right:.2em}.swagger-ui table.model tr.property-row .star{color:red}.swagger-ui table.model tr.extension{color:#777}.swagger-ui table.model tr.extension td:last-child{vertical-align:top}.swagger-ui table.model tr.external-docs td:first-child{font-weight:700}.swagger-ui table.model tr .renderedMarkdown p:first-child{margin-top:0}.swagger-ui section.models{border:1px solid rgba(59,65,81,.3);border-radius:4px;margin:30px 0}.swagger-ui section.models .pointer{cursor:pointer}.swagger-ui section.models.is-open{padding:0 0 20px}.swagger-ui section.models.is-open h4{border-bottom:1px solid rgba(59,65,81,.3);margin:0 0 5px}.swagger-ui section.models h4{align-items:center;color:#606060;cursor:pointer;display:flex;font-family:sans-serif;font-size:16px;margin:0;padding:10px 20px 10px 10px;transition:all .2s}.swagger-ui section.models h4 svg{transition:all .4s}.swagger-ui section.models h4 span{flex:1}.swagger-ui section.models h4:hover{background:rgba(0,0,0,.02)}.swagger-ui section.models h5{color:#707070;font-family:sans-serif;font-size:16px;margin:0 0 10px}.swagger-ui section.models .model-jump-to-path{position:relative;top:5px}.swagger-ui section.models .model-container{background:rgba(0,0,0,.05);border-radius:4px;margin:0 20px 15px;position:relative;transition:all .5s}.swagger-ui section.models .model-container:hover{background:rgba(0,0,0,.07)}.swagger-ui section.models .model-container:first-of-type{margin:20px}.swagger-ui section.models .model-container:last-of-type{margin:0 20px}.swagger-ui section.models .model-container .models-jump-to-path{opacity:.65;position:absolute;right:5px;top:8px}.swagger-ui section.models .model-box{background:none}.swagger-ui .model-box{background:rgba(0,0,0,.1);border-radius:4px;display:inline-block;padding:10px}.swagger-ui .model-box .model-jump-to-path{position:relative;top:4px}.swagger-ui .model-box.deprecated{opacity:.5}.swagger-ui .model-title{color:#505050;font-family:sans-serif;font-size:16px}.swagger-ui .model-title img{bottom:0;margin-left:1em;position:relative}.swagger-ui .model-deprecated-warning{color:#f93e3e;font-family:sans-serif;font-size:16px;font-weight:600;margin-right:1em}.swagger-ui span>span.model .brace-close{padding:0 0 0 10px}.swagger-ui .prop-name{display:inline-block;margin-right:1em}.swagger-ui .prop-type{color:#55a}.swagger-ui .prop-enum{display:block}.swagger-ui .prop-format{color:#606060}.swagger-ui .servers>label{color:#3b4151;font-family:sans-serif;font-size:12px;margin:-20px 15px 0 0}.swagger-ui .servers>label select{max-width:100%;min-width:130px;width:100%}.swagger-ui .servers h4.message{padding-bottom:2em}.swagger-ui .servers table tr{width:30em}.swagger-ui .servers table td{display:inline-block;max-width:15em;padding-bottom:10px;padding-top:10px;vertical-align:middle}.swagger-ui .servers table td:first-of-type{padding-right:1em}.swagger-ui .servers table td input{height:100%;width:100%}.swagger-ui .servers .computed-url{margin:2em 0}.swagger-ui .servers .computed-url code{display:inline-block;font-size:16px;margin:0 1em;padding:4px}.swagger-ui .servers-title{font-size:12px;font-weight:700}.swagger-ui .operation-servers h4.message{margin-bottom:2em}.swagger-ui table{border-collapse:collapse;padding:0 10px;width:100%}.swagger-ui table.model tbody tr td{padding:0;vertical-align:top}.swagger-ui table.model tbody tr td:first-of-type{padding:0 0 0 2em;width:174px}.swagger-ui table.headers td{color:#3b4151;font-family:monospace;font-size:12px;font-weight:300;font-weight:600;vertical-align:middle}.swagger-ui table.headers .header-example{color:#999;font-style:italic}.swagger-ui table tbody tr td{padding:10px 0 0;vertical-align:top}.swagger-ui table tbody tr td:first-of-type{min-width:6em;padding:10px 0}.swagger-ui table thead tr td,.swagger-ui table thead tr th{border-bottom:1px solid rgba(59,65,81,.2);color:#3b4151;font-family:sans-serif;font-size:12px;font-weight:700;padding:12px 0;text-align:left}.swagger-ui .parameters-col_description{margin-bottom:2em;width:99%}.swagger-ui .parameters-col_description input{max-width:340px;width:100%}.swagger-ui .parameters-col_description select{border-width:1px}.swagger-ui .parameters-col_description .markdown p,.swagger-ui .parameters-col_description .renderedMarkdown p{margin:0}.swagger-ui .parameter__name{color:#3b4151;font-family:sans-serif;font-size:16px;font-weight:400;margin-right:.75em}.swagger-ui .parameter__name.required{font-weight:700}.swagger-ui .parameter__name.required span{color:red}.swagger-ui .parameter__name.required:after{color:rgba(255,0,0,.6);content:"required";font-size:10px;padding:5px;position:relative;top:-6px}.swagger-ui .parameter__extension,.swagger-ui .parameter__in{color:grey;font-family:monospace;font-size:12px;font-style:italic;font-weight:600}.swagger-ui .parameter__deprecated{color:red;font-family:monospace;font-size:12px;font-style:italic;font-weight:600}.swagger-ui .parameter__empty_value_toggle{display:block;font-size:13px;padding-bottom:12px;padding-top:5px}.swagger-ui .parameter__empty_value_toggle input{margin-right:7px;width:auto}.swagger-ui .parameter__empty_value_toggle.disabled{opacity:.7}.swagger-ui .table-container{padding:20px}.swagger-ui .response-col_description{width:99%}.swagger-ui .response-col_description .markdown p,.swagger-ui .response-col_description .renderedMarkdown p{margin:0}.swagger-ui .response-col_links{min-width:6em}.swagger-ui .response__extension{color:grey;font-family:monospace;font-size:12px;font-style:italic;font-weight:600}.swagger-ui .topbar{background-color:#1b1b1b;padding:10px 0}.swagger-ui .topbar .topbar-wrapper{align-items:center;display:flex;flex-wrap:wrap;gap:10px}@media(max-width:550px){.swagger-ui .topbar .topbar-wrapper{align-items:start;flex-direction:column}}.swagger-ui .topbar a{align-items:center;color:#fff;display:flex;flex:1;font-family:sans-serif;font-size:1.5em;font-weight:700;max-width:300px;-webkit-text-decoration:none;text-decoration:none}.swagger-ui .topbar a span{margin:0;padding:0 10px}.swagger-ui .topbar .download-url-wrapper{display:flex;flex:3;justify-content:flex-end}.swagger-ui .topbar .download-url-wrapper input[type=text]{border:2px solid #62a03f;border-radius:4px 0 0 4px;margin:0;max-width:100%;outline:none;width:100%}.swagger-ui .topbar .download-url-wrapper .select-label{align-items:center;color:#f0f0f0;display:flex;margin:0;max-width:600px;width:100%}.swagger-ui .topbar .download-url-wrapper .select-label span{flex:1;font-size:16px;padding:0 10px 0 0;text-align:right}.swagger-ui .topbar .download-url-wrapper .select-label select{border:2px solid #62a03f;box-shadow:none;flex:2;outline:none;width:100%}.swagger-ui .topbar .download-url-wrapper .download-url-button{background:#62a03f;border:none;border-radius:0 4px 4px 0;color:#fff;font-family:sans-serif;font-size:16px;font-weight:700;padding:4px 30px}@media(max-width:550px){.swagger-ui .topbar .download-url-wrapper{width:100%}}.swagger-ui .info{margin:50px 0}.swagger-ui .info.failed-config{margin-left:auto;margin-right:auto;max-width:880px;text-align:center}.swagger-ui .info hgroup.main{margin:0 0 20px}.swagger-ui .info hgroup.main a{font-size:12px}.swagger-ui .info pre{font-size:14px}.swagger-ui .info li,.swagger-ui .info p,.swagger-ui .info table{color:#3b4151;font-family:sans-serif;font-size:14px}.swagger-ui .info h1,.swagger-ui .info h2,.swagger-ui .info h3,.swagger-ui .info h4,.swagger-ui .info h5{color:#3b4151;font-family:sans-serif}.swagger-ui .info a{color:#4990e2;font-family:sans-serif;font-size:14px;transition:all .4s}.swagger-ui .info a:hover{color:#1f69c0}.swagger-ui .info>div{margin:0 0 5px}.swagger-ui .info .base-url{color:#3b4151;font-family:monospace;font-size:12px;font-weight:300!important;font-weight:600;margin:0}.swagger-ui .info .title{color:#3b4151;font-family:sans-serif;font-size:36px;margin:0}.swagger-ui .info .title small{background:#7d8492;border-radius:57px;display:inline-block;font-size:10px;margin:0 0 0 5px;padding:2px 4px;position:relative;top:-5px;vertical-align:super}.swagger-ui .info .title small.version-stamp{background-color:#89bf04}.swagger-ui .info .title small pre{color:#fff;font-family:sans-serif;margin:0;padding:0}.swagger-ui .auth-btn-wrapper{display:flex;justify-content:center;padding:10px 0}.swagger-ui .auth-btn-wrapper .btn-done{margin-right:1em}.swagger-ui .auth-wrapper{display:flex;flex:1;justify-content:flex-end}.swagger-ui .auth-wrapper .authorize{margin-left:10px;margin-right:10px;padding-right:20px}.swagger-ui .auth-container{border-bottom:1px solid #ebebeb;margin:0 0 10px;padding:10px 20px}.swagger-ui .auth-container:last-of-type{border:0;margin:0;padding:10px 20px}.swagger-ui .auth-container h4{margin:5px 0 15px!important}.swagger-ui .auth-container .wrapper{margin:0;padding:0}.swagger-ui .auth-container input[type=password],.swagger-ui .auth-container input[type=text]{min-width:230px}.swagger-ui .auth-container .errors{background-color:#fee;border-radius:4px;color:red;color:#3b4151;font-family:monospace;font-size:12px;font-weight:600;margin:1em;padding:10px}.swagger-ui .auth-container .errors b{margin-right:1em;text-transform:capitalize}.swagger-ui .scopes h2{color:#3b4151;font-family:sans-serif;font-size:14px}.swagger-ui .scopes h2 a{color:#4990e2;cursor:pointer;font-size:12px;padding-left:10px;-webkit-text-decoration:underline;text-decoration:underline}.swagger-ui .scope-def{padding:0 0 20px}.swagger-ui .errors-wrapper{animation:scaleUp .5s;background:rgba(249,62,62,.1);border:2px solid #f93e3e;border-radius:4px;margin:20px;padding:10px 20px}.swagger-ui .errors-wrapper .error-wrapper{margin:0 0 10px}.swagger-ui .errors-wrapper .errors h4{color:#3b4151;font-family:monospace;font-size:14px;font-weight:600;margin:0}.swagger-ui .errors-wrapper .errors small{color:#606060}.swagger-ui .errors-wrapper .errors .message{white-space:pre-line}.swagger-ui .errors-wrapper .errors .message.thrown{max-width:100%}.swagger-ui .errors-wrapper .errors .error-line{cursor:pointer;-webkit-text-decoration:underline;text-decoration:underline}.swagger-ui .errors-wrapper hgroup{align-items:center;display:flex}.swagger-ui .errors-wrapper hgroup h4{color:#3b4151;flex:1;font-family:sans-serif;font-size:20px;margin:0}@keyframes scaleUp{0%{opacity:0;transform:scale(.8)}to{opacity:1;transform:scale(1)}}.swagger-ui .Resizer.vertical.disabled{display:none}.swagger-ui .markdown p,.swagger-ui .markdown pre,.swagger-ui .renderedMarkdown p,.swagger-ui .renderedMarkdown pre{margin:1em auto;word-break:break-all;word-break:break-word}.swagger-ui .markdown pre,.swagger-ui .renderedMarkdown pre{background:none;color:#000;font-weight:400;padding:0;white-space:pre-wrap}.swagger-ui .markdown code,.swagger-ui .renderedMarkdown code{background:rgba(0,0,0,.05);border-radius:4px;color:#9012fe;font-family:monospace;font-size:14px;font-weight:600;padding:5px 7px}.swagger-ui .markdown pre>code,.swagger-ui .renderedMarkdown pre>code{display:block}.swagger-ui .json-schema-2020-12{background-color:rgba(0,0,0,.05);border-radius:4px;margin:0 20px 15px;padding:12px 0 12px 20px}.swagger-ui .json-schema-2020-12:first-of-type{margin:20px}.swagger-ui .json-schema-2020-12:last-of-type{margin:0 20px}.swagger-ui .json-schema-2020-12--embedded{background-color:inherit;padding-bottom:0;padding-left:inherit;padding-right:inherit;padding-top:0}.swagger-ui .json-schema-2020-12-body{border-left:1px dashed rgba(0,0,0,.1);margin:2px 0}.swagger-ui .json-schema-2020-12-body--collapsed{display:none}.swagger-ui .json-schema-2020-12-accordion{border:none;outline:none;padding-left:0}.swagger-ui .json-schema-2020-12-accordion__children{display:inline-block}.swagger-ui .json-schema-2020-12-accordion__icon{display:inline-block;height:18px;vertical-align:bottom;width:18px}.swagger-ui .json-schema-2020-12-accordion__icon--expanded{transform:rotate(-90deg);transform-origin:50% 50%;transition:transform .15s ease-in}.swagger-ui .json-schema-2020-12-accordion__icon--collapsed{transform:rotate(0deg);transform-origin:50% 50%;transition:transform .15s ease-in}.swagger-ui .json-schema-2020-12-accordion__icon svg{height:20px;width:20px}.swagger-ui .json-schema-2020-12-expand-deep-button{border:none;color:#505050;color:#afaeae;font-family:sans-serif;font-size:12px;padding-right:0}.swagger-ui .json-schema-2020-12-keyword{margin:5px 0}.swagger-ui .json-schema-2020-12-keyword__children{border-left:1px dashed rgba(0,0,0,.1);margin:0 0 0 20px;padding:0}.swagger-ui .json-schema-2020-12-keyword__children--collapsed{display:none}.swagger-ui .json-schema-2020-12-keyword__name{font-size:12px;font-weight:700;margin-left:20px}.swagger-ui .json-schema-2020-12-keyword__name--primary{color:#3b4151;font-style:normal}.swagger-ui .json-schema-2020-12-keyword__name--secondary{color:#6b6b6b;font-style:italic}.swagger-ui .json-schema-2020-12-keyword__value{color:#6b6b6b;font-size:12px;font-style:italic;font-weight:400}.swagger-ui .json-schema-2020-12-keyword__value--primary{color:#3b4151;font-style:normal}.swagger-ui .json-schema-2020-12-keyword__value--secondary{color:#6b6b6b;font-style:italic}.swagger-ui .json-schema-2020-12-keyword__value--const,.swagger-ui .json-schema-2020-12-keyword__value--warning{border:1px dashed #6b6b6b;border-radius:4px;color:#3b4151;color:#6b6b6b;display:inline-block;font-family:monospace;font-style:normal;font-weight:600;line-height:1.5;margin-left:10px;padding:1px 4px}.swagger-ui .json-schema-2020-12-keyword__value--warning{border:1px dashed red;color:red}.swagger-ui .json-schema-2020-12-keyword__name--secondary+.json-schema-2020-12-keyword__value--secondary:before{content:"="}.swagger-ui .json-schema-2020-12__attribute{color:#3b4151;font-family:monospace;font-size:12px;padding-left:10px;text-transform:lowercase}.swagger-ui .json-schema-2020-12__attribute--primary{color:#55a}.swagger-ui .json-schema-2020-12__attribute--muted{color:gray}.swagger-ui .json-schema-2020-12__attribute--warning{color:red}.swagger-ui .json-schema-2020-12-keyword--\$vocabulary ul{border-left:1px dashed rgba(0,0,0,.1);margin:0 0 0 20px}.swagger-ui .json-schema-2020-12-\$vocabulary-uri{margin-left:35px}.swagger-ui .json-schema-2020-12-\$vocabulary-uri--disabled{-webkit-text-decoration:line-through;text-decoration:line-through}.swagger-ui .json-schema-2020-12-keyword--description{color:#6b6b6b;font-size:12px;margin-left:20px}.swagger-ui .json-schema-2020-12-keyword--description p{margin:0}.swagger-ui .json-schema-2020-12__title{color:#505050;display:inline-block;font-family:sans-serif;font-size:12px;font-weight:700;line-height:normal}.swagger-ui .json-schema-2020-12__title .json-schema-2020-12-keyword__name{margin:0}.swagger-ui .json-schema-2020-12-property{margin:7px 0}.swagger-ui .json-schema-2020-12-property .json-schema-2020-12__title{color:#3b4151;font-family:monospace;font-size:12px;font-weight:600;vertical-align:middle}.swagger-ui .json-schema-2020-12-keyword--properties>ul{border:none;margin:0;padding:0}.swagger-ui .json-schema-2020-12-property{list-style-type:none}.swagger-ui .json-schema-2020-12-property--required>.json-schema-2020-12:first-of-type>.json-schema-2020-12-head .json-schema-2020-12__title:after{color:red;content:"*";font-weight:700}.swagger-ui .json-schema-2020-12-keyword--patternProperties ul{border:none;margin:0;padding:0}.swagger-ui .json-schema-2020-12-keyword--patternProperties .json-schema-2020-12__title:first-of-type:after,.swagger-ui .json-schema-2020-12-keyword--patternProperties .json-schema-2020-12__title:first-of-type:before{color:#55a;content:"/"}.swagger-ui .json-schema-2020-12-keyword--enum>ul{display:inline-block;margin:0;padding:0}.swagger-ui .json-schema-2020-12-keyword--enum>ul li{display:inline;list-style-type:none}.swagger-ui .json-schema-2020-12__constraint{background-color:#805ad5;border-radius:4px;color:#3b4151;color:#fff;font-family:monospace;font-weight:600;line-height:1.5;margin-left:10px;padding:1px 3px}.swagger-ui .json-schema-2020-12__constraint--string{background-color:#d69e2e;color:#fff}.swagger-ui .json-schema-2020-12-keyword--dependentRequired>ul{display:inline-block;margin:0;padding:0}.swagger-ui .json-schema-2020-12-keyword--dependentRequired>ul li{display:inline;list-style-type:none}.swagger-ui .model-box .json-schema-2020-12:not(.json-schema-2020-12--embedded)>.json-schema-2020-12-head .json-schema-2020-12__title:first-of-type{font-size:16px}.swagger-ui .model-box>.json-schema-2020-12{margin:0}.swagger-ui .model-box .json-schema-2020-12{background-color:transparent;padding:0}.swagger-ui .model-box .json-schema-2020-12-accordion,.swagger-ui .model-box .json-schema-2020-12-expand-deep-button{background-color:transparent}.swagger-ui .models .json-schema-2020-12:not(.json-schema-2020-12--embedded)>.json-schema-2020-12-head .json-schema-2020-12__title:first-of-type{font-size:16px} + +/*# sourceMappingURL=swagger-ui.css.map*/ \ No newline at end of file
        +
        + {{app_name}} + {{app_name}} +
        +
        diff --git a/tests/.DS_Store b/tests/.DS_Store deleted file mode 100644 index ea19a3e..0000000 Binary files a/tests/.DS_Store and /dev/null differ diff --git a/tests/Http/ApiAuthTest.php b/tests/Http/ApiAuthTest.php new file mode 100644 index 0000000..4b6f8d0 --- /dev/null +++ b/tests/Http/ApiAuthTest.php @@ -0,0 +1,69 @@ +serverBackup = $_SERVER; + } + + protected function tearDown(): void + { + $_SERVER = $this->serverBackup; + } + + public function testExtractsValidBearerToken(): void + { + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer abc123def456'; + unset($_SERVER['REDIRECT_HTTP_AUTHORIZATION']); + $this->assertSame('abc123def456', ApiAuth::extractBearerToken()); + } + + public function testReturnsEmptyStringWhenNoHeader(): void + { + unset($_SERVER['HTTP_AUTHORIZATION'], $_SERVER['REDIRECT_HTTP_AUTHORIZATION']); + $this->assertSame('', ApiAuth::extractBearerToken()); + } + + public function testReturnsEmptyStringForNonBearerScheme(): void + { + $_SERVER['HTTP_AUTHORIZATION'] = 'Basic dXNlcjpwYXNz'; + unset($_SERVER['REDIRECT_HTTP_AUTHORIZATION']); + $this->assertSame('', ApiAuth::extractBearerToken()); + } + + public function testReturnsEmptyStringForBearerWithNoToken(): void + { + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer '; + unset($_SERVER['REDIRECT_HTTP_AUTHORIZATION']); + $this->assertSame('', ApiAuth::extractBearerToken()); + } + + public function testFallsBackToRedirectHeader(): void + { + unset($_SERVER['HTTP_AUTHORIZATION']); + $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] = 'Bearer fallback_token'; + $this->assertSame('fallback_token', ApiAuth::extractBearerToken()); + } + + public function testIsCaseInsensitiveForBearerScheme(): void + { + $_SERVER['HTTP_AUTHORIZATION'] = 'bearer lowercase_token'; + unset($_SERVER['REDIRECT_HTTP_AUTHORIZATION']); + $this->assertSame('lowercase_token', ApiAuth::extractBearerToken()); + } + + public function testTrimsWhitespaceFromToken(): void + { + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer token_with_spaces '; + unset($_SERVER['REDIRECT_HTTP_AUTHORIZATION']); + $this->assertSame('token_with_spaces', ApiAuth::extractBearerToken()); + } +} diff --git a/tests/Repository/RepoQueryTest.php b/tests/Repository/RepoQueryTest.php index df1dae3..8640c6f 100644 --- a/tests/Repository/RepoQueryTest.php +++ b/tests/Repository/RepoQueryTest.php @@ -45,7 +45,7 @@ class RepoQueryTest extends TestCase $this->assertSame([], $params); RepoQuery::addLikeFilter($where, $params, ['a', 'b'], 'foo'); - $this->assertSame(['(a like ? or b like ?)'], $where); + $this->assertSame(["(a like ? escape '\\\\' or b like ? escape '\\\\')"], $where); $this->assertSame(['%foo%', '%foo%'], $params); } diff --git a/tests/Service/UserServicePasswordTest.php b/tests/Service/UserServicePasswordTest.php index c38925c..0046b37 100644 --- a/tests/Service/UserServicePasswordTest.php +++ b/tests/Service/UserServicePasswordTest.php @@ -1,49 +1,33 @@ invoke(null, $password, $password2, $required, $email); - } - public function testRequiresPasswordWhenRequired(): void { - $errors = $this->validate('', '', true, 'test@example.com'); + $errors = UserService::validatePassword('', '', true, 'test@example.com'); $this->assertNotEmpty($errors); } public function testRejectsMismatch(): void { - $errors = $this->validate('Abcd1234$efg', 'Abcd1234$xxx', true, 'test@example.com'); + $errors = UserService::validatePassword('Abcd1234$efg', 'Abcd1234$xxx', true, 'test@example.com'); $this->assertNotEmpty($errors); } public function testAcceptsStrongPassword(): void { - $errors = $this->validate('StrongPass1!', 'StrongPass1!', true, 'test@example.com'); + $errors = UserService::validatePassword('StrongPass1!', 'StrongPass1!', true, 'test@example.com'); $this->assertSame([], $errors); } public function testRejectsPasswordContainingEmail(): void { - $errors = $this->validate('test@example.comA1!', 'test@example.comA1!', true, 'test@example.com'); + $errors = UserService::validatePassword('test@example.comA1!', 'test@example.comA1!', true, 'test@example.com'); $this->assertNotEmpty($errors); } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..48316b6 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,4 @@ + highest priority): + * - tokens: design tokens and theme variables + * - vendor: third-party styles + * - layout: shell, header, sidebar and app structure + * - vendor-overrides: project overrides for third-party CSS + * - components: reusable UI building blocks + * - pages: page-specific styles + * - utilities: last-resort helpers/overrides + */ +@layer tokens, vendor, layout, vendor-overrides, components, pages, utilities; + +/* Load third-party styles into the vendor layer so app layers can override them predictably. */ +@import url("../vendor/bootstrap-icons/bootstrap-icons.css") layer(vendor); +@import url("../vendor/gridjs/mermaid.min.css") layer(vendor); +@import url("../vendor/multi-select/MultiSelect.css") layer(vendor); diff --git a/web/css/base/variables.base.css b/web/css/base/variables.base.css index a4d864f..f8a9e4b 100644 --- a/web/css/base/variables.base.css +++ b/web/css/base/variables.base.css @@ -1,5 +1,6 @@ @charset "UTF-8"; +@layer tokens { :host, :root { --app-font-family-emoji: @@ -795,3 +796,4 @@ :root { --app-scrollbar-width: 0px; } +} diff --git a/web/css/base/variables.contrast.css b/web/css/base/variables.contrast.css index a4f4ed4..8928070 100644 --- a/web/css/base/variables.contrast.css +++ b/web/css/base/variables.contrast.css @@ -1,5 +1,6 @@ @charset "UTF-8"; +@layer tokens { /* High contrast overrides */ html[data-contrast="high"][data-theme="light"], [data-contrast="high"][data-theme="light"] { @@ -42,3 +43,4 @@ html[data-contrast="high"][data-theme^="dark"], --app-tooltip-background-color: #fff; --app-tooltip-color: #0b0f14; } +} diff --git a/web/css/base/variables.theme-dark-green.css b/web/css/base/variables.theme-dark-green.css index 8edaa79..6aa9101 100644 --- a/web/css/base/variables.theme-dark-green.css +++ b/web/css/base/variables.theme-dark-green.css @@ -1,5 +1,6 @@ @charset "UTF-8"; +@layer tokens { [data-theme="dark-green"] { --app-background-color: #0f1713; --app-sidebar-background-color: #0d1511; @@ -40,3 +41,4 @@ --app-tile-bg: rgb(20 28 24); --app-blockquote-background-color: rgb(20 28 24); } +} diff --git a/web/css/components/app-badges.css b/web/css/components/app-badges.css index 5a2ada6..84e3d94 100644 --- a/web/css/components/app-badges.css +++ b/web/css/components/app-badges.css @@ -1,3 +1,4 @@ +@layer components { .badge[role="button"], .badge { display: inline-flex; @@ -68,6 +69,18 @@ border-color: var(--badge-info-border); } +.badge[data-variant="primary"] { + background: var(--badge-info-bg); + color: var(--badge-info-color); + border-color: var(--badge-info-border); +} + +.badge[data-variant="secondary"] { + background: var(--badge-neutral-bg); + color: var(--badge-neutral-color); + border-color: var(--badge-neutral-border); +} + .badge[data-variant="warn"] { background: var(--badge-warn-bg); color: var(--badge-warn-color); @@ -105,3 +118,4 @@ display: flex; gap: 0.35rem; } +} diff --git a/web/css/components/app-blockquote.css b/web/css/components/app-blockquote.css index 480fbfd..13ce337 100644 --- a/web/css/components/app-blockquote.css +++ b/web/css/components/app-blockquote.css @@ -1,60 +1,66 @@ -blockquote { - --blockquote-bg: var(--app-blockquote-background-color); - --blockquote-color: inherit; - --blockquote-border: var(--app-blockquote-border-color); - --blockquote-footer-color: var(--app-blockquote-footer-color); +@layer components { + blockquote { + --blockquote-bg: var(--app-blockquote-background-color); + --blockquote-color: inherit; + --blockquote-border: var(--app-blockquote-border-color); + --blockquote-footer-color: var(--app-blockquote-footer-color); - display: block; - margin: var(--app-typography-spacing-vertical) 0; - padding: var(--app-spacing); - border-right: none; - border-left: 0.25rem solid var(--app-blockquote-border-color); - border-inline-start: 0.25rem solid var(--app-blockquote-border-color); - border-inline-end: none; - background-color: var(--blockquote-bg); - color: var(--blockquote-color); - border-left-color: var(--blockquote-border); - border-inline-start-color: var(--blockquote-border); - border-radius: var(--app-border-radius); -} + display: block; + margin: var(--app-typography-spacing-vertical) 0; + padding: var(--app-spacing); + border-right: none; + border-left: 0.25rem solid var(--app-blockquote-border-color); + border-inline-start: 0.25rem solid var(--app-blockquote-border-color); + border-inline-end: none; + background-color: var(--blockquote-bg); + color: var(--blockquote-color); + border-left-color: var(--blockquote-border); + border-inline-start-color: var(--blockquote-border); + border-radius: var(--app-border-radius); + } + blockquote p:last-child { + margin: 0; + } -blockquote footer { - margin-top: calc(var(--app-typography-spacing-vertical) * 0.5); - color: var(--blockquote-footer-color); -} + blockquote footer { + margin-top: calc(var(--app-typography-spacing-vertical) * 0.5); + color: var(--blockquote-footer-color); + } -blockquote[data-variant="success"] { - --blockquote-bg: var(--badge-success-bg); - --blockquote-color: var(--badge-success-color); - --blockquote-border: var(--badge-success-border); - --blockquote-footer-color: var(--badge-success-color); -} + blockquote[data-variant="success"] { + --blockquote-bg: var(--badge-success-bg); + --blockquote-color: var(--badge-success-color); + --blockquote-border: var(--badge-success-border); + --blockquote-footer-color: var(--badge-success-color); + } -blockquote[data-variant="danger"] { - --blockquote-bg: var(--badge-danger-bg); - --blockquote-color: var(--badge-danger-color); - --blockquote-border: var(--badge-danger-border); - --blockquote-footer-color: var(--badge-danger-color); -} + blockquote[data-variant="error"], + blockquote[data-variant="danger"] { + --blockquote-bg: var(--badge-danger-bg); + --blockquote-color: var(--badge-danger-color); + --blockquote-border: var(--badge-danger-border); + --blockquote-footer-color: var(--badge-danger-color); + } -blockquote[data-variant="info"] { - --blockquote-bg: var(--badge-info-bg); - --blockquote-color: var(--badge-info-color); - --blockquote-border: var(--badge-info-border); - --blockquote-footer-color: var(--badge-info-color); -} + blockquote[data-variant="info"] { + --blockquote-bg: var(--badge-info-bg); + --blockquote-color: var(--badge-info-color); + --blockquote-border: var(--badge-info-border); + --blockquote-footer-color: var(--badge-info-color); + } -blockquote[data-variant="warn"], -blockquote[data-variant="warning"] { - --blockquote-bg: var(--badge-warn-bg); - --blockquote-color: var(--badge-warn-color); - --blockquote-border: var(--badge-warn-border); - --blockquote-footer-color: var(--badge-warn-color); -} + blockquote[data-variant="warn"], + blockquote[data-variant="warning"] { + --blockquote-bg: var(--badge-warn-bg); + --blockquote-color: var(--badge-warn-color); + --blockquote-border: var(--badge-warn-border); + --blockquote-footer-color: var(--badge-warn-color); + } -blockquote[data-variant="neutral"] { - --blockquote-bg: var(--badge-neutral-bg); - --blockquote-color: var(--badge-neutral-color); - --blockquote-border: var(--badge-neutral-border); - --blockquote-footer-color: var(--badge-neutral-color); + blockquote[data-variant="neutral"] { + --blockquote-bg: var(--badge-neutral-bg); + --blockquote-color: var(--badge-neutral-color); + --blockquote-border: var(--badge-neutral-border); + --blockquote-footer-color: var(--badge-neutral-color); + } } diff --git a/web/css/components/app-brand.css b/web/css/components/app-brand.css index 0d54441..22d61f2 100644 --- a/web/css/components/app-brand.css +++ b/web/css/components/app-brand.css @@ -1,3 +1,4 @@ +@layer components { .brand { display: flex; align-items: center; @@ -28,4 +29,5 @@ img.brand-logo { .badge:empty { padding: 0; -} \ No newline at end of file +} +} diff --git a/web/css/components/app-breadcrumb.css b/web/css/components/app-breadcrumb.css index 913296c..3d367a1 100644 --- a/web/css/components/app-breadcrumb.css +++ b/web/css/components/app-breadcrumb.css @@ -1,3 +1,4 @@ +@layer components { .app-breadcrumb { margin-bottom: 5px; display: flex; @@ -9,4 +10,5 @@ .app-breadcrumb a { color: inherit; text-decoration: none; -} \ No newline at end of file +} +} diff --git a/web/css/components/app-buttons.css b/web/css/components/app-buttons.css index d8f3d3d..78c36db 100644 --- a/web/css/components/app-buttons.css +++ b/web/css/components/app-buttons.css @@ -1,3 +1,4 @@ +@layer components { .danger:is(button, [type="submit"], [type="button"], [role="button"]) { --app-background-color: var(--app-notice-error-border-color); --app-border-color: var(--app-notice-error-border-color); @@ -42,3 +43,4 @@ var(--app-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)), 0 0 0 var(--app-outline-width) var(--app-form-element-invalid-focus-color); } +} diff --git a/web/css/components/app-dashboard-titlebar.css b/web/css/components/app-dashboard-titlebar.css index b33ada9..399a8c8 100644 --- a/web/css/components/app-dashboard-titlebar.css +++ b/web/css/components/app-dashboard-titlebar.css @@ -1,3 +1,4 @@ +@layer components { .app-dashboard-titlebar { display: flex; align-items: center; @@ -52,4 +53,5 @@ .app-dashboard-titlebar h1 a:hover i { background: var(--app-border); -} \ No newline at end of file +} +} diff --git a/web/css/components/app-details-titlebar.css b/web/css/components/app-details-titlebar.css index 203d643..4037cf4 100644 --- a/web/css/components/app-details-titlebar.css +++ b/web/css/components/app-details-titlebar.css @@ -1,4 +1,5 @@ -.app-details-titlebar { +@layer components { + .app-details-titlebar { display: flex; align-items: center; justify-content: space-between; @@ -7,33 +8,38 @@ border-bottom: 1px solid var(--app-border); padding-block-end: var(--app-spacing); margin-bottom: calc(var(--app-spacing) * 2); -} + } -.app-details-titlebar h1, -.app-details-titlebar h2, -.app-details-titlebar h3, -.app-details-titlebar h4, -.app-details-titlebar h5, -.app-details-titlebar h6 { - margin: 0; -} + .app-details-titlebar h1, + .app-details-titlebar h2, + .app-details-titlebar h3, + .app-details-titlebar h4, + .app-details-titlebar h5, + .app-details-titlebar h6 { + margin: 0; + } -.app-details-titlebar button, -.app-details-titlebar [role="button"] { - --app-button-padding-horizontal: 10px; - --app-button-padding-vertical: 6px; - font-size: 13px; - margin: 0; -} + .app-details-titlebar button, + .app-details-titlebar [role="button"] { + --app-button-padding-horizontal: 10px; + --app-button-padding-vertical: 6px; + font-size: 13px; + margin: 0; + } -.app-details-titlebar-actions { - display: flex; - align-items: center; - gap: 10px; - white-space: nowrap; -} + .app-details-titlebar button i, + .app-details-titlebar [role="button"] i { + font-size: 10px; + } -.app-details-titlebar h1 i { + .app-details-titlebar-actions { + display: flex; + align-items: center; + gap: 10px; + white-space: nowrap; + } + + .app-details-titlebar h1 i { background: var(--app-background-color); width: 35px; height: 29px; @@ -46,42 +52,43 @@ border: 1px solid var(--app-border); color: var(--app-muted-color); box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 2px; -} + } -.app-details-titlebar h1:has(i) { + .app-details-titlebar h1:has(i) { display: flex; align-items: center; gap: 7px; line-height: 1; -} -.app-details-titlebar details.dropdown { - margin-bottom: 0; -} + } + .app-details-titlebar details.dropdown { + margin-bottom: 0; + } -.app-details-titlebar details.dropdown summary:after { + .app-details-titlebar details.dropdown summary:after { display: none; -} + } -.app-details-titlebar details.dropdown li { - padding: 0!important; - margin: 0!important; + .app-details-titlebar details.dropdown li { + padding: 0 !important; + margin: 0 !important; border-bottom: 1px solid var(--app-border); -} + } -.app-details-titlebar details.dropdown li:last-child { + .app-details-titlebar details.dropdown li:last-child { border-bottom: none; -} + } -.app-details-titlebar details.dropdown ul { - padding: 0!important; - margin: 0!important; -} + .app-details-titlebar details.dropdown ul { + padding: 0 !important; + margin: 0 !important; + } -.app-details-titlebar details.dropdown li button, -.app-details-titlebar details.dropdown li [role=button] { - margin: 0!important; + .app-details-titlebar details.dropdown li button, + .app-details-titlebar details.dropdown li [role="button"] { + margin: 0 !important; border: none; border-radius: 0; width: 100%; text-align: left; -} \ No newline at end of file + } +} diff --git a/web/css/components/app-details.css b/web/css/components/app-details.css index e253aee..8c655c8 100644 --- a/web/css/components/app-details.css +++ b/web/css/components/app-details.css @@ -1,7 +1,22 @@ +@layer components { .app-details-container aside { padding-top: var(--app-spacing); } +.app-details-aside-actions { + display: grid; + gap: calc(var(--app-spacing) * 0.5); +} + +.app-details-aside-actions form { + margin: 0; +} + +.app-details-aside-actions a[role="button"], +.app-details-aside-actions button { + width: 100%; +} + .user-avatar-block, .entity-avatar-block { --avatar-size: 96px; @@ -54,7 +69,7 @@ .avatar-square .user-avatar-placeholder, .avatar-square .entity-avatar-image, .avatar-square .entity-avatar-placeholder { - border-radius: var(--app-radius); + border-radius: var(--app-border-radius); } .avatar-ratio-1-1 { @@ -102,13 +117,15 @@ .app-details-container .app-breadcrumb { padding-top: calc(var(--app-spacing) * 2); position: sticky; - top: 20px; + top: 16px; + z-index: 10; background: var(--app-background-color); } .app-details-container .app-details-titlebar { position: sticky; - top: 69px; + top: 64px; + z-index: 10; background: var(--app-background-color); } @@ -146,5 +163,4 @@ min-height: 100vh; } } - - +} diff --git a/web/css/components/app-flash.css b/web/css/components/app-flash.css index cf22919..6c7790a 100644 --- a/web/css/components/app-flash.css +++ b/web/css/components/app-flash.css @@ -1,3 +1,4 @@ +@layer components { .notice { --app-notice-border-color: var(--app-muted-border-color); --app-notice-background-color: var(--app-card-background-color); @@ -127,4 +128,5 @@ .notice li { list-style: none; -} \ No newline at end of file +} +} diff --git a/web/css/components/app-forms.css b/web/css/components/app-forms.css index 4d7a5a2..dd1fae5 100644 --- a/web/css/components/app-forms.css +++ b/web/css/components/app-forms.css @@ -1,62 +1,103 @@ -.form-hint { - font-size: small; -} +@layer components { + .form-hint { + font-size: small; + } -ul.form-hint-list { - margin: 0; - padding-left: var(--app-spacing); -} + ul.form-hint-list { + margin: 0; + padding-left: var(--app-spacing); + } -ul.form-hint-list li { - margin: 0; -} + ul.form-hint-list li { + margin: 0; + } -ul.form-hint-list li.is-valid { - color: var(--app-form-element-valid-border-color); -} + ul.form-hint-list li.is-valid { + color: var(--app-form-element-valid-border-color); + } -ul.form-hint-list li.is-invalid { - color: var(--app-form-element-invalid-border-color); -} + ul.form-hint-list li.is-invalid { + color: var(--app-form-element-invalid-border-color); + } -ul.form-hint-list li.is-valid:after { - content: "\F26B"; - font-family: "Bootstrap-icons"; - margin-left: 4px; - font-size: 10px; -} + ul.form-hint-list li.is-valid:after { + content: "\F26B"; + font-family: "Bootstrap-icons"; + margin-left: 4px; + font-size: 10px; + } -ul.form-hint-list li.is-invalid:after { - content: "\F33A"; - font-family: "Bootstrap-icons"; - margin-left: 4px; - font-size: 10px; -} + ul.form-hint-list li.is-invalid:after { + content: "\F33A"; + font-family: "Bootstrap-icons"; + margin-left: 4px; + font-size: 10px; + } -label:has([type="checkbox"], [type="radio"]) { - display: flex; - line-height: 1; - align-items: center; -} + label:has([type="checkbox"], [type="radio"]) { + display: flex; + line-height: 1; + align-items: center; + } -input[type="color"] { + input[type="color"] { padding: 0; width: 42px; border: 0; margin: 0; height: 42px; flex-shrink: 0; -} + } -label:has(input[type="color"]) { - display: flex; - align-items: center; - gap: 10px; -} + label:has(input[type="color"]) { + display: flex; + align-items: center; + gap: 10px; + } -small.muted { + small.muted { display: block; width: 100%; color: var(--app-muted-color); -} + } + a.muted { + color: var(--app-muted-color); + } + label.app-field:has(input[type="checkbox"][disabled]), + label.app-field:has(input[type="radio"][disabled]) { + cursor: not-allowed; + opacity: 0.8; + } + + label.app-field:has(input[type="checkbox"][disabled]) > span, + label.app-field:has(input[type="radio"][disabled]) > span { + color: var(--app-muted-color); + } + + input[type="checkbox"][disabled], + input[type="radio"][disabled] { + --app-border-color: var(--app-muted-border-color); + --app-background-color: var(--app-form-element-background-color); + cursor: not-allowed; + } + + input[type="checkbox"][disabled]:checked, + input[type="checkbox"][disabled]:indeterminate, + input[type="radio"][disabled]:checked { + --app-border-color: var(--app-muted-border-color); + --app-background-color: var(--app-muted-border-color); + } + + fieldset[aria-disabled="true"] { + border-color: var(--app-muted-border-color); + } + + fieldset[aria-disabled="true"] .muted, + fieldset[aria-disabled="true"] legend small { + color: var(--app-muted-color); + } + label:has(.badge) > span { + margin-bottom: 5px; + } +} diff --git a/web/css/components/app-list-table.css b/web/css/components/app-list-table.css index 3ff8ae8..a9f8d36 100644 --- a/web/css/components/app-list-table.css +++ b/web/css/components/app-list-table.css @@ -1,7 +1,9 @@ +@layer components { .app-list-table table { margin: 0; } .app-list-table { white-space: nowrap; -} \ No newline at end of file +} +} diff --git a/web/css/components/app-list-tabs.css b/web/css/components/app-list-tabs.css index c45b318..0850858 100644 --- a/web/css/components/app-list-tabs.css +++ b/web/css/components/app-list-tabs.css @@ -1,3 +1,4 @@ +@layer components { .app-list-tabs { display: flex; flex-wrap: wrap; @@ -29,3 +30,4 @@ color: var(--app-color); border-color: var(--list-tabs-active); } +} diff --git a/web/css/components/app-list-titlebar.css b/web/css/components/app-list-titlebar.css index 0c86fad..0bb0439 100644 --- a/web/css/components/app-list-titlebar.css +++ b/web/css/components/app-list-titlebar.css @@ -1,36 +1,38 @@ -.app-list-titlebar { - display: flex; - align-items: center; - justify-content: space-between; - flex-wrap: wrap; - gap: 10px; - margin-bottom: calc(var(--app-spacing) * 1); -} +@layer components { + .app-list-titlebar { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 10px; + margin-bottom: calc(var(--app-spacing) * 1); + } -.app-list-titlebar h1, -.app-list-titlebar h2, -.app-list-titlebar h3, -.app-list-titlebar h4, -.app-list-titlebar h5, -.app-list-titlebar h6 { - margin: 0; -} + .app-list-titlebar h1, + .app-list-titlebar h2, + .app-list-titlebar h3, + .app-list-titlebar h4, + .app-list-titlebar h5, + .app-list-titlebar h6 { + margin: 0; + } -.app-list-titlebar button, -.app-list-titlebar [role="button"] { - --app-button-padding-horizontal: 10px; - --app-button-padding-vertical: 6px; - font-size: 13px; - margin: 0; -} + .app-list-titlebar button, + .app-list-titlebar [role="button"] { + --app-button-padding-horizontal: 10px; + --app-button-padding-vertical: 6px; + font-size: 13px; + margin: 0; + } -.app-list-titlebar-actions { - display: flex; - align-items: center; - gap: 10px; -} + .app-list-titlebar-actions { + display: flex; + align-items: center; + gap: 10px; + white-space: nowrap; + } -.app-list-titlebar h1 i { + .app-list-titlebar h1 i { background: var(--app-muted-border-color); width: 35px; height: 35px; @@ -39,41 +41,43 @@ display: inline-flex; align-items: center; justify-content: center; -} + } -.app-list-titlebar h1:has(i) { + .app-list-titlebar h1:has(i) { display: flex; align-items: center; gap: 7px; -} + } -.app-list-titlebar details.dropdown { - margin-bottom: 0; -} + .app-list-titlebar details.dropdown { + margin-bottom: 0; + } -.app-list-titlebar details.dropdown summary:after { + .app-list-titlebar details.dropdown summary:after { display: none; -} + } -.app-list-titlebar details.dropdown li { - padding: 0!important; - margin: 0!important; + .app-list-titlebar details.dropdown li { + padding: 0 !important; + margin: 0 !important; border-bottom: 1px solid var(--app-border); -} + } -.app-list-titlebar details.dropdown li:last-child { + .app-list-titlebar details.dropdown li:last-child { border-bottom: none; -} + } -.app-list-titlebar details.dropdown ul { - padding: 0!important; - margin: 0!important; -} + .app-list-titlebar details.dropdown ul { + padding: 0 !important; + margin: 0 !important; + } -.app-list-titlebar details.dropdown li button, .app-list-titlebar details.dropdown li [role=button] { + .app-list-titlebar details.dropdown li button, + .app-list-titlebar details.dropdown li [role="button"] { margin: 0 !important; border: none; border-radius: 0; width: 100%; text-align: left; -} \ No newline at end of file + } +} diff --git a/web/css/components/app-list-toolbar.css b/web/css/components/app-list-toolbar.css index d7d5909..b3dd9dd 100644 --- a/web/css/components/app-list-toolbar.css +++ b/web/css/components/app-list-toolbar.css @@ -1,3 +1,4 @@ +@layer components { .app-list-toolbar { display: flex; align-items: flex-end; @@ -74,4 +75,5 @@ padding: 0 !important; padding-left: 3px !important; font-size: 11px !important; -} \ No newline at end of file +} +} diff --git a/web/css/components/app-page-copy.css b/web/css/components/app-page-copy.css index c06755e..3c44d53 100644 --- a/web/css/components/app-page-copy.css +++ b/web/css/components/app-page-copy.css @@ -1,3 +1,4 @@ +@layer components { .app-page-copy .app-field { @@ -7,4 +8,5 @@ .app-page-copy .app-field [role="group"] { margin-bottom: 0; -} \ No newline at end of file +} +} diff --git a/web/css/components/app-page-editor.css b/web/css/components/app-page-editor.css index b3c4798..2297be9 100644 --- a/web/css/components/app-page-editor.css +++ b/web/css/components/app-page-editor.css @@ -1,3 +1,4 @@ +@layer components { .app-page-header { display: flex; align-items: center; @@ -71,4 +72,5 @@ label.app-field > small { .app-details-aside-section button { margin-bottom: 0; -} \ No newline at end of file +} +} diff --git a/web/css/components/app-search.css b/web/css/components/app-search.css index c88e3ac..19f345d 100644 --- a/web/css/components/app-search.css +++ b/web/css/components/app-search.css @@ -1,5 +1,49 @@ +@layer components { .app-search { - padding: 0 var(--app-spacing); + position: relative; + padding: 0 var(--app-spacing); + margin-bottom: var(--app-spacing); +} + +.app-search-shortcut { + position: absolute; + right: calc(var(--app-spacing) + 0.5rem); + top: 50%; + transform: translateY(-50%); + border: 1px solid var(--app-border); + border-radius: 6px; + background: var(--app-card-background-color); + color: var(--app-muted-color); + font-size: 0.75rem; + line-height: 1; + padding: 0.2rem 0.4rem; + pointer-events: none; +} + +.app-search-empty-state { + margin: var(--app-spacing); + padding: 1rem; + border: 1px dashed var(--app-border); + border-radius: var(--app-border-radius); + text-align: center; + color: var(--app-muted-color); + font-size: 0.9rem; + cursor: text; +} + +.app-search-empty-state .bi-search { + display: inline-block; + font-size: 1.25rem; + margin-bottom: 0.5rem; + color: var(--app-color); +} + +.app-search-empty-state p { + margin: 0 0 0.25rem; +} + +.app-search-empty-state small { + display: block; } ul.app-search-results { @@ -14,21 +58,23 @@ ul.app-search-results > li > a { ul.app-search-results .badge { font-size: 10px; padding: 1px; - min-width: 20px; + min-width: 18px; text-align: center; border-radius: 999px; background: var(--app-accordion-border-color); display: inline-flex; justify-content: center; align-items: center; - height: 20px; + height: 18px; + position: relative; + top: -1px; } ul.app-search-preview { position: relative; font-size: 11px; - padding: 0 0 0 20px; - margin: 6px 0 1.5rem 10px; + padding: 0 0 0 0; + margin: 0; opacity: 0.75; list-style: none; } @@ -86,6 +132,8 @@ input#side-search { --app-form-element-spacing-vertical: 6px; height: auto; font-size: 13px; + padding-right: 3rem; + margin-bottom:0; } ul.app-search-preview:has(li) { @@ -137,3 +185,4 @@ ul.app-search-preview:has(li) { color: var(--app-muted-color); font-weight: 600; } +} diff --git a/web/css/components/app-tabs.css b/web/css/components/app-tabs.css index d1012f1..acec9aa 100644 --- a/web/css/components/app-tabs.css +++ b/web/css/components/app-tabs.css @@ -1,9 +1,24 @@ +@layer components { .app-tabs { display: flex; flex-direction: column; gap: var(--app-spacing); } +/* No-JS fallback: show only first tab panel. */ +html.no-js [data-tabs] [data-tab-panel] { + display: none; +} + +html.no-js [data-tabs] [data-tab-panel]:first-of-type { + display: block; +} + +/* JS mode: keep panels hidden until tab init completed (prevents flash). */ +html.js [data-tabs]:not([data-tabs-ready="1"]) [data-tab-panel] { + display: none !important; +} + .app-tabs-nav { display: flex; border-bottom: 1px solid var(--tabs-border); @@ -42,6 +57,21 @@ border-color: var(--tabs-active); } +.app-tabs-nav button.has-invalid, +.app-tabs-nav a.has-invalid { + color: var(--app-danger, #dc3545) !important; +} + [data-tab-panel][hidden] { display: none; } + +html.js [data-tabs][data-tabs-ready="1"] [data-tab-panel]:not([hidden]) { + display: block; +} + +/* Safety fallback when JS is available but the ready marker is missing. */ +html:not(.no-js) [data-tabs] [data-tab-panel]:not([hidden]) { + display: block; +} +} diff --git a/web/css/components/app-tile.css b/web/css/components/app-tile.css index f4a664f..560ceb0 100644 --- a/web/css/components/app-tile.css +++ b/web/css/components/app-tile.css @@ -1,74 +1,83 @@ -.app-tiles { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); - gap: calc(var(--app-spacing) * 1); - margin-bottom: calc(var(--app-spacing) * 2); -} +@layer components { + .app-tiles { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: calc(var(--app-spacing) * 1); + margin-bottom: calc(var(--app-spacing) * 2); + } -.app-stats-table { - overflow: auto; - border: 1px solid var(--app-border); - border-radius: var(--app-border-radius); - margin-bottom: calc(var(--app-spacing) * 1); -} + .app-stats-table { + overflow: auto; + border: 1px solid var(--app-border); + border-radius: var(--app-border-radius); + margin-bottom: calc(var(--app-spacing) * 1); + } -.app-stats-table th { - border-top: 0; -} + .app-stats-table th { + border-top: 0; + } -.app-stats-table table { - margin: 0; -} + .app-stats-table table { + margin: 0; + } -.app-stats-table table tr:last-child td { - border-bottom: 0; -} + .app-stats-table table tr:last-child td { + border-bottom: 0; + } -.app-tile { - --tile-icon-bg: var(--app-tile-icon-bg); - --tile-icon-color: var(--app-tile-icon-color); - --tile-count-color: var(--app-tile-count-color); - --tile-label-color: var(--app-tile-label-color); - --tile-link-color: var(--app-tile-link-color); - position: relative; - display: block; - padding: calc(var(--app-spacing) * 1); - border: 1px solid var(--app-tile-border); - border-radius: calc(var(--app-border-radius) * 1.1); - background: var(--app-tile-bg); - color: var(--app-tile-label-color); - text-decoration: none; - transition: - transform var(--app-transition), - box-shadow var(--app-transition), - border-color var(--app-transition); -} + .app-stats-table-empty { + padding: var(--app-spacing); + } -.app-tile:hover { - box-shadow: 0 12px 30px rgba(0, 0, 0, 0.08); -} + .app-stats-table-explanation { + padding: var(--app-spacing); + } -.app-tile-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 36px; - height: 36px; - border-radius: 10px; - background: var(--tile-icon-bg); - color: var(--tile-icon-color); - font-size: 1.1rem; -} + .app-tile { + --tile-icon-bg: var(--app-tile-icon-bg); + --tile-icon-color: var(--app-tile-icon-color); + --tile-count-color: var(--app-tile-count-color); + --tile-label-color: var(--app-tile-label-color); + --tile-link-color: var(--app-tile-link-color); + position: relative; + display: block; + padding: calc(var(--app-spacing) * 1); + border: 1px solid var(--app-tile-border); + border-radius: calc(var(--app-border-radius) * 1.1); + background: var(--app-tile-bg); + color: var(--app-tile-label-color); + text-decoration: none; + transition: + transform var(--app-transition), + box-shadow var(--app-transition), + border-color var(--app-transition); + } -.app-tile-count { - position: absolute; - top: calc(var(--app-spacing) * 1); - right: calc(var(--app-spacing) * 1); - font-weight: 700; - color: var(--tile-count-color); -} + .app-tile:hover { + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.08); + } -.app-tile-label { + .app-tile-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 10px; + background: var(--tile-icon-bg); + color: var(--tile-icon-color); + font-size: 1.1rem; + } + + .app-tile-count { + position: absolute; + top: calc(var(--app-spacing) * 1); + right: calc(var(--app-spacing) * 1); + font-weight: 700; + color: var(--tile-count-color); + } + + .app-tile-label { display: block; margin-top: calc(var(--app-spacing) * 1); color: var(--tile-label-color); @@ -76,55 +85,56 @@ overflow: hidden; white-space: nowrap; max-width: 191px; -} + } -.app-tile-link { - position: absolute; - right: calc(var(--app-spacing) * 1); - bottom: calc(var(--app-spacing) * 1); - color: var(--tile-link-color); - font-size: 0.9rem; -} + .app-tile-link { + position: absolute; + right: calc(var(--app-spacing) * 1); + bottom: calc(var(--app-spacing) * 1); + color: var(--tile-link-color); + font-size: 0.9rem; + } -.app-tile.is-small { - padding: 10px; - display: flex; - align-items: center; - min-height: 50px; -} -.app-tile.is-small span.app-tile-icon { - width: 25px; - height: 25px; - border-radius: 6px; - font-size: 12px; - margin-right: 10px; -} + .app-tile.is-small { + padding: 10px; + display: flex; + align-items: center; + min-height: 50px; + } + .app-tile.is-small span.app-tile-icon { + width: 25px; + height: 25px; + border-radius: 6px; + font-size: 12px; + margin-right: 10px; + } -.app-tile.is-small .app-tile-link { + .app-tile.is-small .app-tile-link { bottom: 14px; -} + } -.app-tile.is-small .app-tile-count { + .app-tile.is-small .app-tile-count { position: static; margin-right: 5px; display: block; -} -.app-tile.is-small .app-tile-label { - margin-top: 0; - font-weight: 400; -} + } + .app-tile.is-small .app-tile-label { + margin-top: 0; + font-weight: 400; + } -.app-stats-table-header { - border-bottom: 1px solid var(--app-border); - padding: 4px 10px 4px 10px; - text-transform: uppercase; - font-size: 13px; - letter-spacing: 0.4px; - background: var(--app-blockquote-background-color); -} + .app-stats-table-header { + border-bottom: 1px solid var(--app-border); + padding: 4px 10px 4px 10px; + text-transform: uppercase; + font-size: 13px; + letter-spacing: 0.4px; + background: var(--app-blockquote-background-color); + } -@media (max-width: 640px) { - .app-tiles { - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + @media (max-width: 640px) { + .app-tiles { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + } } } diff --git a/web/css/components/app-tooltips.css b/web/css/components/app-tooltips.css index 0f64695..155c664 100644 --- a/web/css/components/app-tooltips.css +++ b/web/css/components/app-tooltips.css @@ -1,3 +1,4 @@ +@layer components { [data-tooltip] { --app-card-background-color: inherit; position: relative; @@ -97,3 +98,4 @@ left: auto; transform: translate(calc(-1 * (var(--tooltip-offset) - 2px)), -50%) rotate(45deg); } +} diff --git a/web/css/components/vendor-gridjs.css b/web/css/components/vendor-gridjs.css deleted file mode 100644 index 31d4733..0000000 --- a/web/css/components/vendor-gridjs.css +++ /dev/null @@ -1,212 +0,0 @@ -.gridjs-container { - color: var(--app-color); - width: 100%; - padding: 0; -} - -.gridjs-wrapper { - border: 0; - border-radius: 0; - box-shadow: none; - background-color: var(--app-background-color); - border-top: var(--app-border-width) solid var(--app-table-border-color); - padding-block-end: 1rem; - min-height: 515px; -} - -.gridjs-footer { - background-color: var(--app-background-color); - border: 0; - border-top: 0; - box-shadow: none; - color: var(--app-muted-color); - padding: 1rem 0 0; -} - -.gridjs-table { - color: var(--app-color); -} - -th.gridjs-th { - padding: calc(var(--app-spacing) / 2) var(--app-spacing); - border: none; - border-bottom: var(--app-border-width) solid var(--app-table-border-color); - background-color: var(--app-background-color); - color: var(--app-color); - font-weight: var(--app-font-weight); - text-align: left; - text-align: start; -} - -button.gridjs-sort { - height: 15px; -} - -th.gridjs-th-sort:hover, -th.gridjs-th-sort:focus { - background-color: var(--app-table-row-stripped-background-color); -} - -td.gridjs-td { - background-color: var(--app-background-color); - border: none; - border-color: var(--app-table-border-color); - padding: calc(var(--app-spacing) / 2) var(--app-spacing); - border-bottom: var(--app-border-width) solid var(--app-table-border-color); - background-color: var(--app-background-color); - color: var(--app-color); - font-weight: var(--app-font-weight); - text-align: left; - text-align: start; -} - -.gridjs-search { - float: none; -} - -.gridjs-search-input { - width: min(320px, 100%); -} - -input.gridjs-input { - background-color: var(--app-background-color); - border: var(--app-border-width) solid var(--app-form-element-border-color); - border-radius: var(--app-border-radius); - color: var(--app-color); - font-size: 0.95rem; - padding: 0.5rem 0.75rem; -} - -input.gridjs-input:focus { - border-color: var(--app-primary); - box-shadow: 0 0 0 var(--app-outline-width) var(--app-primary-focus); -} - -.gridjs-pagination { - color: var(--app-muted-color); -} - -.gridjs-pagination .gridjs-pages button { - background-color: var(--app-background-color); - border: var(--app-border-width) solid var(--app-table-border-color); - color: var(--app-color); - padding: 1px 10px; -} - -.gridjs-pagination .gridjs-pages button:hover { - background-color: var(--app-primary-hover-background); - border-color: var(--app-primary-hover-border); - color: var(--app-primary-inverse); -} - -.gridjs-pagination .gridjs-pages button:focus { - box-shadow: 0 0 0 var(--app-outline-width) var(--app-primary-focus); -} - -.gridjs-pagination .gridjs-pages button.gridjs-currentPage { - background-color: var(--app-primary) !important; - font-weight: 400; - padding: 2px 10px; - color: var(--app-secondary-inverse) !important; -} - -.gridjs-pagination .gridjs-pages button:disabled, .gridjs-pagination .gridjs-pages button[disabled] { - background-color: var(--app-background-color); - color: var(--app-muted-color); - cursor: default; - padding: 2px 10px; - border: 0; -} - -.gridjs-pagination .gridjs-pages { - display: inline-flex; - gap: 5px; -} - -.gridjs-loading-bar { - background-color: var(--app-background-color); - opacity: 0.7; -} - -.grid-avatar { - width: 36px; - height: 36px; - border-radius: 999px; - border: 1px solid var(--app-muted-border-color); - background: var(--app-card-background-color); - object-fit: contain; - display: inline-flex; -} - -.grid-avatar-placeholder { - align-items: center; - justify-content: center; - font-weight: 600; - color: var(--app-muted-color); - font-size: 11px; - background: var(--app-background-color); -} - -.gridjs-loading-bar:after { - background-image: linear-gradient( - 90deg, - transparent, - var(--app-primary-focus), - transparent - ); -} - - /* .gridjs-container:not(.gridjs-has-loaded) .gridjs-message.gridjs-notfound { - display: none; - } */ - -.gridjs-container.gridjs-is-updating .gridjs-message.gridjs-notfound { - display: none; -} - -.gridjs-pages button { - background: var(--app-form-element-background-color) !important; - color: var(--app-contrast) !important; -} - -.gridjs-summary { - font-size: small; -} - -tr.gridjs-tr { - cursor: pointer; -} - -tr.gridjs-tr:hover td { - background: var(--app-table-row-stripped-background-color); -} - -html[data-theme=dark] button.gridjs-sort, -html[data-theme=dark] button.gridjs-sort-neutral { - filter: invert(1); -} - -html[data-theme=dark-green] button.gridjs-sort, -html[data-theme=dark-green] button.gridjs-sort-neutral { - filter: invert(1); -} - -.gridjs-tbody, td.gridjs-td { - background-color: var(--app-background-color) -} - -.gridjs-table input[data-grid-select-all] { - width: 1em; - height: 1em; - margin: 0; - border-radius: 4px; - border-width: .5px; -} - -.gridjs-th-content:has(input[data-grid-select-all]) { - text-align: center; -} - -.gridjs-pagination .gridjs-pages button:last-child { - border-right: none; -} \ No newline at end of file diff --git a/web/css/layout/app-aside-icon-bar.css b/web/css/layout/app-aside-icon-bar.css index cec0d14..97b1674 100644 --- a/web/css/layout/app-aside-icon-bar.css +++ b/web/css/layout/app-aside-icon-bar.css @@ -1,3 +1,4 @@ +@layer layout { aside.aside-icon-bar { --app-icon-bar-item-padding: 0.5rem; --app-icon-bar-item-size: 20px; @@ -104,3 +105,4 @@ aside.aside-icon-bar li button.active { border-top: var(--app-icon-bar-border-width) solid var(--app-primary); } } +} diff --git a/web/css/layout/app-page-layout.css b/web/css/layout/app-page-layout.css new file mode 100644 index 0000000..105b5b5 --- /dev/null +++ b/web/css/layout/app-page-layout.css @@ -0,0 +1,34 @@ +.page-layout { + display: flex; + flex-direction: column; + min-height: 100dvh; +} + +.page-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1.5rem; + border-bottom: 1px solid var(--border); +} + +.page-header .app-brand img { + height: 2.5rem; + width: auto; +} + +.page-header nav ul { + display: flex; + gap: 1.5rem; + list-style: none; + padding: 0; + margin: 0; +} + +.page-content { + flex: 1; + max-width: 60rem; + margin: 0 auto; + padding: 2rem 1.5rem; + width: 100%; +} diff --git a/web/css/layout/app-shell.css b/web/css/layout/app-shell.css index 9ce05de..9a4fbe3 100644 --- a/web/css/layout/app-shell.css +++ b/web/css/layout/app-shell.css @@ -1,239 +1,258 @@ @charset "UTF-8"; -a { - --app-text-decoration: underline; -} +@layer layout { + a { + --app-text-decoration: underline; + } -a.contrast, -a.secondary { - --app-text-decoration: underline; -} + a.contrast, + a.secondary { + --app-text-decoration: underline; + } -small { - --app-font-size: 0.875em; -} + small { + --app-font-size: 0.875em; + } -div#debugger-bar { - display: none; -} + div#debugger-bar { + display: none; + } -.app-container.is-sidebar-hidden .app-sidebar { - display: none; -} + .app-container.is-sidebar-hidden .app-sidebar { + display: none; + } -.sidebar-hidden .app-sidebar { - display: none; -} + .sidebar-hidden .app-sidebar { + display: none; + } -h1, -h2, -h3, -h4, -h5, -h6 { - --app-font-weight: 700; -} + h1, + h2, + h3, + h4, + h5, + h6 { + --app-font-weight: 700; + } -h1 { - --app-font-size: 1.5rem; - --app-line-height: 1.125; - --app-typography-spacing-top: 3rem; -} + h1 { + --app-font-size: 1.5rem; + --app-line-height: 1.125; + --app-typography-spacing-top: 3rem; + } -h2 { - --app-font-size: 1.4rem; - --app-line-height: 1.15; - --app-typography-spacing-top: 2.625rem; -} + h2 { + --app-font-size: 1.4rem; + --app-line-height: 1.15; + --app-typography-spacing-top: 2.625rem; + } -h3 { - --app-font-size: 1.3rem; - --app-line-height: 1.175; - --app-typography-spacing-top: 2.25rem; -} + h3 { + --app-font-size: 1.3rem; + --app-line-height: 1.175; + --app-typography-spacing-top: 2.25rem; + } -h4 { - --app-font-size: 1.2rem; - --app-line-height: 1.2; - --app-typography-spacing-top: 1.874rem; -} + h4 { + --app-font-size: 1.2rem; + --app-line-height: 1.2; + --app-typography-spacing-top: 1.874rem; + } -h5 { - --app-font-size: 1.1rem; - --app-line-height: 1.225; - --app-typography-spacing-top: 1.6875rem; -} + h5 { + --app-font-size: 1.1rem; + --app-line-height: 1.225; + --app-typography-spacing-top: 1.6875rem; + } -h6 { - --app-font-size: 1rem; - --app-line-height: 1.25; - --app-typography-spacing-top: 1.5rem; -} + h6 { + --app-font-size: 1rem; + --app-line-height: 1.25; + --app-typography-spacing-top: 1.5rem; + } -tfoot td, -tfoot th, -thead td, -thead th { - --app-font-weight: 600; - --app-border-width: 2px; -} + tfoot td, + tfoot th, + thead td, + thead th { + --app-font-weight: 600; + --app-border-width: 2px; + } -code, -kbd, -pre, -samp { - --app-font-family: var(--app-font-family-monospace); -} + code, + kbd, + pre, + samp { + --app-font-family: var(--app-font-family-monospace); + } -kbd { - --app-font-weight: bolder; -} + kbd { + --app-font-weight: bolder; + } -:where(select, textarea), -input:not( - [type="submit"], - [type="button"], - [type="reset"], - [type="checkbox"], - [type="radio"], - [type="file"] -) { - --app-outline-width: 0.0625rem; -} - -[type="checkbox"], -[type="radio"] { - --app-border-width: 0.125rem; -} - -[type="checkbox"][role="switch"] { - --app-border-width: 0.1875rem; -} - -details.dropdown summary:not([role="button"]) { - --app-outline-width: 0.0625rem; -} - -nav details.dropdown summary:focus-visible { - --app-outline-width: 0.125rem; -} - -[role="search"] { - --app-border-radius: 5rem; -} - -[role="group"]:has( - button.secondary:focus, - [type="submit"].secondary:focus, - [type="button"].secondary:focus, - [role="button"].secondary:focus -), -[role="search"]:has( - button.secondary:focus, - [type="submit"].secondary:focus, - [type="button"].secondary:focus, - [role="button"].secondary:focus -) { - --app-group-box-shadow-focus-with-button: 0 0 0 var(--app-outline-width) - var(--app-secondary-focus); -} - -[role="group"]:has( - button.contrast:focus, - [type="submit"].contrast:focus, - [type="button"].contrast:focus, - [role="button"].contrast:focus -), -[role="search"]:has( - button.contrast:focus, - [type="submit"].contrast:focus, - [type="button"].contrast:focus, - [role="button"].contrast:focus -) { - --app-group-box-shadow-focus-with-button: 0 0 0 var(--app-outline-width) - var(--app-contrast-focus); -} - -[role="group"] [role="button"], -[role="group"] [type="button"], -[role="group"] [type="submit"], -[role="group"] button, -[role="search"] [role="button"], -[role="search"] [type="button"], -[role="search"] [type="submit"], -[role="search"] button { - --app-form-element-spacing-horizontal: 2rem; -} - -[role="group"] form:last-child button { - border-top-left-radius: 0; - border-bottom-left-radius: 0; -} - -table td [role="group"] { - margin: 0; -} - -th { - text-transform: uppercase; - font-size: 11px; - letter-spacing: 0.4px; - border-top: 1px solid var(--app-border); -} - -table td .grid-actions button, -table td .grid-actions [role="button"] { - padding: 4px 10px; - color: var(--app-muted-color); - border: 1px solid var(--app-border); - font-size: small; - background: var(--app-form-element-background-color); - box-shadow: var(--app-group-box-shadow); -} - -table td .grid-actions button:has(i), -table td .grid-actions [role="button"]:has(i) { - max-width: 42px; -} - -table td .grid-actions button:hover, -table td .grid-actions [role="button"]:hover { - color: var(--app-contrast); -} - -details summary[role="button"]:not(.outline)::after { - filter: brightness(0) invert(1); -} - -[aria-busy="true"]:not(input, select, textarea):is( - button, + :where(select, textarea), + input:not( [type="submit"], [type="button"], [type="reset"], - [role="button"] - ):not(.outline)::before { - filter: brightness(0) invert(1); -} - -@media only screen and (prefers-color-scheme: dark) { - :host(:not([data-theme])) - details - summary[role="button"].contrast:not(.outline)::after, - :root:not([data-theme]) - details - summary[role="button"].contrast:not(.outline)::after { - filter: brightness(0); + [type="checkbox"], + [type="radio"], + [type="file"] + ) { + --app-outline-width: 0.0625rem; } - :host(:not([data-theme])) - [aria-busy="true"]:not(input, select, textarea).contrast:is( + [type="checkbox"], + [type="radio"] { + --app-border-width: 0.125rem; + } + + [type="checkbox"][role="switch"] { + --app-border-width: 0.1875rem; + } + + details.dropdown summary:not([role="button"]) { + --app-outline-width: 0.0625rem; + } + + nav details.dropdown summary:focus-visible { + --app-outline-width: 0.125rem; + } + + [role="search"] { + --app-border-radius: 5rem; + } + + [role="group"]:has( + button.secondary:focus, + [type="submit"].secondary:focus, + [type="button"].secondary:focus, + [role="button"].secondary:focus + ), + [role="search"]:has( + button.secondary:focus, + [type="submit"].secondary:focus, + [type="button"].secondary:focus, + [role="button"].secondary:focus + ) { + --app-group-box-shadow-focus-with-button: 0 0 0 var(--app-outline-width) + var(--app-secondary-focus); + } + + [role="group"]:has( + button.contrast:focus, + [type="submit"].contrast:focus, + [type="button"].contrast:focus, + [role="button"].contrast:focus + ), + [role="search"]:has( + button.contrast:focus, + [type="submit"].contrast:focus, + [type="button"].contrast:focus, + [role="button"].contrast:focus + ) { + --app-group-box-shadow-focus-with-button: 0 0 0 var(--app-outline-width) + var(--app-contrast-focus); + } + + [role="group"] [role="button"], + [role="group"] [type="button"], + [role="group"] [type="submit"], + [role="group"] button, + [role="search"] [role="button"], + [role="search"] [type="button"], + [role="search"] [type="submit"], + [role="search"] button { + --app-form-element-spacing-horizontal: 2rem; + } + + [role="group"] form:last-child button { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + + table td [role="group"] { + margin: 0; + } + + th { + text-transform: uppercase; + font-size: 11px; + letter-spacing: 0.4px; + border-top: 1px solid var(--app-border); + } + + table td .grid-actions button, + table td .grid-actions [role="button"] { + padding: 4px 10px; + color: var(--app-muted-color); + border: 1px solid var(--app-border); + font-size: small; + background: var(--app-form-element-background-color); + box-shadow: var(--app-group-box-shadow); + } + + table td .grid-actions button:has(i), + table td .grid-actions [role="button"]:has(i) { + max-width: 42px; + } + + table td .grid-actions button:hover, + table td .grid-actions [role="button"]:hover { + color: var(--app-contrast); + } + + details summary[role="button"]:not(.outline)::after { + filter: brightness(0) invert(1); + } + + [aria-busy="true"]:not(input, select, textarea):is( button, [type="submit"], [type="button"], [type="reset"], [role="button"] - ):not(.outline)::before, - :root:not([data-theme]) + ):not(.outline)::before { + filter: brightness(0) invert(1); + } + + @media only screen and (prefers-color-scheme: dark) { + :host(:not([data-theme])) + details + summary[role="button"].contrast:not(.outline)::after, + :root:not([data-theme]) + details + summary[role="button"].contrast:not(.outline)::after { + filter: brightness(0); + } + + :host(:not([data-theme])) + [aria-busy="true"]:not(input, select, textarea).contrast:is( + button, + [type="submit"], + [type="button"], + [type="reset"], + [role="button"] + ):not(.outline)::before, + :root:not([data-theme]) + [aria-busy="true"]:not(input, select, textarea).contrast:is( + button, + [type="submit"], + [type="button"], + [type="reset"], + [role="button"] + ):not(.outline)::before { + filter: brightness(0); + } + } + + [data-theme="dark"] + details + summary[role="button"].contrast:not(.outline)::after { + filter: brightness(0); + } + + [data-theme="dark"] [aria-busy="true"]:not(input, select, textarea).contrast:is( button, [type="submit"], @@ -243,2787 +262,2800 @@ details summary[role="button"]:not(.outline)::after { ):not(.outline)::before { filter: brightness(0); } -} -[data-theme="dark"] - details - summary[role="button"].contrast:not(.outline)::after { - filter: brightness(0); -} - -[data-theme="dark"] - [aria-busy="true"]:not(input, select, textarea).contrast:is( - button, - [type="submit"], - [type="button"], - [type="reset"], - [role="button"] - ):not(.outline)::before { - filter: brightness(0); -} - -[type="checkbox"], -[type="radio"], -[type="range"], -progress { - accent-color: var(--app-primary); -} - -*, -::after, -::before { - box-sizing: border-box; - background-repeat: no-repeat; -} - -::after, -::before { - text-decoration: inherit; - vertical-align: inherit; -} - -:where(:host), -:where(:root) { - -webkit-tap-highlight-color: transparent; - -webkit-text-size-adjust: 100%; - -moz-text-size-adjust: 100%; - text-size-adjust: 100%; - background-color: var(--app-background-color); - color: var(--app-color); - font-weight: var(--app-font-weight); - font-size: var(--app-font-size); - line-height: var(--app-line-height); - font-family: var(--app-font-family); - text-underline-offset: var(--app-text-underline-offset); - text-rendering: optimizeLegibility; - overflow-wrap: break-word; - -moz-tab-size: 4; - -o-tab-size: 4; - tab-size: 4; -} - -body { - width: 100%; - margin: 0; -} - -main { - display: block; -} - -body > footer, -body > main { - padding-block: var(--app-block-spacing-vertical); -} - -body > footer { - padding-inline: calc(var(--app-spacing) * 2); -} - -footer.site-footer { - padding-inline: calc(var(--app-spacing) * 2); - border-top: 1px solid var(--app-border); -} - -footer.site-footer a { - color: inherit; -} - -body > footer a { - color: inherit; -} -header[data-align="center"], -hgroup[data-align="center"] > h1, -hgroup[data-align="center"] > h2, -hgroup[data-align="center"] > h3, -hgroup[data-align="center"] > p, -hgroup[data-align="center"] > *, -hgroup[data-align="center"] { - text-align: center; - justify-content: center; -} - -section { - margin-bottom: var(--app-block-spacing-vertical); -} - -.container-small { - max-width: 420px; - margin-right: auto; - margin-left: auto; -} - -.container, -.container-fluid { - width: 100%; - margin-right: auto; - margin-left: auto; - padding-right: var(--app-spacing); - padding-left: var(--app-spacing); -} - -.app-main-content { - padding-inline: calc(var(--app-spacing) * 1); -} - -@media (min-width: 576px) { - .container { - max-width: 510px; - padding-right: 0; - padding-left: 0; + [type="checkbox"], + [type="radio"], + [type="range"], + progress { + accent-color: var(--app-primary); } - h1 { - --app-font-size: 1.7rem; - + *, + ::after, + ::before { + box-sizing: border-box; + background-repeat: no-repeat; } - h2 { - --app-font-size: 1.6rem; - + ::after, + ::before { + text-decoration: inherit; + vertical-align: inherit; } - h3 { - --app-font-size: 1.3rem; - + :where(:host), + :where(:root) { + -webkit-tap-highlight-color: transparent; + -webkit-text-size-adjust: 100%; + -moz-text-size-adjust: 100%; + text-size-adjust: 100%; + background-color: var(--app-background-color); + color: var(--app-color); + font-weight: var(--app-font-weight); + font-size: var(--app-font-size); + line-height: var(--app-line-height); + font-family: var(--app-font-family); + text-underline-offset: var(--app-text-underline-offset); + text-rendering: optimizeLegibility; + overflow-wrap: break-word; + -moz-tab-size: 4; + -o-tab-size: 4; + tab-size: 4; } - h4 { - --app-font-size: 1.2rem; - + body { + width: 100%; + margin: 0; } - h5 { - --app-font-size: 1.1rem; - + main { + display: block; } - h6 { - --app-font-size: 1rem; - - } -} - -@media (min-width: 768px) { - .container { - max-width: 700px; - } - .app-container.is-sidebar-hidden:has(.aside-icon-bar):has(.app-sidebar) { - display: grid; - min-height: 100dvh; - align-items: flex-start; - grid-template-columns: minmax(0, 60px) minmax(0, 1fr); - } - .sidebar-hidden .app-container:has(.aside-icon-bar):has(.app-sidebar) { - grid-template-columns: minmax(0, 60px) minmax(0, 1fr); - } - .app-container:has(.aside-icon-bar):has(.app-sidebar) { - display: grid; - min-height: 100dvh; - align-items: flex-start; - grid-template-columns: minmax(0, 60px) minmax(0, 220px) minmax(0, 1fr); - } - .app-container.is-sidebar-collapsed:has(.aside-icon-bar):has(.app-sidebar) { - display: grid; - min-height: 100dvh; - align-items: flex-start; - grid-template-columns: minmax(0, 60px) minmax(0, 220px) minmax(0, 1fr); + body > footer, + body > main { + padding-block: var(--app-block-spacing-vertical); } - .app-container.is-sidebar-collapsed { - grid-template-columns: minmax(0, 60px) minmax(0, 220px) minmax(0, 1fr); + body > footer { + padding-inline: calc(var(--app-spacing) * 2); } - .sidebar-collapsed .app-container { - grid-template-columns: minmax(0, 60px) minmax(0, 220px) minmax(0, 1fr); + footer.site-footer { + padding-inline: calc(var(--app-spacing) * 2); + border-top: 1px solid var(--app-border); } - .app-main { - border-left: 1px solid var(--app-border); - min-height: 100dvh; - background: var(--app-background-color); + footer.site-footer a { + color: inherit; + } + + body > footer a { + color: inherit; + } + header[data-align="center"], + hgroup[data-align="center"] > h1, + hgroup[data-align="center"] > h2, + hgroup[data-align="center"] > h3, + hgroup[data-align="center"] > p, + hgroup[data-align="center"] > *, + hgroup[data-align="center"] { + text-align: center; + justify-content: center; + } + + section { + margin-bottom: var(--app-block-spacing-vertical); + } + + .container-small { + max-width: 420px; + margin-right: auto; + margin-left: auto; + } + + .container, + .container-fluid { + width: 100%; + margin-right: auto; + margin-left: auto; + padding-right: var(--app-spacing); + padding-left: var(--app-spacing); } .app-main-content { - padding-inline: calc(var(--app-spacing) * 2); - margin-bottom: calc(var(--app-spacing) * 2); + padding-inline: calc(var(--app-spacing) * 1); } - .app-main-content:has(.app-details-container) { + + @media (min-width: 576px) { + .container { + max-width: 510px; + padding-right: 0; + padding-left: 0; + } + + h1 { + --app-font-size: 1.7rem; + } + + h2 { + --app-font-size: 1.6rem; + } + + h3 { + --app-font-size: 1.3rem; + } + + h4 { + --app-font-size: 1.2rem; + } + + h5 { + --app-font-size: 1.1rem; + } + + h6 { + --app-font-size: 1rem; + } + } + + @media (min-width: 768px) { + .container { + max-width: 700px; + } + .app-container.is-sidebar-hidden:has(.aside-icon-bar):has(.app-sidebar) { + display: grid; + min-height: 100dvh; + align-items: flex-start; + grid-template-columns: minmax(0, 60px) minmax(0, 1fr); + } + .sidebar-hidden .app-container:has(.aside-icon-bar):has(.app-sidebar) { + grid-template-columns: minmax(0, 60px) minmax(0, 1fr); + } + .app-container:has(.aside-icon-bar):has(.app-sidebar) { + display: grid; + min-height: 100dvh; + align-items: flex-start; + grid-template-columns: minmax(0, 60px) minmax(0, 220px) minmax(0, 1fr); + } + .app-container.is-sidebar-collapsed:has(.aside-icon-bar):has(.app-sidebar) { + display: grid; + min-height: 100dvh; + align-items: flex-start; + grid-template-columns: minmax(0, 60px) minmax(0, 220px) minmax(0, 1fr); + } + + .app-container.is-sidebar-collapsed { + grid-template-columns: minmax(0, 60px) minmax(0, 220px) minmax(0, 1fr); + } + + .sidebar-collapsed .app-container { + grid-template-columns: minmax(0, 60px) minmax(0, 220px) minmax(0, 1fr); + } + + .app-main { + border-left: 1px solid var(--app-border); + min-height: 100dvh; + background: var(--app-background-color); + } + + .app-main-content { + padding-inline: calc(var(--app-spacing) * 2); + margin-bottom: calc(var(--app-spacing) * 2); + } + .app-main-content:has(.app-details-container) { + margin-bottom: 0; + padding: 0; + position: relative; + z-index: 1; + } + } + + @media (min-width: 1024px) { + .container { + max-width: 950px; + } + } + + @media (min-width: 1280px) { + .container { + max-width: 1200px; + } + } + + @media (min-width: 1536px) { + .container { + max-width: 1450px; + } + } + + .grid { + grid-column-gap: var(--app-grid-column-gap); + grid-row-gap: var(--app-grid-row-gap); + display: grid; + --app-grid-template-columns: 1fr; + grid-template-columns: var(--app-grid-template-columns); + } + + .flex { + display: flex; + flex-wrap: wrap; + gap: var(--app-flex-gap); + } + + .flex h1, + .flex h2, + .flex h3, + .flex h4, + .flex h5, + .flex h6 { margin-bottom: 0; - padding: 0; + } + + .align-center { + align-items: center; + } + + .align-flex-start { + align-items: flex-start; + } + + .justify-space-between { + justify-content: space-between; + } + + @media (min-width: 768px) { + .grid { + --app-grid-template-columns: repeat(auto-fit, minmax(0%, 1fr)); + } + + .grid.grid-2, + .grid.grid-1-1 { + --app-grid-template-columns: repeat(2, minmax(0%, 1fr)); + } + + .grid.grid-3 { + --app-grid-template-columns: repeat(3, minmax(0%, 1fr)); + } + + .grid.grid-4 { + --app-grid-template-columns: repeat(4, minmax(0%, 1fr)); + } + + .grid.grid-5 { + --app-grid-template-columns: repeat(5, minmax(0%, 1fr)); + } + + .grid.grid-6 { + --app-grid-template-columns: repeat(6, minmax(0%, 1fr)); + } + + .grid.grid-1-2 { + --app-grid-template-columns: minmax(0%, 1fr) minmax(0%, 2fr); + } + + .grid.grid-2-1 { + --app-grid-template-columns: minmax(0%, 2fr) minmax(0%, 1fr); + } + + .grid.grid-1-3 { + --app-grid-template-columns: minmax(0%, 1fr) minmax(0%, 3fr); + } + + .grid.grid-3-1 { + --app-grid-template-columns: minmax(0%, 3fr) minmax(0%, 1fr); + } + } + + .grid > * { + min-width: 0; + } + + .overflow-auto { + overflow: auto; + } + + b, + strong { + font-weight: bolder; + } + + sub, + sup { position: relative; + font-size: 0.75em; + line-height: 0; + vertical-align: baseline; + } + + sub { + bottom: -0.25em; + } + + sup { + top: -0.5em; + } + + address, + blockquote, + dl, + ol, + p, + pre, + table, + ul { + margin-top: 0; + margin-bottom: var(--app-typography-spacing-vertical); + color: var(--app-color); + font-style: normal; + font-weight: var(--app-font-weight); + } + + h1, + h2, + h3, + h4, + h5, + h6 { + margin-top: 0; + margin-bottom: var(--app-typography-spacing-vertical); + color: var(--app-color); + font-weight: var(--app-font-weight); + font-size: var(--app-font-size); + line-height: var(--app-line-height); + font-family: var(--app-font-family); + } + + h1 { + --app-color: var(--app-h1-color); + } + + h2 { + --app-color: var(--app-h2-color); + } + + h3 { + --app-color: var(--app-h3-color); + } + + h4 { + --app-color: var(--app-h4-color); + } + + h5 { + --app-color: var(--app-h5-color); + } + + h6 { + --app-color: var(--app-h6-color); + } + + :where(article, address, blockquote, dl, figure, form, ol, p, pre, table, ul) + ~ :is(h1, h2, h3, h4, h5, h6) { + margin-top: var(--app-typography-spacing-top); + } + + p { + margin-bottom: var(--app-typography-spacing-vertical); + } + + hgroup { + margin-bottom: var(--app-typography-spacing-vertical); + } + + .flex hgroup { + margin-bottom: 0; + } + + hgroup > * { + margin-top: 0; + margin-bottom: 0; + } + + hgroup > :not(:first-child):last-child { + --app-color: var(--app-muted-color); + --app-font-weight: unset; + font-size: 1rem; + } + + :where(ol, ul) li { + margin-bottom: calc(var(--app-typography-spacing-vertical) * 0.25); + } + + :where(dl, ol, ul) :where(dl, ol, ul) { + margin: 0; + margin-top: calc(var(--app-typography-spacing-vertical) * 0.25); + } + + ul li { + list-style: square; + } + + mark { + padding: 0.125rem 0.25rem; + background-color: var(--app-mark-background-color); + color: var(--app-mark-color); + vertical-align: baseline; + } + + abbr[title] { + border-bottom: 1px dotted; + text-decoration: none; + cursor: help; + } + + ins { + color: var(--app-ins-color); + text-decoration: none; + } + + del { + color: var(--app-del-color); + } + + ::-moz-selection { + background-color: var(--app-text-selection-color); + } + + ::selection { + background-color: var(--app-text-selection-color); + } + + :where(a:not([role="button"])), + [role="link"] { + --app-color: var(--app-primary); + --app-background-color: transparent; + --app-underline: var(--app-primary-underline); + outline: 0; + background-color: var(--app-background-color); + color: cornflowerblue; + -webkit-text-decoration: var(--app-text-decoration); + text-decoration: var(--app-text-decoration); + text-decoration-color: var(--app-underline); + text-underline-offset: 0.125em; + transition: + background-color var(--app-transition), + color var(--app-transition), + box-shadow var(--app-transition), + -webkit-text-decoration var(--app-transition); + transition: + background-color var(--app-transition), + color var(--app-transition), + text-decoration var(--app-transition), + box-shadow var(--app-transition); + transition: + background-color var(--app-transition), + color var(--app-transition), + text-decoration var(--app-transition), + box-shadow var(--app-transition), + -webkit-text-decoration var(--app-transition); + } + + :where(a:not([role="button"])):is( + [aria-current]:not([aria-current="false"]), + :hover, + :active, + :focus + ), + [role="link"]:is( + [aria-current]:not([aria-current="false"]), + :hover, + :active, + :focus + ) { + --app-color: var(--app-primary-hover); + --app-underline: var(--app-primary-hover-underline); + --app-text-decoration: underline; + } + + :where(a:not([role="button"])):focus-visible, + [role="link"]:focus-visible { + box-shadow: 0 0 0 var(--app-outline-width) var(--app-primary-focus); + } + + :where(a:not([role="button"])).secondary, + [role="link"].secondary { + --app-color: var(--app-secondary); + --app-underline: var(--app-secondary-underline); + } + + :where(a:not([role="button"])).secondary:is( + [aria-current]:not([aria-current="false"]), + :hover, + :active, + :focus + ), + [role="link"].secondary:is( + [aria-current]:not([aria-current="false"]), + :hover, + :active, + :focus + ) { + --app-color: var(--app-secondary-hover); + --app-underline: var(--app-secondary-hover-underline); + } + + :where(a:not([role="button"])).contrast, + [role="link"].contrast { + --app-color: var(--app-contrast); + --app-underline: var(--app-contrast-underline); + } + + :where(a:not([role="button"])).contrast:is( + [aria-current]:not([aria-current="false"]), + :hover, + :active, + :focus + ), + [role="link"].contrast:is( + [aria-current]:not([aria-current="false"]), + :hover, + :active, + :focus + ) { + --app-color: var(--app-contrast-hover); + --app-underline: var(--app-contrast-hover-underline); + } + + a[role="button"] { + display: inline-block; + } + + button { + margin: 0; + overflow: visible; + font-family: inherit; + text-transform: none; + } + + a.transparent[role="button"], + button.transparent, + .transparent[role="button"] { + background: transparent; + border: none; + box-shadow: none; + color: var(--app-muted-color); + } + a.transparent[role="button"]:hover, + button.transparent:hover, + .transparent[role="button"]:hover { + background: var(--app-muted-border-color); + color: var(--app-contrast); + } + + [type="button"], + [type="reset"], + [type="submit"], + button { + -webkit-appearance: button; + } + + [role="button"], + [type="button"], + [type="file"]::file-selector-button, + [type="reset"], + [type="submit"], + button { + --app-background-color: var(--app-primary-background); + --app-border-color: var(--app-primary-border); + --app-color: var(--app-primary-inverse); + --app-box-shadow: var(--app-button-box-shadow, 0 0 0 rgba(0, 0, 0, 0)); + padding: var(--app-button-padding-vertical) + var(--app-button-padding-horizontal); + border: var(--app-border-width) solid var(--app-border-color); + border-radius: var(--app-border-radius); + outline: 0; + background-color: var(--app-background-color); + box-shadow: var(--app-box-shadow); + color: var(--app-color); + font-weight: var(--app-font-weight); + font-size: 1rem; + line-height: var(--app-line-height); + text-align: center; + text-decoration: none; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + transition: + background-color var(--app-transition), + border-color var(--app-transition), + color var(--app-transition), + box-shadow var(--app-transition); + } + + :where( + button, + [type="submit"], + [type="reset"], + [type="button"], + [role="button"] + ).small { + --app-button-padding-vertical: var(--app-button-padding-vertical-small); + --app-button-padding-horizontal: var(--app-button-padding-horizontal-small); + font-size: 13px; + } + + [role="button"]:is(:hover, :active, :focus), + [role="button"]:is([aria-current]:not([aria-current="false"])), + [type="button"]:is(:hover, :active, :focus), + [type="button"]:is([aria-current]:not([aria-current="false"])), + [type="file"]::file-selector-button:is(:hover, :active, :focus), + [type="file"]::file-selector-button:is( + [aria-current]:not([aria-current="false"]) + ), + [type="reset"]:is(:hover, :active, :focus), + [type="reset"]:is([aria-current]:not([aria-current="false"])), + [type="submit"]:is(:hover, :active, :focus), + [type="submit"]:is([aria-current]:not([aria-current="false"])), + button:is(:hover, :active, :focus), + button:is([aria-current]:not([aria-current="false"])) { + --app-background-color: var(--app-primary-hover-background); + --app-border-color: var(--app-primary-hover-border); + --app-box-shadow: var( + --app-button-hover-box-shadow, + 0 0 0 rgba(0, 0, 0, 0) + ); + --app-color: var(--app-primary-inverse); + } + + [role="button"]:focus, + [role="button"]:is([aria-current]:not([aria-current="false"])):focus, + [type="button"]:focus, + [type="button"]:is([aria-current]:not([aria-current="false"])):focus, + [type="file"]::file-selector-button:focus, + [type="file"]::file-selector-button:is( + [aria-current]:not([aria-current="false"]) + ):focus, + [type="reset"]:focus, + [type="reset"]:is([aria-current]:not([aria-current="false"])):focus, + [type="submit"]:focus, + [type="submit"]:is([aria-current]:not([aria-current="false"])):focus, + button:focus, + button:is([aria-current]:not([aria-current="false"])):focus { + --app-box-shadow: + var(--app-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)), + 0 0 0 var(--app-outline-width) var(--app-primary-focus); + } + + [type="button"], + [type="reset"] { + margin-bottom: var(--app-spacing); + } + + :is(button, [type="submit"], [type="button"], [role="button"]).secondary, + [type="file"]::file-selector-button, + [type="reset"] { + --app-background-color: var(--app-secondary-background); + --app-border-color: var(--app-secondary-border); + --app-color: var(--app-secondary-inverse); + cursor: pointer; + } + + :is(button, [type="submit"], [type="button"], [role="button"]).secondary:is( + [aria-current]:not([aria-current="false"]), + :hover, + :active, + :focus + ), + [type="file"]::file-selector-button:is( + [aria-current]:not([aria-current="false"]), + :hover, + :active, + :focus + ), + [type="reset"]:is( + [aria-current]:not([aria-current="false"]), + :hover, + :active, + :focus + ) { + --app-background-color: var(--app-secondary-hover-background); + --app-border-color: var(--app-secondary-hover-border); + --app-color: var(--app-secondary-inverse); + } + + :is( + button, + [type="submit"], + [type="button"], + [role="button"] + ).secondary:focus, + :is(button, [type="submit"], [type="button"], [role="button"]).secondary:is( + [aria-current]:not([aria-current="false"]) + ):focus, + [type="file"]::file-selector-button:focus, + [type="file"]::file-selector-button:is( + [aria-current]:not([aria-current="false"]) + ):focus, + [type="reset"]:focus, + [type="reset"]:is([aria-current]:not([aria-current="false"])):focus { + --app-box-shadow: + var(--app-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)), + 0 0 0 var(--app-outline-width) var(--app-secondary-focus); + } + + :is(button, [type="submit"], [type="button"], [role="button"]).contrast { + --app-background-color: var(--app-contrast-background); + --app-border-color: var(--app-contrast-border); + --app-color: var(--app-contrast-inverse); + } + + :is(button, [type="submit"], [type="button"], [role="button"]).contrast:is( + [aria-current]:not([aria-current="false"]), + :hover, + :active, + :focus + ) { + --app-background-color: var(--app-contrast-hover-background); + --app-border-color: var(--app-contrast-hover-border); + --app-color: var(--app-contrast-inverse); + } + + :is(button, [type="submit"], [type="button"], [role="button"]).contrast:focus, + :is(button, [type="submit"], [type="button"], [role="button"]).contrast:is( + [aria-current]:not([aria-current="false"]) + ):focus { + --app-box-shadow: + var(--app-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)), + 0 0 0 var(--app-outline-width) var(--app-contrast-focus); + } + + :is(button, [type="submit"], [type="button"], [role="button"]).outline, + [type="reset"].outline { + --app-background-color: transparent; + --app-color: var(--app-primary); + --app-border-color: var(--app-primary); + } + + :is(button, [type="submit"], [type="button"], [role="button"]).outline:is( + [aria-current]:not([aria-current="false"]), + :hover, + :active, + :focus + ), + [type="reset"].outline:is( + [aria-current]:not([aria-current="false"]), + :hover, + :active, + :focus + ) { + --app-background-color: transparent; + --app-color: var(--app-primary-hover); + --app-border-color: var(--app-primary-hover); + } + + :is( + button, + [type="submit"], + [type="button"], + [role="button"] + ).outline.secondary, + [type="reset"].outline { + --app-color: var(--app-secondary); + --app-border-color: var(--app-secondary); + } + + :is( + button, + [type="submit"], + [type="button"], + [role="button"] + ).outline.secondary:is( + [aria-current]:not([aria-current="false"]), + :hover, + :active, + :focus + ), + [type="reset"].outline:is( + [aria-current]:not([aria-current="false"]), + :hover, + :active, + :focus + ) { + --app-color: var(--app-secondary-hover); + --app-border-color: var(--app-secondary-hover); + } + + :is( + button, + [type="submit"], + [type="button"], + [role="button"] + ).outline.contrast { + --app-color: var(--app-contrast); + --app-border-color: var(--app-contrast); + } + + :is( + button, + [type="submit"], + [type="button"], + [role="button"] + ).outline.contrast:is( + [aria-current]:not([aria-current="false"]), + :hover, + :active, + :focus + ) { + --app-color: var(--app-contrast-hover); + --app-border-color: var(--app-contrast-hover); + } + + :where( + button, + [type="submit"], + [type="reset"], + [type="button"], + [role="button"] + )[disabled], + :where(fieldset[disabled]) + :is( + button, + [type="submit"], + [type="button"], + [type="reset"], + [role="button"] + ) { + opacity: 0.5; + pointer-events: none; + } + + :where(table) { + width: 100%; + border-collapse: collapse; + border-spacing: 0; + text-indent: 0; + } + + td, + th { + padding: calc(var(--app-spacing) / 2) var(--app-spacing); + border-bottom: var(--app-border-width) solid var(--app-table-border-color); + background-color: var(--app-background-color); + color: var(--app-color); + font-weight: var(--app-font-weight); + text-align: left; + text-align: start; + } + + tfoot td, + tfoot th { + border-top: var(--app-border-width) solid var(--app-table-border-color); + border-bottom: 0; + } + + table.striped tbody tr:nth-child(odd) td, + table.striped tbody tr:nth-child(odd) th { + background-color: var(--app-table-row-stripped-background-color); + } + + :where(audio, canvas, iframe, img, svg, video) { + vertical-align: middle; + } + + audio, + video { + display: inline-block; + } + + audio:not([controls]) { + display: none; + height: 0; + } + + :where(iframe) { + border-style: none; + } + + img { + max-width: 100%; + height: auto; + border-style: none; + } + + img.rounded { + border-radius: 0.5rem; + } + + :where(svg:not([fill])) { + fill: currentColor; + } + + svg:not(:host), + svg:not(:root) { + overflow: hidden; + } + + code, + kbd, + pre, + samp { + font-size: 0.875em; + font-family: var(--app-font-family); + } + + pre code, + pre samp { + font-size: inherit; + font-family: inherit; + } + + pre { + -ms-overflow-style: scrollbar; + overflow: auto; + } + + code, + kbd, + pre, + samp { + border-radius: var(--app-border-radius); + background: var(--app-code-background-color); + color: var(--app-code-color); + font-weight: var(--app-font-weight); + line-height: initial; + } + + code, + kbd, + samp { + display: inline-block; + padding: 0.375rem; + } + + pre { + display: block; + margin-bottom: var(--app-spacing); + overflow-x: auto; + } + + pre > code, + pre > samp { + display: block; + padding: var(--app-spacing); + background: 0 0; + line-height: var(--app-line-height); + } + + .code-toolbar { + position: relative; + margin-block: var(--app-spacing); + } + + .code-toolbar > .toolbar { + position: absolute; + top: 0.5rem; + right: 0.5rem; z-index: 1; } -} -@media (min-width: 1024px) { - .container { - max-width: 950px; - } -} - -@media (min-width: 1280px) { - .container { - max-width: 1200px; - } -} - -@media (min-width: 1536px) { - .container { - max-width: 1450px; - } -} - -.grid { - grid-column-gap: var(--app-grid-column-gap); - grid-row-gap: var(--app-grid-row-gap); - display: grid; - --app-grid-template-columns: 1fr; - grid-template-columns: var(--app-grid-template-columns); -} - -.flex { - display: flex; - flex-wrap: wrap; - gap: var(--app-flex-gap); -} - -.flex h1, -.flex h2, -.flex h3, -.flex h4, -.flex h5, -.flex h6 { - margin-bottom: 0; -} - -.align-center { - align-items: center; -} - -.align-flex-start { - align-items: flex-start; -} - -.justify-space-between { - justify-content: space-between; -} - -@media (min-width: 768px) { - .grid { - --app-grid-template-columns: repeat(auto-fit, minmax(0%, 1fr)); + .code-toolbar > .toolbar .toolbar-item { + display: inline-flex; } - .grid.grid-2, - .grid.grid-1-1 { - --app-grid-template-columns: repeat(2, minmax(0%, 1fr)); + .code-toolbar > .toolbar .copy-to-clipboard-button { + --app-background-color: transparent; + --app-border-color: var(--app-muted-border-color); + --app-color: var(--app-muted-color); + --app-box-shadow: none; + --app-button-padding-vertical: var(--app-button-padding-vertical-small); + --app-button-padding-horizontal: var(--app-button-padding-horizontal-small); + font-size: 0.75rem; } - .grid.grid-3 { - --app-grid-template-columns: repeat(3, minmax(0%, 1fr)); + .code-toolbar > .toolbar .copy-to-clipboard-button:is(:hover, :focus) { + --app-background-color: var(--app-card-sectioning-background-color); + --app-border-color: var(--app-muted-border-color); } - .grid.grid-4 { - --app-grid-template-columns: repeat(4, minmax(0%, 1fr)); + .code-toolbar > pre > code { + padding-top: calc(var(--app-spacing) * 2); } - .grid.grid-5 { - --app-grid-template-columns: repeat(5, minmax(0%, 1fr)); + kbd { + background-color: var(--app-code-kbd-background-color); + color: var(--app-code-kbd-color); + vertical-align: baseline; } - .grid.grid-6 { - --app-grid-template-columns: repeat(6, minmax(0%, 1fr)); + figure { + display: block; + margin: 0; + padding: 0; } - .grid.grid-1-2 { - --app-grid-template-columns: minmax(0%, 1fr) minmax(0%, 2fr); + figure figcaption { + padding: calc(var(--app-spacing) * 0.5) 0; + color: var(--app-muted-color); } - .grid.grid-2-1 { - --app-grid-template-columns: minmax(0%, 2fr) minmax(0%, 1fr); + hr { + height: 0; + margin: var(--app-typography-spacing-vertical) 0; + border: 0; + border-top: 1px solid var(--app-muted-border-color); + color: inherit; } - .grid.grid-1-3 { - --app-grid-template-columns: minmax(0%, 1fr) minmax(0%, 3fr); + [hidden], + template { + display: none !important; } - .grid.grid-3-1 { - --app-grid-template-columns: minmax(0%, 3fr) minmax(0%, 1fr); - } -} - -.grid > * { - min-width: 0; -} - -.overflow-auto { - overflow: auto; -} - -b, -strong { - font-weight: bolder; -} - -sub, -sup { - position: relative; - font-size: 0.75em; - line-height: 0; - vertical-align: baseline; -} - -sub { - bottom: -0.25em; -} - -sup { - top: -0.5em; -} - -address, -blockquote, -dl, -ol, -p, -pre, -table, -ul { - margin-top: 0; - margin-bottom: var(--app-typography-spacing-vertical); - color: var(--app-color); - font-style: normal; - font-weight: var(--app-font-weight); -} - -h1, -h2, -h3, -h4, -h5, -h6 { - margin-top: 0; - margin-bottom: var(--app-typography-spacing-vertical); - color: var(--app-color); - font-weight: var(--app-font-weight); - font-size: var(--app-font-size); - line-height: var(--app-line-height); - font-family: var(--app-font-family); -} - -h1 { - --app-color: var(--app-h1-color); -} - -h2 { - --app-color: var(--app-h2-color); -} - -h3 { - --app-color: var(--app-h3-color); -} - -h4 { - --app-color: var(--app-h4-color); -} - -h5 { - --app-color: var(--app-h5-color); -} - -h6 { - --app-color: var(--app-h6-color); -} - -:where(article, address, blockquote, dl, figure, form, ol, p, pre, table, ul) - ~ :is(h1, h2, h3, h4, h5, h6) { - margin-top: var(--app-typography-spacing-top); -} - -p { - margin-bottom: var(--app-typography-spacing-vertical); -} - -hgroup { - margin-bottom: var(--app-typography-spacing-vertical); -} - -.flex hgroup { - margin-bottom: 0; -} - -hgroup > * { - margin-top: 0; - margin-bottom: 0; -} - -hgroup > :not(:first-child):last-child { - --app-color: var(--app-muted-color); - --app-font-weight: unset; - font-size: 1rem; -} - -:where(ol, ul) li { - margin-bottom: calc(var(--app-typography-spacing-vertical) * 0.25); -} - -:where(dl, ol, ul) :where(dl, ol, ul) { - margin: 0; - margin-top: calc(var(--app-typography-spacing-vertical) * 0.25); -} - -ul li { - list-style: square; -} - -mark { - padding: 0.125rem 0.25rem; - background-color: var(--app-mark-background-color); - color: var(--app-mark-color); - vertical-align: baseline; -} - -abbr[title] { - border-bottom: 1px dotted; - text-decoration: none; - cursor: help; -} - -ins { - color: var(--app-ins-color); - text-decoration: none; -} - -del { - color: var(--app-del-color); -} - -::-moz-selection { - background-color: var(--app-text-selection-color); -} - -::selection { - background-color: var(--app-text-selection-color); -} - -:where(a:not([role="button"])), -[role="link"] { - --app-color: var(--app-primary); - --app-background-color: transparent; - --app-underline: var(--app-primary-underline); - outline: 0; - background-color: var(--app-background-color); - color: cornflowerblue; - -webkit-text-decoration: var(--app-text-decoration); - text-decoration: var(--app-text-decoration); - text-decoration-color: var(--app-underline); - text-underline-offset: 0.125em; - transition: - background-color var(--app-transition), - color var(--app-transition), - box-shadow var(--app-transition), - -webkit-text-decoration var(--app-transition); - transition: - background-color var(--app-transition), - color var(--app-transition), - text-decoration var(--app-transition), - box-shadow var(--app-transition); - transition: - background-color var(--app-transition), - color var(--app-transition), - text-decoration var(--app-transition), - box-shadow var(--app-transition), - -webkit-text-decoration var(--app-transition); -} - -:where(a:not([role="button"])):is( - [aria-current]:not([aria-current="false"]), - :hover, - :active, - :focus -), -[role="link"]:is( - [aria-current]:not([aria-current="false"]), - :hover, - :active, - :focus -) { - --app-color: var(--app-primary-hover); - --app-underline: var(--app-primary-hover-underline); - --app-text-decoration: underline; -} - -:where(a:not([role="button"])):focus-visible, -[role="link"]:focus-visible { - box-shadow: 0 0 0 var(--app-outline-width) var(--app-primary-focus); -} - -:where(a:not([role="button"])).secondary, -[role="link"].secondary { - --app-color: var(--app-secondary); - --app-underline: var(--app-secondary-underline); -} - -:where(a:not([role="button"])).secondary:is( - [aria-current]:not([aria-current="false"]), - :hover, - :active, - :focus - ), -[role="link"].secondary:is( - [aria-current]:not([aria-current="false"]), - :hover, - :active, - :focus - ) { - --app-color: var(--app-secondary-hover); - --app-underline: var(--app-secondary-hover-underline); -} - -:where(a:not([role="button"])).contrast, -[role="link"].contrast { - --app-color: var(--app-contrast); - --app-underline: var(--app-contrast-underline); -} - -:where(a:not([role="button"])).contrast:is( - [aria-current]:not([aria-current="false"]), - :hover, - :active, - :focus - ), -[role="link"].contrast:is( - [aria-current]:not([aria-current="false"]), - :hover, - :active, - :focus - ) { - --app-color: var(--app-contrast-hover); - --app-underline: var(--app-contrast-hover-underline); -} - -a[role="button"] { - display: inline-block; -} - -button { - margin: 0; - overflow: visible; - font-family: inherit; - text-transform: none; -} - -a.transparent[role="button"], -button.transparent, -.transparent[role="button"] { - background: transparent; - border: none; - box-shadow: none; - color: var(--app-muted-color); -} -a.transparent[role="button"]:hover, -button.transparent:hover, -.transparent[role="button"]:hover { - background: var(--app-muted-border-color); - color: var(--app-contrast); -} - -[type="button"], -[type="reset"], -[type="submit"], -button { - -webkit-appearance: button; -} - -[role="button"], -[type="button"], -[type="file"]::file-selector-button, -[type="reset"], -[type="submit"], -button { - --app-background-color: var(--app-primary-background); - --app-border-color: var(--app-primary-border); - --app-color: var(--app-primary-inverse); - --app-box-shadow: var(--app-button-box-shadow, 0 0 0 rgba(0, 0, 0, 0)); - padding: var(--app-button-padding-vertical) - var(--app-button-padding-horizontal); - border: var(--app-border-width) solid var(--app-border-color); - border-radius: var(--app-border-radius); - outline: 0; - background-color: var(--app-background-color); - box-shadow: var(--app-box-shadow); - color: var(--app-color); - font-weight: var(--app-font-weight); - font-size: 1rem; - line-height: var(--app-line-height); - text-align: center; - text-decoration: none; - cursor: pointer; - -webkit-user-select: none; - -moz-user-select: none; - user-select: none; - transition: - background-color var(--app-transition), - border-color var(--app-transition), - color var(--app-transition), - box-shadow var(--app-transition); -} - -:where( - button, - [type="submit"], - [type="reset"], - [type="button"], - [role="button"] -).small { - --app-button-padding-vertical: var(--app-button-padding-vertical-small); - --app-button-padding-horizontal: var(--app-button-padding-horizontal-small); -} - -[role="button"]:is(:hover, :active, :focus), -[role="button"]:is([aria-current]:not([aria-current="false"])), -[type="button"]:is(:hover, :active, :focus), -[type="button"]:is([aria-current]:not([aria-current="false"])), -[type="file"]::file-selector-button:is(:hover, :active, :focus), -[type="file"]::file-selector-button:is( - [aria-current]:not([aria-current="false"]) - ), -[type="reset"]:is(:hover, :active, :focus), -[type="reset"]:is([aria-current]:not([aria-current="false"])), -[type="submit"]:is(:hover, :active, :focus), -[type="submit"]:is([aria-current]:not([aria-current="false"])), -button:is(:hover, :active, :focus), -button:is([aria-current]:not([aria-current="false"])) { - --app-background-color: var(--app-primary-hover-background); - --app-border-color: var(--app-primary-hover-border); - --app-box-shadow: var(--app-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)); - --app-color: var(--app-primary-inverse); -} - -[role="button"]:focus, -[role="button"]:is([aria-current]:not([aria-current="false"])):focus, -[type="button"]:focus, -[type="button"]:is([aria-current]:not([aria-current="false"])):focus, -[type="file"]::file-selector-button:focus, -[type="file"]::file-selector-button:is( - [aria-current]:not([aria-current="false"]) - ):focus, -[type="reset"]:focus, -[type="reset"]:is([aria-current]:not([aria-current="false"])):focus, -[type="submit"]:focus, -[type="submit"]:is([aria-current]:not([aria-current="false"])):focus, -button:focus, -button:is([aria-current]:not([aria-current="false"])):focus { - --app-box-shadow: - var(--app-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)), - 0 0 0 var(--app-outline-width) var(--app-primary-focus); -} - -[type="button"], -[type="reset"] { - margin-bottom: var(--app-spacing); -} - -:is(button, [type="submit"], [type="button"], [role="button"]).secondary, -[type="file"]::file-selector-button, -[type="reset"] { - --app-background-color: var(--app-secondary-background); - --app-border-color: var(--app-secondary-border); - --app-color: var(--app-secondary-inverse); - cursor: pointer; -} - -:is(button, [type="submit"], [type="button"], [role="button"]).secondary:is( - [aria-current]:not([aria-current="false"]), - :hover, - :active, - :focus - ), -[type="file"]::file-selector-button:is( - [aria-current]:not([aria-current="false"]), - :hover, - :active, - :focus - ), -[type="reset"]:is( - [aria-current]:not([aria-current="false"]), - :hover, - :active, - :focus -) { - --app-background-color: var(--app-secondary-hover-background); - --app-border-color: var(--app-secondary-hover-border); - --app-color: var(--app-secondary-inverse); -} - -:is(button, [type="submit"], [type="button"], [role="button"]).secondary:focus, -:is(button, [type="submit"], [type="button"], [role="button"]).secondary:is( - [aria-current]:not([aria-current="false"]) - ):focus, -[type="file"]::file-selector-button:focus, -[type="file"]::file-selector-button:is( - [aria-current]:not([aria-current="false"]) - ):focus, -[type="reset"]:focus, -[type="reset"]:is([aria-current]:not([aria-current="false"])):focus { - --app-box-shadow: - var(--app-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)), - 0 0 0 var(--app-outline-width) var(--app-secondary-focus); -} - -:is(button, [type="submit"], [type="button"], [role="button"]).contrast { - --app-background-color: var(--app-contrast-background); - --app-border-color: var(--app-contrast-border); - --app-color: var(--app-contrast-inverse); -} - -:is(button, [type="submit"], [type="button"], [role="button"]).contrast:is( - [aria-current]:not([aria-current="false"]), - :hover, - :active, - :focus - ) { - --app-background-color: var(--app-contrast-hover-background); - --app-border-color: var(--app-contrast-hover-border); - --app-color: var(--app-contrast-inverse); -} - -:is(button, [type="submit"], [type="button"], [role="button"]).contrast:focus, -:is(button, [type="submit"], [type="button"], [role="button"]).contrast:is( - [aria-current]:not([aria-current="false"]) - ):focus { - --app-box-shadow: - var(--app-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)), - 0 0 0 var(--app-outline-width) var(--app-contrast-focus); -} - -:is(button, [type="submit"], [type="button"], [role="button"]).outline, -[type="reset"].outline { - --app-background-color: transparent; - --app-color: var(--app-primary); - --app-border-color: var(--app-primary); -} - -:is(button, [type="submit"], [type="button"], [role="button"]).outline:is( - [aria-current]:not([aria-current="false"]), - :hover, - :active, - :focus - ), -[type="reset"].outline:is( - [aria-current]:not([aria-current="false"]), - :hover, - :active, - :focus - ) { - --app-background-color: transparent; - --app-color: var(--app-primary-hover); - --app-border-color: var(--app-primary-hover); -} - -:is( - button, - [type="submit"], - [type="button"], - [role="button"] - ).outline.secondary, -[type="reset"].outline { - --app-color: var(--app-secondary); - --app-border-color: var(--app-secondary); -} - -:is( - button, - [type="submit"], - [type="button"], - [role="button"] - ).outline.secondary:is( - [aria-current]:not([aria-current="false"]), - :hover, - :active, - :focus - ), -[type="reset"].outline:is( - [aria-current]:not([aria-current="false"]), - :hover, - :active, - :focus - ) { - --app-color: var(--app-secondary-hover); - --app-border-color: var(--app-secondary-hover); -} - -:is( - button, - [type="submit"], - [type="button"], - [role="button"] - ).outline.contrast { - --app-color: var(--app-contrast); - --app-border-color: var(--app-contrast); -} - -:is( - button, - [type="submit"], - [type="button"], - [role="button"] - ).outline.contrast:is( - [aria-current]:not([aria-current="false"]), - :hover, - :active, - :focus - ) { - --app-color: var(--app-contrast-hover); - --app-border-color: var(--app-contrast-hover); -} - -:where( - button, - [type="submit"], - [type="reset"], - [type="button"], - [role="button"] -)[disabled], -:where(fieldset[disabled]) - :is( - button, - [type="submit"], - [type="button"], - [type="reset"], - [role="button"] - ) { - opacity: 0.5; - pointer-events: none; -} - -:where(table) { - width: 100%; - border-collapse: collapse; - border-spacing: 0; - text-indent: 0; -} - -td, -th { - padding: calc(var(--app-spacing) / 2) var(--app-spacing); - border-bottom: var(--app-border-width) solid var(--app-table-border-color); - background-color: var(--app-background-color); - color: var(--app-color); - font-weight: var(--app-font-weight); - text-align: left; - text-align: start; -} - -tfoot td, -tfoot th { - border-top: var(--app-border-width) solid var(--app-table-border-color); - border-bottom: 0; -} - -table.striped tbody tr:nth-child(odd) td, -table.striped tbody tr:nth-child(odd) th { - background-color: var(--app-table-row-stripped-background-color); -} - -:where(audio, canvas, iframe, img, svg, video) { - vertical-align: middle; -} - -audio, -video { - display: inline-block; -} - -audio:not([controls]) { - display: none; - height: 0; -} - -:where(iframe) { - border-style: none; -} - -img { - max-width: 100%; - height: auto; - border-style: none; -} - -img.rounded { - border-radius: 0.5rem; -} - -:where(svg:not([fill])) { - fill: currentColor; -} - -svg:not(:host), -svg:not(:root) { - overflow: hidden; -} - -code, -kbd, -pre, -samp { - font-size: 0.875em; - font-family: var(--app-font-family); -} - -pre code, -pre samp { - font-size: inherit; - font-family: inherit; -} - -pre { - -ms-overflow-style: scrollbar; - overflow: auto; -} - -code, -kbd, -pre, -samp { - border-radius: var(--app-border-radius); - background: var(--app-code-background-color); - color: var(--app-code-color); - font-weight: var(--app-font-weight); - line-height: initial; -} - -code, -kbd, -samp { - display: inline-block; - padding: 0.375rem; -} - -pre { - display: block; - margin-bottom: var(--app-spacing); - overflow-x: auto; -} - -pre > code, -pre > samp { - display: block; - padding: var(--app-spacing); - background: 0 0; - line-height: var(--app-line-height); -} - -.code-toolbar { - position: relative; - margin-block: var(--app-spacing); -} - -.code-toolbar > .toolbar { - position: absolute; - top: 0.5rem; - right: 0.5rem; - z-index: 1; -} - -.code-toolbar > .toolbar .toolbar-item { - display: inline-flex; -} - -.code-toolbar > .toolbar .copy-to-clipboard-button { - --app-background-color: transparent; - --app-border-color: var(--app-muted-border-color); - --app-color: var(--app-muted-color); - --app-box-shadow: none; - --app-button-padding-vertical: var(--app-button-padding-vertical-small); - --app-button-padding-horizontal: var(--app-button-padding-horizontal-small); - font-size: 0.75rem; -} - -.code-toolbar > .toolbar .copy-to-clipboard-button:is(:hover, :focus) { - --app-background-color: var(--app-card-sectioning-background-color); - --app-border-color: var(--app-muted-border-color); -} - -.code-toolbar > pre > code { - padding-top: calc(var(--app-spacing) * 2); -} - -kbd { - background-color: var(--app-code-kbd-background-color); - color: var(--app-code-kbd-color); - vertical-align: baseline; -} - -figure { - display: block; - margin: 0; - padding: 0; -} - -figure figcaption { - padding: calc(var(--app-spacing) * 0.5) 0; - color: var(--app-muted-color); -} - -hr { - height: 0; - margin: var(--app-typography-spacing-vertical) 0; - border: 0; - border-top: 1px solid var(--app-muted-border-color); - color: inherit; -} - -[hidden], -template { - display: none !important; -} - -canvas { - display: inline-block; -} - -input, -optgroup, -select, -textarea { - margin: 0; - font-size: 1rem; - line-height: var(--app-line-height); - font-family: inherit; - letter-spacing: inherit; -} - -input { - overflow: visible; -} - -select { - text-transform: none; -} - -legend { - max-width: 100%; - white-space: normal; - margin: 0; - display: block; - color: var(--app-color); - font-weight: var(--app-form-label-font-weight, var(--app-font-weight)); -} - -textarea { - overflow: auto; -} - -[type="checkbox"], -[type="radio"] { - padding: 0; -} - -::-webkit-inner-spin-button, -::-webkit-outer-spin-button { - height: auto; -} - -[type="search"] { - -webkit-appearance: textfield; - outline-offset: -2px; -} - -[type="search"]::-webkit-search-decoration { - -webkit-appearance: none; -} - -::-webkit-file-upload-button { - -webkit-appearance: button; - font: inherit; -} - -::-moz-focus-inner { - padding: 0; - border-style: none; -} - -:-moz-focusring { - outline: 0; -} - -:-moz-ui-invalid { - box-shadow: none; -} - -::-ms-expand { - display: none; -} - -[type="file"], -[type="range"] { - padding: 0; - border-width: 0; -} - -input:not([type="checkbox"], [type="radio"], [type="range"]) { - height: calc( - 1rem * var(--app-line-height) + var(--app-form-element-spacing-vertical) * - 2 + var(--app-border-width) * 2 - ); -} - -fieldset { - width: 100%; - margin-bottom: var(--app-spacing); - border: 1px solid var(--app-form-element-border-color); - padding: var(--app-spacing); - border-radius: var(--app-border-radius); -} - -fieldset[role="group"] { - padding: 0; - border: 0; - border-radius: 0; -} - -label { - display: block; - margin-bottom: calc(var(--app-spacing) * 0.375); - color: var(--app-color); - font-weight: var(--app-form-label-font-weight, var(--app-font-weight)); - font-size: small; -} - -label:has(input[required]) span:after { - content: " *"; -} - -button[type="submit"], -input:not([type="checkbox"], [type="radio"]), -select, -textarea { - width: 100%; -} - -input:not([type="checkbox"], [type="radio"], [type="range"], [type="file"]), -select, -textarea { - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - padding: var(--app-form-element-spacing-vertical) - var(--app-form-element-spacing-horizontal); -} - -input, -select, -textarea { - --app-background-color: var(--app-form-element-background-color); - --app-border-color: var(--app-form-element-border-color); - --app-color: var(--app-form-element-color); - --app-box-shadow: none; - border: var(--app-border-width) solid var(--app-border-color); - border-radius: var(--app-border-radius); - outline: 0; - background-color: var(--app-background-color); - box-shadow: var(--app-box-shadow); - color: var(--app-color); - font-weight: var(--app-font-weight); - transition: - background-color var(--app-transition), - border-color var(--app-transition), - color var(--app-transition), - box-shadow var(--app-transition); -} - -:where(select, textarea):not([readonly]):is(:active, :focus), -input:not( - [type="submit"], - [type="button"], - [type="reset"], - [type="checkbox"], - [type="radio"], - [readonly] - ):is(:active, :focus) { - --app-background-color: var(--app-form-element-active-background-color); -} - -:where(select, textarea):not([readonly]):is(:active, :focus), -input:not( - [type="submit"], - [type="button"], - [type="reset"], - [role="switch"], - [readonly] - ):is(:active, :focus) { - --app-border-color: var(--app-form-element-active-border-color); -} - -:where(select, textarea):not([readonly]):focus, -input:not( - [type="submit"], - [type="button"], - [type="reset"], - [type="range"], - [type="file"], - [readonly] - ):focus { - --app-box-shadow: 0 0 0 var(--app-outline-width) - var(--app-form-element-focus-color); -} - -:where(fieldset[disabled]) - :is( - input:not([type="submit"], [type="button"], [type="reset"]), - select, - textarea - ), -input:not([type="submit"], [type="button"], [type="reset"])[disabled], -label[aria-disabled="true"], -select[disabled], -textarea[disabled] { - opacity: var(--app-form-element-disabled-opacity); - pointer-events: none; -} - -label[aria-disabled="true"] input[disabled] { - opacity: 1; -} - -:where(input, select, textarea):not( - [type="checkbox"], - [type="radio"], - [type="date"], - [type="datetime-local"], - [type="month"], - [type="time"], - [type="week"], - [type="range"] - )[aria-invalid] { - padding-right: calc( - var(--app-form-element-spacing-horizontal) + 1.5rem - ) !important; - padding-left: var(--app-form-element-spacing-horizontal); - padding-inline-start: var(--app-form-element-spacing-horizontal) !important; - padding-inline-end: calc( - var(--app-form-element-spacing-horizontal) + 1.5rem - ) !important; - background-position: center right 0.75rem; - background-size: 1rem auto; - background-repeat: no-repeat; -} - -:where(input, select, textarea):not( - [type="checkbox"], - [type="radio"], - [type="date"], - [type="datetime-local"], - [type="month"], - [type="time"], - [type="week"], - [type="range"] - )[aria-invalid="false"]:not(select) { - background-image: var(--app-icon-valid); -} - -:where(input, select, textarea):not( - [type="checkbox"], - [type="radio"], - [type="date"], - [type="datetime-local"], - [type="month"], - [type="time"], - [type="week"], - [type="range"] - )[aria-invalid="true"]:not(select) { - background-image: var(--app-icon-invalid); -} - -:where(input, select, textarea)[aria-invalid="false"] { - --app-border-color: var(--app-form-element-valid-border-color); -} - -:where(input, select, textarea)[aria-invalid="false"]:is(:active, :focus) { - --app-border-color: var( - --app-form-element-valid-active-border-color - ) !important -; -} - -:where(input, select, textarea)[aria-invalid="false"]:is(:active, :focus):not( - [type="checkbox"], - [type="radio"] - ) { - --app-box-shadow: 0 0 0 var(--app-outline-width) - var(--app-form-element-valid-focus-color) !important -; -} - -:where(input, select, textarea)[aria-invalid="true"] { - --app-border-color: var(--app-form-element-invalid-border-color); -} - -:where(input, select, textarea)[aria-invalid="true"]:is(:active, :focus) { - --app-border-color: var( - --app-form-element-invalid-active-border-color - ) !important -; -} - -:where(input, select, textarea)[aria-invalid="true"]:is(:active, :focus):not( - [type="checkbox"], - [type="radio"] - ) { - --app-box-shadow: 0 0 0 var(--app-outline-width) - var(--app-form-element-invalid-focus-color) !important -; -} - -[dir="rtl"] - :where(input, select, textarea):not([type="checkbox"], [type="radio"]):is( - [aria-invalid], - [aria-invalid="true"], - [aria-invalid="false"] - ) { - background-position: center left 0.75rem; -} - -input::-webkit-input-placeholder, -input::placeholder, -select:invalid, -textarea::-webkit-input-placeholder, -textarea::placeholder { - color: var(--app-form-element-placeholder-color); - opacity: 1; -} - -input:not([type="checkbox"], [type="radio"]), -select, -textarea { - margin-bottom: var(--app-spacing); -} - -select::-ms-expand { - border: 0; - background-color: transparent; -} - -select:not([multiple], [size]) { - padding-right: calc(var(--app-form-element-spacing-horizontal) + 1.5rem); - padding-left: var(--app-form-element-spacing-horizontal); - padding-inline-start: var(--app-form-element-spacing-horizontal); - padding-inline-end: calc(var(--app-form-element-spacing-horizontal) + 1.5rem); - background-image: var(--app-icon-chevron); - background-position: center right 0.75rem; - background-size: 1rem auto; - background-repeat: no-repeat; -} - -select[multiple] option:checked { - background: var(--app-form-element-selected-background-color); - color: var(--app-form-element-color); -} - -[dir="rtl"] select:not([multiple], [size]) { - background-position: center left 0.75rem; -} - -textarea { - display: block; - resize: vertical; -} - -textarea[aria-invalid] { - --app-icon-height: calc( - 1rem * var(--app-line-height) + var(--app-form-element-spacing-vertical) * - 2 + var(--app-border-width) * 2 - ); - background-position: top right 0.75rem !important; - background-size: 1rem var(--app-icon-height) !important; -} - -:where(input, select, textarea, fieldset, .grid) + small { - display: block; - width: 100%; - margin-top: calc(var(--app-spacing) * -0.75); - margin-bottom: var(--app-spacing); - color: var(--app-muted-color); -} - -:where(input, select, textarea, fieldset, .grid)[aria-invalid="false"] + small { - color: var(--app-ins-color); -} - -:where(input, select, textarea, fieldset, .grid)[aria-invalid="true"] + small { - color: var(--app-del-color); -} - -label > :where(input, select, textarea) { - margin-top: calc(var(--app-spacing) * 0.25); -} - -label:has([type="checkbox"], [type="radio"]) { - width: -moz-fit-content; - width: fit-content; - cursor: pointer; -} - -[type="checkbox"], -[type="radio"] { - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - width: 1.25em; - height: 1.25em; - margin-top: -0.125em; - margin-inline-end: 0.5em; - border-width: var(--app-border-width); - vertical-align: middle; - cursor: pointer; -} - -[type="checkbox"]::-ms-check, -[type="radio"]::-ms-check { - display: none; -} - -[type="checkbox"]:checked, -[type="checkbox"]:checked:active, -[type="checkbox"]:checked:focus, -[type="radio"]:checked, -[type="radio"]:checked:active, -[type="radio"]:checked:focus { - --app-background-color: var(--app-primary-background); - --app-border-color: var(--app-primary-border); - background-image: var(--app-icon-checkbox); - background-position: center; - background-size: 0.75em auto; - background-repeat: no-repeat; -} - -[type="checkbox"] ~ label, -[type="radio"] ~ label { - display: inline-block; - margin-bottom: 0; - cursor: pointer; -} - -[type="checkbox"] ~ label:not(:last-of-type), -[type="radio"] ~ label:not(:last-of-type) { - margin-inline-end: 1em; -} - -[type="checkbox"]:indeterminate { - --app-background-color: var(--app-primary-background); - --app-border-color: var(--app-primary-border); - background-image: var(--app-icon-minus); - background-position: center; - background-size: 0.75em auto; - background-repeat: no-repeat; -} - -[type="radio"] { - border-radius: 50%; -} - -[type="radio"]:checked, -[type="radio"]:checked:active, -[type="radio"]:checked:focus { - --app-background-color: var(--app-primary-inverse); - border-width: 0.35em; - background-image: none; -} - -[type="checkbox"][role="switch"] { - --app-background-color: var(--app-switch-background-color); - --app-color: var(--app-switch-color); - width: 2.25em; - height: 1.25em; - border: var(--app-border-width) solid var(--app-border-color); - border-radius: 1.25em; - background-color: var(--app-background-color); - line-height: 1.25em; -} - -[type="checkbox"][role="switch"]:not([aria-invalid]) { - --app-border-color: var(--app-switch-background-color); -} - -[type="checkbox"][role="switch"]:before { - display: block; - aspect-ratio: 1; - height: 100%; - border-radius: 50%; - background-color: var(--app-color); - box-shadow: var(--app-switch-thumb-box-shadow); - content: ""; - transition: margin 0.1s ease-in-out; -} - -[type="checkbox"][role="switch"]:focus { - --app-background-color: var(--app-switch-background-color); - --app-border-color: var(--app-switch-background-color); -} - -[type="checkbox"][role="switch"]:checked { - --app-background-color: var(--app-switch-checked-background-color); - --app-border-color: var(--app-switch-checked-background-color); - background-image: none; -} - -[type="checkbox"][role="switch"]:checked::before { - margin-inline-start: calc(2.25em - 1.25em); -} - -[type="checkbox"][role="switch"][disabled] { - --app-background-color: var(--app-border-color); -} - -[type="checkbox"][aria-invalid="false"]:checked, -[type="checkbox"][aria-invalid="false"]:checked:active, -[type="checkbox"][aria-invalid="false"]:checked:focus, -[type="checkbox"][role="switch"][aria-invalid="false"]:checked, -[type="checkbox"][role="switch"][aria-invalid="false"]:checked:active, -[type="checkbox"][role="switch"][aria-invalid="false"]:checked:focus { - --app-background-color: var(--app-form-element-valid-border-color); -} - -[type="checkbox"]:checked:active[aria-invalid="true"], -[type="checkbox"]:checked:focus[aria-invalid="true"], -[type="checkbox"]:checked[aria-invalid="true"], -[type="checkbox"][role="switch"]:checked:active[aria-invalid="true"], -[type="checkbox"][role="switch"]:checked:focus[aria-invalid="true"], -[type="checkbox"][role="switch"]:checked[aria-invalid="true"] { - --app-background-color: var(--app-form-element-invalid-border-color); -} - -[type="checkbox"][aria-invalid="false"]:checked, -[type="checkbox"][aria-invalid="false"]:checked:active, -[type="checkbox"][aria-invalid="false"]:checked:focus, -[type="checkbox"][role="switch"][aria-invalid="false"]:checked, -[type="checkbox"][role="switch"][aria-invalid="false"]:checked:active, -[type="checkbox"][role="switch"][aria-invalid="false"]:checked:focus, -[type="radio"][aria-invalid="false"]:checked, -[type="radio"][aria-invalid="false"]:checked:active, -[type="radio"][aria-invalid="false"]:checked:focus { - --app-border-color: var(--app-form-element-valid-border-color); -} - -[type="checkbox"]:checked:active[aria-invalid="true"], -[type="checkbox"]:checked:focus[aria-invalid="true"], -[type="checkbox"]:checked[aria-invalid="true"], -[type="checkbox"][role="switch"]:checked:active[aria-invalid="true"], -[type="checkbox"][role="switch"]:checked:focus[aria-invalid="true"], -[type="checkbox"][role="switch"]:checked[aria-invalid="true"], -[type="radio"]:checked:active[aria-invalid="true"], -[type="radio"]:checked:focus[aria-invalid="true"], -[type="radio"]:checked[aria-invalid="true"] { - --app-border-color: var(--app-form-element-invalid-border-color); -} - -[type="color"]::-webkit-color-swatch-wrapper { - padding: 0; -} - -[type="color"]::-moz-focus-inner { - padding: 0; -} - -[type="color"]::-webkit-color-swatch { - border: 0; - border-radius: calc(var(--app-border-radius) * 0.5); -} - -[type="color"]::-moz-color-swatch { - border: 0; - border-radius: calc(var(--app-border-radius) * 0.5); -} - -input:not([type="checkbox"], [type="radio"], [type="range"], [type="file"]):is( - [type="date"], - [type="datetime-local"], - [type="month"], - [type="time"], - [type="week"] - ) { - --app-icon-position: 0.75rem; - --app-icon-width: 1rem; - padding-right: calc(var(--app-icon-width) + var(--app-icon-position)); - background-image: var(--app-icon-date); - background-position: center right var(--app-icon-position); - background-size: var(--app-icon-width) auto; - background-repeat: no-repeat; -} - -input:not( - [type="checkbox"], - [type="radio"], - [type="range"], - [type="file"] - )[type="time"] { - background-image: var(--app-icon-time); -} - -[type="date"]::-webkit-calendar-picker-indicator, -[type="datetime-local"]::-webkit-calendar-picker-indicator, -[type="month"]::-webkit-calendar-picker-indicator, -[type="time"]::-webkit-calendar-picker-indicator, -[type="week"]::-webkit-calendar-picker-indicator { - width: var(--app-icon-width); - margin-right: calc(var(--app-icon-width) * -1); - margin-left: var(--app-icon-position); - opacity: 0; -} - -@-moz-document url-prefix() { - [type="date"], - [type="datetime-local"], - [type="month"], - [type="time"], - [type="week"] { - padding-right: var(--app-form-element-spacing-horizontal) !important; - background-image: none !important; - } -} - -[dir="rtl"] - :is( - [type="date"], - [type="datetime-local"], - [type="month"], - [type="time"], - [type="week"] - ) { - text-align: right; -} - -[type="file"] { - --app-color: var(--app-muted-color); - margin-left: calc(var(--app-outline-width) * -1); - background: 0 0; - border: 2px dashed var(--app-form-element-border-color); - padding: 1.5rem; - height: auto !important; -} - -[type="file"]::file-selector-button { - margin-right: calc(var(--app-spacing) / 2); - padding: calc(var(--app-form-element-spacing-vertical) * 0.5) - var(--app-form-element-spacing-horizontal); -} - -[type="file"]:is(:hover, :active, :focus)::file-selector-button { - --app-background-color: var(--app-secondary-hover-background); - --app-border-color: var(--app-secondary-hover-border); -} - -[type="file"]:focus::file-selector-button { - --app-box-shadow: - var(--app-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)), - 0 0 0 var(--app-outline-width) var(--app-secondary-focus); -} - -[type="range"] { - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - width: 100%; - height: 1.25rem; - background: 0 0; -} - -[type="range"]::-webkit-slider-runnable-track { - width: 100%; - height: 0.375rem; - border-radius: var(--app-border-radius); - background-color: var(--app-range-border-color); - -webkit-transition: - background-color var(--app-transition), - box-shadow var(--app-transition); - transition: - background-color var(--app-transition), - box-shadow var(--app-transition); -} - -[type="range"]::-moz-range-track { - width: 100%; - height: 0.375rem; - border-radius: var(--app-border-radius); - background-color: var(--app-range-border-color); - -moz-transition: - background-color var(--app-transition), - box-shadow var(--app-transition); - transition: - background-color var(--app-transition), - box-shadow var(--app-transition); -} - -[type="range"]::-ms-track { - width: 100%; - height: 0.375rem; - border-radius: var(--app-border-radius); - background-color: var(--app-range-border-color); - -ms-transition: - background-color var(--app-transition), - box-shadow var(--app-transition); - transition: - background-color var(--app-transition), - box-shadow var(--app-transition); -} - -[type="range"]::-webkit-slider-thumb { - -webkit-appearance: none; - width: 1.25rem; - height: 1.25rem; - margin-top: -0.4375rem; - border: 2px solid var(--app-range-thumb-border-color); - border-radius: 50%; - background-color: var(--app-range-thumb-color); - cursor: pointer; - -webkit-transition: - background-color var(--app-transition), - transform var(--app-transition); - transition: - background-color var(--app-transition), - transform var(--app-transition); -} - -[type="range"]::-moz-range-thumb { - -webkit-appearance: none; - width: 1.25rem; - height: 1.25rem; - margin-top: -0.4375rem; - border: 2px solid var(--app-range-thumb-border-color); - border-radius: 50%; - background-color: var(--app-range-thumb-color); - cursor: pointer; - -moz-transition: - background-color var(--app-transition), - transform var(--app-transition); - transition: - background-color var(--app-transition), - transform var(--app-transition); -} - -[type="range"]::-ms-thumb { - -webkit-appearance: none; - width: 1.25rem; - height: 1.25rem; - margin-top: -0.4375rem; - border: 2px solid var(--app-range-thumb-border-color); - border-radius: 50%; - background-color: var(--app-range-thumb-color); - cursor: pointer; - -ms-transition: - background-color var(--app-transition), - transform var(--app-transition); - transition: - background-color var(--app-transition), - transform var(--app-transition); -} - -[type="range"]:active, -[type="range"]:focus-within { - --app-range-border-color: var(--app-range-active-border-color); - --app-range-thumb-color: var(--app-range-thumb-active-color); -} - -[type="range"]:active::-webkit-slider-thumb { - transform: scale(1.25); -} - -[type="range"]:active::-moz-range-thumb { - transform: scale(1.25); -} - -[type="range"]:active::-ms-thumb { - transform: scale(1.25); -} - -input:not( - [type="checkbox"], - [type="radio"], - [type="range"], - [type="file"] - )[type="search"] { - padding-inline-start: calc( - var(--app-form-element-spacing-horizontal) + 1.75rem - ); - background-image: var(--app-icon-search); - background-position: center left - calc(var(--app-form-element-spacing-horizontal) + 0.125rem); - background-size: 1rem auto; - background-repeat: no-repeat; -} - -input:not( - [type="checkbox"], - [type="radio"], - [type="range"], - [type="file"] - )[type="search"][aria-invalid] { - padding-inline-start: calc( - var(--app-form-element-spacing-horizontal) + 1.75rem - ) !important; - background-position: - center left 1.125rem, - center right 0.75rem; -} - -input:not( - [type="checkbox"], - [type="radio"], - [type="range"], - [type="file"] - )[type="search"][aria-invalid="false"] { - background-image: var(--app-icon-search), var(--app-icon-valid); -} - -input:not( - [type="checkbox"], - [type="radio"], - [type="range"], - [type="file"] - )[type="search"][aria-invalid="true"] { - background-image: var(--app-icon-search), var(--app-icon-invalid); -} - -[dir="rtl"] - :where(input):not( - [type="checkbox"], - [type="radio"], - [type="range"], - [type="file"] - )[type="search"] { - background-position: center right 1.125rem; -} - -[dir="rtl"] - :where(input):not( - [type="checkbox"], - [type="radio"], - [type="range"], - [type="file"] - )[type="search"][aria-invalid] { - background-position: - center right 1.125rem, - center left 0.75rem; -} - -details { - display: block; - margin-bottom: var(--app-spacing); -} - -details summary { - line-height: 1rem; - list-style-type: none; - cursor: pointer; - transition: color var(--app-transition); -} - -details summary:not([role]) { - color: var(--app-accordion-close-summary-color); -} - -details summary::-webkit-details-marker { - display: none; -} - -details summary::marker { - display: none; -} - -details summary::-moz-list-bullet { - list-style-type: none; -} - -details summary::after { - display: block; - width: 1rem; - height: 1rem; - margin-inline-start: calc(var(--app-spacing, 1rem) * 0.5); - float: right; - transform: rotate(-90deg); - background-image: var(--app-icon-chevron); - background-position: right center; - background-size: 1rem auto; - background-repeat: no-repeat; - content: ""; - transition: transform var(--app-transition); -} - -details summary:focus { - outline: 0; -} - -details summary:focus:not([role]) { - color: var(--app-accordion-active-summary-color); -} - -details summary:focus-visible:not([role]) { - outline: var(--app-outline-width) solid var(--app-primary-focus); - outline-offset: calc(var(--app-spacing, 1rem) * 0.5); - color: var(--app-primary); -} - -details summary[role="button"] { - width: 100%; - text-align: left; -} - -details summary[role="button"]::after { - height: calc(1rem * var(--app-line-height, 1.5)); -} - -details[open] > summary { - margin-bottom: calc(var(--app-spacing) * 0.5); -} - -details[open] > summary:not([role]):not(:focus) { - color: var(--app-accordion-close-summary-color); -} - -details[open] > summary::after { - transform: rotate(0); -} - -[dir="rtl"] details summary { - text-align: right; -} - -[dir="rtl"] details summary::after { - float: left; - background-position: left center; -} - -article { - margin-bottom: var(--app-block-spacing-vertical); - padding: var(--app-block-spacing-vertical) var(--app-block-spacing-horizontal); - border-radius: var(--app-border-radius); - background: var(--app-card-background-color); - box-shadow: var(--app-card-box-shadow); -} - -article:not(:has(footer)) p:last-child { - margin: 0; -} - -article > footer, -article > header { - margin-right: calc(var(--app-block-spacing-horizontal) * -1); - margin-left: calc(var(--app-block-spacing-horizontal) * -1); - padding: calc(var(--app-block-spacing-vertical) * 1) - var(--app-block-spacing-horizontal); - background-color: var(--app-card-sectioning-background-color); -} - -article > header { - margin-top: calc(var(--app-block-spacing-vertical) * -1); - margin-bottom: var(--app-block-spacing-vertical); - border-bottom: var(--app-border-width) solid var(--app-card-border-color); - border-top-right-radius: var(--app-border-radius); - border-top-left-radius: var(--app-border-radius); -} - -article > header > hgroup { - margin: 0; -} - -article > footer { - margin-top: var(--app-block-spacing-vertical); - margin-bottom: calc(var(--app-block-spacing-vertical) * -1); - border-top: var(--app-border-width) solid var(--app-card-border-color); - border-bottom-right-radius: var(--app-border-radius); - border-bottom-left-radius: var(--app-border-radius); -} - -details.dropdown { - position: relative; - border-bottom: none; -} - -details.dropdown > a::after, -details.dropdown > button::after, -details.dropdown > summary::after { - display: block; - width: 1rem; - height: calc(1rem * var(--app-line-height, 1.5)); - margin-inline-start: 0.25rem; - float: right; - transform: rotate(0) translateX(0.2rem); - background-image: var(--app-icon-chevron); - background-position: right center; - background-size: 1rem auto; - background-repeat: no-repeat; - content: ""; -} - -nav details.dropdown { - margin-bottom: 0; -} - -details.dropdown > summary:not([role]) { - height: calc( - 1rem * var(--app-line-height) + var(--app-form-element-spacing-vertical) * - 2 + var(--app-border-width) * 2 - ); - padding: var(--app-form-element-spacing-vertical) - var(--app-form-element-spacing-horizontal); - border: var(--app-border-width) solid var(--app-form-element-border-color); - border-radius: var(--app-border-radius); - background-color: var(--app-form-element-background-color); - color: var(--app-form-element-placeholder-color); - line-height: inherit; - cursor: pointer; - -webkit-user-select: none; - -moz-user-select: none; - user-select: none; - transition: - background-color var(--app-transition), - border-color var(--app-transition), - color var(--app-transition), - box-shadow var(--app-transition); -} - -details.dropdown > summary:not([role]):active, -details.dropdown > summary:not([role]):focus { - border-color: var(--app-form-element-active-border-color); - background-color: var(--app-form-element-active-background-color); -} - -details.dropdown > summary:not([role]):focus { - box-shadow: 0 0 0 var(--app-outline-width) var(--app-form-element-focus-color); -} - -details.dropdown > summary:not([role]):focus-visible { - outline: 0; -} - -details.dropdown > summary:not([role])[aria-invalid="false"] { - --app-form-element-border-color: var(--app-form-element-valid-border-color); - --app-form-element-active-border-color: var( - --app-form-element-valid-focus-color - ); - --app-form-element-focus-color: var(--app-form-element-valid-focus-color); -} - -details.dropdown > summary:not([role])[aria-invalid="true"] { - --app-form-element-border-color: var(--app-form-element-invalid-border-color); - --app-form-element-active-border-color: var( - --app-form-element-invalid-focus-color - ); - --app-form-element-focus-color: var(--app-form-element-invalid-focus-color); -} - -nav details.dropdown { - display: inline; - margin: calc(var(--app-nav-element-spacing-vertical) * -1) 0; -} - -nav details.dropdown > summary::after { - transform: rotate(0) translateX(0); -} - -nav details.dropdown > summary:not([role]) { - height: calc( - 1rem * var(--app-line-height) + var(--app-nav-link-spacing-vertical) * 2 - ); - padding: calc( - var(--app-nav-link-spacing-vertical) - var(--app-border-width) * 2 - ) - var(--app-nav-link-spacing-horizontal); -} - -nav details.dropdown > summary:not([role]):focus-visible { - box-shadow: 0 0 0 var(--app-outline-width) var(--app-primary-focus); -} - -details.dropdown > summary + ul { - display: flex; - z-index: 99; - position: absolute; - left: 0; - flex-direction: column; - width: 100%; - min-width: -moz-fit-content; - min-width: fit-content; - margin: 0; - margin-top: var(--app-outline-width); - padding: 0; - border: var(--app-border-width) solid var(--app-dropdown-border-color); - border-radius: var(--app-border-radius); - background-color: var(--app-dropdown-background-color); - box-shadow: var(--app-dropdown-box-shadow); - color: var(--app-dropdown-color); - white-space: nowrap; - opacity: 0; - transition: - opacity var(--app-transition), - transform 0s ease-in-out 1s; -} - -details.dropdown > summary + ul[dir="rtl"] { - right: 0; - left: auto; -} - -details.dropdown > summary + ul li { - width: 100%; - margin-bottom: 0; - padding: calc(var(--app-form-element-spacing-vertical) * 0.5) - var(--app-form-element-spacing-horizontal); - list-style: none; - text-align: left; - border-bottom: 1px solid var(--app-accordion-border-color); -} - -details.dropdown > summary + ul li:first-of-type { - margin-top: calc(var(--app-form-element-spacing-vertical) * 0.5); -} - -details.dropdown > summary + ul li:last-of-type { - margin-bottom: calc(var(--app-form-element-spacing-vertical) * 0.5); - border-bottom: 0; -} - -details.dropdown > summary + ul li a { - display: block; - margin: calc(var(--app-form-element-spacing-vertical) * -0.5) - calc(var(--app-form-element-spacing-horizontal) * -1); - padding: calc(var(--app-form-element-spacing-vertical) * 0.5) - var(--app-form-element-spacing-horizontal); - overflow: hidden; - border-radius: 0; - color: var(--app-dropdown-color); - text-decoration: none; - text-overflow: ellipsis; -} - -details.dropdown > summary + ul li a:active, -details.dropdown > summary + ul li a:focus, -details.dropdown > summary + ul li a:focus-visible, -details.dropdown > summary + ul li a:hover, -details.dropdown > summary + ul li a[aria-current]:not([aria-current="false"]) { - background-color: var(--app-dropdown-hover-background-color); -} - -details.dropdown > summary + ul li label { - width: 100%; -} - -details.dropdown > summary + ul li:has(label):hover { - background-color: var(--app-dropdown-hover-background-color); -} - -details.dropdown[open] > summary { - margin-bottom: 0; -} - -details.dropdown[open] > summary + ul { - transform: scaleY(1); - opacity: 1; - transition: - opacity var(--app-transition), - transform 0s ease-in-out 0s; -} - -details.dropdown[open] > summary::before { - display: block; - z-index: 1; - position: fixed; - width: 100vw; - height: 100vh; - inset: 0; - background: 0 0; - content: ""; - cursor: default; -} - -label > details.dropdown { - margin-top: calc(var(--app-spacing) * 0.25); -} - -[role="group"], -[role="search"] { - display: inline-flex; - position: relative; - width: 100%; - margin-bottom: var(--app-spacing); - border-radius: var(--app-border-radius); - box-shadow: var(--app-group-box-shadow, 0 0 0 transparent); - vertical-align: middle; - transition: box-shadow var(--app-transition); -} - -[role="group"] input:not([type="checkbox"], [type="radio"]), -[role="group"] select, -[role="group"] > *, -[role="search"] input:not([type="checkbox"], [type="radio"]), -[role="search"] select, -[role="search"] > * { - position: relative; - flex: 1 1 auto; - margin-bottom: 0; -} - -[role="group"] input:not([type="checkbox"], [type="radio"]):not(:first-child), -[role="group"] select:not(:first-child), -[role="group"] > :not(:first-child), -[role="search"] input:not([type="checkbox"], [type="radio"]):not(:first-child), -[role="search"] select:not(:first-child), -[role="search"] > :not(:first-child) { - margin-left: 0; - border-top-left-radius: 0; - border-bottom-left-radius: 0; -} - -[role="group"] input:not([type="checkbox"], [type="radio"]):not(:last-child), -[role="group"] select:not(:last-child), -[role="group"] > :not(:last-child), -[role="search"] input:not([type="checkbox"], [type="radio"]):not(:last-child), -[role="search"] select:not(:last-child), -[role="search"] > :not(:last-child) { - border-top-right-radius: 0; - border-bottom-right-radius: 0; -} - -[role="group"] input:not([type="checkbox"], [type="radio"]):focus, -[role="group"] select:focus, -[role="group"] > :focus, -[role="search"] input:not([type="checkbox"], [type="radio"]):focus, -[role="search"] select:focus, -[role="search"] > :focus { - z-index: 2; -} - -[role="group"] [role="button"]:not(:first-child), -[role="group"] [type="button"]:not(:first-child), -[role="group"] [type="reset"]:not(:first-child), -[role="group"] [type="submit"]:not(:first-child), -[role="group"] button:not(:first-child), -[role="group"] input:not([type="checkbox"], [type="radio"]):not(:first-child), -[role="group"] select:not(:first-child), -[role="search"] [role="button"]:not(:first-child), -[role="search"] [type="button"]:not(:first-child), -[role="search"] [type="reset"]:not(:first-child), -[role="search"] [type="submit"]:not(:first-child), -[role="search"] button:not(:first-child), -[role="search"] input:not([type="checkbox"], [type="radio"]):not(:first-child), -[role="search"] select:not(:first-child) { - margin-left: calc(var(--app-border-width) * -1); -} - -[role="group"] [role="button"], -[role="group"] [type="button"], -[role="group"] [type="reset"], -[role="group"] [type="submit"], -[role="group"] button, -[role="search"] [role="button"], -[role="search"] [type="button"], -[role="search"] [type="reset"], -[role="search"] [type="submit"], -[role="search"] button { - width: auto; -} - -@supports selector(:has(*)) { - [role="group"]:has( - button:focus, - [type="submit"]:focus, - [type="button"]:focus, - [role="button"]:focus - ), - [role="search"]:has( - button:focus, - [type="submit"]:focus, - [type="button"]:focus, - [role="button"]:focus - ) { - --app-group-box-shadow: var(--app-group-box-shadow-focus-with-button); + canvas { + display: inline-block; } - [role="group"]:has( - button:focus, - [type="submit"]:focus, - [type="button"]:focus, - [role="button"]:focus - ) - input:not([type="checkbox"], [type="radio"]), - [role="group"]:has( - button:focus, - [type="submit"]:focus, - [type="button"]:focus, - [role="button"]:focus - ) - select, - [role="search"]:has( - button:focus, - [type="submit"]:focus, - [type="button"]:focus, - [role="button"]:focus - ) - input:not([type="checkbox"], [type="radio"]), - [role="search"]:has( - button:focus, - [type="submit"]:focus, - [type="button"]:focus, - [role="button"]:focus - ) - select { - border-color: transparent; + input, + optgroup, + select, + textarea { + margin: 0; + font-size: 1rem; + line-height: var(--app-line-height); + font-family: inherit; + letter-spacing: inherit; } - [role="group"]:has( - input:not([type="submit"], [type="button"]):focus, - select:focus - ), - [role="search"]:has( - input:not([type="submit"], [type="button"]):focus, - select:focus - ) { - --app-group-box-shadow: var(--app-group-box-shadow-focus-with-input); + input { + overflow: visible; } - [role="group"]:has( - input:not([type="submit"], [type="button"]):focus, - select:focus - ) - [role="button"], - [role="group"]:has( - input:not([type="submit"], [type="button"]):focus, - select:focus - ) - [type="button"], - [role="group"]:has( - input:not([type="submit"], [type="button"]):focus, - select:focus - ) - [type="submit"], - [role="group"]:has( - input:not([type="submit"], [type="button"]):focus, - select:focus - ) - button, - [role="search"]:has( - input:not([type="submit"], [type="button"]):focus, - select:focus - ) - [role="button"], - [role="search"]:has( - input:not([type="submit"], [type="button"]):focus, - select:focus - ) - [type="button"], - [role="search"]:has( - input:not([type="submit"], [type="button"]):focus, - select:focus - ) - [type="submit"], - [role="search"]:has( - input:not([type="submit"], [type="button"]):focus, - select:focus - ) - button { - --app-button-box-shadow: 0 0 0 var(--app-border-width) - var(--app-primary-border); - --app-button-hover-box-shadow: 0 0 0 var(--app-border-width) - var(--app-primary-hover-border); + select { + text-transform: none; } - [role="group"] [role="button"]:focus, - [role="group"] [type="button"]:focus, - [role="group"] [type="reset"]:focus, - [role="group"] [type="submit"]:focus, - [role="group"] button:focus, - [role="search"] [role="button"]:focus, - [role="search"] [type="button"]:focus, - [role="search"] [type="reset"]:focus, - [role="search"] [type="submit"]:focus, - [role="search"] button:focus { + legend { + max-width: 100%; + white-space: normal; + margin: 0; + display: block; + color: var(--app-color); + font-weight: var(--app-form-label-font-weight, var(--app-font-weight)); + } + + textarea { + overflow: auto; + } + + [type="checkbox"], + [type="radio"] { + padding: 0; + } + + ::-webkit-inner-spin-button, + ::-webkit-outer-spin-button { + height: auto; + } + + [type="search"] { + -webkit-appearance: textfield; + outline-offset: -2px; + } + + [type="search"]::-webkit-search-decoration { + -webkit-appearance: none; + } + + ::-webkit-file-upload-button { + -webkit-appearance: button; + font: inherit; + } + + ::-moz-focus-inner { + padding: 0; + border-style: none; + } + + :-moz-focusring { + outline: 0; + } + + :-moz-ui-invalid { box-shadow: none; } -} -[role="search"] > :first-child { - border-top-left-radius: 5rem; - border-bottom-left-radius: 5rem; -} - -[role="search"] > :last-child { - border-top-right-radius: 5rem; - border-bottom-right-radius: 5rem; -} - -html { - scroll-behavior: smooth; - background: var(--app-sidebar-background-color); -} - -.white-space-nowrap { - white-space: nowrap; -} - -[aria-busy="true"]:not(input, select, textarea, html, form) { - white-space: nowrap; -} - -[aria-busy="true"]:not(input, select, textarea, html, form)::before { - display: inline-block; - width: 1em; - height: 1em; - background-image: var(--app-icon-loading); - background-size: 1em auto; - background-repeat: no-repeat; - content: ""; - vertical-align: -0.125em; -} - -[aria-busy="true"]:not(input, select, textarea, html, form):not( - :empty - )::before { - margin-inline-end: calc(var(--app-spacing) * 0.5); -} - -[aria-busy="true"]:not(input, select, textarea, html, form):empty { - text-align: center; -} - -[role="button"][aria-busy="true"], -[type="button"][aria-busy="true"], -[type="reset"][aria-busy="true"], -[type="submit"][aria-busy="true"], -a[aria-busy="true"], -button[aria-busy="true"] { - pointer-events: none; -} - -dialog { - display: flex; - z-index: 999; - position: fixed; - top: 0; - right: 0; - bottom: 0; - left: 0; - align-items: center; - justify-content: center; - width: inherit; - min-width: 100%; - height: inherit; - min-height: 100%; - padding: 0; - border: 0; - -webkit-backdrop-filter: var(--app-modal-overlay-backdrop-filter); - backdrop-filter: var(--app-modal-overlay-backdrop-filter); - background-color: var(--app-modal-overlay-background-color); - color: var(--app-color); -} - -dialog > article { - width: 100%; - max-height: calc(100vh - var(--app-spacing) * 2); - margin: var(--app-spacing); - overflow: auto; -} - -@media (min-width: 576px) { - dialog > article { - max-width: 510px; + ::-ms-expand { + display: none; } -} -@media (min-width: 768px) { - dialog > article { - max-width: 700px; + [type="file"], + [type="range"] { + padding: 0; + border-width: 0; } -} -dialog > article > header > * { - margin-bottom: 0; -} + input:not([type="checkbox"], [type="radio"], [type="range"]) { + height: calc( + 1rem * var(--app-line-height) + var(--app-form-element-spacing-vertical) * + 2 + var(--app-border-width) * 2 + ); + } -dialog > article > header .close, -dialog > article > header :is(a, button)[rel="prev"] { - margin: 0; - margin-left: var(--app-spacing); - padding: 0; - float: right; -} + fieldset { + width: 100%; + margin-bottom: var(--app-spacing); + border: 1px solid var(--app-form-element-border-color); + padding: var(--app-spacing); + border-radius: var(--app-border-radius); + } -dialog > article > footer { - text-align: right; -} + fieldset[role="group"] { + padding: 0; + border: 0; + border-radius: 0; + } -dialog > article > footer [role="button"], -dialog > article > footer button { - margin-bottom: 0; -} + label { + display: block; + margin-bottom: calc(var(--app-spacing) * 0.375); + color: var(--app-color); + font-weight: var(--app-form-label-font-weight, var(--app-font-weight)); + font-size: small; + } -dialog > article > footer [role="button"]:not(:first-of-type), -dialog > article > footer button:not(:first-of-type) { - margin-left: calc(var(--app-spacing) * 0.5); -} + label:has(input[required]) span:after { + content: " *"; + } -dialog > article .close, -dialog > article :is(a, button)[rel="prev"] { - display: block; - width: 1rem; - height: 1rem; - margin-top: calc(var(--app-spacing) * -1); - margin-bottom: var(--app-spacing); - margin-left: auto; - border: none; - background-image: var(--app-icon-close); - background-position: center; - background-size: auto 1rem; - background-repeat: no-repeat; - background-color: transparent; - opacity: 0.5; - transition: opacity var(--app-transition); -} + button[type="submit"], + input:not([type="checkbox"], [type="radio"]), + select, + textarea { + width: 100%; + } -dialog - > article - .close:is( - [aria-current]:not([aria-current="false"]), - :hover, - :active, - :focus - ), -dialog - > article - :is(a, button)[rel="prev"]:is( - [aria-current]:not([aria-current="false"]), - :hover, - :active, - :focus - ) { - opacity: 1; -} + input:not([type="checkbox"], [type="radio"], [type="range"], [type="file"]), + select, + textarea { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + padding: var(--app-form-element-spacing-vertical) + var(--app-form-element-spacing-horizontal); + } -dialog:not([open]), -dialog[open="false"] { - display: none; -} + input, + select, + textarea { + --app-background-color: var(--app-form-element-background-color); + --app-border-color: var(--app-form-element-border-color); + --app-color: var(--app-form-element-color); + --app-box-shadow: none; + border: var(--app-border-width) solid var(--app-border-color); + border-radius: var(--app-border-radius); + outline: 0; + background-color: var(--app-background-color); + box-shadow: var(--app-box-shadow); + color: var(--app-color); + font-weight: var(--app-font-weight); + transition: + background-color var(--app-transition), + border-color var(--app-transition), + color var(--app-transition), + box-shadow var(--app-transition); + } -.modal-is-open { - padding-right: var(--app-scrollbar-width, 0); - overflow: hidden; - pointer-events: none; - touch-action: none; -} + :where(select, textarea):not([readonly]):is(:active, :focus), + input:not( + [type="submit"], + [type="button"], + [type="reset"], + [type="checkbox"], + [type="radio"], + [readonly] + ):is(:active, :focus) { + --app-background-color: var(--app-form-element-active-background-color); + } -.modal-is-open dialog { - pointer-events: auto; - touch-action: auto; -} + :where(select, textarea):not([readonly]):is(:active, :focus), + input:not( + [type="submit"], + [type="button"], + [type="reset"], + [role="switch"], + [readonly] + ):is(:active, :focus) { + --app-border-color: var(--app-form-element-active-border-color); + } -:where(.modal-is-opening, .modal-is-closing) dialog, -:where(.modal-is-opening, .modal-is-closing) dialog > article { - animation-duration: 0.2s; - animation-timing-function: ease-in-out; - animation-fill-mode: both; -} + :where(select, textarea):not([readonly]):focus, + input:not( + [type="submit"], + [type="button"], + [type="reset"], + [type="range"], + [type="file"], + [readonly] + ):focus { + --app-box-shadow: 0 0 0 var(--app-outline-width) + var(--app-form-element-focus-color); + } -:where(.modal-is-opening, .modal-is-closing) dialog { - animation-duration: 0.8s; - animation-name: modal-overlay; -} + :where(fieldset[disabled]) + :is( + input:not([type="submit"], [type="button"], [type="reset"]), + select, + textarea + ), + input:not([type="submit"], [type="button"], [type="reset"])[disabled], + label[aria-disabled="true"], + select[disabled], + textarea[disabled] { + opacity: var(--app-form-element-disabled-opacity); + pointer-events: none; + } -:where(.modal-is-opening, .modal-is-closing) dialog > article { - animation-delay: 0.2s; - animation-name: modal; -} + label[aria-disabled="true"] input[disabled] { + opacity: 1; + } -.modal-is-closing dialog, -.modal-is-closing dialog > article { - animation-delay: 0s; - animation-direction: reverse; -} + :where(input, select, textarea):not( + [type="checkbox"], + [type="radio"], + [type="date"], + [type="datetime-local"], + [type="month"], + [type="time"], + [type="week"], + [type="range"] + )[aria-invalid] { + padding-right: calc( + var(--app-form-element-spacing-horizontal) + 1.5rem + ) !important; + padding-left: var(--app-form-element-spacing-horizontal); + padding-inline-start: var(--app-form-element-spacing-horizontal) !important; + padding-inline-end: calc( + var(--app-form-element-spacing-horizontal) + 1.5rem + ) !important; + background-position: center right 0.75rem; + background-size: 1rem auto; + background-repeat: no-repeat; + } -@keyframes modal-overlay { - from { - -webkit-backdrop-filter: none; - backdrop-filter: none; + :where(input, select, textarea):not( + [type="checkbox"], + [type="radio"], + [type="date"], + [type="datetime-local"], + [type="month"], + [type="time"], + [type="week"], + [type="range"] + )[aria-invalid="false"]:not(select) { + background-image: var(--app-icon-valid); + } + + :where(input, select, textarea):not( + [type="checkbox"], + [type="radio"], + [type="date"], + [type="datetime-local"], + [type="month"], + [type="time"], + [type="week"], + [type="range"] + )[aria-invalid="true"]:not(select) { + background-image: var(--app-icon-invalid); + } + + :where(input, select, textarea)[aria-invalid="false"] { + --app-border-color: var(--app-form-element-valid-border-color); + } + + :where(input, select, textarea)[aria-invalid="false"]:is(:active, :focus) { + --app-border-color: var( + --app-form-element-valid-active-border-color + ) !important +; + } + + :where(input, select, textarea)[aria-invalid="false"]:is(:active, :focus):not( + [type="checkbox"], + [type="radio"] + ) { + --app-box-shadow: 0 0 0 var(--app-outline-width) + var(--app-form-element-valid-focus-color) !important +; + } + + :where(input, select, textarea)[aria-invalid="true"] { + --app-border-color: var(--app-form-element-invalid-border-color); + } + + :where(input, select, textarea)[aria-invalid="true"]:is(:active, :focus) { + --app-border-color: var( + --app-form-element-invalid-active-border-color + ) !important +; + } + + :where(input, select, textarea)[aria-invalid="true"]:is(:active, :focus):not( + [type="checkbox"], + [type="radio"] + ) { + --app-box-shadow: 0 0 0 var(--app-outline-width) + var(--app-form-element-invalid-focus-color) !important +; + } + + [dir="rtl"] + :where(input, select, textarea):not([type="checkbox"], [type="radio"]):is( + [aria-invalid], + [aria-invalid="true"], + [aria-invalid="false"] + ) { + background-position: center left 0.75rem; + } + + input::-webkit-input-placeholder, + input::placeholder, + select:invalid, + textarea::-webkit-input-placeholder, + textarea::placeholder { + color: var(--app-form-element-placeholder-color); + opacity: 1; + } + + input:not([type="checkbox"], [type="radio"]), + select, + textarea { + margin-bottom: var(--app-spacing); + } + + select::-ms-expand { + border: 0; background-color: transparent; } -} -@keyframes modal { - from { - transform: translateY(-100%); + select:not([multiple], [size]) { + padding-right: calc(var(--app-form-element-spacing-horizontal) + 1.5rem); + padding-left: var(--app-form-element-spacing-horizontal); + padding-inline-start: var(--app-form-element-spacing-horizontal); + padding-inline-end: calc( + var(--app-form-element-spacing-horizontal) + 1.5rem + ); + background-image: var(--app-icon-chevron); + background-position: center right 0.75rem; + background-size: 1rem auto; + background-repeat: no-repeat; + } + + select[multiple] option:checked { + background: var(--app-form-element-selected-background-color); + color: var(--app-form-element-color); + } + + [dir="rtl"] select:not([multiple], [size]) { + background-position: center left 0.75rem; + } + + textarea { + display: block; + resize: vertical; + } + + textarea[aria-invalid] { + --app-icon-height: calc( + 1rem * var(--app-line-height) + var(--app-form-element-spacing-vertical) * + 2 + var(--app-border-width) * 2 + ); + background-position: top right 0.75rem !important; + background-size: 1rem var(--app-icon-height) !important; + } + + :where(input, select, textarea, fieldset, .grid) + small { + display: block; + width: 100%; + margin-top: calc(var(--app-spacing) * -0.75); + margin-bottom: var(--app-spacing); + color: var(--app-muted-color); + } + + :where(input, select, textarea, fieldset, .grid)[aria-invalid="false"] + + small { + color: var(--app-ins-color); + } + + :where(input, select, textarea, fieldset, .grid)[aria-invalid="true"] + + small { + color: var(--app-del-color); + } + + label > :where(input, select, textarea) { + margin-top: calc(var(--app-spacing) * 0.25); + } + + label:has([type="checkbox"], [type="radio"]) { + width: -moz-fit-content; + width: fit-content; + cursor: pointer; + } + + [type="checkbox"], + [type="radio"] { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + width: 1.25em; + height: 1.25em; + margin-top: -0.125em; + margin-inline-end: 0.5em; + border-width: var(--app-border-width); + vertical-align: middle; + cursor: pointer; + flex-shrink: 0; + } + + [type="checkbox"]::-ms-check, + [type="radio"]::-ms-check { + display: none; + } + + [type="checkbox"]:checked, + [type="checkbox"]:checked:active, + [type="checkbox"]:checked:focus, + [type="radio"]:checked, + [type="radio"]:checked:active, + [type="radio"]:checked:focus { + --app-background-color: var(--app-primary-background); + --app-border-color: var(--app-primary-border); + background-image: var(--app-icon-checkbox); + background-position: center; + background-size: 0.75em auto; + background-repeat: no-repeat; + } + + [type="checkbox"] ~ label, + [type="radio"] ~ label { + display: inline-block; + margin-bottom: 0; + cursor: pointer; + } + + [type="checkbox"] ~ label:not(:last-of-type), + [type="radio"] ~ label:not(:last-of-type) { + margin-inline-end: 1em; + } + + [type="checkbox"]:indeterminate { + --app-background-color: var(--app-primary-background); + --app-border-color: var(--app-primary-border); + background-image: var(--app-icon-minus); + background-position: center; + background-size: 0.75em auto; + background-repeat: no-repeat; + } + + [type="radio"] { + border-radius: 50%; + } + + [type="radio"]:checked, + [type="radio"]:checked:active, + [type="radio"]:checked:focus { + --app-background-color: var(--app-primary-inverse); + border-width: 0.35em; + background-image: none; + } + + [type="checkbox"][role="switch"] { + --app-background-color: var(--app-switch-background-color); + --app-color: var(--app-switch-color); + width: 2.25em; + height: 1.25em; + border: var(--app-border-width) solid var(--app-border-color); + border-radius: 1.25em; + background-color: var(--app-background-color); + line-height: 1.25em; + } + + [type="checkbox"][role="switch"]:not([aria-invalid]) { + --app-border-color: var(--app-switch-background-color); + } + + [type="checkbox"][role="switch"]:before { + display: block; + aspect-ratio: 1; + height: 100%; + border-radius: 50%; + background-color: var(--app-color); + box-shadow: var(--app-switch-thumb-box-shadow); + content: ""; + transition: margin 0.1s ease-in-out; + } + + [type="checkbox"][role="switch"]:focus { + --app-background-color: var(--app-switch-background-color); + --app-border-color: var(--app-switch-background-color); + } + + [type="checkbox"][role="switch"]:checked { + --app-background-color: var(--app-switch-checked-background-color); + --app-border-color: var(--app-switch-checked-background-color); + background-image: none; + } + + [type="checkbox"][role="switch"]:checked::before { + margin-inline-start: calc(2.25em - 1.25em); + } + + [type="checkbox"][role="switch"][disabled] { + --app-background-color: var(--app-border-color); + } + + [type="checkbox"][aria-invalid="false"]:checked, + [type="checkbox"][aria-invalid="false"]:checked:active, + [type="checkbox"][aria-invalid="false"]:checked:focus, + [type="checkbox"][role="switch"][aria-invalid="false"]:checked, + [type="checkbox"][role="switch"][aria-invalid="false"]:checked:active, + [type="checkbox"][role="switch"][aria-invalid="false"]:checked:focus { + --app-background-color: var(--app-form-element-valid-border-color); + } + + [type="checkbox"]:checked:active[aria-invalid="true"], + [type="checkbox"]:checked:focus[aria-invalid="true"], + [type="checkbox"]:checked[aria-invalid="true"], + [type="checkbox"][role="switch"]:checked:active[aria-invalid="true"], + [type="checkbox"][role="switch"]:checked:focus[aria-invalid="true"], + [type="checkbox"][role="switch"]:checked[aria-invalid="true"] { + --app-background-color: var(--app-form-element-invalid-border-color); + } + + [type="checkbox"][aria-invalid="false"]:checked, + [type="checkbox"][aria-invalid="false"]:checked:active, + [type="checkbox"][aria-invalid="false"]:checked:focus, + [type="checkbox"][role="switch"][aria-invalid="false"]:checked, + [type="checkbox"][role="switch"][aria-invalid="false"]:checked:active, + [type="checkbox"][role="switch"][aria-invalid="false"]:checked:focus, + [type="radio"][aria-invalid="false"]:checked, + [type="radio"][aria-invalid="false"]:checked:active, + [type="radio"][aria-invalid="false"]:checked:focus { + --app-border-color: var(--app-form-element-valid-border-color); + } + + [type="checkbox"]:checked:active[aria-invalid="true"], + [type="checkbox"]:checked:focus[aria-invalid="true"], + [type="checkbox"]:checked[aria-invalid="true"], + [type="checkbox"][role="switch"]:checked:active[aria-invalid="true"], + [type="checkbox"][role="switch"]:checked:focus[aria-invalid="true"], + [type="checkbox"][role="switch"]:checked[aria-invalid="true"], + [type="radio"]:checked:active[aria-invalid="true"], + [type="radio"]:checked:focus[aria-invalid="true"], + [type="radio"]:checked[aria-invalid="true"] { + --app-border-color: var(--app-form-element-invalid-border-color); + } + + [type="color"]::-webkit-color-swatch-wrapper { + padding: 0; + } + + [type="color"]::-moz-focus-inner { + padding: 0; + } + + [type="color"]::-webkit-color-swatch { + border: 0; + border-radius: calc(var(--app-border-radius) * 0.5); + } + + [type="color"]::-moz-color-swatch { + border: 0; + border-radius: calc(var(--app-border-radius) * 0.5); + } + + input:not( + [type="checkbox"], + [type="radio"], + [type="range"], + [type="file"] + ):is( + [type="date"], + [type="datetime-local"], + [type="month"], + [type="time"], + [type="week"] + ) { + --app-icon-position: 0.75rem; + --app-icon-width: 1rem; + padding-right: calc(var(--app-icon-width) + var(--app-icon-position)); + background-image: var(--app-icon-date); + background-position: center right var(--app-icon-position); + background-size: var(--app-icon-width) auto; + background-repeat: no-repeat; + } + + input:not( + [type="checkbox"], + [type="radio"], + [type="range"], + [type="file"] + )[type="time"] { + background-image: var(--app-icon-time); + } + + [type="date"]::-webkit-calendar-picker-indicator, + [type="datetime-local"]::-webkit-calendar-picker-indicator, + [type="month"]::-webkit-calendar-picker-indicator, + [type="time"]::-webkit-calendar-picker-indicator, + [type="week"]::-webkit-calendar-picker-indicator { + width: var(--app-icon-width); + margin-right: calc(var(--app-icon-width) * -1); + margin-left: var(--app-icon-position); opacity: 0; } -} -:where(nav li)::before { - float: left; - content: "​"; -} + @-moz-document url-prefix() { + [type="date"], + [type="datetime-local"], + [type="month"], + [type="time"], + [type="week"] { + padding-right: var(--app-form-element-spacing-horizontal) !important; + background-image: none !important; + } + } -nav, -nav ul { - display: flex; -} + [dir="rtl"] + :is( + [type="date"], + [type="datetime-local"], + [type="month"], + [type="time"], + [type="week"] + ) { + text-align: right; + } -nav { - justify-content: space-between; - overflow: visible; -} + [type="file"] { + --app-color: var(--app-muted-color); + margin-left: calc(var(--app-outline-width) * -1); + background: 0 0; + border: 2px dashed var(--app-form-element-border-color); + padding: 1.5rem; + height: auto !important; + } -nav ol, -nav ul { - align-items: center; - margin-bottom: 0; - padding: 0; - list-style: none; -} + [type="file"]::file-selector-button { + margin-right: calc(var(--app-spacing) / 2); + padding: calc(var(--app-form-element-spacing-vertical) * 0.5) + var(--app-form-element-spacing-horizontal); + } -nav ol:first-of-type, -nav ul:first-of-type { - margin-left: calc(var(--app-nav-element-spacing-horizontal) * -1); -} + [type="file"]:is(:hover, :active, :focus)::file-selector-button { + --app-background-color: var(--app-secondary-hover-background); + --app-border-color: var(--app-secondary-hover-border); + } -nav ol:last-of-type, -nav ul:last-of-type { - margin-right: calc(var(--app-nav-element-spacing-horizontal) * -1); -} + [type="file"]:focus::file-selector-button { + --app-box-shadow: + var(--app-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)), + 0 0 0 var(--app-outline-width) var(--app-secondary-focus); + } -nav li { - display: inline-block; - margin: 0; - padding: var(--app-nav-element-spacing-vertical) - var(--app-nav-element-spacing-horizontal); -} + [type="range"] { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + width: 100%; + height: 1.25rem; + background: 0 0; + } -nav li :where(a, [role="link"]) { - display: inline-block; - margin: calc(var(--app-nav-link-spacing-vertical) * -1) - calc(var(--app-nav-link-spacing-horizontal) * -1); - padding: var(--app-nav-link-spacing-vertical) - var(--app-nav-link-spacing-horizontal); - border-radius: var(--app-border-radius); -} + [type="range"]::-webkit-slider-runnable-track { + width: 100%; + height: 0.375rem; + border-radius: var(--app-border-radius); + background-color: var(--app-range-border-color); + -webkit-transition: + background-color var(--app-transition), + box-shadow var(--app-transition); + transition: + background-color var(--app-transition), + box-shadow var(--app-transition); + } -nav li :where(a, [role="link"]):not(:hover) { - text-decoration: none; -} + [type="range"]::-moz-range-track { + width: 100%; + height: 0.375rem; + border-radius: var(--app-border-radius); + background-color: var(--app-range-border-color); + -moz-transition: + background-color var(--app-transition), + box-shadow var(--app-transition); + transition: + background-color var(--app-transition), + box-shadow var(--app-transition); + } -nav li [role="button"], -nav li [type="button"], -nav li button, -nav - li - input:not([type="checkbox"], [type="radio"], [type="range"], [type="file"]), -nav li select { - height: auto; - margin-right: inherit; - margin-bottom: 0; - margin-left: inherit; - padding: calc( - var(--app-nav-link-spacing-vertical) - var(--app-border-width) * 2 - ) - var(--app-nav-link-spacing-horizontal); -} + [type="range"]::-ms-track { + width: 100%; + height: 0.375rem; + border-radius: var(--app-border-radius); + background-color: var(--app-range-border-color); + -ms-transition: + background-color var(--app-transition), + box-shadow var(--app-transition); + transition: + background-color var(--app-transition), + box-shadow var(--app-transition); + } -nav[aria-label="breadcrumb"] { - --app-breadcrumb-separator-size: 0.75rem; - --app-nav-element-spacing-vertical: 0.25rem; - --app-nav-element-spacing-horizontal: 0.25rem; - --app-nav-link-spacing-vertical: 0.25rem; - --app-nav-link-spacing-horizontal: 0.25rem; - align-items: center; - justify-content: start; - font-size: 0.875em; -} + [type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 1.25rem; + height: 1.25rem; + margin-top: -0.4375rem; + border: 2px solid var(--app-range-thumb-border-color); + border-radius: 50%; + background-color: var(--app-range-thumb-color); + cursor: pointer; + -webkit-transition: + background-color var(--app-transition), + transform var(--app-transition); + transition: + background-color var(--app-transition), + transform var(--app-transition); + } -nav[aria-label="breadcrumb"] ol, -nav[aria-label="breadcrumb"] ul { - margin: 0; -} + [type="range"]::-moz-range-thumb { + -webkit-appearance: none; + width: 1.25rem; + height: 1.25rem; + margin-top: -0.4375rem; + border: 2px solid var(--app-range-thumb-border-color); + border-radius: 50%; + background-color: var(--app-range-thumb-color); + cursor: pointer; + -moz-transition: + background-color var(--app-transition), + transform var(--app-transition); + transition: + background-color var(--app-transition), + transform var(--app-transition); + } -nav[aria-label="breadcrumb"] :is(ol, ul) li { - padding: 0; -} + [type="range"]::-ms-thumb { + -webkit-appearance: none; + width: 1.25rem; + height: 1.25rem; + margin-top: -0.4375rem; + border: 2px solid var(--app-range-thumb-border-color); + border-radius: 50%; + background-color: var(--app-range-thumb-color); + cursor: pointer; + -ms-transition: + background-color var(--app-transition), + transform var(--app-transition); + transition: + background-color var(--app-transition), + transform var(--app-transition); + } -nav[aria-label="breadcrumb"] :is(ol, ul) li:not(:first-child) { - margin-inline-start: 0; -} + [type="range"]:active, + [type="range"]:focus-within { + --app-range-border-color: var(--app-range-active-border-color); + --app-range-thumb-color: var(--app-range-thumb-active-color); + } -nav[aria-label="breadcrumb"] :is(ol, ul) li a { - margin: calc(var(--app-nav-link-spacing-vertical) * -1) 0; - margin-inline-start: calc(var(--app-nav-link-spacing-horizontal) * -1); -} + [type="range"]:active::-webkit-slider-thumb { + transform: scale(1.25); + } -nav[aria-label="breadcrumb"] :is(ol, ul) li:not(:last-child)::after { - display: inline-block; - width: var(--app-breadcrumb-separator-size); - height: var(--app-breadcrumb-separator-size); - margin-inline: calc(var(--app-nav-link-spacing-horizontal) * 0.75); - content: ""; - background-image: var(--app-icon-chevron); - background-size: var(--app-breadcrumb-separator-size) auto; - background-repeat: no-repeat; - opacity: 0.7; - transform: rotate(-90deg); -} + [type="range"]:active::-moz-range-thumb { + transform: scale(1.25); + } -nav[aria-label="breadcrumb"] a[aria-current]:not([aria-current="false"]) { - background-color: transparent; - color: inherit; - text-decoration: none; - pointer-events: none; -} + [type="range"]:active::-ms-thumb { + transform: scale(1.25); + } -aside li, -aside nav, -aside ol, -aside ul { - display: block; -} + input:not( + [type="checkbox"], + [type="radio"], + [type="range"], + [type="file"] + )[type="search"] { + padding-inline-start: calc( + var(--app-form-element-spacing-horizontal) + 1.75rem + ); + background-image: var(--app-icon-search); + background-position: center left + calc(var(--app-form-element-spacing-horizontal) + 0.125rem); + background-size: 1rem auto; + background-repeat: no-repeat; + } -aside li { - padding: calc(var(--app-nav-element-spacing-vertical) * 0.5) - var(--app-nav-element-spacing-horizontal); -} + input:not( + [type="checkbox"], + [type="radio"], + [type="range"], + [type="file"] + )[type="search"][aria-invalid] { + padding-inline-start: calc( + var(--app-form-element-spacing-horizontal) + 1.75rem + ) !important; + background-position: + center left 1.125rem, + center right 0.75rem; + } -aside li a { - display: block; -} + input:not( + [type="checkbox"], + [type="radio"], + [type="range"], + [type="file"] + )[type="search"][aria-invalid="false"] { + background-image: var(--app-icon-search), var(--app-icon-valid); + } -aside li [role="button"] { - margin: inherit; -} + input:not( + [type="checkbox"], + [type="radio"], + [type="range"], + [type="file"] + )[type="search"][aria-invalid="true"] { + background-image: var(--app-icon-search), var(--app-icon-invalid); + } -.text-align-center { - text-align: center; -} + [dir="rtl"] + :where(input):not( + [type="checkbox"], + [type="radio"], + [type="range"], + [type="file"] + )[type="search"] { + background-position: center right 1.125rem; + } -.primary { - box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 2px; -} + [dir="rtl"] + :where(input):not( + [type="checkbox"], + [type="radio"], + [type="range"], + [type="file"] + )[type="search"][aria-invalid] { + background-position: + center right 1.125rem, + center left 0.75rem; + } -.secondary { - border: 1px solid var(--app-border); - color: var(--app-muted-color); - box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 2px; -} -.secondary:hover { - border: 1px solid var(--app-contrast); - color: var(--app-contrast); -} + details { + display: block; + margin-bottom: var(--app-spacing); + } -/* Loading state - global cursor and interaction blocking */ -body.is-loading { - cursor: wait; -} + details summary { + line-height: 1rem; + list-style-type: none; + cursor: pointer; + transition: color var(--app-transition); + } -body.is-loading * { - cursor: wait !important; -} + details summary:not([role]) { + color: var(--app-accordion-close-summary-color); + } -body.is-loading button, -body.is-loading a, -body.is-loading input, -body.is-loading select, -body.is-loading textarea { - pointer-events: none; + details summary::-webkit-details-marker { + display: none; + } + + details summary::marker { + display: none; + } + + details summary::-moz-list-bullet { + list-style-type: none; + } + + details summary::after { + display: block; + width: 1rem; + height: 1rem; + margin-inline-start: calc(var(--app-spacing, 1rem) * 0.5); + float: right; + transform: rotate(-90deg); + background-image: var(--app-icon-chevron); + background-position: right center; + background-size: 1rem auto; + background-repeat: no-repeat; + content: ""; + transition: transform var(--app-transition); + } + + details summary:focus { + outline: 0; + } + + details summary:focus:not([role]) { + color: var(--app-accordion-active-summary-color); + } + + details summary:focus-visible:not([role]) { + outline: var(--app-outline-width) solid var(--app-primary-focus); + outline-offset: calc(var(--app-spacing, 1rem) * 0.5); + color: var(--app-primary); + } + + details summary[role="button"] { + width: 100%; + text-align: left; + } + + details summary[role="button"]::after { + height: calc(1rem * var(--app-line-height, 1.5)); + } + + details[open] > summary { + margin-bottom: calc(var(--app-spacing) * 0.5); + } + + details[open] > summary:not([role]):not(:focus) { + color: var(--app-accordion-close-summary-color); + } + + details[open] > summary::after { + transform: rotate(0); + } + + [dir="rtl"] details summary { + text-align: right; + } + + [dir="rtl"] details summary::after { + float: left; + background-position: left center; + } + + article { + margin-bottom: var(--app-block-spacing-vertical); + padding: var(--app-block-spacing-vertical) + var(--app-block-spacing-horizontal); + border-radius: var(--app-border-radius); + background: var(--app-card-background-color); + box-shadow: var(--app-card-box-shadow); + } + + article:not(:has(footer)) p:last-child { + margin: 0; + } + + article > footer, + article > header { + margin-right: calc(var(--app-block-spacing-horizontal) * -1); + margin-left: calc(var(--app-block-spacing-horizontal) * -1); + padding: calc(var(--app-block-spacing-vertical) * 1) + var(--app-block-spacing-horizontal); + background-color: var(--app-card-sectioning-background-color); + } + + article > header { + margin-top: calc(var(--app-block-spacing-vertical) * -1); + margin-bottom: var(--app-block-spacing-vertical); + border-bottom: var(--app-border-width) solid var(--app-card-border-color); + border-top-right-radius: var(--app-border-radius); + border-top-left-radius: var(--app-border-radius); + } + + article > header > hgroup { + margin: 0; + } + + article > footer { + margin-top: var(--app-block-spacing-vertical); + margin-bottom: calc(var(--app-block-spacing-vertical) * -1); + border-top: var(--app-border-width) solid var(--app-card-border-color); + border-bottom-right-radius: var(--app-border-radius); + border-bottom-left-radius: var(--app-border-radius); + } + + details.dropdown { + position: relative; + border-bottom: none; + } + + details.dropdown > a::after, + details.dropdown > button::after, + details.dropdown > summary::after { + display: block; + width: 1rem; + height: calc(1rem * var(--app-line-height, 1.5)); + margin-inline-start: 0.25rem; + float: right; + transform: rotate(0) translateX(0.2rem); + background-image: var(--app-icon-chevron); + background-position: right center; + background-size: 1rem auto; + background-repeat: no-repeat; + content: ""; + } + + nav details.dropdown { + margin-bottom: 0; + } + + details.dropdown > summary:not([role]) { + height: calc( + 1rem * var(--app-line-height) + var(--app-form-element-spacing-vertical) * + 2 + var(--app-border-width) * 2 + ); + padding: var(--app-form-element-spacing-vertical) + var(--app-form-element-spacing-horizontal); + border: var(--app-border-width) solid var(--app-form-element-border-color); + border-radius: var(--app-border-radius); + background-color: var(--app-form-element-background-color); + color: var(--app-form-element-placeholder-color); + line-height: inherit; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + transition: + background-color var(--app-transition), + border-color var(--app-transition), + color var(--app-transition), + box-shadow var(--app-transition); + } + + details.dropdown > summary:not([role]):active, + details.dropdown > summary:not([role]):focus { + border-color: var(--app-form-element-active-border-color); + background-color: var(--app-form-element-active-background-color); + } + + details.dropdown > summary:not([role]):focus { + box-shadow: 0 0 0 var(--app-outline-width) + var(--app-form-element-focus-color); + } + + details.dropdown > summary:not([role]):focus-visible { + outline: 0; + } + + details.dropdown > summary:not([role])[aria-invalid="false"] { + --app-form-element-border-color: var(--app-form-element-valid-border-color); + --app-form-element-active-border-color: var( + --app-form-element-valid-focus-color + ); + --app-form-element-focus-color: var(--app-form-element-valid-focus-color); + } + + details.dropdown > summary:not([role])[aria-invalid="true"] { + --app-form-element-border-color: var( + --app-form-element-invalid-border-color + ); + --app-form-element-active-border-color: var( + --app-form-element-invalid-focus-color + ); + --app-form-element-focus-color: var(--app-form-element-invalid-focus-color); + } + + nav details.dropdown { + display: inline; + margin: calc(var(--app-nav-element-spacing-vertical) * -1) 0; + } + + nav details.dropdown > summary::after { + transform: rotate(0) translateX(0); + } + + nav details.dropdown > summary:not([role]) { + height: calc( + 1rem * var(--app-line-height) + var(--app-nav-link-spacing-vertical) * 2 + ); + padding: calc( + var(--app-nav-link-spacing-vertical) - var(--app-border-width) * 2 + ) + var(--app-nav-link-spacing-horizontal); + } + + nav details.dropdown > summary:not([role]):focus-visible { + box-shadow: 0 0 0 var(--app-outline-width) var(--app-primary-focus); + } + + details.dropdown > summary + ul { + display: flex; + z-index: 99; + position: absolute; + left: 0; + flex-direction: column; + width: 100%; + min-width: -moz-fit-content; + min-width: fit-content; + margin: 0; + margin-top: var(--app-outline-width); + padding: 0; + border: var(--app-border-width) solid var(--app-dropdown-border-color); + border-radius: var(--app-border-radius); + background-color: var(--app-dropdown-background-color); + box-shadow: var(--app-dropdown-box-shadow); + color: var(--app-dropdown-color); + white-space: nowrap; + opacity: 0; + transition: + opacity var(--app-transition), + transform 0s ease-in-out 1s; + } + + details.dropdown > summary + ul[dir="rtl"] { + right: 0; + left: auto; + } + + details.dropdown > summary + ul li { + width: 100%; + margin-bottom: 0; + padding: calc(var(--app-form-element-spacing-vertical) * 0.5) + var(--app-form-element-spacing-horizontal); + list-style: none; + text-align: left; + border-bottom: 1px solid var(--app-accordion-border-color); + } + + details.dropdown > summary + ul li:first-of-type { + margin-top: calc(var(--app-form-element-spacing-vertical) * 0.5); + } + + details.dropdown > summary + ul li:last-of-type { + margin-bottom: calc(var(--app-form-element-spacing-vertical) * 0.5); + border-bottom: 0; + } + + details.dropdown > summary + ul li a { + display: block; + margin: calc(var(--app-form-element-spacing-vertical) * -0.5) + calc(var(--app-form-element-spacing-horizontal) * -1); + padding: calc(var(--app-form-element-spacing-vertical) * 0.5) + var(--app-form-element-spacing-horizontal); + overflow: hidden; + border-radius: 0; + color: var(--app-dropdown-color); + text-decoration: none; + text-overflow: ellipsis; + } + + details.dropdown > summary + ul li a:active, + details.dropdown > summary + ul li a:focus, + details.dropdown > summary + ul li a:focus-visible, + details.dropdown > summary + ul li a:hover, + details.dropdown + > summary + + ul + li + a[aria-current]:not([aria-current="false"]) { + background-color: var(--app-dropdown-hover-background-color); + } + + details.dropdown > summary + ul li label { + width: 100%; + } + + details.dropdown > summary + ul li:has(label):hover { + background-color: var(--app-dropdown-hover-background-color); + } + + details.dropdown[open] > summary { + margin-bottom: 0; + } + + details.dropdown[open] > summary + ul { + transform: scaleY(1); + opacity: 1; + transition: + opacity var(--app-transition), + transform 0s ease-in-out 0s; + } + + details.dropdown[open] > summary::before { + display: block; + z-index: 1; + position: fixed; + width: 100vw; + height: 100vh; + inset: 0; + background: 0 0; + content: ""; + cursor: default; + } + + label > details.dropdown { + margin-top: calc(var(--app-spacing) * 0.25); + } + + [role="group"], + [role="search"] { + display: inline-flex; + position: relative; + width: 100%; + margin-bottom: var(--app-spacing); + border-radius: var(--app-border-radius); + box-shadow: var(--app-group-box-shadow, 0 0 0 transparent); + vertical-align: middle; + transition: box-shadow var(--app-transition); + } + + [role="group"] input:not([type="checkbox"], [type="radio"]), + [role="group"] select, + [role="group"] > *, + [role="search"] input:not([type="checkbox"], [type="radio"]), + [role="search"] select, + [role="search"] > * { + position: relative; + flex: 1 1 auto; + margin-bottom: 0; + } + + [role="group"] input:not([type="checkbox"], [type="radio"]):not(:first-child), + [role="group"] select:not(:first-child), + [role="group"] > :not(:first-child), + [role="search"] + input:not([type="checkbox"], [type="radio"]):not(:first-child), + [role="search"] select:not(:first-child), + [role="search"] > :not(:first-child) { + margin-left: 0; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + + [role="group"] input:not([type="checkbox"], [type="radio"]):not(:last-child), + [role="group"] select:not(:last-child), + [role="group"] > :not(:last-child), + [role="search"] input:not([type="checkbox"], [type="radio"]):not(:last-child), + [role="search"] select:not(:last-child), + [role="search"] > :not(:last-child) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + [role="group"] input:not([type="checkbox"], [type="radio"]):focus, + [role="group"] select:focus, + [role="group"] > :focus, + [role="search"] input:not([type="checkbox"], [type="radio"]):focus, + [role="search"] select:focus, + [role="search"] > :focus { + z-index: 2; + } + + [role="group"] [role="button"]:not(:first-child), + [role="group"] [type="button"]:not(:first-child), + [role="group"] [type="reset"]:not(:first-child), + [role="group"] [type="submit"]:not(:first-child), + [role="group"] button:not(:first-child), + [role="group"] input:not([type="checkbox"], [type="radio"]):not(:first-child), + [role="group"] select:not(:first-child), + [role="search"] [role="button"]:not(:first-child), + [role="search"] [type="button"]:not(:first-child), + [role="search"] [type="reset"]:not(:first-child), + [role="search"] [type="submit"]:not(:first-child), + [role="search"] button:not(:first-child), + [role="search"] + input:not([type="checkbox"], [type="radio"]):not(:first-child), + [role="search"] select:not(:first-child) { + margin-left: calc(var(--app-border-width) * -1); + } + + [role="group"] [role="button"], + [role="group"] [type="button"], + [role="group"] [type="reset"], + [role="group"] [type="submit"], + [role="group"] button, + [role="search"] [role="button"], + [role="search"] [type="button"], + [role="search"] [type="reset"], + [role="search"] [type="submit"], + [role="search"] button { + width: auto; + } + + @supports selector(:has(*)) { + [role="group"]:has( + button:focus, + [type="submit"]:focus, + [type="button"]:focus, + [role="button"]:focus + ), + [role="search"]:has( + button:focus, + [type="submit"]:focus, + [type="button"]:focus, + [role="button"]:focus + ) { + --app-group-box-shadow: var(--app-group-box-shadow-focus-with-button); + } + + [role="group"]:has( + button:focus, + [type="submit"]:focus, + [type="button"]:focus, + [role="button"]:focus + ) + input:not([type="checkbox"], [type="radio"]), + [role="group"]:has( + button:focus, + [type="submit"]:focus, + [type="button"]:focus, + [role="button"]:focus + ) + select, + [role="search"]:has( + button:focus, + [type="submit"]:focus, + [type="button"]:focus, + [role="button"]:focus + ) + input:not([type="checkbox"], [type="radio"]), + [role="search"]:has( + button:focus, + [type="submit"]:focus, + [type="button"]:focus, + [role="button"]:focus + ) + select { + border-color: transparent; + } + + [role="group"]:has( + input:not([type="submit"], [type="button"]):focus, + select:focus + ), + [role="search"]:has( + input:not([type="submit"], [type="button"]):focus, + select:focus + ) { + --app-group-box-shadow: var(--app-group-box-shadow-focus-with-input); + } + + [role="group"]:has( + input:not([type="submit"], [type="button"]):focus, + select:focus + ) + [role="button"], + [role="group"]:has( + input:not([type="submit"], [type="button"]):focus, + select:focus + ) + [type="button"], + [role="group"]:has( + input:not([type="submit"], [type="button"]):focus, + select:focus + ) + [type="submit"], + [role="group"]:has( + input:not([type="submit"], [type="button"]):focus, + select:focus + ) + button, + [role="search"]:has( + input:not([type="submit"], [type="button"]):focus, + select:focus + ) + [role="button"], + [role="search"]:has( + input:not([type="submit"], [type="button"]):focus, + select:focus + ) + [type="button"], + [role="search"]:has( + input:not([type="submit"], [type="button"]):focus, + select:focus + ) + [type="submit"], + [role="search"]:has( + input:not([type="submit"], [type="button"]):focus, + select:focus + ) + button { + --app-button-box-shadow: 0 0 0 var(--app-border-width) + var(--app-primary-border); + --app-button-hover-box-shadow: 0 0 0 var(--app-border-width) + var(--app-primary-hover-border); + } + + [role="group"] [role="button"]:focus, + [role="group"] [type="button"]:focus, + [role="group"] [type="reset"]:focus, + [role="group"] [type="submit"]:focus, + [role="group"] button:focus, + [role="search"] [role="button"]:focus, + [role="search"] [type="button"]:focus, + [role="search"] [type="reset"]:focus, + [role="search"] [type="submit"]:focus, + [role="search"] button:focus { + box-shadow: none; + } + } + + [role="search"] > :first-child { + border-top-left-radius: 5rem; + border-bottom-left-radius: 5rem; + } + + [role="search"] > :last-child { + border-top-right-radius: 5rem; + border-bottom-right-radius: 5rem; + } + + html { + scroll-behavior: smooth; + background: var(--app-sidebar-background-color); + } + + .white-space-nowrap { + white-space: nowrap; + } + + [aria-busy="true"]:not(input, select, textarea, html, form) { + white-space: nowrap; + } + + [aria-busy="true"]:not(input, select, textarea, html, form)::before { + display: inline-block; + width: 1em; + height: 1em; + background-image: var(--app-icon-loading); + background-size: 1em auto; + background-repeat: no-repeat; + content: ""; + vertical-align: -0.125em; + } + + [aria-busy="true"]:not(input, select, textarea, html, form):not( + :empty + )::before { + margin-inline-end: calc(var(--app-spacing) * 0.5); + } + + [aria-busy="true"]:not(input, select, textarea, html, form):empty { + text-align: center; + } + + [role="button"][aria-busy="true"], + [type="button"][aria-busy="true"], + [type="reset"][aria-busy="true"], + [type="submit"][aria-busy="true"], + a[aria-busy="true"], + button[aria-busy="true"] { + pointer-events: none; + } + + dialog { + display: flex; + z-index: 999; + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + align-items: center; + justify-content: center; + width: inherit; + min-width: 100%; + height: inherit; + min-height: 100%; + padding: 0; + border: 0; + -webkit-backdrop-filter: var(--app-modal-overlay-backdrop-filter); + backdrop-filter: var(--app-modal-overlay-backdrop-filter); + background-color: var(--app-modal-overlay-background-color); + color: var(--app-color); + } + + dialog > article { + width: 100%; + max-height: calc(100vh - var(--app-spacing) * 2); + margin: var(--app-spacing); + overflow: auto; + } + + .bi::before, + [class^="bi-"]::before, + [class*=" bi-"]::before { + vertical-align: -0.125em; + } + + @media (min-width: 576px) { + dialog > article { + max-width: 510px; + } + } + + @media (min-width: 768px) { + dialog > article { + max-width: 700px; + } + } + + dialog > article > header > * { + margin-bottom: 0; + } + + dialog > article > header .close, + dialog > article > header :is(a, button)[rel="prev"] { + margin: 0; + margin-left: var(--app-spacing); + padding: 0; + float: right; + } + + dialog > article > footer { + text-align: right; + } + + dialog > article > footer [role="button"], + dialog > article > footer button { + margin-bottom: 0; + } + + dialog > article > footer [role="button"]:not(:first-of-type), + dialog > article > footer button:not(:first-of-type) { + margin-left: calc(var(--app-spacing) * 0.5); + } + + dialog > article .close, + dialog > article :is(a, button)[rel="prev"] { + display: block; + width: 1rem; + height: 1rem; + margin-top: calc(var(--app-spacing) * -1); + margin-bottom: var(--app-spacing); + margin-left: auto; + border: none; + background-image: var(--app-icon-close); + background-position: center; + background-size: auto 1rem; + background-repeat: no-repeat; + background-color: transparent; + opacity: 0.5; + transition: opacity var(--app-transition); + } + + dialog + > article + .close:is( + [aria-current]:not([aria-current="false"]), + :hover, + :active, + :focus + ), + dialog + > article + :is(a, button)[rel="prev"]:is( + [aria-current]:not([aria-current="false"]), + :hover, + :active, + :focus + ) { + opacity: 1; + } + + dialog:not([open]), + dialog[open="false"] { + display: none; + } + + .modal-is-open { + padding-right: var(--app-scrollbar-width, 0); + overflow: hidden; + pointer-events: none; + touch-action: none; + } + + .modal-is-open dialog { + pointer-events: auto; + touch-action: auto; + } + + :where(.modal-is-opening, .modal-is-closing) dialog, + :where(.modal-is-opening, .modal-is-closing) dialog > article { + animation-duration: 0.2s; + animation-timing-function: ease-in-out; + animation-fill-mode: both; + } + + :where(.modal-is-opening, .modal-is-closing) dialog { + animation-duration: 0.8s; + animation-name: modal-overlay; + } + + :where(.modal-is-opening, .modal-is-closing) dialog > article { + animation-delay: 0.2s; + animation-name: modal; + } + + .modal-is-closing dialog, + .modal-is-closing dialog > article { + animation-delay: 0s; + animation-direction: reverse; + } + + @keyframes modal-overlay { + from { + -webkit-backdrop-filter: none; + backdrop-filter: none; + background-color: transparent; + } + } + + @keyframes modal { + from { + transform: translateY(-100%); + opacity: 0; + } + } + + :where(nav li)::before { + float: left; + content: "​"; + } + + nav, + nav ul { + display: flex; + } + + nav { + justify-content: space-between; + overflow: visible; + } + + nav ol, + nav ul { + align-items: center; + margin-bottom: 0; + padding: 0; + list-style: none; + } + + nav ol:first-of-type, + nav ul:first-of-type { + margin-left: calc(var(--app-nav-element-spacing-horizontal) * -1); + } + + nav ol:last-of-type, + nav ul:last-of-type { + margin-right: calc(var(--app-nav-element-spacing-horizontal) * -1); + } + + nav li { + display: inline-block; + margin: 0; + padding: var(--app-nav-element-spacing-vertical) + var(--app-nav-element-spacing-horizontal); + } + + nav li :where(a, [role="link"]) { + display: inline-block; + margin: calc(var(--app-nav-link-spacing-vertical) * -1) + calc(var(--app-nav-link-spacing-horizontal) * -1); + padding: var(--app-nav-link-spacing-vertical) + var(--app-nav-link-spacing-horizontal); + border-radius: var(--app-border-radius); + } + + nav li :where(a, [role="link"]):not(:hover) { + text-decoration: none; + } + + nav li [role="button"], + nav li [type="button"], + nav li button, + nav + li + input:not([type="checkbox"], [type="radio"], [type="range"], [type="file"]), + nav li select { + height: auto; + margin-right: inherit; + margin-bottom: 0; + margin-left: inherit; + padding: calc( + var(--app-nav-link-spacing-vertical) - var(--app-border-width) * 2 + ) + var(--app-nav-link-spacing-horizontal); + } + + nav[aria-label="breadcrumb"] { + --app-breadcrumb-separator-size: 0.75rem; + --app-nav-element-spacing-vertical: 0.25rem; + --app-nav-element-spacing-horizontal: 0.25rem; + --app-nav-link-spacing-vertical: 0.25rem; + --app-nav-link-spacing-horizontal: 0.25rem; + align-items: center; + justify-content: start; + font-size: 0.875em; + } + + nav[aria-label="breadcrumb"] ol, + nav[aria-label="breadcrumb"] ul { + margin: 0; + } + + nav[aria-label="breadcrumb"] :is(ol, ul) li { + padding: 0; + } + + nav[aria-label="breadcrumb"] :is(ol, ul) li:not(:first-child) { + margin-inline-start: 0; + } + + nav[aria-label="breadcrumb"] :is(ol, ul) li a { + margin: calc(var(--app-nav-link-spacing-vertical) * -1) 0; + margin-inline-start: calc(var(--app-nav-link-spacing-horizontal) * -1); + } + + nav[aria-label="breadcrumb"] :is(ol, ul) li:not(:last-child)::after { + display: inline-block; + width: var(--app-breadcrumb-separator-size); + height: var(--app-breadcrumb-separator-size); + margin-inline: calc(var(--app-nav-link-spacing-horizontal) * 0.75); + content: ""; + background-image: var(--app-icon-chevron); + background-size: var(--app-breadcrumb-separator-size) auto; + background-repeat: no-repeat; + opacity: 0.7; + transform: rotate(-90deg); + } + + nav[aria-label="breadcrumb"] a[aria-current]:not([aria-current="false"]) { + background-color: transparent; + color: inherit; + text-decoration: none; + pointer-events: none; + } + + aside li, + aside nav, + aside ol, + aside ul { + display: block; + } + + aside li { + padding: calc(var(--app-nav-element-spacing-vertical) * 0.5) + var(--app-nav-element-spacing-horizontal); + } + + aside li a { + display: block; + text-decoration: none; + } + + aside li [role="button"] { + margin: inherit; + } + + .text-align-center { + text-align: center; + } + + .primary { + box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 2px; + } + + .secondary { + border: 1px solid var(--app-border); + color: var(--app-muted-color); + box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 2px; + } + .secondary:hover { + border: 1px solid var(--app-contrast); + color: var(--app-contrast); + } + + /* Loading state - global cursor and interaction blocking */ + body.is-loading { + cursor: wait; + } + + body.is-loading * { + cursor: wait !important; + } + + body.is-loading button, + body.is-loading a, + body.is-loading input, + body.is-loading select, + body.is-loading textarea { + pointer-events: none; + } } diff --git a/web/css/layout/app-sidebar.css b/web/css/layout/app-sidebar.css index 4d2bf67..4a36316 100644 --- a/web/css/layout/app-sidebar.css +++ b/web/css/layout/app-sidebar.css @@ -1,238 +1,297 @@ -.app-sidebar { - background: var(--app-sidebar-background-color); - --app-sidebar-gap: 0.5rem; - --app-sidebar-item-gap: 10px; - --app-sidebar-title-size: 10px; - --app-sidebar-summary-size: 12px; - --app-sidebar-tenant-size: 14px; - --app-sidebar-titlebar-height: 30px; - --app-sidebar-border-width: 2px; - --app-sidebar-summary-max-width: 157px; - --app-sidebar-section-gap: 2rem; - --app-sidebar-section-space: 5px; - --app-sidebar-hover-bg: transparent; -} - -.app-sidebar .brand { - padding: var(--app-spacing); -} - -.app-sidebar-titlebar { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--app-sidebar-gap); - padding: 0 var(--app-spacing); - margin-bottom: 0; - min-height: var(--app-sidebar-titlebar-height); -} - -.aside-pending .app-sidebar-titlebar { - visibility: hidden; -} - -.app-sidebar .app-sidebar-panel { - display: block; -} - -.app-sidebar .app-sidebar-panel[hidden] { - display: none !important; -} - -.app-sidebar.is-collapsed .app-sidebar-panels, -.app-sidebar.is-collapsed .app-sidebar-titlebar, -.sidebar-collapsed .app-sidebar .app-sidebar-titlebar, -.sidebar-collapsed .app-sidebar .app-sidebar-panels { - display: none; -} - -.app-sidebar-tenant-logo { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--app-sidebar-gap); - padding: var(--app-spacing); -} - -.app-sidebar .app-sidebar-tenant-logo a:hover { - background: transparent; - border: 0; -} - -.app-sidebar .app-sidebar-tenant-logo > a { - padding: 0; - flex: 1; - border: 0; -} - -.app-sidebar-tenant-toggle { - display: none; - border: 1px solid var(--app-border); - background: transparent; - margin: 0; -} - -.app-sidebar-tenant-toggle:hover { - color: var(--app-contrast); - border-color: var(--app-contrast); -} - -.app-sidebar-tenant-name { - flex: 1; - font-weight: 600; - font-size: var(--app-sidebar-tenant-size); - color: var(--app-contrast); - padding: 8px 12px; - border-radius: var(--app-border-radius); - text-align: left; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - border: 1px solid var(--app-border); -} - -.app-sidebar-title { - text-transform: uppercase; - font-size: var(--app-sidebar-title-size); - color: var(--app-muted-color); -} - -.app-sidebar-tools { - display: flex; - align-items: center; - gap: 0.35rem; -} - -.app-sidebar-tools .icon-button { - border: 0; - background: transparent; - color: var(--app-muted-color); - padding: 0.25rem; - font-size: 0.9rem; - cursor: pointer; - margin-bottom: 0; -} - -.app-sidebar-tools .icon-button:hover { - color: var(--app-contrast); -} - -@media (min-width: 768px) { - .app-sidebar .brand { - margin-bottom: 1rem; - padding-block-start: 0.5rem; +@layer layout { + .app-sidebar { + background: var(--app-sidebar-background-color); + --app-sidebar-gap: 0.5rem; + --app-sidebar-item-gap: 10px; + --app-sidebar-title-size: 10px; + --app-sidebar-summary-size: 12px; + --app-sidebar-tenant-size: 14px; + --app-sidebar-titlebar-height: 30px; + --app-sidebar-border-width: 2px; + --app-sidebar-summary-max-width: 157px; + --app-sidebar-section-gap: 2rem; + --app-sidebar-section-space: 5px; + --app-sidebar-hover-bg: transparent; } - .app-sidebar-title { + .app-sidebar .brand { + padding: var(--app-spacing); + } + + .app-sidebar-titlebar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--app-sidebar-gap); + padding: 0 var(--app-spacing); + margin-bottom: 0; + min-height: var(--app-sidebar-titlebar-height); + } + + .aside-pending .app-sidebar-titlebar { + visibility: hidden; + } + + .app-sidebar .app-sidebar-panel { display: block; } - .app-sidebar { - position: sticky; - top: 0; - z-index: 1; + .app-sidebar .app-sidebar-panel[hidden] { + display: none !important; } -} -@media (max-width: 767px) { - .app-sidebar-tenant-toggle { - display: inline-flex; + .app-sidebar.is-collapsed .app-sidebar-panels, + .app-sidebar.is-collapsed .app-sidebar-titlebar, + .sidebar-collapsed .app-sidebar .app-sidebar-titlebar, + .sidebar-collapsed .app-sidebar .app-sidebar-panels { + display: none; + } + + .app-sidebar-tenant-logo { + display: flex; align-items: center; - justify-content: center; - } - .app-sidebar-tenant-logo img { - max-width: 120px; - flex-shrink: 0; - } - .app-sidebar { - border-bottom: 1px solid var(--app-border); + justify-content: space-between; + gap: var(--app-sidebar-gap); + padding: var(--app-spacing); } - .app-sidebar-panels { - padding-block-end: var(--app-spacing); + .app-sidebar .app-sidebar-tenant-logo a:hover { + background: transparent; + border: 0; + } + + .app-sidebar .app-sidebar-tenant-logo > a { + padding: 0; + flex: 1; + border: 0; + } + + .app-sidebar-tenant-toggle { + display: none; + border: 1px solid var(--app-border); + background: transparent; + margin: 0; + } + + .app-sidebar-tenant-toggle:hover { + color: var(--app-contrast); + border-color: var(--app-contrast); + } + + .app-sidebar-tenant-name { + flex: 1; + font-weight: 600; + font-size: var(--app-sidebar-tenant-size); + color: var(--app-contrast); + padding: 8px 12px; + border-radius: var(--app-border-radius); + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + border: 1px solid var(--app-border); + } + + .app-sidebar-title { + text-transform: uppercase; + font-size: var(--app-sidebar-title-size); + color: var(--app-muted-color); + } + + .app-sidebar-tools { + display: flex; + align-items: center; + gap: 0.35rem; + } + + .app-sidebar-tools .icon-button { + border: 0; + background: transparent; + color: var(--app-muted-color); + padding: 0.25rem; + font-size: 0.9rem; + cursor: pointer; + margin-bottom: 0; + } + + .app-sidebar-tools .icon-button:hover { + color: var(--app-contrast); + } + + @media (min-width: 768px) { + .app-sidebar .brand { + margin-bottom: 1rem; + padding-block-start: 0.5rem; + } + + .app-sidebar-title { + display: block; + } + + .app-sidebar { + position: sticky; + top: 0; + z-index: 1; + height: 100vh; + overflow-y: auto; + } + } + + @media (max-width: 767px) { + .app-sidebar-tenant-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + } + .app-sidebar-tenant-logo img { + max-width: 120px; + flex-shrink: 0; + } + .app-sidebar { + border-bottom: 1px solid var(--app-border); + } + + .app-sidebar-panels { + padding-block-end: var(--app-spacing); + } + } + + .app-sidebar a { + color: inherit; + display: block; + padding: 5px var(--app-spacing); + border-radius: 0; + margin: 0; + border-left: var(--app-sidebar-border-width) solid transparent; + width: 100%; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + + .app-sidebar a:hover, + .app-sidebar a.active, + .app-sidebar .active > a { + color: var(--app-primary); + background: var(--app-sidebar-hover-bg); + border-left: var(--app-sidebar-border-width) solid var(--app-primary); + } + + aside.app-sidebar ul, + aside.app-sidebar li { + margin: 0; + padding: 0; + list-style: none; + } + + .app-sidebar small { + margin-top: var(--app-sidebar-section-gap); + margin-bottom: var(--app-sidebar-section-space); + display: block; + padding-inline: var(--app-spacing); + } + + .app-sidebar .app-sidebar-group small { + margin-top: 0; + margin-bottom: 0; + display: block; + padding-inline: 0; + opacity: 1; + } + + .app-sidebar .app-sidebar-group details { + padding: 0; + margin: 0; + } + + .app-sidebar .app-sidebar-group summary { + padding: 0 var(--app-spacing); + font-size: var(--app-sidebar-summary-size); + color: var(--app-muted-color); + line-height: 1; + } + + nav#aside-panel-people { + margin-top: var(--app-spacing); + } + + .app-sidebar .app-sidebar-group summary::-webkit-details-marker { + display: none; + } + + .app-sidebar .app-sidebar-group details > ul { + margin: 0; + padding: 0; + } + + .app-sidebar .app-sidebar-group summary > span { + text-overflow: ellipsis; + display: inline-block; + max-width: var(--app-sidebar-summary-max-width); + overflow: hidden; + white-space: nowrap; + } + + .app-sidebar .app-sidebar-group details > ul li:first-child { + margin-top: 10px; + } + + .app-sidebar .app-sidebar-saved-filters { + margin-bottom: var(--app-spacing); + } + + .app-sidebar .app-sidebar-saved-filter-item:before { + content: ""; + display: none; + } + + .app-sidebar .app-sidebar-group.app-sidebar-saved-filters > small { + padding: 5px var(--app-spacing); + } + + .app-sidebar .app-sidebar-saved-filter-item { + display: flex; + align-items: center; + gap: 4px; + justify-content: space-between; + padding-right: 10px; + } + + .app-sidebar .app-sidebar-saved-filter-item > a { + flex: 1; + min-width: 0; + } + + .app-sidebar .app-sidebar-saved-filter-delete { + margin: 0; + display: flex; + align-items: center; + } + + .app-sidebar .app-sidebar-saved-filter-delete button { + margin: 0; + border: 0; + background: transparent; + color: var(--app-muted-color); + padding: 4px; + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: opacity 120ms ease; + } + + .app-sidebar .app-sidebar-saved-filter-delete button:hover { + color: var(--app-contrast); + } + + .app-sidebar .app-sidebar-saved-filter-item:hover .app-sidebar-saved-filter-delete button, + .app-sidebar .app-sidebar-saved-filter-item:focus-within .app-sidebar-saved-filter-delete button { + opacity: 1; + visibility: visible; + pointer-events: auto; + } + + aside.app-sidebar li.app-sidebar-group { + margin-bottom: var(--app-spacing); } } - -.app-sidebar a { - color: inherit; - display: flex; - align-items: center; - gap: var(--app-sidebar-item-gap); - text-decoration: none; - padding: 5px var(--app-spacing); - border-radius: 0; - margin: 0; - border-left: var(--app-sidebar-border-width) solid transparent; - width: 100%; -} - -.app-sidebar a:hover, -.app-sidebar a.active, -.app-sidebar .active > a { - color: var(--app-primary); - background: var(--app-sidebar-hover-bg); - border-left: var(--app-sidebar-border-width) solid var(--app-primary); -} - -aside.app-sidebar ul, -aside.app-sidebar li { - margin: 0; - padding: 0; - list-style: none; -} - -.app-sidebar small { - margin-top: var(--app-sidebar-section-gap); - margin-bottom: var(--app-sidebar-section-space); - display: block; - padding-inline: var(--app-spacing); -} - -.app-sidebar .app-sidebar-group small { - margin-top: 0; - margin-bottom: 0; - display: block; - padding-inline: 0; - opacity: 1; -} - -.app-sidebar .app-sidebar-group details { - padding: 0; - margin: 0; -} - -.app-sidebar .app-sidebar-group summary { - padding: 0 var(--app-spacing); - font-size: var(--app-sidebar-summary-size); - color: var(--app-muted-color); - line-height: 1; -} - -nav#aside-panel-people { - margin-top: var(--app-spacing); -} - -.app-sidebar .app-sidebar-group summary::-webkit-details-marker { - display: none; -} - -.app-sidebar .app-sidebar-group details > ul { - margin: 0; - padding: 0; -} - -.app-sidebar .app-sidebar-group summary > span { - text-overflow: ellipsis; - display: inline-block; - max-width: var(--app-sidebar-summary-max-width); - overflow: hidden; - white-space: nowrap; -} - -.app-sidebar .app-sidebar-group details > ul li:first-child { - margin-top: 10px; -} - -.app-sidebar .app-sidebar-group > small { - padding-inline: var(--app-spacing); -} diff --git a/web/css/layout/app-topbar.css b/web/css/layout/app-topbar.css index c5765c0..4381c08 100644 --- a/web/css/layout/app-topbar.css +++ b/web/css/layout/app-topbar.css @@ -1,3 +1,4 @@ +@layer layout { .app-header { --app-topbar-item-padding: 5px; --app-topbar-list-gap: 8px; @@ -91,3 +92,4 @@ main:has(#app-details-aside-section) li:has(#toggle-main-content-aside) { .app-header details.dropdown[open] > summary::before { display: none; } +} diff --git a/web/css/components/app-profile.css b/web/css/pages/address-book-view.css similarity index 99% rename from web/css/components/app-profile.css rename to web/css/pages/address-book-view.css index 41a466e..37ac9b4 100644 --- a/web/css/components/app-profile.css +++ b/web/css/pages/address-book-view.css @@ -1,3 +1,8 @@ +@layer pages { +.app-profile-card-container { + margin-top: calc(var(--app-spacing) * 2); +} + .app-profile-card { border: 1px solid var(--app-border); background: var(--app-card-background-color); @@ -568,3 +573,4 @@ contain: paint; height: 160px; } +} diff --git a/web/css/pages/app-docs.css b/web/css/pages/app-docs.css new file mode 100644 index 0000000..e9f787d --- /dev/null +++ b/web/css/pages/app-docs.css @@ -0,0 +1,366 @@ +@layer components { + /* ─── Layout ─────────────────────────────────────────────────── */ + + .app-docs-container { + display: grid; + grid-template-columns: 220px 1fr 200px; + grid-template-areas: "nav content toc"; + align-items: start; + gap: 0 2rem; + min-height: 100%; + } + + .app-docs-nav { + grid-area: nav; + } + .app-docs-content { + grid-area: content; + } + .app-docs-toc { + grid-area: toc; + } + + /* Stick nav and toc to viewport while scrolling */ + .app-docs-nav, + .app-docs-toc { + position: sticky; + top: 70px; + max-height: calc(100dvh - 6rem); + overflow-y: auto; + overscroll-behavior: contain; + } + + @media (max-width: 1100px) { + .app-docs-container { + grid-template-columns: 220px 1fr; + grid-template-areas: "nav content"; + } + + .app-docs-toc { + display: none; + } + } + + @media (max-width: 768px) { + .app-docs-container { + grid-template-columns: 1fr; + grid-template-areas: + "nav" + "content"; + } + + .app-docs-nav { + position: static; + max-height: none; + overflow-y: visible; + border-bottom: 1px solid var(--app-border); + padding-bottom: 1rem; + margin-bottom: 1rem; + } + } + + /* ─── Left navigation ─────────────────────────────────────────── */ + + .app-docs-nav { + --docs-nav-border-width: 2px; + padding-block: 0.25rem; + } + + .app-docs-nav-group { + margin-bottom: 0.25rem; + } + + .app-docs-nav-group small { + display: block; + padding: 1.25rem 0.75rem 0.25rem; + font-size: 0.7rem; + letter-spacing: 0.05em; + text-transform: uppercase; + } + + .app-docs-nav-group:first-child small { + padding-top: 0.25rem; + } + + .app-docs-nav ul { + list-style: none; + margin: 0; + padding: 0; + } + + .app-docs-nav a { + display: block; + padding: 4px 0.75rem; + color: inherit; + border-left: var(--docs-nav-border-width) solid transparent; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 0.875rem; + border-radius: 0; + text-decoration: none; + } + + .app-docs-nav a:hover { + color: var(--app-primary); + border-left-color: var(--app-muted-border-color); + } + + .app-docs-nav a.active { + color: var(--app-primary); + border-left-color: var(--app-primary); + font-weight: 500; + } + + /* ─── TOC ─────────────────────────────────────────────────────── */ + + .app-docs-toc { + padding-block: 0.25rem; + } + + .app-docs-toc > small { + display: block; + padding: 0 0 0.5rem; + font-size: 0.7rem; + letter-spacing: 0.05em; + text-transform: uppercase; + } + + .app-docs-toc ul { + margin: 0; + padding: 0; + list-style: none; + } + + .app-docs-toc li { + position: relative; + } + + .app-docs-toc a { + display: block; + text-decoration: none; + color: inherit; + line-height: 1.35; + padding-block: 0.12rem; + } + + .app-docs-toc a:hover { + color: var(--app-primary); + } + + .app-docs-toc-level-2 { + margin: 0; + padding: 0 0 10px 0; + } + + .app-docs-toc-level-3 { + margin: 0; + padding: 0; + padding-left: 1rem; + } + + .app-docs-toc-level-3 a { + font-size: 0.75rem; + padding-left: 0.45rem; + } + + .app-docs-toc-level-3::before { + content: ""; + position: absolute; + left: 0.2rem; + top: 2px; + width: 13px; + height: 0.55rem; + background: transparent; + border-left: 1px solid var(--app-border); + border-bottom: 1px solid var(--app-border); + border-bottom-left-radius: 6px; + } + + .app-docs-toc-level-3:has(+ .app-docs-toc-level-2) { + margin-bottom: 1rem; + } + + /* ─── Prose (rendered markdown) ────────────────────────────────── */ + + .app-docs-content { + max-width: 72ch; + min-width: 0; + background: transparent; + box-shadow: none; + } + + .app-docs-content h1, + .app-docs-content h2, + .app-docs-content h3, + .app-docs-content h4, + .app-docs-content h5, + .app-docs-content h6 { + scroll-margin-top: 1.5rem; + line-height: 1.3; + } + + .app-docs-content h1 { + color: var(--app-h1-color); + margin-top: 0; + font-size: 2.75rem; + } + .app-docs-content h2 { + color: var(--app-h2-color); + font-size: 1.375rem; + margin-top: 2.5rem; + padding-top: 1rem; + border-top: 1px solid var(--app-border); + } + .app-docs-content h3 { + color: var(--app-h3-color); + font-size: 1.125rem; + margin-top: 1.75rem; + } + .app-docs-content h4 { + color: var(--app-h4-color); + font-size: 1rem; + margin-top: 1.25rem; + } + .app-docs-content h5, + .app-docs-content h6 { + color: var(--app-h5-color); + font-size: 0.9rem; + margin-top: 1rem; + } + + .app-docs-content p { + margin-block: 0.875rem; + line-height: 1.7; + } + + .app-docs-content a { + color: var(--app-primary); + text-decoration: underline; + text-underline-offset: 0.15em; + } + + .app-docs-content .app-breadcrumb a { + text-decoration: none; + color: inherit; + } + + .app-docs-content a:hover { + color: var(--app-primary-hover); + } + + .app-docs-content ul, + .app-docs-content ol { + padding-left: 1.5rem; + margin-block: 0.875rem; + } + + .app-docs-content li { + margin-block: 0.3rem; + line-height: 1.6; + } + + .app-docs-content li > ul, + .app-docs-content li > ol { + margin-block: 0.25rem; + } + + .app-docs-content ul.contains-task-list, + .app-docs-content ol.contains-task-list, + .app-docs-content ul:has(> li > input[type="checkbox"]), + .app-docs-content ol:has(> li > input[type="checkbox"]) { + list-style: none; + padding-left: 0; + } + + .app-docs-content .task-list-item, + .app-docs-content li:has(> input[type="checkbox"]) { + list-style: none; + position: relative; + padding-left: 1.6rem; + } + + .app-docs-content .task-list-item .task-list-item-checkbox, + .app-docs-content .task-list-item > input[type="checkbox"] { + position: absolute; + left: 0; + top: 0.3rem; + width: 1rem; + height: 1rem; + margin: 0; + accent-color: var(--app-primary); + } + + .app-docs-content pre { + background: var(--app-code-background-color); + border: 1px solid var(--app-border); + border-radius: var(--app-border-radius); + padding: 1rem 1.25rem; + overflow-x: auto; + margin-block: 1.25rem; + } + + .app-docs-content pre code { + background: none; + padding: 0; + font-size: 0.85rem; + color: var(--app-color); + } + + .app-docs-content blockquote { + border-left: 3px solid var(--app-blockquote-border-color); + margin-left: 0; + margin-right: 0; + padding: 0.5rem 1rem; + background: var(--app-blockquote-background-color); + border-radius: 0 var(--app-border-radius) var(--app-border-radius) 0; + color: var(--app-muted-color); + } + + .app-docs-content blockquote p { + margin: 0; + } + + .app-docs-content hr { + border: 0; + border-top: 1px solid var(--app-border); + margin-block: 2rem; + } + + .app-docs-content table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; + margin-block: 1.25rem; + overflow-x: auto; + display: block; + } + + .app-docs-content th, + .app-docs-content td { + text-align: left; + padding: 0.5rem 0.75rem; + border: 1px solid var(--app-table-border-color); + } + + .app-docs-content th { + background: var(--app-code-background-color); + font-weight: 600; + color: var(--app-color); + } + + .app-docs-content tbody tr:nth-child(odd) { + background: var(--app-table-row-stripped-background-color); + } + + .app-docs-content img { + max-width: 100%; + height: auto; + border-radius: var(--app-border-radius); + } + + /* HeadingPermalink anchors – keep invisible */ + .app-docs-content .docs-heading-anchor { + display: none; + } +} diff --git a/web/css/pages/app-error.css b/web/css/pages/app-error.css new file mode 100644 index 0000000..59e627d --- /dev/null +++ b/web/css/pages/app-error.css @@ -0,0 +1,38 @@ +.error-page { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100dvh; + padding: 2rem; + text-align: center; +} + +.error-page__code { + font-size: 5rem; + font-weight: 700; + line-height: 1; + color: var(--color-text-muted); +} + +.error-page__message { + margin-top: 1rem; + font-size: 1.125rem; + color: var(--color-text); +} + +.error-page__back { + display: inline-block; + margin-top: 2rem; + padding: 0.5rem 1.25rem; + font-size: 0.875rem; + text-decoration: none; + border: 1px solid var(--color-border); + border-radius: 0.375rem; + color: var(--color-text); + background: var(--color-bg-surface); +} + +.error-page__back:hover { + opacity: 0.8; +} diff --git a/web/css/pages/app-login.css b/web/css/pages/app-login.css index ca961f4..059669c 100644 --- a/web/css/pages/app-login.css +++ b/web/css/pages/app-login.css @@ -1,10 +1,662 @@ -.back_to_login { - margin-bottom: 1rem; -} +@layer pages { + @keyframes ani-gradient { + 0% { + --s-start-0: 14.489998991212337%; + --s-end-0: 72%; + --x-0: 93%; + --c-0: hsla(212, 0%, 0%, 1); + --y-0: 93%; + --y-1: 9%; + --s-start-1: 0%; + --s-end-1: 45%; + --c-1: hsla(212, 0%, 0%, 1); + --x-1: 26%; + --c-2: hsla(257, 91%, 27%, 0.35); + --s-start-2: 2.9253667596993065%; + --s-end-2: 22.388851682060018%; + --x-2: 15%; + --y-2: 79%; + --c-3: hsla(212, 100%, 50%, 0.5); + --x-3: 40%; + --s-start-3: 3.985353824694249%; + --s-end-3: 47.580278608924694%; + --y-3: 104%; + --x-4: 0%; + --s-start-4: 2.391200382592061%; + --s-end-4: 29.307684556768592%; + --y-4: 60%; + --c-4: hsla(224, 72%, 36%, 1); + --c-5: hsla(248, 52%, 24%, 1); + --s-start-5: 2.9253667596993065%; + --s-end-5: 22.388851682060018%; + --x-5: 92%; + --y-5: 37%; + --c-6: hsla(212, 100%, 50%, 0.19); + --y-6: 16%; + --x-6: 101%; + --s-start-6: 13.173642363290591%; + --s-end-6: 31.747336520355095%; + --s-start-7: 1%; + --s-end-7: 31%; + --y-7: 13%; + --c-7: hsla(227, 98%, 53%, 1); + --x-7: 90%; + --y-8: 56%; + --x-8: 104%; + --c-8: hsla(166, 71%, 60%, 0.32); + --s-start-8: 3.985353824694249%; + --s-end-8: 13.103042116379756%; + --x-9: 97%; + --y-9: 19%; + --s-start-9: 18.597054544690312%; + --s-end-9: 31%; + --c-9: hsla(219, 83%, 23%, 0.18); + } -@media (min-width: 600px) { - main { - min-height: 90vh; - place-content: center; + 100% { + --s-start-0: 2.391200382592061%; + --s-end-0: 43.902064173373226%; + --x-0: 7%; + --c-0: hsla(306, 0%, 0%, 1); + --y-0: 9%; + --y-1: 93%; + --s-start-1: 9%; + --s-end-1: 54.805582404585024%; + --c-1: hsla(306, 0%, 0%, 1); + --x-1: 96%; + --c-2: hsla(166, 72%, 60%, 1); + --s-start-2: 3%; + --s-end-2: 26.722813338714598%; + --x-2: -2%; + --y-2: 103%; + --c-3: hsla(180, 100%, 50%, 0.26); + --x-3: 33%; + --s-start-3: 2.391200382592061%; + --s-end-3: 32.0689540200964%; + --y-3: 82%; + --x-4: 37%; + --s-start-4: 4.40642490323111%; + --s-end-4: 37.23528104246256%; + --y-4: 81%; + --c-4: hsla(212, 88%, 26%, 0.58); + --c-5: hsla(271, 98%, 53%, 0.31); + --s-start-5: 3%; + --s-end-5: 32.537089799783296%; + --x-5: 54%; + --y-5: 99%; + --c-6: hsla(262, 100%, 50%, 0.15); + --y-6: 43%; + --x-6: 104%; + --s-start-6: 6%; + --s-end-6: 42.501105312974815%; + --s-start-7: 5%; + --s-end-7: 13.10107024898374%; + --y-7: -16%; + --c-7: hsla(298, 36%, 23%, 1); + --x-7: 104%; + --y-8: 30%; + --x-8: 97%; + --c-8: hsla(180, 100%, 50%, 0.11); + --s-start-8: 2.391200382592061%; + --s-end-8: 27.141813016850573%; + --x-9: 78%; + --y-9: 4%; + --s-start-9: 5%; + --s-end-9: 21.32164536610654%; + --c-9: hsla(219, 83%, 23%, 0.59); + } + } + + @property --s-start-0 { + syntax: ""; + inherits: false; + initial-value: 14.489998991212337%; + } + + @property --s-end-0 { + syntax: ""; + inherits: false; + initial-value: 72%; + } + + @property --x-0 { + syntax: ""; + inherits: false; + initial-value: 93%; + } + + @property --c-0 { + syntax: ""; + inherits: false; + initial-value: hsla(212, 0%, 0%, 1); + } + + @property --y-0 { + syntax: ""; + inherits: false; + initial-value: 93%; + } + + @property --y-1 { + syntax: ""; + inherits: false; + initial-value: 9%; + } + + @property --s-start-1 { + syntax: ""; + inherits: false; + initial-value: 0%; + } + + @property --s-end-1 { + syntax: ""; + inherits: false; + initial-value: 45%; + } + + @property --c-1 { + syntax: ""; + inherits: false; + initial-value: hsla(212, 0%, 0%, 1); + } + + @property --x-1 { + syntax: ""; + inherits: false; + initial-value: 26%; + } + + @property --c-2 { + syntax: ""; + inherits: false; + initial-value: hsla(257, 91%, 27%, 0.35); + } + + @property --s-start-2 { + syntax: ""; + inherits: false; + initial-value: 2.9253667596993065%; + } + + @property --s-end-2 { + syntax: ""; + inherits: false; + initial-value: 22.388851682060018%; + } + + @property --x-2 { + syntax: ""; + inherits: false; + initial-value: 15%; + } + + @property --y-2 { + syntax: ""; + inherits: false; + initial-value: 79%; + } + + @property --c-3 { + syntax: ""; + inherits: false; + initial-value: hsla(212, 100%, 50%, 0.5); + } + + @property --x-3 { + syntax: ""; + inherits: false; + initial-value: 40%; + } + + @property --s-start-3 { + syntax: ""; + inherits: false; + initial-value: 3.985353824694249%; + } + + @property --s-end-3 { + syntax: ""; + inherits: false; + initial-value: 47.580278608924694%; + } + + @property --y-3 { + syntax: ""; + inherits: false; + initial-value: 104%; + } + + @property --x-4 { + syntax: ""; + inherits: false; + initial-value: 0%; + } + + @property --s-start-4 { + syntax: ""; + inherits: false; + initial-value: 2.391200382592061%; + } + + @property --s-end-4 { + syntax: ""; + inherits: false; + initial-value: 29.307684556768592%; + } + + @property --y-4 { + syntax: ""; + inherits: false; + initial-value: 60%; + } + + @property --c-4 { + syntax: ""; + inherits: false; + initial-value: hsla(224, 72%, 36%, 1); + } + + @property --c-5 { + syntax: ""; + inherits: false; + initial-value: hsla(248, 52%, 24%, 1); + } + + @property --s-start-5 { + syntax: ""; + inherits: false; + initial-value: 2.9253667596993065%; + } + + @property --s-end-5 { + syntax: ""; + inherits: false; + initial-value: 22.388851682060018%; + } + + @property --x-5 { + syntax: ""; + inherits: false; + initial-value: 92%; + } + + @property --y-5 { + syntax: ""; + inherits: false; + initial-value: 37%; + } + + @property --c-6 { + syntax: ""; + inherits: false; + initial-value: hsla(212, 100%, 50%, 0.19); + } + + @property --y-6 { + syntax: ""; + inherits: false; + initial-value: 16%; + } + + @property --x-6 { + syntax: ""; + inherits: false; + initial-value: 101%; + } + + @property --s-start-6 { + syntax: ""; + inherits: false; + initial-value: 13.173642363290591%; + } + + @property --s-end-6 { + syntax: ""; + inherits: false; + initial-value: 31.747336520355095%; + } + + @property --s-start-7 { + syntax: ""; + inherits: false; + initial-value: 1%; + } + + @property --s-end-7 { + syntax: ""; + inherits: false; + initial-value: 31%; + } + + @property --y-7 { + syntax: ""; + inherits: false; + initial-value: 13%; + } + + @property --c-7 { + syntax: ""; + inherits: false; + initial-value: hsla(227, 98%, 53%, 1); + } + + @property --x-7 { + syntax: ""; + inherits: false; + initial-value: 90%; + } + + @property --y-8 { + syntax: ""; + inherits: false; + initial-value: 56%; + } + + @property --x-8 { + syntax: ""; + inherits: false; + initial-value: 104%; + } + + @property --c-8 { + syntax: ""; + inherits: false; + initial-value: hsla(166, 71%, 60%, 0.32); + } + + @property --s-start-8 { + syntax: ""; + inherits: false; + initial-value: 3.985353824694249%; + } + + @property --s-end-8 { + syntax: ""; + inherits: false; + initial-value: 13.103042116379756%; + } + + @property --x-9 { + syntax: ""; + inherits: false; + initial-value: 97%; + } + + @property --y-9 { + syntax: ""; + inherits: false; + initial-value: 19%; + } + + @property --s-start-9 { + syntax: ""; + inherits: false; + initial-value: 18.597054544690312%; + } + + @property --s-end-9 { + syntax: ""; + inherits: false; + initial-value: 31%; + } + + @property --c-9 { + syntax: ""; + inherits: false; + initial-value: hsla(219, 83%, 23%, 0.18); + } + + #gradient { + position: fixed; + left: 0; + top: 0; + width: 100%; + height: 100%; + z-index: -1; + pointer-events: none; + } + + html[data-theme="light"] #gradient { + background-color: hsla(333, 0%, 100%, 1); + background-image: + radial-gradient( + circle at 0% 0%, + hsla(64, 82%, 77%, 0.35) 3.1210986267166043%, + transparent 40% + ), + radial-gradient( + circle at 20% 0%, + hsla(5, 77%, 74%, 0.35) 3.1210986267166043%, + transparent 40% + ), + radial-gradient( + circle at 40% 0%, + hsla(315, 77%, 74%, 0.35) 3.1210986267166043%, + transparent 40% + ), + radial-gradient( + circle at 60% 0%, + hsla(256, 77%, 74%, 0.35) 3.1210986267166043%, + transparent 40% + ), + radial-gradient( + circle at 80% 0%, + hsla(191, 77%, 74%, 0.35) 3.1210986267166043%, + transparent 40% + ), + radial-gradient( + circle at 100% 0%, + hsla(152, 77%, 74%, 0.35) 3%, + transparent 40% + ); + background-blend-mode: normal, normal, normal, normal, normal, normal; + } + + html[data-theme="dark"] #gradient { + --x-0: 93%; + --c-0: hsla(212, 0%, 0%, 1); + --y-0: 93%; + --y-1: 9%; + --c-1: hsla(212, 0%, 0%, 1); + --x-1: 26%; + --c-2: hsla(257, 91%, 27%, 0.35); + --x-2: 15%; + --y-2: 79%; + --c-3: hsla(212, 100%, 50%, 0.5); + --x-3: 40%; + --y-3: 104%; + --x-4: 0%; + --y-4: 60%; + --c-4: hsla(224, 72%, 36%, 1); + --c-5: hsla(248, 52%, 24%, 1); + --x-5: 92%; + --y-5: 37%; + --c-6: hsla(212, 100%, 50%, 0.19); + --y-6: 16%; + --x-6: 101%; + --y-7: 13%; + --c-7: hsla(227, 98%, 53%, 1); + --x-7: 90%; + --y-8: 56%; + --x-8: 104%; + --c-8: hsla(166, 71%, 60%, 0.32); + --x-9: 97%; + --y-9: 19%; + --c-9: hsla(219, 83%, 23%, 0.18); + background-color: hsla(305, 0%, 0%, 1); + background-image: + radial-gradient( + circle at var(--x-0) var(--y-0), + var(--c-0) var(--s-start-0), + transparent var(--s-end-0) + ), + radial-gradient( + circle at var(--x-1) var(--y-1), + var(--c-1) var(--s-start-1), + transparent var(--s-end-1) + ), + radial-gradient( + circle at var(--x-2) var(--y-2), + var(--c-2) var(--s-start-2), + transparent var(--s-end-2) + ), + radial-gradient( + circle at var(--x-3) var(--y-3), + var(--c-3) var(--s-start-3), + transparent var(--s-end-3) + ), + radial-gradient( + circle at var(--x-4) var(--y-4), + var(--c-4) var(--s-start-4), + transparent var(--s-end-4) + ), + radial-gradient( + circle at var(--x-5) var(--y-5), + var(--c-5) var(--s-start-5), + transparent var(--s-end-5) + ), + radial-gradient( + circle at var(--x-6) var(--y-6), + var(--c-6) var(--s-start-6), + transparent var(--s-end-6) + ), + radial-gradient( + circle at var(--x-7) var(--y-7), + var(--c-7) var(--s-start-7), + transparent var(--s-end-7) + ), + radial-gradient( + circle at var(--x-8) var(--y-8), + var(--c-8) var(--s-start-8), + transparent var(--s-end-8) + ), + radial-gradient( + circle at var(--x-9) var(--y-9), + var(--c-9) var(--s-start-9), + transparent var(--s-end-9) + ); + animation: ani-gradient 10s linear infinite alternate; + background-blend-mode: + normal, normal, normal, normal, normal, normal, normal, normal, normal, + normal; + will-change: transform, opacity; + contain: paint; + } + + .back_to_login { + margin-bottom: 1rem; + } + + .login-tenant-select-form { + margin-bottom: 1rem; + } + + .login-tenant-select-label { + display: block; + margin-bottom: 0.5rem; + } + + .login-tenant-choice-grid { + display: grid; + gap: 0.625rem; + margin-bottom: 2rem; + } + .login-tenant-choice { + align-items: stretch; + margin: 0; + width: 100%; + padding: 0.625rem 0.75rem; + border: 1px solid var(--app-border); + border-radius: var(--app-border-radius); + background: var(--app-card-background-color); + justify-content: space-between; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease, + background-color 0.15s ease; + flex-direction: row-reverse; + } + + .login-tenant-choice:hover { + border-color: var(--app-primary); + } + + .login-tenant-choice:has(input[type="radio"]:checked) { + border-color: var(--app-primary); + box-shadow: 0 0 0 1px var(--app-primary); + background: color-mix( + in srgb, + var(--app-primary) 8%, + var(--app-card-background-color) + ); + } + + .login-tenant-choice:has(input[type="radio"]:focus-visible) { + outline: 2px solid var(--app-primary-focus); + outline-offset: 2px; + } + + .login-tenant-choice input[type="radio"] { + align-self: center; + } + + .login-tenant-choice-avatar { + width: 8rem; + height: 3rem; + display: block; + object-fit: contain; + object-position: left center; + justify-self: start; + } + + .login-tenant-choice-avatar-placeholder { + width: 8rem; + height: 3rem; + border-radius: var(--app-border-radius); + display: inline-flex; + align-items: center; + justify-content: flex-start; + padding-left: 0.5rem; + font-size: 0.875rem; + font-weight: 600; + color: var(--app-contrast); + } + + .login-alt-separator { + position: relative; + margin: 1rem 0; + text-align: center; + } + + .login-alt-separator::before { + content: ""; + position: absolute; + top: 50%; + left: 0; + right: 0; + border-top: 1px solid var(--app-border); + transform: translateY(-50%); + } + + .login-alt-separator span { + position: relative; + z-index: 1; + display: inline-block; + padding: 0 0.75rem; + font-size: 0.875rem; + color: var(--app-muted); + background: var(--app-surface); + } + + .login-alt-separator + [role="button"] { + width: 100%; + } + + @media (min-width: 600px) { + main { + min-height: 90vh; + place-content: center; + } } } diff --git a/web/css/components/vendor-editorjs.css b/web/css/vendor-overrides/editorjs.css similarity index 97% rename from web/css/components/vendor-editorjs.css rename to web/css/vendor-overrides/editorjs.css index 0f8a3b6..b3ce094 100644 --- a/web/css/components/vendor-editorjs.css +++ b/web/css/vendor-overrides/editorjs.css @@ -1,3 +1,4 @@ +@layer vendor-overrides { .codex-editor { font-family: var(--app-font-family); color: var(--app-color); @@ -71,4 +72,5 @@ table.tc-table { .ce-popover--inline .ce-popover-item__icon svg { color: black; -} \ No newline at end of file +} +} diff --git a/web/css/vendor-overrides/gridjs.css b/web/css/vendor-overrides/gridjs.css new file mode 100644 index 0000000..783a10c --- /dev/null +++ b/web/css/vendor-overrides/gridjs.css @@ -0,0 +1,253 @@ +@layer vendor-overrides { + .gridjs-container { + color: var(--app-color); + width: 100%; + padding: 0; + } + + .gridjs-wrapper { + border: 0; + border-radius: 0; + box-shadow: none; + background-color: var(--app-background-color); + border-top: var(--app-border-width) solid var(--app-table-border-color); + padding-block-end: 1rem; + min-height: 515px; + overflow-x: auto; + overflow-y: hidden; + } + + .gridjs-footer { + background-color: var(--app-background-color); + border: 0; + border-top: 0; + box-shadow: none; + color: var(--app-muted-color); + padding: 1rem 0 0; + } + + .gridjs-table { + color: var(--app-color); + min-width: 100%; + width: max-content; + } + + th.gridjs-th { + padding: calc(var(--app-spacing) / 2) var(--app-spacing); + border: none; + border-bottom: var(--app-border-width) solid var(--app-table-border-color); + background-color: var(--app-background-color); + color: var(--app-color); + font-weight: var(--app-font-weight); + text-align: left; + text-align: start; + } + + button.gridjs-sort { + height: 15px; + } + + th.gridjs-th-sort:hover, + th.gridjs-th-sort:focus { + background-color: var(--app-table-row-stripped-background-color); + } + + td.gridjs-td { + background-color: var(--app-background-color); + border: none; + border-color: var(--app-table-border-color); + padding: calc(var(--app-spacing) / 2) var(--app-spacing); + border-bottom: var(--app-border-width) solid var(--app-table-border-color); + background-color: var(--app-background-color); + color: var(--app-color); + font-weight: var(--app-font-weight); + text-align: left; + text-align: start; + } + + .gridjs-search { + float: none; + } + + .gridjs-search-input { + width: min(320px, 100%); + } + + input.gridjs-input { + background-color: var(--app-background-color); + border: var(--app-border-width) solid var(--app-form-element-border-color); + border-radius: var(--app-border-radius); + color: var(--app-color); + font-size: 0.95rem; + padding: 0.5rem 0.75rem; + } + + input.gridjs-input:focus { + border-color: var(--app-primary); + box-shadow: 0 0 0 var(--app-outline-width) var(--app-primary-focus); + } + + .gridjs-pagination { + color: var(--app-muted-color); + } + + .gridjs-pagination .gridjs-pages button { + background-color: var(--app-background-color); + border: var(--app-border-width) solid var(--app-table-border-color); + color: var(--app-color); + padding: 1px 10px; + } + + .gridjs-pagination .gridjs-pages button:hover { + background-color: var(--app-primary-hover-background); + border-color: var(--app-primary-hover-border); + color: var(--app-primary-inverse); + } + + .gridjs-pagination .gridjs-pages button:focus { + box-shadow: 0 0 0 var(--app-outline-width) var(--app-primary-focus); + } + + .gridjs-pagination .gridjs-pages button.gridjs-currentPage { + background-color: var(--app-primary) !important; + font-weight: 400; + padding: 2px 10px; + color: var(--app-secondary-inverse) !important; + } + + .gridjs-pagination .gridjs-pages button:disabled, + .gridjs-pagination .gridjs-pages button[disabled] { + background-color: var(--app-background-color); + color: var(--app-muted-color); + cursor: default; + padding: 2px 10px; + border: 0; + } + + .gridjs-pagination .gridjs-pages { + display: inline-flex; + gap: 5px; + } + + .gridjs-loading-bar { + background-color: var(--app-background-color); + opacity: 0.7; + } + + .grid-avatar { + width: 36px; + height: 36px; + border-radius: 999px; + border: 1px solid var(--app-muted-border-color); + background: var(--app-card-background-color); + object-fit: cover; + display: inline-flex; + flex-shrink: 0; + } + + .grid-avatar-placeholder { + align-items: center; + justify-content: center; + font-weight: 600; + color: var(--app-muted-color); + font-size: 11px; + background: var(--app-background-color); + } + + .grid-name-cell { + display: inline-flex; + align-items: center; + gap: 0.5rem; + min-width: 0; + } + + .grid-name-cell > a { + display: inline-flex; + flex-shrink: 0; + } + + .grid-name-cell > span:last-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + } + + .gridjs-loading-bar:after { + background-image: linear-gradient( + 90deg, + transparent, + var(--app-primary-focus), + transparent + ); + } + + /* .gridjs-container:not(.gridjs-has-loaded) .gridjs-message.gridjs-notfound { + display: none; + } */ + + .gridjs-container.gridjs-is-updating .gridjs-message.gridjs-notfound { + display: none; + } + + .gridjs-pages button { + background: var(--app-form-element-background-color) !important; + color: var(--app-contrast) !important; + } + + .gridjs-summary { + font-size: small; + } + + tr.gridjs-tr { + cursor: pointer; + } + + tr.gridjs-tr:hover td { + background: var(--app-table-row-stripped-background-color); + } + + html[data-theme="dark"] button.gridjs-sort, + html[data-theme="dark"] button.gridjs-sort-neutral { + filter: invert(1); + } + + html[data-theme="dark-green"] button.gridjs-sort, + html[data-theme="dark-green"] button.gridjs-sort-neutral { + filter: invert(1); + } + + .gridjs-tbody, + td.gridjs-td { + background-color: var(--app-background-color); + } + + .gridjs-table input[data-grid-select-all] { + width: 1em; + height: 1em; + margin: 0; + border-radius: 4px; + border-width: 0.5px; + } + + .gridjs-th-content:has(input[data-grid-select-all]) { + text-align: center; + } + + .gridjs-pagination .gridjs-pages button:last-child { + border-right: none; + } + button.gridjs-sort { + background-color: transparent; + background-position-x: center; + background-repeat: no-repeat; + background-size: contain; + border: none; + cursor: pointer; + float: right; + height: 24px; + margin: 0; + outline: none; + padding: 0; + width: 13px; + } +} diff --git a/web/css/components/vendor-multi-select.css b/web/css/vendor-overrides/multi-select.css similarity index 99% rename from web/css/components/vendor-multi-select.css rename to web/css/vendor-overrides/multi-select.css index c1e6a04..248f1e8 100644 --- a/web/css/components/vendor-multi-select.css +++ b/web/css/vendor-overrides/multi-select.css @@ -1,3 +1,4 @@ +@layer vendor-overrides { /* Overrides for MultiSelect */ .multi-select { @@ -101,3 +102,4 @@ opacity: 0.45; cursor: not-allowed; } +} diff --git a/web/css/vendor-overrides/swagger-ui.css b/web/css/vendor-overrides/swagger-ui.css new file mode 100644 index 0000000..56c10e3 --- /dev/null +++ b/web/css/vendor-overrides/swagger-ui.css @@ -0,0 +1,150 @@ +@import url("../../vendor/swagger-ui/swagger-ui.css") layer(vendor); + +@layer vendor-overrides { + .app-swagger-container { + background: var(--app-background-color); + color: var(--app-color); + } + + .swagger-ui { + color: var(--app-color); + font-family: var(--app-font-family); + } + + .swagger-ui .info .title, + .swagger-ui .info h1, + .swagger-ui .info h2, + .swagger-ui .info h3, + .swagger-ui h1, + .swagger-ui h2, + .swagger-ui h3, + .swagger-ui h4 { + color: var(--app-color); + } + + .swagger-ui .info a, + .swagger-ui a.nostyle, + .swagger-ui a.nostyle:visited { + color: var(--app-primary); + } + + .swagger-ui .scheme-container { + background: var(--app-card-background-color); + box-shadow: none; + border: var(--app-border-width) solid var(--app-form-element-border-color); + border-radius: var(--app-border-radius); + } + + .swagger-ui .opblock { + border-color: var(--app-form-element-border-color); + border-radius: var(--app-border-radius); + background: var(--app-card-background-color); + } + + .swagger-ui .opblock .opblock-summary { + border-color: var(--app-form-element-border-color); + } + + .swagger-ui .opblock-tag { + border-color: var(--app-form-element-border-color); + color: var(--app-color); + } + + .swagger-ui .responses-inner h4, + .swagger-ui .responses-inner h5, + .swagger-ui .response-col_status, + .swagger-ui .response-col_description__inner p, + .swagger-ui .tab li, + .swagger-ui .model-title { + color: var(--app-color); + } + + .swagger-ui .parameter__name, + .swagger-ui .parameter__type, + .swagger-ui .parameter__deprecated, + .swagger-ui .parameter__in { + color: var(--app-color); + } + + .swagger-ui input[type="text"], + .swagger-ui input[type="password"], + .swagger-ui input[type="search"], + .swagger-ui input[type="email"], + .swagger-ui textarea, + .swagger-ui select { + background: var(--app-form-element-background-color); + color: var(--app-form-element-color); + border: var(--app-border-width) solid var(--app-form-element-border-color); + border-radius: var(--app-border-radius); + box-shadow: none; + } + + .swagger-ui input[type="text"]:focus, + .swagger-ui input[type="password"]:focus, + .swagger-ui input[type="search"]:focus, + .swagger-ui input[type="email"]:focus, + .swagger-ui textarea:focus, + .swagger-ui select:focus { + border-color: var(--app-primary); + box-shadow: 0 0 0 var(--app-outline-width) var(--app-primary-focus); + outline: none; + } + + .swagger-ui .btn { + border-radius: var(--app-border-radius); + } + + .swagger-ui .btn.authorize { + color: var(--app-primary); + border-color: var(--app-primary); + } + + .swagger-ui .btn.authorize svg { + fill: currentColor; + } + + .swagger-ui .model-box, + .swagger-ui .highlight-code, + .swagger-ui .microlight, + .swagger-ui .responses-table .response-col_description__inner { + background: var(--app-card-background-color); + color: var(--app-color); + } + + .swagger-ui button { + cursor: pointer; + background: transparent; + border: 0; + } + + .swagger-ui .opblock .opblock-summary-description { + color: var(--app-color); + } + + .swagger-ui .opblock .opblock-section-header { + background: var(--app-accordion-border-color); + } + + .swagger-ui section { + margin: 0; + } + + .swagger-ui .servers > label { + margin: 0; + } + + pre.version { + all: inherit; + padding: 0; + margin: 0; + font-size: inherit; + } + + .swagger-ui .info .title small { + top: 0; + } + + select#servers { + margin: 0; + } +} diff --git a/web/img/favicon.ico b/web/img/favicon.ico deleted file mode 100644 index f9c244c..0000000 Binary files a/web/img/favicon.ico and /dev/null differ diff --git a/web/img/forkme_right_red_aa0000.png b/web/img/forkme_right_red_aa0000.png deleted file mode 100644 index 1e19c21..0000000 Binary files a/web/img/forkme_right_red_aa0000.png and /dev/null differ diff --git a/web/img/minty_square.png b/web/img/minty_square.png deleted file mode 100644 index 250db3a..0000000 Binary files a/web/img/minty_square.png and /dev/null differ diff --git a/web/img/mintyphp_logo.png b/web/img/mintyphp_logo.png deleted file mode 100644 index a5a2631..0000000 Binary files a/web/img/mintyphp_logo.png and /dev/null differ diff --git a/web/index.php b/web/index.php index d550c5a..8d01157 100644 --- a/web/index.php +++ b/web/index.php @@ -12,6 +12,7 @@ use MintyPHP\Http\Request; use MintyPHP\Http\LocaleResolver; use MintyPHP\Http\AccessControl; use MintyPHP\Http\AssetDetector; +use MintyPHP\Support\Flash; // Change directory to project root chdir(__DIR__ . '/..'); @@ -34,10 +35,8 @@ Session::start(); $defaultLocale = appDefaultLocale(); if ($defaultLocale !== null) { I18n::$defaultLocale = $defaultLocale; - if (!defined('APP_LOCALES') || in_array($defaultLocale, APP_LOCALES, true)) { - if (I18n::$locale === '' || !in_array(I18n::$locale, APP_LOCALES, true)) { - I18n::$locale = $defaultLocale; - } + if (I18n::$locale === '') { + I18n::$locale = $defaultLocale; } } @@ -72,15 +71,43 @@ I18n::$locale = $localeResolver->resolveLocale( ); $isAssetRequest = AssetDetector::isAssetRequest($pathWithoutLocale); +$isApiRequest = str_starts_with($pathWithoutLocale, 'api/'); -// Refresh tenant context if stale or missing (skip for assets) -if (!empty($_SESSION['user']['id']) && !$isAssetRequest) { +if ($isApiRequest) { + if (!defined('MINTY_API_REQUEST')) { + define('MINTY_API_REQUEST', true); + } + + // Minty router enforces CSRF for non-GET requests before action execution. + // Our API is Bearer-token based and does not use form-CSRF, so mark only + // API write requests as AJAX to bypass the router-level form CSRF gate. + $requestMethod = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')); + $isApiWriteMethod = !in_array($requestMethod, ['GET', 'HEAD', 'OPTIONS'], true); + if ($isApiWriteMethod && empty($_SERVER['HTTP_X_REQUESTED_WITH'])) { + $_SERVER['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest'; + } +} + +// Refresh tenant context if stale or missing (skip for assets and API requests) +if (!empty($_SESSION['user']['id']) && !$isAssetRequest && !$isApiRequest) { $userId = (int) ($_SESSION['user']['id'] ?? 0); if ($userId > 0) { - $ttl = defined('TENANT_SNAPSHOT_TTL') ? (int) TENANT_SNAPSHOT_TTL : 120; + $authState = \MintyPHP\Service\Auth\AuthService::refreshSessionAuthState($userId); + if (!empty($authState['logout_required'])) { + \MintyPHP\Service\Auth\AuthService::logout(); + $reason = (string) ($authState['reason'] ?? ''); + if ($reason === 'inactive') { + Flash::error(t('Account is inactive'), 'login', 'login_inactive'); + } else { + Flash::error(t('No active tenant assigned'), 'login', 'no_active_tenant'); + } + Router::redirect('login'); + } + + $ttl = 120; $lastRefresh = (int) ($_SESSION['tenant_snapshot_at'] ?? 0); $needsRefresh = empty($_SESSION['current_tenant']) || empty($_SESSION['available_tenants']); - if ($ttl > 0 && (time() - $lastRefresh) >= $ttl) { + if ((time() - $lastRefresh) >= $ttl) { $needsRefresh = true; } if ($needsRefresh) { @@ -90,7 +117,7 @@ if (!empty($_SESSION['user']['id']) && !$isAssetRequest) { } } -if ($requestedLocale === '' && !$isAssetRequest) { +if ($requestedLocale === '' && !$isAssetRequest && !$isApiRequest) { $method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')); if (in_array($method, ['GET', 'HEAD'], true)) { $target = Request::withLocale($pathWithoutLocale, I18n::$locale ?? I18n::$defaultLocale); diff --git a/web/js/app-init.js b/web/js/app-init.js index 6a72a7d..e3d4605 100644 --- a/web/js/app-init.js +++ b/web/js/app-init.js @@ -1,4 +1,5 @@ import './components/app-flash-auto-dismiss.js'; +import './components/app-tabs.js'; import './components/app-fslightbox-refresh.js'; import './components/app-password-hints.js'; import './components/app-copy-badge.js'; @@ -7,13 +8,13 @@ import './components/app-nav-history.js'; import './components/app-toggle-sidebar.js'; import './components/app-toggle-aside-panels.js'; import './components/app-toggle-details-aside.js'; -import { initToolbarToggles } from './components/app-toggle-toolbar.js'; +import './components/app-toggle-toolbar.js'; import './components/app-filter-overflow.js'; import './components/app-global-search.js'; import './components/app-theme-toggle.js'; import './components/app-contrast-toggle.js'; +import './components/app-details-state.js'; import './components/app-tenant-switcher.js'; -import './components/app-tabs.js'; import './components/app-color-default-toggle.js'; - -initToolbarToggles(); +import './components/app-custom-field-options-toggle.js'; +import './components/app-tenant-sso-toggle.js'; diff --git a/web/js/components/_template.js b/web/js/components/_template.js new file mode 100644 index 0000000..517a4fd --- /dev/null +++ b/web/js/components/_template.js @@ -0,0 +1,28 @@ +import { optionalEl, warnOnce } from '../core/app-dom.js'; + +export function initTemplateFeature(root = document) { + const host = optionalEl('[data-template-feature]'); + if (!host) { + return; + } + if (host.dataset.templateFeatureBound === '1') { + return; + } + host.dataset.templateFeatureBound = '1'; + + const action = host.querySelector('[data-template-action]'); + if (!action) { + warnOnce('UI_EL_MISSING', 'Missing [data-template-action]', { module: 'template-feature' }); + return; + } + + action.addEventListener('click', () => { + host.classList.toggle('is-active'); + }); +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => initTemplateFeature()); +} else { + initTemplateFeature(); +} diff --git a/web/js/components/app-contrast-toggle.js b/web/js/components/app-contrast-toggle.js index 0c3c4b8..0de414d 100644 --- a/web/js/components/app-contrast-toggle.js +++ b/web/js/components/app-contrast-toggle.js @@ -9,14 +9,14 @@ const setContrast = (contrast) => { const setIcon = (button, contrast) => { const icon = button.querySelector('i'); - if (!icon) return; + if (!icon) {return;} icon.classList.remove('bi-circle-half', 'bi-highlights'); icon.classList.add(contrast === 'high' ? 'bi-highlights' : 'bi-circle-half'); }; const initContrastToggle = () => { const button = requireEl('[data-contrast-toggle]', { module: 'contrast-toggle' }); - if (!button) return; + if (!button) {return;} const root = document.documentElement; const getCurrent = () => (root.dataset.contrast === 'high' ? 'high' : 'normal'); setIcon(button, getCurrent()); diff --git a/web/js/components/app-copy-badge.js b/web/js/components/app-copy-badge.js index b6ceab1..8c56e73 100644 --- a/web/js/components/app-copy-badge.js +++ b/web/js/components/app-copy-badge.js @@ -10,7 +10,7 @@ const getCopyValue = (badge) => { }; const copyText = async (value) => { - if (!value) return false; + if (!value) {return false;} if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(value); return true; @@ -28,7 +28,7 @@ const copyText = async (value) => { }; const enhanceBadge = (badge) => { - if (badge.querySelector('.badge-copy-icon')) return; + if (badge.querySelector('.badge-copy-icon')) {return;} const icon = document.createElement('span'); icon.className = 'badge-copy-icon'; icon.setAttribute('aria-hidden', 'true'); @@ -43,7 +43,7 @@ const enhanceBadge = (badge) => { event.preventDefault(); const value = getCopyValue(badge); const ok = await copyText(value); - if (!ok) return; + if (!ok) {return;} badge.classList.add(COPIED_CLASS); window.setTimeout(() => badge.classList.remove(COPIED_CLASS), 1200); }; diff --git a/web/js/components/app-custom-field-options-toggle.js b/web/js/components/app-custom-field-options-toggle.js new file mode 100644 index 0000000..97269dd --- /dev/null +++ b/web/js/components/app-custom-field-options-toggle.js @@ -0,0 +1,98 @@ +import { warnOnce } from '../core/app-dom.js'; + +const TYPES_WITH_OPTIONS = new Set(['select', 'multiselect']); +const FILTERABLE_TYPES = new Set(['select', 'multiselect', 'boolean', 'date']); +const sources = document.querySelectorAll('[data-custom-field-type-source]'); + +const syncOptionsVisibility = (source, target) => { + const selectedType = String(source.value || '').trim().toLowerCase(); + const showOptions = TYPES_WITH_OPTIONS.has(selectedType); + + target.hidden = !showOptions; + + target.querySelectorAll('textarea, input, select').forEach((control) => { + if (!control.dataset.initialDisabled) { + control.dataset.initialDisabled = control.hasAttribute('disabled') ? '1' : '0'; + } + + if (!showOptions) { + control.setAttribute('disabled', 'disabled'); + return; + } + + if (control.dataset.initialDisabled === '1') { + control.setAttribute('disabled', 'disabled'); + return; + } + + control.removeAttribute('disabled'); + }); +}; + +const syncFilterableAvailability = (source, target) => { + const selectedType = String(source.value || '').trim().toLowerCase(); + const filterableAllowed = FILTERABLE_TYPES.has(selectedType); + const checkbox = target.querySelector('[data-custom-field-filterable-input]'); + + if (!checkbox) { + warnOnce('UI_EL_MISSING', 'Missing filterable checkbox in custom field group', { + module: 'custom-field-options-toggle' + }); + return; + } + + if (!checkbox.dataset.initialDisabled) { + checkbox.dataset.initialDisabled = checkbox.hasAttribute('disabled') ? '1' : '0'; + } + + if (!filterableAllowed) { + checkbox.checked = false; + checkbox.setAttribute('disabled', 'disabled'); + target.setAttribute('aria-disabled', 'true'); + return; + } + + target.removeAttribute('aria-disabled'); + if (checkbox.dataset.initialDisabled === '1') { + checkbox.setAttribute('disabled', 'disabled'); + return; + } + + checkbox.removeAttribute('disabled'); +}; + +sources.forEach((source) => { + const group = String(source.dataset.customFieldGroup || '').trim(); + if (!group) { + warnOnce('UI_EL_MISSING', 'Missing data-custom-field-group', { module: 'custom-field-options-toggle' }); + return; + } + + const target = document.querySelector( + `[data-custom-field-options-target][data-custom-field-group="${group}"]` + ); + if (!target) { + warnOnce('UI_EL_MISSING', `Missing custom field options target for group: ${group}`, { + module: 'custom-field-options-toggle' + }); + return; + } + + const filterableTarget = document.querySelector( + `[data-custom-field-filterable-target][data-custom-field-group="${group}"]` + ); + if (!filterableTarget) { + warnOnce('UI_EL_MISSING', `Missing custom field filterable target for group: ${group}`, { + module: 'custom-field-options-toggle' + }); + return; + } + + source.addEventListener('change', () => { + syncOptionsVisibility(source, target); + syncFilterableAvailability(source, filterableTarget); + }); + + syncOptionsVisibility(source, target); + syncFilterableAvailability(source, filterableTarget); +}); diff --git a/web/js/components/app-details-state.js b/web/js/components/app-details-state.js new file mode 100644 index 0000000..f719563 --- /dev/null +++ b/web/js/components/app-details-state.js @@ -0,0 +1,80 @@ +const readStored = (storageKey) => { + try { + const raw = window.localStorage.getItem(storageKey); + if (!raw) { + return []; + } + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch (error) { + return []; + } +}; + +const writeStored = (storageKey, openKeys) => { + try { + window.localStorage.setItem(storageKey, JSON.stringify(openKeys)); + } catch (error) { + // ignore storage errors + } +}; + +const initDetailsGroup = (group) => { + if (!group || group.dataset.detailsStorageBound === '1') { + return; + } + + const storageKey = (group.dataset.detailsStorage || '').trim(); + if (!storageKey) { + return; + } + + const managedDetails = Array.from(group.querySelectorAll('details[data-details-key]')); + const alwaysOpenDetails = Array.from(group.querySelectorAll('details[data-details-always-open]')); + + if (!managedDetails.length && !alwaysOpenDetails.length) { + return; + } + + const storedOpenKeys = readStored(storageKey); + if (storedOpenKeys.length > 0) { + managedDetails.forEach((details) => { + const key = (details.dataset.detailsKey || '').trim(); + if (key !== '') { + details.open = storedOpenKeys.includes(key); + } + }); + } + + alwaysOpenDetails.forEach((details) => { + details.open = true; + details.addEventListener('toggle', () => { + if (!details.open) { + details.open = true; + } + }); + }); + + managedDetails.forEach((details) => { + details.addEventListener('toggle', () => { + const openKeys = managedDetails + .filter((item) => item.open) + .map((item) => (item.dataset.detailsKey || '').trim()) + .filter(Boolean); + writeStored(storageKey, openKeys); + }); + }); + + group.dataset.detailsStorageBound = '1'; +}; + +const initDetailsState = () => { + const groups = Array.from(document.querySelectorAll('[data-details-storage]')); + groups.forEach(initDetailsGroup); +}; + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initDetailsState); +} else { + initDetailsState(); +} diff --git a/web/js/components/app-filter-overflow.js b/web/js/components/app-filter-overflow.js index 1b69a10..1fced6b 100644 --- a/web/js/components/app-filter-overflow.js +++ b/web/js/components/app-filter-overflow.js @@ -12,10 +12,10 @@ const getEmptyValues = (field) => { }; const isFieldActive = (field) => { - if (!field) return false; - if (field.disabled) return false; - if (field.type === 'hidden') return false; - if (field.type === 'checkbox') return field.checked; + if (!field) {return false;} + if (field.disabled) {return false;} + if (field.type === 'hidden') {return false;} + if (field.type === 'checkbox') {return field.checked;} if (field.tagName === 'SELECT' && field.multiple) { return Array.from(field.selectedOptions || []).some((option) => option.value); } @@ -37,11 +37,11 @@ const isOptionalActive = (item) => { export function initFilterOverflow(root = document) { const toolbars = root.querySelectorAll('[data-filter-overflow]'); toolbars.forEach((toolbar) => { - if (toolbar.dataset.filterOverflowBound === '1') return; + if (toolbar.dataset.filterOverflowBound === '1') {return;} toolbar.dataset.filterOverflowBound = '1'; const optional = Array.from(toolbar.querySelectorAll('[data-filter-optional]')); - if (!optional.length) return; + if (!optional.length) {return;} const toggle = toolbar.querySelector('[data-filter-toggle]'); if (!toggle) { diff --git a/web/js/components/app-flash-auto-dismiss.js b/web/js/components/app-flash-auto-dismiss.js index 0a64ddc..4e4f25b 100644 --- a/web/js/components/app-flash-auto-dismiss.js +++ b/web/js/components/app-flash-auto-dismiss.js @@ -11,7 +11,7 @@ export function initFlashAutoDismiss(options = {}) { const postForm = async (form) => { const action = form.getAttribute('action'); - if (!action) return null; + if (!action) {return null;} const body = new URLSearchParams(new FormData(form)); return fetch(action, { method: 'POST', diff --git a/web/js/components/app-fslightbox-refresh.js b/web/js/components/app-fslightbox-refresh.js index 1fb6158..bcb87a6 100644 --- a/web/js/components/app-fslightbox-refresh.js +++ b/web/js/components/app-fslightbox-refresh.js @@ -13,7 +13,7 @@ const refreshFsLightbox = () => { let refreshTimer = null; const scheduleRefresh = () => { - if (refreshTimer) return; + if (refreshTimer) {return;} refreshTimer = window.setTimeout(() => { refreshTimer = null; refreshFsLightbox(); @@ -27,7 +27,7 @@ const initObserver = () => { const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { for (const node of mutation.addedNodes) { - if (!(node instanceof HTMLElement)) continue; + if (!(node instanceof HTMLElement)) {continue;} if (node.matches?.('[data-fslightbox]') || node.querySelector?.('[data-fslightbox]')) { scheduleRefresh(); return; diff --git a/web/js/components/app-global-search.js b/web/js/components/app-global-search.js index 09a972e..6984520 100644 --- a/web/js/components/app-global-search.js +++ b/web/js/components/app-global-search.js @@ -4,6 +4,9 @@ const input = requireEl('#side-search', { module: 'global-search' }); const resultsEl = requireEl('[data-global-search-results]', { module: 'global-search' }); if (input && resultsEl) { + const emptyStateEl = optionalEl('[data-global-search-empty]'); + const emptyHintEl = optionalEl('[data-search-empty-hint-template]'); + const shortcutEl = optionalEl('[data-search-shortcut]'); const panel = optionalEl('#aside-panel-search'); const toggleButton = optionalEl('[data-search-details-toggle]'); const toggleIcon = optionalEl('[data-search-details-icon]'); @@ -15,14 +18,19 @@ if (input && resultsEl) { const shortcutLabel = /(Mac|iPhone|iPad|iPod)/i.test(navigator.platform || navigator.userAgent) ? '⌘K' : 'Ctrl+K'; - if (input.placeholder && !input.placeholder.includes('⌘') && !input.placeholder.includes('Ctrl+K')) { - input.placeholder = `${input.placeholder} (${shortcutLabel})`; + + if (emptyHintEl) { + const template = emptyHintEl.getAttribute('data-search-empty-hint-template') || ''; + emptyHintEl.textContent = template.replace('{shortcut}', shortcutLabel); + } + if (shortcutEl) { + shortcutEl.textContent = shortcutLabel; } const itemsByKey = new Map(); resultsEl.querySelectorAll('[data-search-key]').forEach((item) => { const key = item.getAttribute('data-search-key'); - if (key) itemsByKey.set(key, item); + if (key) {itemsByKey.set(key, item);} }); if (itemsByKey.size === 0) { warnOnce('UI_EL_MISSING', 'No [data-search-key] elements found in results container', { module: 'global-search' }); @@ -34,13 +42,22 @@ if (input && resultsEl) { }); }; + const setIdleState = (idle) => { + if (emptyStateEl) { + emptyStateEl.hidden = !idle; + } + resultsEl.hidden = idle; + }; + const render = (items, query) => { + const renderedKeys = new Set(); items.forEach((item) => { const li = itemsByKey.get(item.key); if (!li) { warnOnce('UI_EL_MISSING', `No element found for key: ${item.key}`, { module: 'global-search' }); return; } + renderedKeys.add(item.key); const countEl = li.querySelector('[data-search-count]'); const link = li.querySelector('a'); const previewEl = li.querySelector('[data-search-preview]'); @@ -49,7 +66,7 @@ if (input && resultsEl) { if (item.key !== 'pages') { url.searchParams.set('search', query); } - if (countEl) countEl.textContent = item.count.toString(); + if (countEl) {countEl.textContent = item.count.toString();} if (link) { link.href = item.count > 0 ? url.toString() : base; link.setAttribute('aria-disabled', item.count > 0 ? 'false' : 'true'); @@ -80,9 +97,29 @@ if (input && resultsEl) { li.hidden = true; } }); + + itemsByKey.forEach((li, key) => { + if (renderedKeys.has(key)) { + return; + } + const countEl = li.querySelector('[data-search-count]'); + const link = li.querySelector('a'); + const previewEl = li.querySelector('[data-search-preview]'); + const base = li.getAttribute('data-search-base') || '#'; + if (countEl) {countEl.textContent = '0';} + if (link) { + link.href = base; + link.setAttribute('aria-disabled', 'true'); + } + if (previewEl) { + previewEl.innerHTML = ''; + } + li.hidden = true; + }); }; const clear = () => { + setIdleState(true); setLoadingState('0'); resultsEl.querySelectorAll('a').forEach((link) => { link.setAttribute('aria-disabled', 'true'); @@ -98,6 +135,7 @@ if (input && resultsEl) { }; const fetchCounts = async (value) => { + setIdleState(false); const url = new URL(endpoint.toString()); url.searchParams.set('q', value); setLoadingState('…'); @@ -127,7 +165,7 @@ if (input && resultsEl) { clear(); return; } - if (pending) window.clearTimeout(pending); + if (pending) {window.clearTimeout(pending);} pending = window.setTimeout(() => { fetchCounts(value).catch((err) => { warnOnce('FETCH_ERROR', 'Global search fetch error', { module: 'global-search', err }); @@ -139,26 +177,26 @@ if (input && resultsEl) { input.addEventListener('input', onInput); const submitSearch = () => { const value = input.value.trim(); - if (value.length < minLength) return; + if (value.length < minLength) {return;} resultsPage.searchParams.set('search', value); window.location.href = resultsPage.toString(); }; input.addEventListener('keydown', (event) => { - if (event.key !== 'Enter') return; + if (event.key !== 'Enter') {return;} event.preventDefault(); submitSearch(); }); const isEditableTarget = (target) => { - if (!target) return false; - if (target.isContentEditable) return true; + if (!target) {return false;} + if (target.isContentEditable) {return true;} const tag = target.tagName; return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; }; const focusSearch = () => { - if (!input) return; + if (!input) {return;} window.requestAnimationFrame(() => { input.focus(); input.select(); @@ -174,11 +212,17 @@ if (input && resultsEl) { focusSearch(); }; + if (emptyStateEl) { + emptyStateEl.addEventListener('click', () => { + focusSearch(); + }); + } + document.addEventListener('keydown', (event) => { - if (!(event.metaKey || event.ctrlKey)) return; + if (!(event.metaKey || event.ctrlKey)) {return;} const key = event.key.toLowerCase(); - if (key !== 'k' && key !== '/') return; - if (isEditableTarget(event.target) && event.target !== input) return; + if (key !== 'k' && key !== '/') {return;} + if (isEditableTarget(event.target) && event.target !== input) {return;} event.preventDefault(); openSearch(); }); @@ -201,11 +245,20 @@ if (input && resultsEl) { }); } - // Seed from current URL search param if present + // Seed from current URL search param if present. const currentUrl = new URL(window.location.href); - const preset = currentUrl.searchParams.get('search'); + const preset = (currentUrl.searchParams.get('search') || '').trim(); if (preset && !input.value) { input.value = preset; - onInput(); + } + + const initialValue = input.value.trim(); + if (initialValue.length >= minLength) { + fetchCounts(initialValue).catch((err) => { + warnOnce('FETCH_ERROR', 'Global search fetch error', { module: 'global-search', err }); + clear(); + }); + } else { + clear(); } } diff --git a/web/js/components/app-multiselect-cascade.js b/web/js/components/app-multiselect-cascade.js index 9579bb8..e457cee 100644 --- a/web/js/components/app-multiselect-cascade.js +++ b/web/js/components/app-multiselect-cascade.js @@ -29,7 +29,7 @@ const setDisabledState = (option, disabled) => { const toggleSelectAll = (instance, disabled) => { const selectAll = instance?.element?.querySelector('.multi-select-all'); - if (!selectAll) return; + if (!selectAll) {return;} selectAll.classList.toggle('is-disabled', disabled); selectAll.setAttribute('aria-disabled', disabled ? 'true' : 'false'); selectAll.style.pointerEvents = disabled ? 'none' : ''; @@ -86,7 +86,7 @@ export const initMultiSelectCascade = ({ options.forEach((option) => { const isAllowed = allowAll || allowed.has(String(option.dataset.value || '')); const disabled = mode === 'disable' ? !isAllowed : false; - if (disabled) disabledCount += 1; + if (disabled) {disabledCount += 1;} setDisabledState(option, disabled); }); diff --git a/web/js/components/app-multiselect-init.js b/web/js/components/app-multiselect-init.js index f5f5be7..e5ae249 100644 --- a/web/js/components/app-multiselect-init.js +++ b/web/js/components/app-multiselect-init.js @@ -3,7 +3,7 @@ import { warnOnce } from '../core/app-dom.js'; let multiSelectLoader = null; const resolveScriptUrl = (script) => { - if (script) return script; + if (script) {return script;} const assetBase = window.APP_ASSET_BASE || document.baseURI; return new URL('vendor/multi-select/MultiSelect.js', assetBase).toString(); }; @@ -27,17 +27,17 @@ const ensureMultiSelect = async (script) => { export const initMultiSelect = async (target = '[data-multi-select]') => { const elements = typeof target === 'string' ? document.querySelectorAll(target) : [target]; - if (!elements.length) return []; + if (!elements.length) {return [];} const MultiSelect = await ensureMultiSelect(); const instances = []; elements.forEach((element) => { - if (!element || element.dataset.multiSelectInit === '1') return; + if (!element || element.dataset.multiSelectInit === '1') {return;} const syncTarget = element.dataset.syncTarget; const syncInput = syncTarget ? document.querySelector(syncTarget) : null; const options = {}; const placeholder = element.dataset.placeholder; - if (placeholder) options.placeholder = placeholder; + if (placeholder) {options.placeholder = placeholder;} if (element.dataset.search !== undefined) { options.search = element.dataset.search === 'true'; } @@ -79,7 +79,7 @@ export const initMultiSelect = async (target = '[data-multi-select]') => { if (syncInput) { syncInput._multiSelectInstance = instance; const syncValues = () => { - if (!syncInput || isSyncing) return; + if (!syncInput || isSyncing) {return;} isSyncing = true; const values = instance.selectedValues || []; syncInput.value = Array.isArray(values) ? values.join(',') : ''; @@ -90,7 +90,7 @@ export const initMultiSelect = async (target = '[data-multi-select]') => { const values = instance.selectedValues || []; syncInput.value = Array.isArray(values) ? values.join(',') : ''; syncInput.addEventListener('change', () => { - if (isSyncing) return; + if (isSyncing) {return;} const nextValues = syncInput.value ? syncInput.value.split(',').map((item) => item.trim()).filter(Boolean) : []; @@ -103,12 +103,12 @@ export const initMultiSelect = async (target = '[data-multi-select]') => { export const getMultiSelectInstance = (target) => { const element = typeof target === 'string' ? document.querySelector(target) : target; - if (!element) return null; - if (element._multiSelectInstance) return element._multiSelectInstance; + if (!element) {return null;} + if (element._multiSelectInstance) {return element._multiSelectInstance;} const syncTarget = element.dataset?.syncTarget; if (syncTarget) { const syncElement = document.querySelector(syncTarget); - if (syncElement && syncElement._multiSelectInstance) return syncElement._multiSelectInstance; + if (syncElement && syncElement._multiSelectInstance) {return syncElement._multiSelectInstance;} } return null; }; diff --git a/web/js/components/app-nav-history.js b/web/js/components/app-nav-history.js index c44e3c6..c3f707f 100644 --- a/web/js/components/app-nav-history.js +++ b/web/js/components/app-nav-history.js @@ -1,7 +1,7 @@ const updateHistoryButtons = () => { const back = document.querySelector('#global-back'); const forward = document.querySelector('#global-forward'); - if (!back && !forward) return; + if (!back && !forward) {return;} const hasHistory = window.history.length > 1; @@ -20,15 +20,15 @@ const initHistoryButtons = () => { const forward = document.querySelector('#global-forward'); const isEditableTarget = (target) => { - if (!target) return false; - if (target.isContentEditable) return true; + if (!target) {return false;} + if (target.isContentEditable) {return true;} const tag = target.tagName; return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; }; document.addEventListener('keydown', (event) => { - if (!event.altKey || event.metaKey || event.ctrlKey) return; - if (isEditableTarget(event.target)) return; + if (!event.altKey || event.metaKey || event.ctrlKey) {return;} + if (isEditableTarget(event.target)) {return;} if (event.key === 'ArrowLeft') { if (window.history.length > 1) { event.preventDefault(); diff --git a/web/js/components/app-password-hints.js b/web/js/components/app-password-hints.js index b2637a8..f1e4a40 100644 --- a/web/js/components/app-password-hints.js +++ b/web/js/components/app-password-hints.js @@ -7,18 +7,18 @@ const rules = { number: (value) => /\d/.test(value), symbol: (value) => /[^a-zA-Z0-9]/.test(value), email: (value, _min, email) => { - if (!email) return true; + if (!email) {return true;} return !value.toLowerCase().includes(email.toLowerCase()); }, match: (_value, _min, _email, confirm) => { - if (!confirm) return false; + if (!confirm) {return false;} return true; } }; export function initPasswordHints() { const containers = document.querySelectorAll('[data-password-hints]'); - if (!containers.length) return; + if (!containers.length) {return;} containers.forEach((container) => { const passwordSelector = container.dataset.passwordInput; @@ -41,8 +41,8 @@ export function initPasswordHints() { const setState = (item, state) => { item.classList.remove('is-valid', 'is-invalid'); - if (state === 'valid') item.classList.add('is-valid'); - if (state === 'invalid') item.classList.add('is-invalid'); + if (state === 'valid') {item.classList.add('is-valid');} + if (state === 'invalid') {item.classList.add('is-invalid');} }; const evaluate = () => { @@ -53,7 +53,7 @@ export function initPasswordHints() { items.forEach((item) => { const rule = item.dataset.rule; - if (!rule) return; + if (!rule) {return;} if (!hasValue) { setState(item, 'neutral'); return; @@ -67,15 +67,15 @@ export function initPasswordHints() { return; } const check = rules[rule]; - if (!check) return; + if (!check) {return;} const ok = check(password, minLength, email, confirm); setState(item, ok ? 'valid' : 'invalid'); }); }; - if (passwordInput) passwordInput.addEventListener('input', evaluate); - if (confirmInput) confirmInput.addEventListener('input', evaluate); - if (emailInput) emailInput.addEventListener('input', evaluate); + if (passwordInput) {passwordInput.addEventListener('input', evaluate);} + if (confirmInput) {confirmInput.addEventListener('input', evaluate);} + if (emailInput) {emailInput.addEventListener('input', evaluate);} evaluate(); }); } diff --git a/web/js/components/app-tabs.js b/web/js/components/app-tabs.js index f1b12cb..8a4973f 100644 --- a/web/js/components/app-tabs.js +++ b/web/js/components/app-tabs.js @@ -10,13 +10,14 @@ export function initTabs(root = document) { const containers = root.querySelectorAll('[data-tabs]'); containers.forEach((container) => { - if (container.dataset.tabsBound === '1') return; + if (container.dataset.tabsBound === '1') {return;} container.dataset.tabsBound = '1'; + container.dataset.tabsReady = '0'; const tabs = Array.from(container.querySelectorAll('[data-tab]')); const panels = Array.from(container.querySelectorAll('[data-tab-panel]')); - if (!tabs.length || !panels.length) return; + if (!tabs.length || !panels.length) {return;} const storageKey = container.dataset.tabsStorageKey || (container.id ? `tabs:${container.id}` : null); const urlParamName = container.dataset.tabsParam || 'tab'; @@ -99,6 +100,114 @@ export function initTabs(root = document) { if (activeTab) { activateTab(activeTab); } + container.dataset.tabsReady = '1'; + + // --- Form validation awareness --- + const form = container.closest('form'); + if (form) { + const inputSelector = 'input,select,textarea'; + const formId = form.id; + + const belongsToForm = (field) => { + const f = field.getAttribute('form'); + if (!f) return true; // kein form-Attr → gehört zum Ancestor-Form + if (!formId) return false; // Feld hat form-Attr, unser Form hat keine ID + return f === formId; + }; + + const clearInvalidMarkers = () => { + tabs.forEach(tab => tab.classList.remove('has-invalid')); + }; + + const findInvalidPanels = () => { + clearInvalidMarkers(); + let firstInvalidTab = null; + + panels.forEach(panel => { + const fields = panel.querySelectorAll(inputSelector); + const hasInvalid = Array.from(fields).some(f => belongsToForm(f) && !f.disabled && !f.checkValidity()); + if (hasInvalid) { + const tabName = panel.dataset.tabPanel; + const tab = tabs.find(t => t.dataset.tab === tabName); + if (tab) { + tab.classList.add('has-invalid'); + } + if (!firstInvalidTab) { + firstInvalidTab = tabName; + } + } + }); + + return firstInvalidTab; + }; + + // Intercept submit buttons — both inside the form and external ones linked via form="id". + // We use document-level click (capture) so we catch ALL submit triggers before native validation. + document.addEventListener('click', (e) => { + const btn = e.target.closest('button, [type="submit"]'); + if (!btn) {return;} + + // Check if this button submits OUR form + const btnFormAttr = btn.getAttribute('form'); + const isInternalSubmit = !btnFormAttr && form.contains(btn) && (btn.type === 'submit' || (!btn.type && btn.tagName === 'BUTTON')); + const isExternalSubmit = btnFormAttr && formId && btnFormAttr === formId && btn.type === 'submit'; + if (!isInternalSubmit && !isExternalSubmit) {return;} + + const firstInvalidTab = findInvalidPanels(); + if (!firstInvalidTab) {return;} + + // Active panel already shows the first invalid tab — let native validation handle it + const activePanel = panels.find(p => !p.hidden); + const needsTabSwitch = activePanel?.dataset.tabPanel !== firstInvalidTab; + + // Open any closed
        ancestors of invalid fields so the browser can focus them + const invalidFields = form.querySelectorAll(inputSelector); + for (const field of invalidFields) { + if (!belongsToForm(field) || field.disabled || field.checkValidity()) {continue;} + let el = field.parentElement; + while (el && el !== form) { + if (el.tagName === 'DETAILS' && !el.open) { + el.open = true; + } + el = el.parentElement; + } + } + + if (!needsTabSwitch) {return;} + + // Prevent native submit so the browser doesn't try to focus a hidden field + e.preventDefault(); + activateTab(firstInvalidTab); + + requestAnimationFrame(() => { + form.reportValidity(); + }); + }, true); + + form.addEventListener('input', (e) => { + const panel = e.target.closest('[data-tab-panel]'); + if (!panel) {return;} + const tabName = panel.dataset.tabPanel; + const tab = tabs.find(t => t.dataset.tab === tabName); + if (!tab) {return;} + const fields = panel.querySelectorAll(inputSelector); + if (Array.from(fields).every(f => !belongsToForm(f) || f.disabled || f.checkValidity())) { + tab.classList.remove('has-invalid'); + } + }); + + form.addEventListener('change', (e) => { + const panel = e.target.closest('[data-tab-panel]'); + if (!panel) {return;} + const tabName = panel.dataset.tabPanel; + const tab = tabs.find(t => t.dataset.tab === tabName); + if (!tab) {return;} + const fields = panel.querySelectorAll(inputSelector); + if (Array.from(fields).every(f => !belongsToForm(f) || f.disabled || f.checkValidity())) { + tab.classList.remove('has-invalid'); + } + }); + } // Listen for popstate (back/forward navigation) window.addEventListener('popstate', () => { diff --git a/web/js/components/app-tenant-sso-toggle.js b/web/js/components/app-tenant-sso-toggle.js new file mode 100644 index 0000000..aa1808e --- /dev/null +++ b/web/js/components/app-tenant-sso-toggle.js @@ -0,0 +1,75 @@ +const roots = document.querySelectorAll('[data-tenant-sso-root]'); + +const syncControlState = (container, show) => { + if (!container) { + return; + } + + container.hidden = !show; + + container.querySelectorAll('input, select, textarea, button').forEach((control) => { + if ((control instanceof HTMLInputElement) && control.type === 'hidden') { + return; + } + + if (!control.dataset.initialDisabled) { + control.dataset.initialDisabled = control.hasAttribute('disabled') ? '1' : '0'; + } + + if (!show) { + control.setAttribute('disabled', 'disabled'); + return; + } + + if (control.dataset.initialDisabled === '1') { + control.setAttribute('disabled', 'disabled'); + return; + } + + control.removeAttribute('disabled'); + }); +}; + +const initRoot = (root) => { + const enabledInput = root.querySelector('[data-tenant-sso-enabled]'); + if (!(enabledInput instanceof HTMLInputElement)) { + return; + } + + const syncToggle = root.querySelector('[data-tenant-sso-sync-toggle]'); + const sharedToggle = root.querySelector('[data-tenant-sso-shared-toggle]'); + const enabledBlocks = root.querySelectorAll('[data-tenant-sso-when-enabled]'); + const syncFieldsBlock = root.querySelector('[data-tenant-sso-sync-fields]'); + const overrideDetails = root.querySelector('[data-tenant-sso-override-details]'); + const overrideFields = root.querySelectorAll('[data-tenant-sso-override-fields]'); + + const updateUi = () => { + const ssoEnabled = enabledInput.checked; + const syncEnabled = syncToggle instanceof HTMLInputElement ? syncToggle.checked : false; + const useSharedApp = sharedToggle instanceof HTMLInputElement ? sharedToggle.checked : true; + + enabledBlocks.forEach((block) => syncControlState(block, ssoEnabled)); + syncControlState(syncFieldsBlock, ssoEnabled && syncEnabled); + + if (overrideDetails instanceof HTMLElement) { + overrideDetails.hidden = !ssoEnabled; + if (!ssoEnabled && overrideDetails instanceof HTMLDetailsElement) { + overrideDetails.open = false; + } + } + + overrideFields.forEach((block) => syncControlState(block, ssoEnabled && !useSharedApp)); + }; + + enabledInput.addEventListener('change', updateUi); + if (syncToggle instanceof HTMLInputElement) { + syncToggle.addEventListener('change', updateUi); + } + if (sharedToggle instanceof HTMLInputElement) { + sharedToggle.addEventListener('change', updateUi); + } + + updateUi(); +}; + +roots.forEach(initRoot); diff --git a/web/js/components/app-theme-toggle.js b/web/js/components/app-theme-toggle.js index 2e81a16..2d7b6a2 100644 --- a/web/js/components/app-theme-toggle.js +++ b/web/js/components/app-theme-toggle.js @@ -7,7 +7,7 @@ const setTheme = (theme) => { const isDarkTheme = (theme) => theme && theme.startsWith('dark'); const setIcon = (iconEl, theme) => { - if (!iconEl) return; + if (!iconEl) {return;} iconEl.classList.remove('bi-sun-fill', 'bi-moon-stars-fill'); iconEl.classList.add(isDarkTheme(theme) ? 'bi-moon-stars-fill' : 'bi-sun-fill'); }; @@ -34,7 +34,7 @@ const updateTheme = async (menu, theme) => { const initThemeMenu = () => { const menu = optionalEl('[data-theme-menu]'); - if (!menu) return; + if (!menu) {return;} const icon = menu.querySelector('[data-theme-icon]'); const options = Array.from(menu.querySelectorAll('[data-theme-option]')); if (!options.length) { @@ -62,9 +62,9 @@ const initThemeMenu = () => { option.addEventListener('click', async (event) => { event.preventDefault(); const next = option.dataset.themeValue || ''; - if (!next) return; + if (!next) {return;} const current = getCurrent(); - if (next === current) return; + if (next === current) {return;} setTheme(next); setIcon(icon, next); setActive(next); @@ -80,7 +80,7 @@ const initThemeMenu = () => { const initThemeToggle = () => { const button = optionalEl('[data-theme-toggle]'); - if (!button) return; + if (!button) {return;} const icon = button.querySelector('i'); const getCurrent = () => document.documentElement.dataset.theme || ''; setIcon(icon, getCurrent()); diff --git a/web/js/components/app-toggle-aside-panels.js b/web/js/components/app-toggle-aside-panels.js index f16bcac..0459755 100644 --- a/web/js/components/app-toggle-aside-panels.js +++ b/web/js/components/app-toggle-aside-panels.js @@ -11,7 +11,7 @@ export function initAsidePanels(options = {}) { } = options; const bar = requireEl(barSelector, { module: 'aside-panels' }); - if (!bar) return; + if (!bar) {return;} const tabs = Array.from(bar.querySelectorAll('[data-aside-target]')); if (!tabs.length) { @@ -37,18 +37,18 @@ export function initAsidePanels(options = {}) { const getPanelTools = (panel) => panel?.querySelector('[data-aside-panel-tools]') || null; const getTabHref = (tab) => tab?.dataset?.asideHref || ''; const isEditableTarget = (target) => { - if (!target) return false; - if (target.isContentEditable) return true; + if (!target) {return false;} + if (target.isContentEditable) {return true;} const tag = target.tagName; return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; }; const initPanelDetails = (panel) => { - if (!panel || panel.dataset.detailsBound === '1') return; + if (!panel || panel.dataset.detailsBound === '1') {return;} const storageKey = panel.dataset.asideDetailsStorage || ''; - if (!storageKey) return; + if (!storageKey) {return;} const detailsEls = Array.from(panel.querySelectorAll('details[data-details-key]')); - if (!detailsEls.length) return; + if (!detailsEls.length) {return;} const readStored = () => { try { @@ -70,7 +70,7 @@ export function initAsidePanels(options = {}) { const applyStored = () => { const stored = readStored(); - if (!stored.length) return; + if (!stored.length) {return;} detailsEls.forEach((details) => { details.open = stored.includes(details.dataset.detailsKey || ''); }); @@ -91,7 +91,7 @@ export function initAsidePanels(options = {}) { }; const readStored = () => { - if (!storageKey) return null; + if (!storageKey) {return null;} try { return window.localStorage.getItem(storageKey); } catch (error) { @@ -100,7 +100,7 @@ export function initAsidePanels(options = {}) { }; const writeStored = (key) => { - if (!storageKey) return; + if (!storageKey) {return;} try { window.localStorage.setItem(storageKey, key); } catch (error) { @@ -109,29 +109,29 @@ export function initAsidePanels(options = {}) { }; const syncTools = (panel) => { - if (!toolsHost) return; + if (!toolsHost) {return;} toolsHost.innerHTML = ''; const tools = getPanelTools(panel); - if (!tools) return; + if (!tools) {return;} toolsHost.innerHTML = tools.innerHTML; }; const openSidebar = (persist) => { - if (!revealSidebarOnActivate || !sidebarController) return; + if (!revealSidebarOnActivate || !sidebarController) {return;} if (typeof sidebarController.isHidden === 'function' && sidebarController.isHidden()) { sidebarController.show({ persist }); } - if (!sidebarController.isCollapsed()) return; + if (!sidebarController.isCollapsed()) {return;} sidebarController.open({ persist }); }; const setActive = (key, { fromUser = false } = {}) => { - if (!key) return; + if (!key) {return;} let activePanel = null; panels.forEach((panel) => { const isActive = getPanelKey(panel) === key; - if (isActive) activePanel = panel; + if (isActive) {activePanel = panel;} panel.hidden = !isActive; panel.setAttribute('aria-hidden', isActive ? 'false' : 'true'); }); @@ -165,9 +165,9 @@ export function initAsidePanels(options = {}) { return stored; } const activeTab = tabs.find((tab) => tab.classList.contains('active')); - if (activeTab) return getTabKey(activeTab); + if (activeTab) {return getTabKey(activeTab);} const visiblePanel = panels.find((panel) => !panel.hidden); - if (visiblePanel) return getPanelKey(visiblePanel); + if (visiblePanel) {return getPanelKey(visiblePanel);} return getTabKey(tabs[0]); }; @@ -180,7 +180,7 @@ export function initAsidePanels(options = {}) { shortcutItems.forEach((item, idx) => { const base = item.dataset.tooltipBase || item.getAttribute('data-tooltip') || item.getAttribute('aria-label') || ''; - if (!base) return; + if (!base) {return;} const shortcutKey = idx === 9 ? '0' : String(idx + 1); item.dataset.tooltipBase = base; item.setAttribute('data-tooltip', `${base} (${shortcutPrefix}${shortcutKey})`); @@ -221,13 +221,13 @@ export function initAsidePanels(options = {}) { }); document.addEventListener('keydown', (event) => { - if (!(event.metaKey || event.ctrlKey)) return; - if (event.altKey) return; - if (isEditableTarget(event.target)) return; + if (!(event.metaKey || event.ctrlKey)) {return;} + if (event.altKey) {return;} + if (isEditableTarget(event.target)) {return;} const key = event.key; - if (!/^[0-9]$/.test(key)) return; + if (!/^[0-9]$/.test(key)) {return;} const index = key === '0' ? 9 : (Number.parseInt(key, 10) - 1); - if (index < 0 || index >= shortcutItems.length) return; + if (index < 0 || index >= shortcutItems.length) {return;} event.preventDefault(); shortcutItems[index].click(); }); diff --git a/web/js/components/app-toggle-details-aside.js b/web/js/components/app-toggle-details-aside.js index 5b1c150..9d1a031 100644 --- a/web/js/components/app-toggle-details-aside.js +++ b/web/js/components/app-toggle-details-aside.js @@ -10,7 +10,7 @@ export function initDetailsAsideToggle(options = {}) { const button = optionalEl(buttonSelector); const aside = optionalEl(asideSelector); - if (!button || !aside) return; + if (!button || !aside) {return;} const container = aside.closest(containerSelector) || document.querySelector(containerSelector); if (!container) { diff --git a/web/js/components/app-toggle-sidebar.js b/web/js/components/app-toggle-sidebar.js index 33b9c2b..f0b4cd8 100644 --- a/web/js/components/app-toggle-sidebar.js +++ b/web/js/components/app-toggle-sidebar.js @@ -10,7 +10,7 @@ export function initSidebarToggle(options = {}) { } = options; const aside = requireEl(asideSelector, { module: 'sidebar-toggle' }); - if (!aside) return null; + if (!aside) {return null;} const buttons = Array.from(document.querySelectorAll(buttonSelector)); if (!buttons.length) { @@ -135,12 +135,12 @@ export function initSidebarToggle(options = {}) { const detail = event.detail || {}; const action = detail.action; const persist = detail.persist ?? false; - if (action === 'open') controller.open({ persist }); - if (action === 'close') controller.close({ persist }); - if (action === 'toggle') controller.toggle({ persist }); - if (action === 'show') controller.show({ persist }); - if (action === 'hide') controller.hide({ persist }); - if (action === 'toggle-visibility') controller.toggleVisibility({ persist }); + if (action === 'open') {controller.open({ persist });} + if (action === 'close') {controller.close({ persist });} + if (action === 'toggle') {controller.toggle({ persist });} + if (action === 'show') {controller.show({ persist });} + if (action === 'hide') {controller.hide({ persist });} + if (action === 'toggle-visibility') {controller.toggleVisibility({ persist });} }); window.AppSidebar = controller; @@ -148,18 +148,18 @@ export function initSidebarToggle(options = {}) { } const isEditableTarget = (target) => { - if (!target) return false; - if (target.isContentEditable) return true; + if (!target) {return false;} + if (target.isContentEditable) {return true;} const tag = target.tagName; return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; }; document.addEventListener('keydown', (event) => { - if (!(event.metaKey || event.ctrlKey)) return; - if (event.altKey) return; - if (event.key.toLowerCase() !== 'b') return; - if (isEditableTarget(event.target)) return; - if (!window.AppSidebar || typeof window.AppSidebar.toggleVisibility !== 'function') return; + if (!(event.metaKey || event.ctrlKey)) {return;} + if (event.altKey) {return;} + if (event.key.toLowerCase() !== 'b') {return;} + if (isEditableTarget(event.target)) {return;} + if (!window.AppSidebar || typeof window.AppSidebar.toggleVisibility !== 'function') {return;} event.preventDefault(); window.AppSidebar.toggleVisibility({ persist: true }); }); diff --git a/web/js/components/app-toggle-toolbar.js b/web/js/components/app-toggle-toolbar.js index 5a3a3ea..e196489 100644 --- a/web/js/components/app-toggle-toolbar.js +++ b/web/js/components/app-toggle-toolbar.js @@ -84,3 +84,9 @@ export function initToolbarToggles(root = document) { }); }); } + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => initToolbarToggles()); +} else { + initToolbarToggles(); +} diff --git a/web/js/core/app-dom.js b/web/js/core/app-dom.js index 493db8d..98691ed 100644 --- a/web/js/core/app-dom.js +++ b/web/js/core/app-dom.js @@ -2,7 +2,7 @@ const warned = new Set(); export const warnOnce = (code, message, details = {}) => { const key = `${code}:${message}`; - if (warned.has(key)) return; + if (warned.has(key)) {return;} warned.add(key); console.warn(`[${code}] ${message}`, details); }; diff --git a/web/js/core/app-grid-factory.js b/web/js/core/app-grid-factory.js index ce6fecf..b83af23 100644 --- a/web/js/core/app-grid-factory.js +++ b/web/js/core/app-grid-factory.js @@ -14,6 +14,7 @@ export function createServerGrid(options) { search = null, filters = [], urlSync = true, + urlState = null, gridjs, rowDblClick = null, actions = null, @@ -34,20 +35,64 @@ export function createServerGrid(options) { } const params = new URLSearchParams(window.location.search); + const urlStateConfig = { + pageParam: 'page', + sortOrderParam: 'order', + sortDirParam: 'dir', + cleanFirstPage: true, + syncPage: true, + syncSort: true, + ...(urlState || {}) + }; + const pageParam = urlStateConfig.pageParam || 'page'; + const sortOrderParam = urlStateConfig.sortOrderParam || 'order'; + const sortDirParam = urlStateConfig.sortDirParam || 'dir'; const resolveUrl = (path) => new URL(path, appBase).toString(); const addParam = (url, key, value) => { const nextUrl = new URL(url); nextUrl.searchParams.set(key, value); return nextUrl.toString(); }; + const deleteParam = (url, key) => { + const nextUrl = new URL(url); + nextUrl.searchParams.delete(key); + return nextUrl.toString(); + }; + const clearSortParams = (url) => deleteParam(deleteParam(url, sortOrderParam), sortDirParam); + const parsePage = (rawValue) => { + const parsed = Number.parseInt(String(rawValue ?? ''), 10); + if (!Number.isFinite(parsed) || parsed < 1) { + return 1; + } + return parsed; + }; + const validSortKeys = new Set( + (sortColumns || []) + .filter((key) => typeof key === 'string') + .map((key) => key.trim()) + .filter(Boolean) + ); + const normalizeDir = (rawValue) => { + const normalized = String(rawValue ?? '').trim().toLowerCase(); + return normalized === 'asc' || normalized === 'desc' ? normalized : ''; + }; + let currentPage = urlStateConfig.syncPage ? parsePage(params.get(pageParam)) : 1; + let currentSort = null; + if (urlStateConfig.syncSort) { + const initialOrder = String(params.get(sortOrderParam) ?? '').trim(); + const initialDir = normalizeDir(params.get(sortDirParam)); + if (initialOrder !== '' && initialDir !== '' && validSortKeys.has(initialOrder)) { + currentSort = { order: initialOrder, dir: initialDir }; + } + } const getInput = (input) => { - if (!input) return null; + if (!input) {return null;} return typeof input === 'string' ? document.querySelector(input) : input; }; const getInputValue = (input) => { - if (!input) return ''; + if (!input) {return '';} if (input.type === 'checkbox') { return input.checked ? (input.value || '1') : ''; } @@ -58,7 +103,7 @@ export function createServerGrid(options) { }; const setInputValue = (input, value) => { - if (!input) return; + if (!input) {return;} if (input.type === 'checkbox') { input.checked = value === true || value === 'true' || value === '1'; } else if (input.tagName === 'SELECT' && input.multiple) { @@ -116,7 +161,7 @@ export function createServerGrid(options) { return value; }; - const buildParams = () => { + const buildDataParams = () => { const query = new URLSearchParams(); if (searchValue) { query.set(searchParam, searchValue); @@ -135,24 +180,47 @@ export function createServerGrid(options) { query.set(filter.param, normalized); } }); + if (currentSort?.order && currentSort?.dir) { + query.set(sortOrderParam, currentSort.order); + query.set(sortDirParam, currentSort.dir); + } + return query; + }; + + const buildUrlParams = () => { + const query = buildDataParams(); + if (!urlStateConfig.syncSort) { + query.delete(sortOrderParam); + query.delete(sortDirParam); + } + if (urlStateConfig.syncPage) { + if (urlStateConfig.cleanFirstPage && currentPage <= 1) { + query.delete(pageParam); + } else { + query.set(pageParam, String(currentPage)); + } + } else { + query.delete(pageParam); + } return query; }; const baseUrl = () => { const url = new URL(dataUrl, appBase); - const query = buildParams(); + const query = buildDataParams(); query.forEach((value, key) => url.searchParams.set(key, value)); return url.toString(); }; const updateUrl = () => { - if (!urlSync) return; + if (!urlSync) {return;} const url = new URL(window.location.href); - if (searchParam) { - url.searchParams.delete(searchParam); - } + if (searchParam) { url.searchParams.delete(searchParam); } filterState.forEach((filter) => url.searchParams.delete(filter.param)); - const query = buildParams(); + url.searchParams.delete(sortOrderParam); + url.searchParams.delete(sortDirParam); + url.searchParams.delete(pageParam); + const query = buildUrlParams(); query.forEach((value, key) => url.searchParams.set(key, value)); window.history.replaceState({}, '', url.toString()); }; @@ -177,7 +245,7 @@ export function createServerGrid(options) { } : null; const insertActionColumn = (cols) => { - if (!actionConfig) return cols; + if (!actionConfig) {return cols;} const actionColumn = { name: actionConfig.label, sort: false, @@ -187,9 +255,9 @@ export function createServerGrid(options) { return gridjsLib.html(''); } const buttons = actionConfig.items.map((item) => { - if (!item || !item.key) return ''; + if (!item || !item.key) {return '';} const label = typeof item.label === 'function' ? item.label(row, id) : item.label; - if (!label) return ''; + if (!label) {return '';} const className = item.className ? ` ${item.className}` : ''; const type = item.type || 'button'; if (type === 'link') { @@ -210,7 +278,7 @@ export function createServerGrid(options) { }; const insertSelectionColumn = (cols) => { - if (!selectionConfig) return cols; + if (!selectionConfig) {return cols;} const headerLabel = selectionConfig.selectAllLabel || 'Select all'; const header = selectionConfig.selectAllEnabled ? gridjsLib.html(``) @@ -231,7 +299,7 @@ export function createServerGrid(options) { }; const insertActionSort = (cols) => { - if (!actionConfig || !cols.length) return cols; + if (!actionConfig || !cols.length) {return cols;} const next = [...cols]; const index = actionConfig.index === null ? next.length : Math.min(Math.max(actionConfig.index, 0), next.length); next.splice(index, 0, null); @@ -239,7 +307,7 @@ export function createServerGrid(options) { }; const insertSelectionSort = (cols) => { - if (!selectionConfig || !cols.length) return cols; + if (!selectionConfig || !cols.length) {return cols;} const next = [...cols]; const index = Math.min(Math.max(selectionConfig.index ?? 0, 0), next.length); next.splice(index, 0, null); @@ -253,7 +321,7 @@ export function createServerGrid(options) { } const index = actionConfig.index === null ? null : Math.min(Math.max(actionConfig.index, 0), Infinity); return rows.map((row) => { - if (!Array.isArray(row)) return row; + if (!Array.isArray(row)) {return row;} const next = [...row]; const insertAt = index === null ? next.length : Math.min(index, next.length); next.splice(insertAt, 0, null); @@ -274,34 +342,54 @@ export function createServerGrid(options) { if (actionConfig) { finalSortColumns = insertActionSort(finalSortColumns); } - let currentSort = null; const sortConfig = finalSortColumns.length ? { enabled: true, server: { url: (prev, columns) => { if (!columns.length) { + // Keep URL/bootstrap sort when grid has no active sort columns yet (e.g. initial load from deep-link). + if (currentSort?.order && currentSort?.dir) { + return addParam(addParam(clearSortParams(prev), sortOrderParam, currentSort.order), sortDirParam, currentSort.dir); + } currentSort = null; - return prev; + return clearSortParams(prev); } const column = columns[0]; const key = finalSortColumns[column.index]; if (!key) { + if (currentSort?.order && currentSort?.dir) { + return addParam(addParam(clearSortParams(prev), sortOrderParam, currentSort.order), sortDirParam, currentSort.dir); + } currentSort = null; - return prev; + return clearSortParams(prev); } const direction = column.direction === 1 ? 'asc' : 'desc'; currentSort = { order: key, dir: direction }; - return addParam(addParam(prev, 'order', key), 'dir', direction); + if (urlSync && urlStateConfig.syncSort) { + updateUrl(); + } + return addParam(addParam(clearSortParams(prev), sortOrderParam, key), sortDirParam, direction); } } } : { enabled: false }; const paginationConfig = { limit: paginationLimit, + page: Math.max(currentPage - 1, 0), + // We control page resets explicitly (search/filter/reset actions) to keep deep-link pages stable. + resetPageOnUpdate: false, server: { url: (prev, page, limit) => { - const offset = page * limit; - return addParam(addParam(prev, 'limit', limit), 'offset', offset); + const parsedPage = Number.parseInt(String(page ?? ''), 10); + const parsedLimit = Number.parseInt(String(limit ?? ''), 10); + const nextPage = Number.isFinite(parsedPage) && parsedPage >= 0 ? parsedPage : 0; + const nextLimit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : paginationLimit; + currentPage = nextPage + 1; + if (urlSync && urlStateConfig.syncPage) { + updateUrl(); + } + const offset = nextPage * nextLimit; + return addParam(addParam(prev, 'limit', nextLimit), 'offset', offset); } } }; @@ -333,7 +421,7 @@ export function createServerGrid(options) { // Prevent empty-state flash before data arrives. let hasLoadedOnce = false; const markLoadedOnce = () => { - if (hasLoadedOnce) return; + if (hasLoadedOnce) {return;} hasLoadedOnce = true; containerEl.classList.add('gridjs-has-loaded'); }; @@ -343,7 +431,7 @@ export function createServerGrid(options) { const store = grid?.config?.store; if (store?.subscribe) { store.subscribe((state) => { - if (!state) return; + if (!state) {return;} // Grid.js status: 0=Init, 1=Loading, 2=Loaded, 3=Rendered, 4=Error if (state.status === 1) { setUpdating(); @@ -360,12 +448,12 @@ export function createServerGrid(options) { } const applyRowDataset = () => { - if (typeof rowDataset !== 'function') return; + if (typeof rowDataset !== 'function') {return;} const rowEls = containerEl.querySelectorAll('.gridjs-tbody .gridjs-tr'); const dataRows = grid.config.store.getState().data?.rows || []; rowEls.forEach((rowEl, index) => { const rowData = dataRows[index] || null; - if (!rowData) return; + if (!rowData) {return;} const attrs = rowDataset(rowData, index) || {}; Object.keys(attrs).forEach((key) => { const value = attrs[key]; @@ -380,9 +468,9 @@ export function createServerGrid(options) { }; const initSelectAll = () => { - if (!selectionConfig?.component || !selectionConfig.selectAllEnabled) return; + if (!selectionConfig?.component || !selectionConfig.selectAllEnabled) {return;} const header = containerEl.querySelector(`th[data-column-id="${selectionConfig.id}"] input[data-grid-select-all]`); - if (!header) return; + if (!header) {return;} if (header.dataset.bound === '1') { return; } @@ -421,13 +509,13 @@ export function createServerGrid(options) { } const getRowDataByElement = (rowEl) => { - if (!rowEl) return null; + if (!rowEl) {return null;} const rows = Array.from(rowEl.parentElement?.children || []); const rowIndex = rows.indexOf(rowEl); - if (rowIndex < 0) return null; + if (rowIndex < 0) {return null;} const dataRows = grid.config.store.getState().data?.rows || []; const rowData = dataRows[rowIndex] || null; - if (!rowData) return null; + if (!rowData) {return null;} return { rowData, rowIndex }; }; @@ -498,11 +586,19 @@ export function createServerGrid(options) { }); } - const updateGrid = () => { + const updateGrid = (options = {}) => { + const resetPage = options && options.resetPage === true; + if (resetPage) { + currentPage = 1; + } updateUrl(); - grid.updateConfig({ + const nextConfig = { server: { ...serverConfig, url: baseUrl() } - }).forceRender(); + }; + if (resetPage) { + nextConfig.pagination = { ...paginationConfig, page: 0 }; + } + grid.updateConfig(nextConfig).forceRender(); }; const debounce = (fn, delay = 250) => { @@ -516,16 +612,16 @@ export function createServerGrid(options) { if (searchInput) { searchInput.addEventListener('input', debounce((event) => { searchValue = event.target.value.trim(); - updateGrid(); + updateGrid({ resetPage: true }); }, searchDebounce)); } filterState.forEach((filter) => { - if (!filter.input) return; + if (!filter.input) {return;} const eventName = filter.event || 'change'; filter.input.addEventListener(eventName, () => { filter.value = getInputValue(filter.input); - updateGrid(); + updateGrid({ resetPage: true }); }); }); @@ -544,7 +640,7 @@ export function createServerGrid(options) { setInputValue(filter.input, defaultValue); } }); - updateGrid(); + updateGrid({ resetPage: true }); }; const getSortParams = () => (currentSort ? { ...currentSort } : null); @@ -556,14 +652,14 @@ export function createServerGrid(options) { const plugin = grid?.config?.plugin?.get?.(selectionConfig.id); const store = plugin?.props?.store; const state = store?.state ?? store?.getState?.(); - if (!state) return []; - if (Array.isArray(state.selected)) return state.selected; - if (Array.isArray(state.ids)) return state.ids; + if (!state) {return [];} + if (Array.isArray(state.selected)) {return state.selected;} + if (Array.isArray(state.ids)) {return state.ids;} if (state.rows && typeof state.rows === 'object') { return Object.keys(state.rows).filter((key) => state.rows[key]); } - if (state instanceof Set) return Array.from(state); - if (Array.isArray(state)) return state; + if (state instanceof Set) {return Array.from(state);} + if (Array.isArray(state)) {return state;} if (typeof state === 'object') { return Object.keys(state).filter((key) => state[key]); } @@ -587,8 +683,8 @@ export function createServerGrid(options) { const sync = () => { selectionConfig.onChange(selectionApi?.getSelectedIds?.() || []); }; - if (store?.on) store.on('updated', sync); - if (store?.subscribe) store.subscribe(sync); + if (store?.on) {store.on('updated', sync);} + if (store?.subscribe) {store.subscribe(sync);} sync(); }); } diff --git a/web/js/pages/app-list-utils.js b/web/js/pages/app-list-utils.js index 11cbfad..db14bdf 100644 --- a/web/js/pages/app-list-utils.js +++ b/web/js/pages/app-list-utils.js @@ -29,7 +29,7 @@ export const buildBadgeList = (items, options = {}) => { list = Array.isArray(items.items) ? items.items : []; primary = String(items.primary ?? ''); } - if (!Array.isArray(list) || list.length === 0) return ''; + if (!Array.isArray(list) || list.length === 0) {return '';} const tooltipAttr = primaryTooltip ? ` data-tooltip="${escapeHtml(primaryTooltip)}" data-tooltip-pos="top"` diff --git a/web/js/pages/app-users-list.js b/web/js/pages/app-users-list.js index eaf6823..95a0774 100644 --- a/web/js/pages/app-users-list.js +++ b/web/js/pages/app-users-list.js @@ -5,6 +5,7 @@ export function initUsersListPage(options = {}) { csrf, labels = {}, selectors = {}, + bulkDownloadForms = {}, exportPath = 'admin/users/export', listPath = 'admin/users', buildBulkUrl, @@ -12,7 +13,7 @@ export function initUsersListPage(options = {}) { withLoading } = options; - if (!gridConfig) return; + if (!gridConfig) {return;} const exportSelector = selectors.exportButton || '[data-users-export]'; const resetSelector = selectors.resetButton || '[data-users-reset]'; @@ -67,21 +68,40 @@ export function initUsersListPage(options = {}) { button.addEventListener('click', async () => { const action = button.getAttribute('data-users-bulk') || ''; const ids = gridConfig.selection?.getSelectedIds?.() || []; - const validActions = ['activate', 'deactivate', 'delete', 'send-access']; + const validActions = ['activate', 'deactivate', 'delete', 'send-access', 'access-pdf']; if (!ids.length || !validActions.includes(action)) { + if (action && !validActions.includes(action)) { + console.warn(`Unsupported users bulk action: ${action}`); + } return; } const confirmTexts = { 'activate': labels.bulkActivateConfirm, 'deactivate': labels.bulkDeactivateConfirm, 'delete': labels.bulkDeleteConfirm, - 'send-access': labels.bulkSendAccessConfirm + 'send-access': labels.bulkSendAccessConfirm, + 'access-pdf': labels.bulkAccessPdfConfirm }; const confirmText = confirmTexts[action]; if (confirmText && !window.confirm(confirmText)) { return; } + const downloadFormSelector = bulkDownloadForms?.[action]; + if (downloadFormSelector) { + const downloadForm = document.querySelector(downloadFormSelector); + if (!downloadForm) { + window.location.href = new URL(listPath, appBase).toString(); + return; + } + const uuidsInput = downloadForm.querySelector('input[name="uuids"]'); + if (uuidsInput) { + uuidsInput.value = ids.join(','); + } + downloadForm.submit(); + return; + } + const executeBulk = async () => { const url = resolveBulkUrl(action); const response = await postAction(url, { uuids: ids.join(',') }); diff --git a/web/vendor/swagger-ui/swagger-ui-bundle.js b/web/vendor/swagger-ui/swagger-ui-bundle.js new file mode 100644 index 0000000..4526219 --- /dev/null +++ b/web/vendor/swagger-ui/swagger-ui-bundle.js @@ -0,0 +1,2 @@ +/*! For license information please see swagger-ui-bundle.js.LICENSE.txt */ +!function webpackUniversalModuleDefinition(s,o){"object"==typeof exports&&"object"==typeof module?module.exports=o():"function"==typeof define&&define.amd?define([],o):"object"==typeof exports?exports.SwaggerUIBundle=o():s.SwaggerUIBundle=o()}(this,(()=>(()=>{var s,o,i={69119:(s,o)=>{"use strict";Object.defineProperty(o,"__esModule",{value:!0}),o.BLANK_URL=o.relativeFirstCharacters=o.whitespaceEscapeCharsRegex=o.urlSchemeRegex=o.ctrlCharactersRegex=o.htmlCtrlEntityRegex=o.htmlEntitiesRegex=o.invalidProtocolRegex=void 0,o.invalidProtocolRegex=/^([^\w]*)(javascript|data|vbscript)/im,o.htmlEntitiesRegex=/&#(\w+)(^\w|;)?/g,o.htmlCtrlEntityRegex=/&(newline|tab);/gi,o.ctrlCharactersRegex=/[\u0000-\u001F\u007F-\u009F\u2000-\u200D\uFEFF]/gim,o.urlSchemeRegex=/^.+(:|:)/gim,o.whitespaceEscapeCharsRegex=/(\\|%5[cC])((%(6[eE]|72|74))|[nrt])/g,o.relativeFirstCharacters=[".","/"],o.BLANK_URL="about:blank"},16750:(s,o,i)=>{"use strict";o.J=void 0;var u=i(69119);function decodeURI(s){try{return decodeURIComponent(s)}catch(o){return s}}o.J=function sanitizeUrl(s){if(!s)return u.BLANK_URL;var o,i,_=decodeURI(s);do{o=(_=decodeURI(_=(i=_,i.replace(u.ctrlCharactersRegex,"").replace(u.htmlEntitiesRegex,(function(s,o){return String.fromCharCode(o)}))).replace(u.htmlCtrlEntityRegex,"").replace(u.ctrlCharactersRegex,"").replace(u.whitespaceEscapeCharsRegex,"").trim())).match(u.ctrlCharactersRegex)||_.match(u.htmlEntitiesRegex)||_.match(u.htmlCtrlEntityRegex)||_.match(u.whitespaceEscapeCharsRegex)}while(o&&o.length>0);var w=_;if(!w)return u.BLANK_URL;if(function isRelativeUrlWithoutProtocol(s){return u.relativeFirstCharacters.indexOf(s[0])>-1}(w))return w;var x=w.match(u.urlSchemeRegex);if(!x)return w;var C=x[0];return u.invalidProtocolRegex.test(C)?u.BLANK_URL:w}},67526:(s,o)=>{"use strict";o.byteLength=function byteLength(s){var o=getLens(s),i=o[0],u=o[1];return 3*(i+u)/4-u},o.toByteArray=function toByteArray(s){var o,i,w=getLens(s),x=w[0],C=w[1],j=new _(function _byteLength(s,o,i){return 3*(o+i)/4-i}(0,x,C)),L=0,B=C>0?x-4:x;for(i=0;i>16&255,j[L++]=o>>8&255,j[L++]=255&o;2===C&&(o=u[s.charCodeAt(i)]<<2|u[s.charCodeAt(i+1)]>>4,j[L++]=255&o);1===C&&(o=u[s.charCodeAt(i)]<<10|u[s.charCodeAt(i+1)]<<4|u[s.charCodeAt(i+2)]>>2,j[L++]=o>>8&255,j[L++]=255&o);return j},o.fromByteArray=function fromByteArray(s){for(var o,u=s.length,_=u%3,w=[],x=16383,C=0,j=u-_;Cj?j:C+x));1===_?(o=s[u-1],w.push(i[o>>2]+i[o<<4&63]+"==")):2===_&&(o=(s[u-2]<<8)+s[u-1],w.push(i[o>>10]+i[o>>4&63]+i[o<<2&63]+"="));return w.join("")};for(var i=[],u=[],_="undefined"!=typeof Uint8Array?Uint8Array:Array,w="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",x=0;x<64;++x)i[x]=w[x],u[w.charCodeAt(x)]=x;function getLens(s){var o=s.length;if(o%4>0)throw new Error("Invalid string. Length must be a multiple of 4");var i=s.indexOf("=");return-1===i&&(i=o),[i,i===o?0:4-i%4]}function encodeChunk(s,o,u){for(var _,w,x=[],C=o;C>18&63]+i[w>>12&63]+i[w>>6&63]+i[63&w]);return x.join("")}u["-".charCodeAt(0)]=62,u["_".charCodeAt(0)]=63},48287:(s,o,i)=>{"use strict";const u=i(67526),_=i(251),w="function"==typeof Symbol&&"function"==typeof Symbol.for?Symbol.for("nodejs.util.inspect.custom"):null;o.Buffer=Buffer,o.SlowBuffer=function SlowBuffer(s){+s!=s&&(s=0);return Buffer.alloc(+s)},o.INSPECT_MAX_BYTES=50;const x=2147483647;function createBuffer(s){if(s>x)throw new RangeError('The value "'+s+'" is invalid for option "size"');const o=new Uint8Array(s);return Object.setPrototypeOf(o,Buffer.prototype),o}function Buffer(s,o,i){if("number"==typeof s){if("string"==typeof o)throw new TypeError('The "string" argument must be of type string. Received type number');return allocUnsafe(s)}return from(s,o,i)}function from(s,o,i){if("string"==typeof s)return function fromString(s,o){"string"==typeof o&&""!==o||(o="utf8");if(!Buffer.isEncoding(o))throw new TypeError("Unknown encoding: "+o);const i=0|byteLength(s,o);let u=createBuffer(i);const _=u.write(s,o);_!==i&&(u=u.slice(0,_));return u}(s,o);if(ArrayBuffer.isView(s))return function fromArrayView(s){if(isInstance(s,Uint8Array)){const o=new Uint8Array(s);return fromArrayBuffer(o.buffer,o.byteOffset,o.byteLength)}return fromArrayLike(s)}(s);if(null==s)throw new TypeError("The first argument must be one of type string, Buffer, ArrayBuffer, Array, or Array-like Object. Received type "+typeof s);if(isInstance(s,ArrayBuffer)||s&&isInstance(s.buffer,ArrayBuffer))return fromArrayBuffer(s,o,i);if("undefined"!=typeof SharedArrayBuffer&&(isInstance(s,SharedArrayBuffer)||s&&isInstance(s.buffer,SharedArrayBuffer)))return fromArrayBuffer(s,o,i);if("number"==typeof s)throw new TypeError('The "value" argument must not be of type number. Received type number');const u=s.valueOf&&s.valueOf();if(null!=u&&u!==s)return Buffer.from(u,o,i);const _=function fromObject(s){if(Buffer.isBuffer(s)){const o=0|checked(s.length),i=createBuffer(o);return 0===i.length||s.copy(i,0,0,o),i}if(void 0!==s.length)return"number"!=typeof s.length||numberIsNaN(s.length)?createBuffer(0):fromArrayLike(s);if("Buffer"===s.type&&Array.isArray(s.data))return fromArrayLike(s.data)}(s);if(_)return _;if("undefined"!=typeof Symbol&&null!=Symbol.toPrimitive&&"function"==typeof s[Symbol.toPrimitive])return Buffer.from(s[Symbol.toPrimitive]("string"),o,i);throw new TypeError("The first argument must be one of type string, Buffer, ArrayBuffer, Array, or Array-like Object. Received type "+typeof s)}function assertSize(s){if("number"!=typeof s)throw new TypeError('"size" argument must be of type number');if(s<0)throw new RangeError('The value "'+s+'" is invalid for option "size"')}function allocUnsafe(s){return assertSize(s),createBuffer(s<0?0:0|checked(s))}function fromArrayLike(s){const o=s.length<0?0:0|checked(s.length),i=createBuffer(o);for(let u=0;u=x)throw new RangeError("Attempt to allocate Buffer larger than maximum size: 0x"+x.toString(16)+" bytes");return 0|s}function byteLength(s,o){if(Buffer.isBuffer(s))return s.length;if(ArrayBuffer.isView(s)||isInstance(s,ArrayBuffer))return s.byteLength;if("string"!=typeof s)throw new TypeError('The "string" argument must be one of type string, Buffer, or ArrayBuffer. Received type '+typeof s);const i=s.length,u=arguments.length>2&&!0===arguments[2];if(!u&&0===i)return 0;let _=!1;for(;;)switch(o){case"ascii":case"latin1":case"binary":return i;case"utf8":case"utf-8":return utf8ToBytes(s).length;case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return 2*i;case"hex":return i>>>1;case"base64":return base64ToBytes(s).length;default:if(_)return u?-1:utf8ToBytes(s).length;o=(""+o).toLowerCase(),_=!0}}function slowToString(s,o,i){let u=!1;if((void 0===o||o<0)&&(o=0),o>this.length)return"";if((void 0===i||i>this.length)&&(i=this.length),i<=0)return"";if((i>>>=0)<=(o>>>=0))return"";for(s||(s="utf8");;)switch(s){case"hex":return hexSlice(this,o,i);case"utf8":case"utf-8":return utf8Slice(this,o,i);case"ascii":return asciiSlice(this,o,i);case"latin1":case"binary":return latin1Slice(this,o,i);case"base64":return base64Slice(this,o,i);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return utf16leSlice(this,o,i);default:if(u)throw new TypeError("Unknown encoding: "+s);s=(s+"").toLowerCase(),u=!0}}function swap(s,o,i){const u=s[o];s[o]=s[i],s[i]=u}function bidirectionalIndexOf(s,o,i,u,_){if(0===s.length)return-1;if("string"==typeof i?(u=i,i=0):i>2147483647?i=2147483647:i<-2147483648&&(i=-2147483648),numberIsNaN(i=+i)&&(i=_?0:s.length-1),i<0&&(i=s.length+i),i>=s.length){if(_)return-1;i=s.length-1}else if(i<0){if(!_)return-1;i=0}if("string"==typeof o&&(o=Buffer.from(o,u)),Buffer.isBuffer(o))return 0===o.length?-1:arrayIndexOf(s,o,i,u,_);if("number"==typeof o)return o&=255,"function"==typeof Uint8Array.prototype.indexOf?_?Uint8Array.prototype.indexOf.call(s,o,i):Uint8Array.prototype.lastIndexOf.call(s,o,i):arrayIndexOf(s,[o],i,u,_);throw new TypeError("val must be string, number or Buffer")}function arrayIndexOf(s,o,i,u,_){let w,x=1,C=s.length,j=o.length;if(void 0!==u&&("ucs2"===(u=String(u).toLowerCase())||"ucs-2"===u||"utf16le"===u||"utf-16le"===u)){if(s.length<2||o.length<2)return-1;x=2,C/=2,j/=2,i/=2}function read(s,o){return 1===x?s[o]:s.readUInt16BE(o*x)}if(_){let u=-1;for(w=i;wC&&(i=C-j),w=i;w>=0;w--){let i=!0;for(let u=0;u_&&(u=_):u=_;const w=o.length;let x;for(u>w/2&&(u=w/2),x=0;x>8,_=i%256,w.push(_),w.push(u);return w}(o,s.length-i),s,i,u)}function base64Slice(s,o,i){return 0===o&&i===s.length?u.fromByteArray(s):u.fromByteArray(s.slice(o,i))}function utf8Slice(s,o,i){i=Math.min(s.length,i);const u=[];let _=o;for(;_239?4:o>223?3:o>191?2:1;if(_+x<=i){let i,u,C,j;switch(x){case 1:o<128&&(w=o);break;case 2:i=s[_+1],128==(192&i)&&(j=(31&o)<<6|63&i,j>127&&(w=j));break;case 3:i=s[_+1],u=s[_+2],128==(192&i)&&128==(192&u)&&(j=(15&o)<<12|(63&i)<<6|63&u,j>2047&&(j<55296||j>57343)&&(w=j));break;case 4:i=s[_+1],u=s[_+2],C=s[_+3],128==(192&i)&&128==(192&u)&&128==(192&C)&&(j=(15&o)<<18|(63&i)<<12|(63&u)<<6|63&C,j>65535&&j<1114112&&(w=j))}}null===w?(w=65533,x=1):w>65535&&(w-=65536,u.push(w>>>10&1023|55296),w=56320|1023&w),u.push(w),_+=x}return function decodeCodePointsArray(s){const o=s.length;if(o<=C)return String.fromCharCode.apply(String,s);let i="",u=0;for(;uu.length?(Buffer.isBuffer(o)||(o=Buffer.from(o)),o.copy(u,_)):Uint8Array.prototype.set.call(u,o,_);else{if(!Buffer.isBuffer(o))throw new TypeError('"list" argument must be an Array of Buffers');o.copy(u,_)}_+=o.length}return u},Buffer.byteLength=byteLength,Buffer.prototype._isBuffer=!0,Buffer.prototype.swap16=function swap16(){const s=this.length;if(s%2!=0)throw new RangeError("Buffer size must be a multiple of 16-bits");for(let o=0;oi&&(s+=" ... "),""},w&&(Buffer.prototype[w]=Buffer.prototype.inspect),Buffer.prototype.compare=function compare(s,o,i,u,_){if(isInstance(s,Uint8Array)&&(s=Buffer.from(s,s.offset,s.byteLength)),!Buffer.isBuffer(s))throw new TypeError('The "target" argument must be one of type Buffer or Uint8Array. Received type '+typeof s);if(void 0===o&&(o=0),void 0===i&&(i=s?s.length:0),void 0===u&&(u=0),void 0===_&&(_=this.length),o<0||i>s.length||u<0||_>this.length)throw new RangeError("out of range index");if(u>=_&&o>=i)return 0;if(u>=_)return-1;if(o>=i)return 1;if(this===s)return 0;let w=(_>>>=0)-(u>>>=0),x=(i>>>=0)-(o>>>=0);const C=Math.min(w,x),j=this.slice(u,_),L=s.slice(o,i);for(let s=0;s>>=0,isFinite(i)?(i>>>=0,void 0===u&&(u="utf8")):(u=i,i=void 0)}const _=this.length-o;if((void 0===i||i>_)&&(i=_),s.length>0&&(i<0||o<0)||o>this.length)throw new RangeError("Attempt to write outside buffer bounds");u||(u="utf8");let w=!1;for(;;)switch(u){case"hex":return hexWrite(this,s,o,i);case"utf8":case"utf-8":return utf8Write(this,s,o,i);case"ascii":case"latin1":case"binary":return asciiWrite(this,s,o,i);case"base64":return base64Write(this,s,o,i);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return ucs2Write(this,s,o,i);default:if(w)throw new TypeError("Unknown encoding: "+u);u=(""+u).toLowerCase(),w=!0}},Buffer.prototype.toJSON=function toJSON(){return{type:"Buffer",data:Array.prototype.slice.call(this._arr||this,0)}};const C=4096;function asciiSlice(s,o,i){let u="";i=Math.min(s.length,i);for(let _=o;_u)&&(i=u);let _="";for(let u=o;ui)throw new RangeError("Trying to access beyond buffer length")}function checkInt(s,o,i,u,_,w){if(!Buffer.isBuffer(s))throw new TypeError('"buffer" argument must be a Buffer instance');if(o>_||os.length)throw new RangeError("Index out of range")}function wrtBigUInt64LE(s,o,i,u,_){checkIntBI(o,u,_,s,i,7);let w=Number(o&BigInt(4294967295));s[i++]=w,w>>=8,s[i++]=w,w>>=8,s[i++]=w,w>>=8,s[i++]=w;let x=Number(o>>BigInt(32)&BigInt(4294967295));return s[i++]=x,x>>=8,s[i++]=x,x>>=8,s[i++]=x,x>>=8,s[i++]=x,i}function wrtBigUInt64BE(s,o,i,u,_){checkIntBI(o,u,_,s,i,7);let w=Number(o&BigInt(4294967295));s[i+7]=w,w>>=8,s[i+6]=w,w>>=8,s[i+5]=w,w>>=8,s[i+4]=w;let x=Number(o>>BigInt(32)&BigInt(4294967295));return s[i+3]=x,x>>=8,s[i+2]=x,x>>=8,s[i+1]=x,x>>=8,s[i]=x,i+8}function checkIEEE754(s,o,i,u,_,w){if(i+u>s.length)throw new RangeError("Index out of range");if(i<0)throw new RangeError("Index out of range")}function writeFloat(s,o,i,u,w){return o=+o,i>>>=0,w||checkIEEE754(s,0,i,4),_.write(s,o,i,u,23,4),i+4}function writeDouble(s,o,i,u,w){return o=+o,i>>>=0,w||checkIEEE754(s,0,i,8),_.write(s,o,i,u,52,8),i+8}Buffer.prototype.slice=function slice(s,o){const i=this.length;(s=~~s)<0?(s+=i)<0&&(s=0):s>i&&(s=i),(o=void 0===o?i:~~o)<0?(o+=i)<0&&(o=0):o>i&&(o=i),o>>=0,o>>>=0,i||checkOffset(s,o,this.length);let u=this[s],_=1,w=0;for(;++w>>=0,o>>>=0,i||checkOffset(s,o,this.length);let u=this[s+--o],_=1;for(;o>0&&(_*=256);)u+=this[s+--o]*_;return u},Buffer.prototype.readUint8=Buffer.prototype.readUInt8=function readUInt8(s,o){return s>>>=0,o||checkOffset(s,1,this.length),this[s]},Buffer.prototype.readUint16LE=Buffer.prototype.readUInt16LE=function readUInt16LE(s,o){return s>>>=0,o||checkOffset(s,2,this.length),this[s]|this[s+1]<<8},Buffer.prototype.readUint16BE=Buffer.prototype.readUInt16BE=function readUInt16BE(s,o){return s>>>=0,o||checkOffset(s,2,this.length),this[s]<<8|this[s+1]},Buffer.prototype.readUint32LE=Buffer.prototype.readUInt32LE=function readUInt32LE(s,o){return s>>>=0,o||checkOffset(s,4,this.length),(this[s]|this[s+1]<<8|this[s+2]<<16)+16777216*this[s+3]},Buffer.prototype.readUint32BE=Buffer.prototype.readUInt32BE=function readUInt32BE(s,o){return s>>>=0,o||checkOffset(s,4,this.length),16777216*this[s]+(this[s+1]<<16|this[s+2]<<8|this[s+3])},Buffer.prototype.readBigUInt64LE=defineBigIntMethod((function readBigUInt64LE(s){validateNumber(s>>>=0,"offset");const o=this[s],i=this[s+7];void 0!==o&&void 0!==i||boundsError(s,this.length-8);const u=o+256*this[++s]+65536*this[++s]+this[++s]*2**24,_=this[++s]+256*this[++s]+65536*this[++s]+i*2**24;return BigInt(u)+(BigInt(_)<>>=0,"offset");const o=this[s],i=this[s+7];void 0!==o&&void 0!==i||boundsError(s,this.length-8);const u=o*2**24+65536*this[++s]+256*this[++s]+this[++s],_=this[++s]*2**24+65536*this[++s]+256*this[++s]+i;return(BigInt(u)<>>=0,o>>>=0,i||checkOffset(s,o,this.length);let u=this[s],_=1,w=0;for(;++w=_&&(u-=Math.pow(2,8*o)),u},Buffer.prototype.readIntBE=function readIntBE(s,o,i){s>>>=0,o>>>=0,i||checkOffset(s,o,this.length);let u=o,_=1,w=this[s+--u];for(;u>0&&(_*=256);)w+=this[s+--u]*_;return _*=128,w>=_&&(w-=Math.pow(2,8*o)),w},Buffer.prototype.readInt8=function readInt8(s,o){return s>>>=0,o||checkOffset(s,1,this.length),128&this[s]?-1*(255-this[s]+1):this[s]},Buffer.prototype.readInt16LE=function readInt16LE(s,o){s>>>=0,o||checkOffset(s,2,this.length);const i=this[s]|this[s+1]<<8;return 32768&i?4294901760|i:i},Buffer.prototype.readInt16BE=function readInt16BE(s,o){s>>>=0,o||checkOffset(s,2,this.length);const i=this[s+1]|this[s]<<8;return 32768&i?4294901760|i:i},Buffer.prototype.readInt32LE=function readInt32LE(s,o){return s>>>=0,o||checkOffset(s,4,this.length),this[s]|this[s+1]<<8|this[s+2]<<16|this[s+3]<<24},Buffer.prototype.readInt32BE=function readInt32BE(s,o){return s>>>=0,o||checkOffset(s,4,this.length),this[s]<<24|this[s+1]<<16|this[s+2]<<8|this[s+3]},Buffer.prototype.readBigInt64LE=defineBigIntMethod((function readBigInt64LE(s){validateNumber(s>>>=0,"offset");const o=this[s],i=this[s+7];void 0!==o&&void 0!==i||boundsError(s,this.length-8);const u=this[s+4]+256*this[s+5]+65536*this[s+6]+(i<<24);return(BigInt(u)<>>=0,"offset");const o=this[s],i=this[s+7];void 0!==o&&void 0!==i||boundsError(s,this.length-8);const u=(o<<24)+65536*this[++s]+256*this[++s]+this[++s];return(BigInt(u)<>>=0,o||checkOffset(s,4,this.length),_.read(this,s,!0,23,4)},Buffer.prototype.readFloatBE=function readFloatBE(s,o){return s>>>=0,o||checkOffset(s,4,this.length),_.read(this,s,!1,23,4)},Buffer.prototype.readDoubleLE=function readDoubleLE(s,o){return s>>>=0,o||checkOffset(s,8,this.length),_.read(this,s,!0,52,8)},Buffer.prototype.readDoubleBE=function readDoubleBE(s,o){return s>>>=0,o||checkOffset(s,8,this.length),_.read(this,s,!1,52,8)},Buffer.prototype.writeUintLE=Buffer.prototype.writeUIntLE=function writeUIntLE(s,o,i,u){if(s=+s,o>>>=0,i>>>=0,!u){checkInt(this,s,o,i,Math.pow(2,8*i)-1,0)}let _=1,w=0;for(this[o]=255&s;++w>>=0,i>>>=0,!u){checkInt(this,s,o,i,Math.pow(2,8*i)-1,0)}let _=i-1,w=1;for(this[o+_]=255&s;--_>=0&&(w*=256);)this[o+_]=s/w&255;return o+i},Buffer.prototype.writeUint8=Buffer.prototype.writeUInt8=function writeUInt8(s,o,i){return s=+s,o>>>=0,i||checkInt(this,s,o,1,255,0),this[o]=255&s,o+1},Buffer.prototype.writeUint16LE=Buffer.prototype.writeUInt16LE=function writeUInt16LE(s,o,i){return s=+s,o>>>=0,i||checkInt(this,s,o,2,65535,0),this[o]=255&s,this[o+1]=s>>>8,o+2},Buffer.prototype.writeUint16BE=Buffer.prototype.writeUInt16BE=function writeUInt16BE(s,o,i){return s=+s,o>>>=0,i||checkInt(this,s,o,2,65535,0),this[o]=s>>>8,this[o+1]=255&s,o+2},Buffer.prototype.writeUint32LE=Buffer.prototype.writeUInt32LE=function writeUInt32LE(s,o,i){return s=+s,o>>>=0,i||checkInt(this,s,o,4,4294967295,0),this[o+3]=s>>>24,this[o+2]=s>>>16,this[o+1]=s>>>8,this[o]=255&s,o+4},Buffer.prototype.writeUint32BE=Buffer.prototype.writeUInt32BE=function writeUInt32BE(s,o,i){return s=+s,o>>>=0,i||checkInt(this,s,o,4,4294967295,0),this[o]=s>>>24,this[o+1]=s>>>16,this[o+2]=s>>>8,this[o+3]=255&s,o+4},Buffer.prototype.writeBigUInt64LE=defineBigIntMethod((function writeBigUInt64LE(s,o=0){return wrtBigUInt64LE(this,s,o,BigInt(0),BigInt("0xffffffffffffffff"))})),Buffer.prototype.writeBigUInt64BE=defineBigIntMethod((function writeBigUInt64BE(s,o=0){return wrtBigUInt64BE(this,s,o,BigInt(0),BigInt("0xffffffffffffffff"))})),Buffer.prototype.writeIntLE=function writeIntLE(s,o,i,u){if(s=+s,o>>>=0,!u){const u=Math.pow(2,8*i-1);checkInt(this,s,o,i,u-1,-u)}let _=0,w=1,x=0;for(this[o]=255&s;++_>>=0,!u){const u=Math.pow(2,8*i-1);checkInt(this,s,o,i,u-1,-u)}let _=i-1,w=1,x=0;for(this[o+_]=255&s;--_>=0&&(w*=256);)s<0&&0===x&&0!==this[o+_+1]&&(x=1),this[o+_]=(s/w|0)-x&255;return o+i},Buffer.prototype.writeInt8=function writeInt8(s,o,i){return s=+s,o>>>=0,i||checkInt(this,s,o,1,127,-128),s<0&&(s=255+s+1),this[o]=255&s,o+1},Buffer.prototype.writeInt16LE=function writeInt16LE(s,o,i){return s=+s,o>>>=0,i||checkInt(this,s,o,2,32767,-32768),this[o]=255&s,this[o+1]=s>>>8,o+2},Buffer.prototype.writeInt16BE=function writeInt16BE(s,o,i){return s=+s,o>>>=0,i||checkInt(this,s,o,2,32767,-32768),this[o]=s>>>8,this[o+1]=255&s,o+2},Buffer.prototype.writeInt32LE=function writeInt32LE(s,o,i){return s=+s,o>>>=0,i||checkInt(this,s,o,4,2147483647,-2147483648),this[o]=255&s,this[o+1]=s>>>8,this[o+2]=s>>>16,this[o+3]=s>>>24,o+4},Buffer.prototype.writeInt32BE=function writeInt32BE(s,o,i){return s=+s,o>>>=0,i||checkInt(this,s,o,4,2147483647,-2147483648),s<0&&(s=4294967295+s+1),this[o]=s>>>24,this[o+1]=s>>>16,this[o+2]=s>>>8,this[o+3]=255&s,o+4},Buffer.prototype.writeBigInt64LE=defineBigIntMethod((function writeBigInt64LE(s,o=0){return wrtBigUInt64LE(this,s,o,-BigInt("0x8000000000000000"),BigInt("0x7fffffffffffffff"))})),Buffer.prototype.writeBigInt64BE=defineBigIntMethod((function writeBigInt64BE(s,o=0){return wrtBigUInt64BE(this,s,o,-BigInt("0x8000000000000000"),BigInt("0x7fffffffffffffff"))})),Buffer.prototype.writeFloatLE=function writeFloatLE(s,o,i){return writeFloat(this,s,o,!0,i)},Buffer.prototype.writeFloatBE=function writeFloatBE(s,o,i){return writeFloat(this,s,o,!1,i)},Buffer.prototype.writeDoubleLE=function writeDoubleLE(s,o,i){return writeDouble(this,s,o,!0,i)},Buffer.prototype.writeDoubleBE=function writeDoubleBE(s,o,i){return writeDouble(this,s,o,!1,i)},Buffer.prototype.copy=function copy(s,o,i,u){if(!Buffer.isBuffer(s))throw new TypeError("argument should be a Buffer");if(i||(i=0),u||0===u||(u=this.length),o>=s.length&&(o=s.length),o||(o=0),u>0&&u=this.length)throw new RangeError("Index out of range");if(u<0)throw new RangeError("sourceEnd out of bounds");u>this.length&&(u=this.length),s.length-o>>=0,i=void 0===i?this.length:i>>>0,s||(s=0),"number"==typeof s)for(_=o;_=u+4;i-=3)o=`_${s.slice(i-3,i)}${o}`;return`${s.slice(0,i)}${o}`}function checkIntBI(s,o,i,u,_,w){if(s>i||s3?0===o||o===BigInt(0)?`>= 0${u} and < 2${u} ** ${8*(w+1)}${u}`:`>= -(2${u} ** ${8*(w+1)-1}${u}) and < 2 ** ${8*(w+1)-1}${u}`:`>= ${o}${u} and <= ${i}${u}`,new j.ERR_OUT_OF_RANGE("value",_,s)}!function checkBounds(s,o,i){validateNumber(o,"offset"),void 0!==s[o]&&void 0!==s[o+i]||boundsError(o,s.length-(i+1))}(u,_,w)}function validateNumber(s,o){if("number"!=typeof s)throw new j.ERR_INVALID_ARG_TYPE(o,"number",s)}function boundsError(s,o,i){if(Math.floor(s)!==s)throw validateNumber(s,i),new j.ERR_OUT_OF_RANGE(i||"offset","an integer",s);if(o<0)throw new j.ERR_BUFFER_OUT_OF_BOUNDS;throw new j.ERR_OUT_OF_RANGE(i||"offset",`>= ${i?1:0} and <= ${o}`,s)}E("ERR_BUFFER_OUT_OF_BOUNDS",(function(s){return s?`${s} is outside of buffer bounds`:"Attempt to access memory outside buffer bounds"}),RangeError),E("ERR_INVALID_ARG_TYPE",(function(s,o){return`The "${s}" argument must be of type number. Received type ${typeof o}`}),TypeError),E("ERR_OUT_OF_RANGE",(function(s,o,i){let u=`The value of "${s}" is out of range.`,_=i;return Number.isInteger(i)&&Math.abs(i)>2**32?_=addNumericalSeparator(String(i)):"bigint"==typeof i&&(_=String(i),(i>BigInt(2)**BigInt(32)||i<-(BigInt(2)**BigInt(32)))&&(_=addNumericalSeparator(_)),_+="n"),u+=` It must be ${o}. Received ${_}`,u}),RangeError);const L=/[^+/0-9A-Za-z-_]/g;function utf8ToBytes(s,o){let i;o=o||1/0;const u=s.length;let _=null;const w=[];for(let x=0;x55295&&i<57344){if(!_){if(i>56319){(o-=3)>-1&&w.push(239,191,189);continue}if(x+1===u){(o-=3)>-1&&w.push(239,191,189);continue}_=i;continue}if(i<56320){(o-=3)>-1&&w.push(239,191,189),_=i;continue}i=65536+(_-55296<<10|i-56320)}else _&&(o-=3)>-1&&w.push(239,191,189);if(_=null,i<128){if((o-=1)<0)break;w.push(i)}else if(i<2048){if((o-=2)<0)break;w.push(i>>6|192,63&i|128)}else if(i<65536){if((o-=3)<0)break;w.push(i>>12|224,i>>6&63|128,63&i|128)}else{if(!(i<1114112))throw new Error("Invalid code point");if((o-=4)<0)break;w.push(i>>18|240,i>>12&63|128,i>>6&63|128,63&i|128)}}return w}function base64ToBytes(s){return u.toByteArray(function base64clean(s){if((s=(s=s.split("=")[0]).trim().replace(L,"")).length<2)return"";for(;s.length%4!=0;)s+="=";return s}(s))}function blitBuffer(s,o,i,u){let _;for(_=0;_=o.length||_>=s.length);++_)o[_+i]=s[_];return _}function isInstance(s,o){return s instanceof o||null!=s&&null!=s.constructor&&null!=s.constructor.name&&s.constructor.name===o.name}function numberIsNaN(s){return s!=s}const B=function(){const s="0123456789abcdef",o=new Array(256);for(let i=0;i<16;++i){const u=16*i;for(let _=0;_<16;++_)o[u+_]=s[i]+s[_]}return o}();function defineBigIntMethod(s){return"undefined"==typeof BigInt?BufferBigIntNotDefined:s}function BufferBigIntNotDefined(){throw new Error("BigInt not supported")}},17965:(s,o,i)=>{"use strict";var u=i(16426),_={"text/plain":"Text","text/html":"Url",default:"Text"};s.exports=function copy(s,o){var i,w,x,C,j,L,B=!1;o||(o={}),i=o.debug||!1;try{if(x=u(),C=document.createRange(),j=document.getSelection(),(L=document.createElement("span")).textContent=s,L.ariaHidden="true",L.style.all="unset",L.style.position="fixed",L.style.top=0,L.style.clip="rect(0, 0, 0, 0)",L.style.whiteSpace="pre",L.style.webkitUserSelect="text",L.style.MozUserSelect="text",L.style.msUserSelect="text",L.style.userSelect="text",L.addEventListener("copy",(function(u){if(u.stopPropagation(),o.format)if(u.preventDefault(),void 0===u.clipboardData){i&&console.warn("unable to use e.clipboardData"),i&&console.warn("trying IE specific stuff"),window.clipboardData.clearData();var w=_[o.format]||_.default;window.clipboardData.setData(w,s)}else u.clipboardData.clearData(),u.clipboardData.setData(o.format,s);o.onCopy&&(u.preventDefault(),o.onCopy(u.clipboardData))})),document.body.appendChild(L),C.selectNodeContents(L),j.addRange(C),!document.execCommand("copy"))throw new Error("copy command was unsuccessful");B=!0}catch(u){i&&console.error("unable to copy using execCommand: ",u),i&&console.warn("trying IE specific stuff");try{window.clipboardData.setData(o.format||"text",s),o.onCopy&&o.onCopy(window.clipboardData),B=!0}catch(u){i&&console.error("unable to copy using clipboardData: ",u),i&&console.error("falling back to prompt"),w=function format(s){var o=(/mac os x/i.test(navigator.userAgent)?"⌘":"Ctrl")+"+C";return s.replace(/#{\s*key\s*}/g,o)}("message"in o?o.message:"Copy to clipboard: #{key}, Enter"),window.prompt(w,s)}}finally{j&&("function"==typeof j.removeRange?j.removeRange(C):j.removeAllRanges()),L&&document.body.removeChild(L),x()}return B}},2205:function(s,o,i){var u;u=void 0!==i.g?i.g:this,s.exports=function(s){if(s.CSS&&s.CSS.escape)return s.CSS.escape;var cssEscape=function(s){if(0==arguments.length)throw new TypeError("`CSS.escape` requires an argument.");for(var o,i=String(s),u=i.length,_=-1,w="",x=i.charCodeAt(0);++_=1&&o<=31||127==o||0==_&&o>=48&&o<=57||1==_&&o>=48&&o<=57&&45==x?"\\"+o.toString(16)+" ":0==_&&1==u&&45==o||!(o>=128||45==o||95==o||o>=48&&o<=57||o>=65&&o<=90||o>=97&&o<=122)?"\\"+i.charAt(_):i.charAt(_):w+="�";return w};return s.CSS||(s.CSS={}),s.CSS.escape=cssEscape,cssEscape}(u)},81919:(s,o,i)=>{"use strict";var u=i(48287).Buffer;function isSpecificValue(s){return s instanceof u||s instanceof Date||s instanceof RegExp}function cloneSpecificValue(s){if(s instanceof u){var o=u.alloc?u.alloc(s.length):new u(s.length);return s.copy(o),o}if(s instanceof Date)return new Date(s.getTime());if(s instanceof RegExp)return new RegExp(s);throw new Error("Unexpected situation")}function deepCloneArray(s){var o=[];return s.forEach((function(s,i){"object"==typeof s&&null!==s?Array.isArray(s)?o[i]=deepCloneArray(s):isSpecificValue(s)?o[i]=cloneSpecificValue(s):o[i]=_({},s):o[i]=s})),o}function safeGetProperty(s,o){return"__proto__"===o?void 0:s[o]}var _=s.exports=function(){if(arguments.length<1||"object"!=typeof arguments[0])return!1;if(arguments.length<2)return arguments[0];var s,o,i=arguments[0];return Array.prototype.slice.call(arguments,1).forEach((function(u){"object"!=typeof u||null===u||Array.isArray(u)||Object.keys(u).forEach((function(w){return o=safeGetProperty(i,w),(s=safeGetProperty(u,w))===i?void 0:"object"!=typeof s||null===s?void(i[w]=s):Array.isArray(s)?void(i[w]=deepCloneArray(s)):isSpecificValue(s)?void(i[w]=cloneSpecificValue(s)):"object"!=typeof o||null===o||Array.isArray(o)?void(i[w]=_({},s)):void(i[w]=_(o,s))}))})),i}},14744:s=>{"use strict";var o=function isMergeableObject(s){return function isNonNullObject(s){return!!s&&"object"==typeof s}(s)&&!function isSpecial(s){var o=Object.prototype.toString.call(s);return"[object RegExp]"===o||"[object Date]"===o||function isReactElement(s){return s.$$typeof===i}(s)}(s)};var i="function"==typeof Symbol&&Symbol.for?Symbol.for("react.element"):60103;function cloneUnlessOtherwiseSpecified(s,o){return!1!==o.clone&&o.isMergeableObject(s)?deepmerge(function emptyTarget(s){return Array.isArray(s)?[]:{}}(s),s,o):s}function defaultArrayMerge(s,o,i){return s.concat(o).map((function(s){return cloneUnlessOtherwiseSpecified(s,i)}))}function getKeys(s){return Object.keys(s).concat(function getEnumerableOwnPropertySymbols(s){return Object.getOwnPropertySymbols?Object.getOwnPropertySymbols(s).filter((function(o){return Object.propertyIsEnumerable.call(s,o)})):[]}(s))}function propertyIsOnObject(s,o){try{return o in s}catch(s){return!1}}function mergeObject(s,o,i){var u={};return i.isMergeableObject(s)&&getKeys(s).forEach((function(o){u[o]=cloneUnlessOtherwiseSpecified(s[o],i)})),getKeys(o).forEach((function(_){(function propertyIsUnsafe(s,o){return propertyIsOnObject(s,o)&&!(Object.hasOwnProperty.call(s,o)&&Object.propertyIsEnumerable.call(s,o))})(s,_)||(propertyIsOnObject(s,_)&&i.isMergeableObject(o[_])?u[_]=function getMergeFunction(s,o){if(!o.customMerge)return deepmerge;var i=o.customMerge(s);return"function"==typeof i?i:deepmerge}(_,i)(s[_],o[_],i):u[_]=cloneUnlessOtherwiseSpecified(o[_],i))})),u}function deepmerge(s,i,u){(u=u||{}).arrayMerge=u.arrayMerge||defaultArrayMerge,u.isMergeableObject=u.isMergeableObject||o,u.cloneUnlessOtherwiseSpecified=cloneUnlessOtherwiseSpecified;var _=Array.isArray(i);return _===Array.isArray(s)?_?u.arrayMerge(s,i,u):mergeObject(s,i,u):cloneUnlessOtherwiseSpecified(i,u)}deepmerge.all=function deepmergeAll(s,o){if(!Array.isArray(s))throw new Error("first argument should be an array");return s.reduce((function(s,i){return deepmerge(s,i,o)}),{})};var u=deepmerge;s.exports=u},42838:function(s){s.exports=function(){"use strict";const{entries:s,setPrototypeOf:o,isFrozen:i,getPrototypeOf:u,getOwnPropertyDescriptor:_}=Object;let{freeze:w,seal:x,create:C}=Object,{apply:j,construct:L}="undefined"!=typeof Reflect&&Reflect;w||(w=function freeze(s){return s}),x||(x=function seal(s){return s}),j||(j=function apply(s,o,i){return s.apply(o,i)}),L||(L=function construct(s,o){return new s(...o)});const B=unapply(Array.prototype.forEach),$=unapply(Array.prototype.pop),V=unapply(Array.prototype.push),U=unapply(String.prototype.toLowerCase),z=unapply(String.prototype.toString),Y=unapply(String.prototype.match),Z=unapply(String.prototype.replace),ee=unapply(String.prototype.indexOf),ie=unapply(String.prototype.trim),ae=unapply(Object.prototype.hasOwnProperty),le=unapply(RegExp.prototype.test),ce=unconstruct(TypeError);function unapply(s){return function(o){for(var i=arguments.length,u=new Array(i>1?i-1:0),_=1;_2&&void 0!==arguments[2]?arguments[2]:U;o&&o(s,null);let w=u.length;for(;w--;){let o=u[w];if("string"==typeof o){const s=_(o);s!==o&&(i(u)||(u[w]=s),o=s)}s[o]=!0}return s}function cleanArray(s){for(let o=0;o/gm),$e=x(/\${[\w\W]*}/gm),ze=x(/^data-[\-\w.\u00B7-\uFFFF]/),We=x(/^aria-[\-\w]+$/),He=x(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),Ye=x(/^(?:\w+script|data):/i),Xe=x(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),Qe=x(/^html$/i),et=x(/^[a-z][.\w]*(-[.\w]+)+$/i);var tt=Object.freeze({__proto__:null,MUSTACHE_EXPR:Re,ERB_EXPR:qe,TMPLIT_EXPR:$e,DATA_ATTR:ze,ARIA_ATTR:We,IS_ALLOWED_URI:He,IS_SCRIPT_OR_DATA:Ye,ATTR_WHITESPACE:Xe,DOCTYPE_NAME:Qe,CUSTOM_ELEMENT:et});const rt={element:1,attribute:2,text:3,cdataSection:4,entityReference:5,entityNode:6,progressingInstruction:7,comment:8,document:9,documentType:10,documentFragment:11,notation:12},nt=function getGlobal(){return"undefined"==typeof window?null:window},st=function _createTrustedTypesPolicy(s,o){if("object"!=typeof s||"function"!=typeof s.createPolicy)return null;let i=null;const u="data-tt-policy-suffix";o&&o.hasAttribute(u)&&(i=o.getAttribute(u));const _="dompurify"+(i?"#"+i:"");try{return s.createPolicy(_,{createHTML:s=>s,createScriptURL:s=>s})}catch(s){return console.warn("TrustedTypes policy "+_+" could not be created."),null}};function createDOMPurify(){let o=arguments.length>0&&void 0!==arguments[0]?arguments[0]:nt();const DOMPurify=s=>createDOMPurify(s);if(DOMPurify.version="3.1.6",DOMPurify.removed=[],!o||!o.document||o.document.nodeType!==rt.document)return DOMPurify.isSupported=!1,DOMPurify;let{document:i}=o;const u=i,_=u.currentScript,{DocumentFragment:x,HTMLTemplateElement:j,Node:L,Element:Re,NodeFilter:qe,NamedNodeMap:$e=o.NamedNodeMap||o.MozNamedAttrMap,HTMLFormElement:ze,DOMParser:We,trustedTypes:Ye}=o,Xe=Re.prototype,et=lookupGetter(Xe,"cloneNode"),ot=lookupGetter(Xe,"remove"),it=lookupGetter(Xe,"nextSibling"),at=lookupGetter(Xe,"childNodes"),lt=lookupGetter(Xe,"parentNode");if("function"==typeof j){const s=i.createElement("template");s.content&&s.content.ownerDocument&&(i=s.content.ownerDocument)}let ct,ut="";const{implementation:pt,createNodeIterator:ht,createDocumentFragment:dt,getElementsByTagName:mt}=i,{importNode:gt}=u;let yt={};DOMPurify.isSupported="function"==typeof s&&"function"==typeof lt&&pt&&void 0!==pt.createHTMLDocument;const{MUSTACHE_EXPR:vt,ERB_EXPR:bt,TMPLIT_EXPR:_t,DATA_ATTR:Et,ARIA_ATTR:wt,IS_SCRIPT_OR_DATA:St,ATTR_WHITESPACE:xt,CUSTOM_ELEMENT:kt}=tt;let{IS_ALLOWED_URI:Ct}=tt,Ot=null;const At=addToSet({},[...pe,...de,...fe,...be,...we]);let jt=null;const It=addToSet({},[...Se,...xe,...Pe,...Te]);let Pt=Object.seal(C(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),Mt=null,Tt=null,Nt=!0,Rt=!0,Dt=!1,Lt=!0,Bt=!1,Ft=!0,qt=!1,$t=!1,Vt=!1,Ut=!1,zt=!1,Wt=!1,Kt=!0,Ht=!1;const Jt="user-content-";let Gt=!0,Yt=!1,Xt={},Zt=null;const Qt=addToSet({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","style","svg","template","thead","title","video","xmp"]);let er=null;const tr=addToSet({},["audio","video","img","source","image","track"]);let rr=null;const nr=addToSet({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),sr="http://www.w3.org/1998/Math/MathML",ir="http://www.w3.org/2000/svg",ar="http://www.w3.org/1999/xhtml";let lr=ar,cr=!1,ur=null;const pr=addToSet({},[sr,ir,ar],z);let dr=null;const fr=["application/xhtml+xml","text/html"],mr="text/html";let gr=null,yr=null;const vr=i.createElement("form"),br=function isRegexOrFunction(s){return s instanceof RegExp||s instanceof Function},_r=function _parseConfig(){let s=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};if(!yr||yr!==s){if(s&&"object"==typeof s||(s={}),s=clone(s),dr=-1===fr.indexOf(s.PARSER_MEDIA_TYPE)?mr:s.PARSER_MEDIA_TYPE,gr="application/xhtml+xml"===dr?z:U,Ot=ae(s,"ALLOWED_TAGS")?addToSet({},s.ALLOWED_TAGS,gr):At,jt=ae(s,"ALLOWED_ATTR")?addToSet({},s.ALLOWED_ATTR,gr):It,ur=ae(s,"ALLOWED_NAMESPACES")?addToSet({},s.ALLOWED_NAMESPACES,z):pr,rr=ae(s,"ADD_URI_SAFE_ATTR")?addToSet(clone(nr),s.ADD_URI_SAFE_ATTR,gr):nr,er=ae(s,"ADD_DATA_URI_TAGS")?addToSet(clone(tr),s.ADD_DATA_URI_TAGS,gr):tr,Zt=ae(s,"FORBID_CONTENTS")?addToSet({},s.FORBID_CONTENTS,gr):Qt,Mt=ae(s,"FORBID_TAGS")?addToSet({},s.FORBID_TAGS,gr):{},Tt=ae(s,"FORBID_ATTR")?addToSet({},s.FORBID_ATTR,gr):{},Xt=!!ae(s,"USE_PROFILES")&&s.USE_PROFILES,Nt=!1!==s.ALLOW_ARIA_ATTR,Rt=!1!==s.ALLOW_DATA_ATTR,Dt=s.ALLOW_UNKNOWN_PROTOCOLS||!1,Lt=!1!==s.ALLOW_SELF_CLOSE_IN_ATTR,Bt=s.SAFE_FOR_TEMPLATES||!1,Ft=!1!==s.SAFE_FOR_XML,qt=s.WHOLE_DOCUMENT||!1,Ut=s.RETURN_DOM||!1,zt=s.RETURN_DOM_FRAGMENT||!1,Wt=s.RETURN_TRUSTED_TYPE||!1,Vt=s.FORCE_BODY||!1,Kt=!1!==s.SANITIZE_DOM,Ht=s.SANITIZE_NAMED_PROPS||!1,Gt=!1!==s.KEEP_CONTENT,Yt=s.IN_PLACE||!1,Ct=s.ALLOWED_URI_REGEXP||He,lr=s.NAMESPACE||ar,Pt=s.CUSTOM_ELEMENT_HANDLING||{},s.CUSTOM_ELEMENT_HANDLING&&br(s.CUSTOM_ELEMENT_HANDLING.tagNameCheck)&&(Pt.tagNameCheck=s.CUSTOM_ELEMENT_HANDLING.tagNameCheck),s.CUSTOM_ELEMENT_HANDLING&&br(s.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)&&(Pt.attributeNameCheck=s.CUSTOM_ELEMENT_HANDLING.attributeNameCheck),s.CUSTOM_ELEMENT_HANDLING&&"boolean"==typeof s.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements&&(Pt.allowCustomizedBuiltInElements=s.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements),Bt&&(Rt=!1),zt&&(Ut=!0),Xt&&(Ot=addToSet({},we),jt=[],!0===Xt.html&&(addToSet(Ot,pe),addToSet(jt,Se)),!0===Xt.svg&&(addToSet(Ot,de),addToSet(jt,xe),addToSet(jt,Te)),!0===Xt.svgFilters&&(addToSet(Ot,fe),addToSet(jt,xe),addToSet(jt,Te)),!0===Xt.mathMl&&(addToSet(Ot,be),addToSet(jt,Pe),addToSet(jt,Te))),s.ADD_TAGS&&(Ot===At&&(Ot=clone(Ot)),addToSet(Ot,s.ADD_TAGS,gr)),s.ADD_ATTR&&(jt===It&&(jt=clone(jt)),addToSet(jt,s.ADD_ATTR,gr)),s.ADD_URI_SAFE_ATTR&&addToSet(rr,s.ADD_URI_SAFE_ATTR,gr),s.FORBID_CONTENTS&&(Zt===Qt&&(Zt=clone(Zt)),addToSet(Zt,s.FORBID_CONTENTS,gr)),Gt&&(Ot["#text"]=!0),qt&&addToSet(Ot,["html","head","body"]),Ot.table&&(addToSet(Ot,["tbody"]),delete Mt.tbody),s.TRUSTED_TYPES_POLICY){if("function"!=typeof s.TRUSTED_TYPES_POLICY.createHTML)throw ce('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if("function"!=typeof s.TRUSTED_TYPES_POLICY.createScriptURL)throw ce('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');ct=s.TRUSTED_TYPES_POLICY,ut=ct.createHTML("")}else void 0===ct&&(ct=st(Ye,_)),null!==ct&&"string"==typeof ut&&(ut=ct.createHTML(""));w&&w(s),yr=s}},Er=addToSet({},["mi","mo","mn","ms","mtext"]),wr=addToSet({},["foreignobject","annotation-xml"]),Sr=addToSet({},["title","style","font","a","script"]),xr=addToSet({},[...de,...fe,...ye]),kr=addToSet({},[...be,..._e]),Cr=function _checkValidNamespace(s){let o=lt(s);o&&o.tagName||(o={namespaceURI:lr,tagName:"template"});const i=U(s.tagName),u=U(o.tagName);return!!ur[s.namespaceURI]&&(s.namespaceURI===ir?o.namespaceURI===ar?"svg"===i:o.namespaceURI===sr?"svg"===i&&("annotation-xml"===u||Er[u]):Boolean(xr[i]):s.namespaceURI===sr?o.namespaceURI===ar?"math"===i:o.namespaceURI===ir?"math"===i&&wr[u]:Boolean(kr[i]):s.namespaceURI===ar?!(o.namespaceURI===ir&&!wr[u])&&!(o.namespaceURI===sr&&!Er[u])&&!kr[i]&&(Sr[i]||!xr[i]):!("application/xhtml+xml"!==dr||!ur[s.namespaceURI]))},Or=function _forceRemove(s){V(DOMPurify.removed,{element:s});try{lt(s).removeChild(s)}catch(o){ot(s)}},Ar=function _removeAttribute(s,o){try{V(DOMPurify.removed,{attribute:o.getAttributeNode(s),from:o})}catch(s){V(DOMPurify.removed,{attribute:null,from:o})}if(o.removeAttribute(s),"is"===s&&!jt[s])if(Ut||zt)try{Or(o)}catch(s){}else try{o.setAttribute(s,"")}catch(s){}},jr=function _initDocument(s){let o=null,u=null;if(Vt)s=""+s;else{const o=Y(s,/^[\r\n\t ]+/);u=o&&o[0]}"application/xhtml+xml"===dr&&lr===ar&&(s=''+s+"");const _=ct?ct.createHTML(s):s;if(lr===ar)try{o=(new We).parseFromString(_,dr)}catch(s){}if(!o||!o.documentElement){o=pt.createDocument(lr,"template",null);try{o.documentElement.innerHTML=cr?ut:_}catch(s){}}const w=o.body||o.documentElement;return s&&u&&w.insertBefore(i.createTextNode(u),w.childNodes[0]||null),lr===ar?mt.call(o,qt?"html":"body")[0]:qt?o.documentElement:w},Ir=function _createNodeIterator(s){return ht.call(s.ownerDocument||s,s,qe.SHOW_ELEMENT|qe.SHOW_COMMENT|qe.SHOW_TEXT|qe.SHOW_PROCESSING_INSTRUCTION|qe.SHOW_CDATA_SECTION,null)},Pr=function _isClobbered(s){return s instanceof ze&&("string"!=typeof s.nodeName||"string"!=typeof s.textContent||"function"!=typeof s.removeChild||!(s.attributes instanceof $e)||"function"!=typeof s.removeAttribute||"function"!=typeof s.setAttribute||"string"!=typeof s.namespaceURI||"function"!=typeof s.insertBefore||"function"!=typeof s.hasChildNodes)},Mr=function _isNode(s){return"function"==typeof L&&s instanceof L},Tr=function _executeHook(s,o,i){yt[s]&&B(yt[s],(s=>{s.call(DOMPurify,o,i,yr)}))},Nr=function _sanitizeElements(s){let o=null;if(Tr("beforeSanitizeElements",s,null),Pr(s))return Or(s),!0;const i=gr(s.nodeName);if(Tr("uponSanitizeElement",s,{tagName:i,allowedTags:Ot}),s.hasChildNodes()&&!Mr(s.firstElementChild)&&le(/<[/\w]/g,s.innerHTML)&&le(/<[/\w]/g,s.textContent))return Or(s),!0;if(s.nodeType===rt.progressingInstruction)return Or(s),!0;if(Ft&&s.nodeType===rt.comment&&le(/<[/\w]/g,s.data))return Or(s),!0;if(!Ot[i]||Mt[i]){if(!Mt[i]&&Dr(i)){if(Pt.tagNameCheck instanceof RegExp&&le(Pt.tagNameCheck,i))return!1;if(Pt.tagNameCheck instanceof Function&&Pt.tagNameCheck(i))return!1}if(Gt&&!Zt[i]){const o=lt(s)||s.parentNode,i=at(s)||s.childNodes;if(i&&o)for(let u=i.length-1;u>=0;--u){const _=et(i[u],!0);_.__removalCount=(s.__removalCount||0)+1,o.insertBefore(_,it(s))}}return Or(s),!0}return s instanceof Re&&!Cr(s)?(Or(s),!0):"noscript"!==i&&"noembed"!==i&&"noframes"!==i||!le(/<\/no(script|embed|frames)/i,s.innerHTML)?(Bt&&s.nodeType===rt.text&&(o=s.textContent,B([vt,bt,_t],(s=>{o=Z(o,s," ")})),s.textContent!==o&&(V(DOMPurify.removed,{element:s.cloneNode()}),s.textContent=o)),Tr("afterSanitizeElements",s,null),!1):(Or(s),!0)},Rr=function _isValidAttribute(s,o,u){if(Kt&&("id"===o||"name"===o)&&(u in i||u in vr))return!1;if(Rt&&!Tt[o]&&le(Et,o));else if(Nt&&le(wt,o));else if(!jt[o]||Tt[o]){if(!(Dr(s)&&(Pt.tagNameCheck instanceof RegExp&&le(Pt.tagNameCheck,s)||Pt.tagNameCheck instanceof Function&&Pt.tagNameCheck(s))&&(Pt.attributeNameCheck instanceof RegExp&&le(Pt.attributeNameCheck,o)||Pt.attributeNameCheck instanceof Function&&Pt.attributeNameCheck(o))||"is"===o&&Pt.allowCustomizedBuiltInElements&&(Pt.tagNameCheck instanceof RegExp&&le(Pt.tagNameCheck,u)||Pt.tagNameCheck instanceof Function&&Pt.tagNameCheck(u))))return!1}else if(rr[o]);else if(le(Ct,Z(u,xt,"")));else if("src"!==o&&"xlink:href"!==o&&"href"!==o||"script"===s||0!==ee(u,"data:")||!er[s])if(Dt&&!le(St,Z(u,xt,"")));else if(u)return!1;return!0},Dr=function _isBasicCustomElement(s){return"annotation-xml"!==s&&Y(s,kt)},Lr=function _sanitizeAttributes(s){Tr("beforeSanitizeAttributes",s,null);const{attributes:o}=s;if(!o)return;const i={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:jt};let u=o.length;for(;u--;){const _=o[u],{name:w,namespaceURI:x,value:C}=_,j=gr(w);let L="value"===w?C:ie(C);if(i.attrName=j,i.attrValue=L,i.keepAttr=!0,i.forceKeepAttr=void 0,Tr("uponSanitizeAttribute",s,i),L=i.attrValue,Ft&&le(/((--!?|])>)|<\/(style|title)/i,L)){Ar(w,s);continue}if(i.forceKeepAttr)continue;if(Ar(w,s),!i.keepAttr)continue;if(!Lt&&le(/\/>/i,L)){Ar(w,s);continue}Bt&&B([vt,bt,_t],(s=>{L=Z(L,s," ")}));const V=gr(s.nodeName);if(Rr(V,j,L)){if(!Ht||"id"!==j&&"name"!==j||(Ar(w,s),L=Jt+L),ct&&"object"==typeof Ye&&"function"==typeof Ye.getAttributeType)if(x);else switch(Ye.getAttributeType(V,j)){case"TrustedHTML":L=ct.createHTML(L);break;case"TrustedScriptURL":L=ct.createScriptURL(L)}try{x?s.setAttributeNS(x,w,L):s.setAttribute(w,L),Pr(s)?Or(s):$(DOMPurify.removed)}catch(s){}}}Tr("afterSanitizeAttributes",s,null)},Br=function _sanitizeShadowDOM(s){let o=null;const i=Ir(s);for(Tr("beforeSanitizeShadowDOM",s,null);o=i.nextNode();)Tr("uponSanitizeShadowNode",o,null),Nr(o)||(o.content instanceof x&&_sanitizeShadowDOM(o.content),Lr(o));Tr("afterSanitizeShadowDOM",s,null)};return DOMPurify.sanitize=function(s){let o=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},i=null,_=null,w=null,C=null;if(cr=!s,cr&&(s="\x3c!--\x3e"),"string"!=typeof s&&!Mr(s)){if("function"!=typeof s.toString)throw ce("toString is not a function");if("string"!=typeof(s=s.toString()))throw ce("dirty is not a string, aborting")}if(!DOMPurify.isSupported)return s;if($t||_r(o),DOMPurify.removed=[],"string"==typeof s&&(Yt=!1),Yt){if(s.nodeName){const o=gr(s.nodeName);if(!Ot[o]||Mt[o])throw ce("root node is forbidden and cannot be sanitized in-place")}}else if(s instanceof L)i=jr("\x3c!----\x3e"),_=i.ownerDocument.importNode(s,!0),_.nodeType===rt.element&&"BODY"===_.nodeName||"HTML"===_.nodeName?i=_:i.appendChild(_);else{if(!Ut&&!Bt&&!qt&&-1===s.indexOf("<"))return ct&&Wt?ct.createHTML(s):s;if(i=jr(s),!i)return Ut?null:Wt?ut:""}i&&Vt&&Or(i.firstChild);const j=Ir(Yt?s:i);for(;w=j.nextNode();)Nr(w)||(w.content instanceof x&&Br(w.content),Lr(w));if(Yt)return s;if(Ut){if(zt)for(C=dt.call(i.ownerDocument);i.firstChild;)C.appendChild(i.firstChild);else C=i;return(jt.shadowroot||jt.shadowrootmode)&&(C=gt.call(u,C,!0)),C}let $=qt?i.outerHTML:i.innerHTML;return qt&&Ot["!doctype"]&&i.ownerDocument&&i.ownerDocument.doctype&&i.ownerDocument.doctype.name&&le(Qe,i.ownerDocument.doctype.name)&&($="\n"+$),Bt&&B([vt,bt,_t],(s=>{$=Z($,s," ")})),ct&&Wt?ct.createHTML($):$},DOMPurify.setConfig=function(){_r(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}),$t=!0},DOMPurify.clearConfig=function(){yr=null,$t=!1},DOMPurify.isValidAttribute=function(s,o,i){yr||_r({});const u=gr(s),_=gr(o);return Rr(u,_,i)},DOMPurify.addHook=function(s,o){"function"==typeof o&&(yt[s]=yt[s]||[],V(yt[s],o))},DOMPurify.removeHook=function(s){if(yt[s])return $(yt[s])},DOMPurify.removeHooks=function(s){yt[s]&&(yt[s]=[])},DOMPurify.removeAllHooks=function(){yt={}},DOMPurify}return createDOMPurify()}()},78004:s=>{"use strict";class SubRange{constructor(s,o){this.low=s,this.high=o,this.length=1+o-s}overlaps(s){return!(this.highs.high)}touches(s){return!(this.high+1s.high)}add(s){return new SubRange(Math.min(this.low,s.low),Math.max(this.high,s.high))}subtract(s){return s.low<=this.low&&s.high>=this.high?[]:s.low>this.low&&s.highs+o.length),0)}add(s,o){var _add=s=>{for(var o=0;o{for(var o=0;o{for(var o=0;o{for(var i=o.low;i<=o.high;)s.push(i),i++;return s}),[])}subranges(){return this.ranges.map((s=>({low:s.low,high:s.high,length:1+s.high-s.low})))}}s.exports=DRange},37007:s=>{"use strict";var o,i="object"==typeof Reflect?Reflect:null,u=i&&"function"==typeof i.apply?i.apply:function ReflectApply(s,o,i){return Function.prototype.apply.call(s,o,i)};o=i&&"function"==typeof i.ownKeys?i.ownKeys:Object.getOwnPropertySymbols?function ReflectOwnKeys(s){return Object.getOwnPropertyNames(s).concat(Object.getOwnPropertySymbols(s))}:function ReflectOwnKeys(s){return Object.getOwnPropertyNames(s)};var _=Number.isNaN||function NumberIsNaN(s){return s!=s};function EventEmitter(){EventEmitter.init.call(this)}s.exports=EventEmitter,s.exports.once=function once(s,o){return new Promise((function(i,u){function errorListener(i){s.removeListener(o,resolver),u(i)}function resolver(){"function"==typeof s.removeListener&&s.removeListener("error",errorListener),i([].slice.call(arguments))}eventTargetAgnosticAddListener(s,o,resolver,{once:!0}),"error"!==o&&function addErrorHandlerIfEventEmitter(s,o,i){"function"==typeof s.on&&eventTargetAgnosticAddListener(s,"error",o,i)}(s,errorListener,{once:!0})}))},EventEmitter.EventEmitter=EventEmitter,EventEmitter.prototype._events=void 0,EventEmitter.prototype._eventsCount=0,EventEmitter.prototype._maxListeners=void 0;var w=10;function checkListener(s){if("function"!=typeof s)throw new TypeError('The "listener" argument must be of type Function. Received type '+typeof s)}function _getMaxListeners(s){return void 0===s._maxListeners?EventEmitter.defaultMaxListeners:s._maxListeners}function _addListener(s,o,i,u){var _,w,x;if(checkListener(i),void 0===(w=s._events)?(w=s._events=Object.create(null),s._eventsCount=0):(void 0!==w.newListener&&(s.emit("newListener",o,i.listener?i.listener:i),w=s._events),x=w[o]),void 0===x)x=w[o]=i,++s._eventsCount;else if("function"==typeof x?x=w[o]=u?[i,x]:[x,i]:u?x.unshift(i):x.push(i),(_=_getMaxListeners(s))>0&&x.length>_&&!x.warned){x.warned=!0;var C=new Error("Possible EventEmitter memory leak detected. "+x.length+" "+String(o)+" listeners added. Use emitter.setMaxListeners() to increase limit");C.name="MaxListenersExceededWarning",C.emitter=s,C.type=o,C.count=x.length,function ProcessEmitWarning(s){console&&console.warn&&console.warn(s)}(C)}return s}function onceWrapper(){if(!this.fired)return this.target.removeListener(this.type,this.wrapFn),this.fired=!0,0===arguments.length?this.listener.call(this.target):this.listener.apply(this.target,arguments)}function _onceWrap(s,o,i){var u={fired:!1,wrapFn:void 0,target:s,type:o,listener:i},_=onceWrapper.bind(u);return _.listener=i,u.wrapFn=_,_}function _listeners(s,o,i){var u=s._events;if(void 0===u)return[];var _=u[o];return void 0===_?[]:"function"==typeof _?i?[_.listener||_]:[_]:i?function unwrapListeners(s){for(var o=new Array(s.length),i=0;i0&&(x=o[0]),x instanceof Error)throw x;var C=new Error("Unhandled error."+(x?" ("+x.message+")":""));throw C.context=x,C}var j=w[s];if(void 0===j)return!1;if("function"==typeof j)u(j,this,o);else{var L=j.length,B=arrayClone(j,L);for(i=0;i=0;w--)if(i[w]===o||i[w].listener===o){x=i[w].listener,_=w;break}if(_<0)return this;0===_?i.shift():function spliceOne(s,o){for(;o+1=0;u--)this.removeListener(s,o[u]);return this},EventEmitter.prototype.listeners=function listeners(s){return _listeners(this,s,!0)},EventEmitter.prototype.rawListeners=function rawListeners(s){return _listeners(this,s,!1)},EventEmitter.listenerCount=function(s,o){return"function"==typeof s.listenerCount?s.listenerCount(o):listenerCount.call(s,o)},EventEmitter.prototype.listenerCount=listenerCount,EventEmitter.prototype.eventNames=function eventNames(){return this._eventsCount>0?o(this._events):[]}},85587:(s,o,i)=>{"use strict";var u=i(26311),_=create(Error);function create(s){return FormattedError.displayName=s.displayName||s.name,FormattedError;function FormattedError(o){return o&&(o=u.apply(null,arguments)),new s(o)}}s.exports=_,_.eval=create(EvalError),_.range=create(RangeError),_.reference=create(ReferenceError),_.syntax=create(SyntaxError),_.type=create(TypeError),_.uri=create(URIError),_.create=create},26311:s=>{!function(){var o;function format(s){for(var o,i,u,_,w=1,x=[].slice.call(arguments),C=0,j=s.length,L="",B=!1,$=!1,nextArg=function(){return x[w++]},slurpNumber=function(){for(var i="";/\d/.test(s[C]);)i+=s[C++],o=s[C];return i.length>0?parseInt(i):null};C{function deepFreeze(s){return s instanceof Map?s.clear=s.delete=s.set=function(){throw new Error("map is read-only")}:s instanceof Set&&(s.add=s.clear=s.delete=function(){throw new Error("set is read-only")}),Object.freeze(s),Object.getOwnPropertyNames(s).forEach((function(o){var i=s[o];"object"!=typeof i||Object.isFrozen(i)||deepFreeze(i)})),s}var o=deepFreeze,i=deepFreeze;o.default=i;class Response{constructor(s){void 0===s.data&&(s.data={}),this.data=s.data,this.isMatchIgnored=!1}ignoreMatch(){this.isMatchIgnored=!0}}function escapeHTML(s){return s.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function inherit(s,...o){const i=Object.create(null);for(const o in s)i[o]=s[o];return o.forEach((function(s){for(const o in s)i[o]=s[o]})),i}const emitsWrappingTags=s=>!!s.kind;class HTMLRenderer{constructor(s,o){this.buffer="",this.classPrefix=o.classPrefix,s.walk(this)}addText(s){this.buffer+=escapeHTML(s)}openNode(s){if(!emitsWrappingTags(s))return;let o=s.kind;s.sublanguage||(o=`${this.classPrefix}${o}`),this.span(o)}closeNode(s){emitsWrappingTags(s)&&(this.buffer+="")}value(){return this.buffer}span(s){this.buffer+=``}}class TokenTree{constructor(){this.rootNode={children:[]},this.stack=[this.rootNode]}get top(){return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(s){this.top.children.push(s)}openNode(s){const o={kind:s,children:[]};this.add(o),this.stack.push(o)}closeNode(){if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)}walk(s){return this.constructor._walk(s,this.rootNode)}static _walk(s,o){return"string"==typeof o?s.addText(o):o.children&&(s.openNode(o),o.children.forEach((o=>this._walk(s,o))),s.closeNode(o)),s}static _collapse(s){"string"!=typeof s&&s.children&&(s.children.every((s=>"string"==typeof s))?s.children=[s.children.join("")]:s.children.forEach((s=>{TokenTree._collapse(s)})))}}class TokenTreeEmitter extends TokenTree{constructor(s){super(),this.options=s}addKeyword(s,o){""!==s&&(this.openNode(o),this.addText(s),this.closeNode())}addText(s){""!==s&&this.add(s)}addSublanguage(s,o){const i=s.root;i.kind=o,i.sublanguage=!0,this.add(i)}toHTML(){return new HTMLRenderer(this,this.options).value()}finalize(){return!0}}function source(s){return s?"string"==typeof s?s:s.source:null}const u=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./;const _="[a-zA-Z]\\w*",w="[a-zA-Z_]\\w*",x="\\b\\d+(\\.\\d+)?",C="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",j="\\b(0b[01]+)",L={begin:"\\\\[\\s\\S]",relevance:0},B={className:"string",begin:"'",end:"'",illegal:"\\n",contains:[L]},$={className:"string",begin:'"',end:'"',illegal:"\\n",contains:[L]},V={begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/},COMMENT=function(s,o,i={}){const u=inherit({className:"comment",begin:s,end:o,contains:[]},i);return u.contains.push(V),u.contains.push({className:"doctag",begin:"(?:TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):",relevance:0}),u},U=COMMENT("//","$"),z=COMMENT("/\\*","\\*/"),Y=COMMENT("#","$"),Z={className:"number",begin:x,relevance:0},ee={className:"number",begin:C,relevance:0},ie={className:"number",begin:j,relevance:0},ae={className:"number",begin:x+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",relevance:0},le={begin:/(?=\/[^/\n]*\/)/,contains:[{className:"regexp",begin:/\//,end:/\/[gimuy]*/,illegal:/\n/,contains:[L,{begin:/\[/,end:/\]/,relevance:0,contains:[L]}]}]},ce={className:"title",begin:_,relevance:0},pe={className:"title",begin:w,relevance:0},de={begin:"\\.\\s*"+w,relevance:0};var fe=Object.freeze({__proto__:null,MATCH_NOTHING_RE:/\b\B/,IDENT_RE:_,UNDERSCORE_IDENT_RE:w,NUMBER_RE:x,C_NUMBER_RE:C,BINARY_NUMBER_RE:j,RE_STARTERS_RE:"!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",SHEBANG:(s={})=>{const o=/^#![ ]*\//;return s.binary&&(s.begin=function concat(...s){return s.map((s=>source(s))).join("")}(o,/.*\b/,s.binary,/\b.*/)),inherit({className:"meta",begin:o,end:/$/,relevance:0,"on:begin":(s,o)=>{0!==s.index&&o.ignoreMatch()}},s)},BACKSLASH_ESCAPE:L,APOS_STRING_MODE:B,QUOTE_STRING_MODE:$,PHRASAL_WORDS_MODE:V,COMMENT,C_LINE_COMMENT_MODE:U,C_BLOCK_COMMENT_MODE:z,HASH_COMMENT_MODE:Y,NUMBER_MODE:Z,C_NUMBER_MODE:ee,BINARY_NUMBER_MODE:ie,CSS_NUMBER_MODE:ae,REGEXP_MODE:le,TITLE_MODE:ce,UNDERSCORE_TITLE_MODE:pe,METHOD_GUARD:de,END_SAME_AS_BEGIN:function(s){return Object.assign(s,{"on:begin":(s,o)=>{o.data._beginMatch=s[1]},"on:end":(s,o)=>{o.data._beginMatch!==s[1]&&o.ignoreMatch()}})}});function skipIfhasPrecedingDot(s,o){"."===s.input[s.index-1]&&o.ignoreMatch()}function beginKeywords(s,o){o&&s.beginKeywords&&(s.begin="\\b("+s.beginKeywords.split(" ").join("|")+")(?!\\.)(?=\\b|\\s)",s.__beforeBegin=skipIfhasPrecedingDot,s.keywords=s.keywords||s.beginKeywords,delete s.beginKeywords,void 0===s.relevance&&(s.relevance=0))}function compileIllegal(s,o){Array.isArray(s.illegal)&&(s.illegal=function either(...s){return"("+s.map((s=>source(s))).join("|")+")"}(...s.illegal))}function compileMatch(s,o){if(s.match){if(s.begin||s.end)throw new Error("begin & end are not supported with match");s.begin=s.match,delete s.match}}function compileRelevance(s,o){void 0===s.relevance&&(s.relevance=1)}const ye=["of","and","for","in","not","or","if","then","parent","list","value"];function compileKeywords(s,o,i="keyword"){const u={};return"string"==typeof s?compileList(i,s.split(" ")):Array.isArray(s)?compileList(i,s):Object.keys(s).forEach((function(i){Object.assign(u,compileKeywords(s[i],o,i))})),u;function compileList(s,i){o&&(i=i.map((s=>s.toLowerCase()))),i.forEach((function(o){const i=o.split("|");u[i[0]]=[s,scoreForKeyword(i[0],i[1])]}))}}function scoreForKeyword(s,o){return o?Number(o):function commonKeyword(s){return ye.includes(s.toLowerCase())}(s)?0:1}function compileLanguage(s,{plugins:o}){function langRe(o,i){return new RegExp(source(o),"m"+(s.case_insensitive?"i":"")+(i?"g":""))}class MultiRegex{constructor(){this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0}addRule(s,o){o.position=this.position++,this.matchIndexes[this.matchAt]=o,this.regexes.push([o,s]),this.matchAt+=function countMatchGroups(s){return new RegExp(s.toString()+"|").exec("").length-1}(s)+1}compile(){0===this.regexes.length&&(this.exec=()=>null);const s=this.regexes.map((s=>s[1]));this.matcherRe=langRe(function join(s,o="|"){let i=0;return s.map((s=>{i+=1;const o=i;let _=source(s),w="";for(;_.length>0;){const s=u.exec(_);if(!s){w+=_;break}w+=_.substring(0,s.index),_=_.substring(s.index+s[0].length),"\\"===s[0][0]&&s[1]?w+="\\"+String(Number(s[1])+o):(w+=s[0],"("===s[0]&&i++)}return w})).map((s=>`(${s})`)).join(o)}(s),!0),this.lastIndex=0}exec(s){this.matcherRe.lastIndex=this.lastIndex;const o=this.matcherRe.exec(s);if(!o)return null;const i=o.findIndex(((s,o)=>o>0&&void 0!==s)),u=this.matchIndexes[i];return o.splice(0,i),Object.assign(o,u)}}class ResumableMultiRegex{constructor(){this.rules=[],this.multiRegexes=[],this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(s){if(this.multiRegexes[s])return this.multiRegexes[s];const o=new MultiRegex;return this.rules.slice(s).forEach((([s,i])=>o.addRule(s,i))),o.compile(),this.multiRegexes[s]=o,o}resumingScanAtSamePosition(){return 0!==this.regexIndex}considerAll(){this.regexIndex=0}addRule(s,o){this.rules.push([s,o]),"begin"===o.type&&this.count++}exec(s){const o=this.getMatcher(this.regexIndex);o.lastIndex=this.lastIndex;let i=o.exec(s);if(this.resumingScanAtSamePosition())if(i&&i.index===this.lastIndex);else{const o=this.getMatcher(0);o.lastIndex=this.lastIndex+1,i=o.exec(s)}return i&&(this.regexIndex+=i.position+1,this.regexIndex===this.count&&this.considerAll()),i}}if(s.compilerExtensions||(s.compilerExtensions=[]),s.contains&&s.contains.includes("self"))throw new Error("ERR: contains `self` is not supported at the top-level of a language. See documentation.");return s.classNameAliases=inherit(s.classNameAliases||{}),function compileMode(o,i){const u=o;if(o.isCompiled)return u;[compileMatch].forEach((s=>s(o,i))),s.compilerExtensions.forEach((s=>s(o,i))),o.__beforeBegin=null,[beginKeywords,compileIllegal,compileRelevance].forEach((s=>s(o,i))),o.isCompiled=!0;let _=null;if("object"==typeof o.keywords&&(_=o.keywords.$pattern,delete o.keywords.$pattern),o.keywords&&(o.keywords=compileKeywords(o.keywords,s.case_insensitive)),o.lexemes&&_)throw new Error("ERR: Prefer `keywords.$pattern` to `mode.lexemes`, BOTH are not allowed. (see mode reference) ");return _=_||o.lexemes||/\w+/,u.keywordPatternRe=langRe(_,!0),i&&(o.begin||(o.begin=/\B|\b/),u.beginRe=langRe(o.begin),o.endSameAsBegin&&(o.end=o.begin),o.end||o.endsWithParent||(o.end=/\B|\b/),o.end&&(u.endRe=langRe(o.end)),u.terminatorEnd=source(o.end)||"",o.endsWithParent&&i.terminatorEnd&&(u.terminatorEnd+=(o.end?"|":"")+i.terminatorEnd)),o.illegal&&(u.illegalRe=langRe(o.illegal)),o.contains||(o.contains=[]),o.contains=[].concat(...o.contains.map((function(s){return function expandOrCloneMode(s){s.variants&&!s.cachedVariants&&(s.cachedVariants=s.variants.map((function(o){return inherit(s,{variants:null},o)})));if(s.cachedVariants)return s.cachedVariants;if(dependencyOnParent(s))return inherit(s,{starts:s.starts?inherit(s.starts):null});if(Object.isFrozen(s))return inherit(s);return s}("self"===s?o:s)}))),o.contains.forEach((function(s){compileMode(s,u)})),o.starts&&compileMode(o.starts,i),u.matcher=function buildModeRegex(s){const o=new ResumableMultiRegex;return s.contains.forEach((s=>o.addRule(s.begin,{rule:s,type:"begin"}))),s.terminatorEnd&&o.addRule(s.terminatorEnd,{type:"end"}),s.illegal&&o.addRule(s.illegal,{type:"illegal"}),o}(u),u}(s)}function dependencyOnParent(s){return!!s&&(s.endsWithParent||dependencyOnParent(s.starts))}function BuildVuePlugin(s){const o={props:["language","code","autodetect"],data:function(){return{detectedLanguage:"",unknownLanguage:!1}},computed:{className(){return this.unknownLanguage?"":"hljs "+this.detectedLanguage},highlighted(){if(!this.autoDetect&&!s.getLanguage(this.language))return console.warn(`The language "${this.language}" you specified could not be found.`),this.unknownLanguage=!0,escapeHTML(this.code);let o={};return this.autoDetect?(o=s.highlightAuto(this.code),this.detectedLanguage=o.language):(o=s.highlight(this.language,this.code,this.ignoreIllegals),this.detectedLanguage=this.language),o.value},autoDetect(){return!this.language||function hasValueOrEmptyAttribute(s){return Boolean(s||""===s)}(this.autodetect)},ignoreIllegals:()=>!0},render(s){return s("pre",{},[s("code",{class:this.className,domProps:{innerHTML:this.highlighted}})])}};return{Component:o,VuePlugin:{install(s){s.component("highlightjs",o)}}}}const be={"after:highlightElement":({el:s,result:o,text:i})=>{const u=nodeStream(s);if(!u.length)return;const _=document.createElement("div");_.innerHTML=o.value,o.value=function mergeStreams(s,o,i){let u=0,_="";const w=[];function selectStream(){return s.length&&o.length?s[0].offset!==o[0].offset?s[0].offset"}function close(s){_+=""}function render(s){("start"===s.event?open:close)(s.node)}for(;s.length||o.length;){let o=selectStream();if(_+=escapeHTML(i.substring(u,o[0].offset)),u=o[0].offset,o===s){w.reverse().forEach(close);do{render(o.splice(0,1)[0]),o=selectStream()}while(o===s&&o.length&&o[0].offset===u);w.reverse().forEach(open)}else"start"===o[0].event?w.push(o[0].node):w.pop(),render(o.splice(0,1)[0])}return _+escapeHTML(i.substr(u))}(u,nodeStream(_),i)}};function tag(s){return s.nodeName.toLowerCase()}function nodeStream(s){const o=[];return function _nodeStream(s,i){for(let u=s.firstChild;u;u=u.nextSibling)3===u.nodeType?i+=u.nodeValue.length:1===u.nodeType&&(o.push({event:"start",offset:i,node:u}),i=_nodeStream(u,i),tag(u).match(/br|hr|img|input/)||o.push({event:"stop",offset:i,node:u}));return i}(s,0),o}const _e={},error=s=>{console.error(s)},warn=(s,...o)=>{console.log(`WARN: ${s}`,...o)},deprecated=(s,o)=>{_e[`${s}/${o}`]||(console.log(`Deprecated as of ${s}. ${o}`),_e[`${s}/${o}`]=!0)},we=escapeHTML,Se=inherit,xe=Symbol("nomatch");var Pe=function(s){const i=Object.create(null),u=Object.create(null),_=[];let w=!0;const x=/(^(<[^>]+>|\t|)+|\n)/gm,C="Could not find the language '{}', did you forget to load/include a language module?",j={disableAutodetect:!0,name:"Plain text",contains:[]};let L={noHighlightRe:/^(no-?highlight)$/i,languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-",tabReplace:null,useBR:!1,languages:null,__emitter:TokenTreeEmitter};function shouldNotHighlight(s){return L.noHighlightRe.test(s)}function highlight(s,o,i,u){let _="",w="";"object"==typeof o?(_=s,i=o.ignoreIllegals,w=o.language,u=void 0):(deprecated("10.7.0","highlight(lang, code, ...args) has been deprecated."),deprecated("10.7.0","Please use highlight(code, options) instead.\nhttps://github.com/highlightjs/highlight.js/issues/2277"),w=s,_=o);const x={code:_,language:w};fire("before:highlight",x);const C=x.result?x.result:_highlight(x.language,x.code,i,u);return C.code=x.code,fire("after:highlight",C),C}function _highlight(s,o,u,x){function keywordData(s,o){const i=B.case_insensitive?o[0].toLowerCase():o[0];return Object.prototype.hasOwnProperty.call(s.keywords,i)&&s.keywords[i]}function processBuffer(){null!=U.subLanguage?function processSubLanguage(){if(""===Z)return;let s=null;if("string"==typeof U.subLanguage){if(!i[U.subLanguage])return void Y.addText(Z);s=_highlight(U.subLanguage,Z,!0,z[U.subLanguage]),z[U.subLanguage]=s.top}else s=highlightAuto(Z,U.subLanguage.length?U.subLanguage:null);U.relevance>0&&(ee+=s.relevance),Y.addSublanguage(s.emitter,s.language)}():function processKeywords(){if(!U.keywords)return void Y.addText(Z);let s=0;U.keywordPatternRe.lastIndex=0;let o=U.keywordPatternRe.exec(Z),i="";for(;o;){i+=Z.substring(s,o.index);const u=keywordData(U,o);if(u){const[s,_]=u;if(Y.addText(i),i="",ee+=_,s.startsWith("_"))i+=o[0];else{const i=B.classNameAliases[s]||s;Y.addKeyword(o[0],i)}}else i+=o[0];s=U.keywordPatternRe.lastIndex,o=U.keywordPatternRe.exec(Z)}i+=Z.substr(s),Y.addText(i)}(),Z=""}function startNewMode(s){return s.className&&Y.openNode(B.classNameAliases[s.className]||s.className),U=Object.create(s,{parent:{value:U}}),U}function endOfMode(s,o,i){let u=function startsWith(s,o){const i=s&&s.exec(o);return i&&0===i.index}(s.endRe,i);if(u){if(s["on:end"]){const i=new Response(s);s["on:end"](o,i),i.isMatchIgnored&&(u=!1)}if(u){for(;s.endsParent&&s.parent;)s=s.parent;return s}}if(s.endsWithParent)return endOfMode(s.parent,o,i)}function doIgnore(s){return 0===U.matcher.regexIndex?(Z+=s[0],1):(le=!0,0)}function doBeginMatch(s){const o=s[0],i=s.rule,u=new Response(i),_=[i.__beforeBegin,i["on:begin"]];for(const i of _)if(i&&(i(s,u),u.isMatchIgnored))return doIgnore(o);return i&&i.endSameAsBegin&&(i.endRe=function escape(s){return new RegExp(s.replace(/[-/\\^$*+?.()|[\]{}]/g,"\\$&"),"m")}(o)),i.skip?Z+=o:(i.excludeBegin&&(Z+=o),processBuffer(),i.returnBegin||i.excludeBegin||(Z=o)),startNewMode(i),i.returnBegin?0:o.length}function doEndMatch(s){const i=s[0],u=o.substr(s.index),_=endOfMode(U,s,u);if(!_)return xe;const w=U;w.skip?Z+=i:(w.returnEnd||w.excludeEnd||(Z+=i),processBuffer(),w.excludeEnd&&(Z=i));do{U.className&&Y.closeNode(),U.skip||U.subLanguage||(ee+=U.relevance),U=U.parent}while(U!==_.parent);return _.starts&&(_.endSameAsBegin&&(_.starts.endRe=_.endRe),startNewMode(_.starts)),w.returnEnd?0:i.length}let j={};function processLexeme(i,_){const x=_&&_[0];if(Z+=i,null==x)return processBuffer(),0;if("begin"===j.type&&"end"===_.type&&j.index===_.index&&""===x){if(Z+=o.slice(_.index,_.index+1),!w){const o=new Error("0 width match regex");throw o.languageName=s,o.badRule=j.rule,o}return 1}if(j=_,"begin"===_.type)return doBeginMatch(_);if("illegal"===_.type&&!u){const s=new Error('Illegal lexeme "'+x+'" for mode "'+(U.className||"")+'"');throw s.mode=U,s}if("end"===_.type){const s=doEndMatch(_);if(s!==xe)return s}if("illegal"===_.type&&""===x)return 1;if(ae>1e5&&ae>3*_.index){throw new Error("potential infinite loop, way more iterations than matches")}return Z+=x,x.length}const B=getLanguage(s);if(!B)throw error(C.replace("{}",s)),new Error('Unknown language: "'+s+'"');const $=compileLanguage(B,{plugins:_});let V="",U=x||$;const z={},Y=new L.__emitter(L);!function processContinuations(){const s=[];for(let o=U;o!==B;o=o.parent)o.className&&s.unshift(o.className);s.forEach((s=>Y.openNode(s)))}();let Z="",ee=0,ie=0,ae=0,le=!1;try{for(U.matcher.considerAll();;){ae++,le?le=!1:U.matcher.considerAll(),U.matcher.lastIndex=ie;const s=U.matcher.exec(o);if(!s)break;const i=processLexeme(o.substring(ie,s.index),s);ie=s.index+i}return processLexeme(o.substr(ie)),Y.closeAllNodes(),Y.finalize(),V=Y.toHTML(),{relevance:Math.floor(ee),value:V,language:s,illegal:!1,emitter:Y,top:U}}catch(i){if(i.message&&i.message.includes("Illegal"))return{illegal:!0,illegalBy:{msg:i.message,context:o.slice(ie-100,ie+100),mode:i.mode},sofar:V,relevance:0,value:we(o),emitter:Y};if(w)return{illegal:!1,relevance:0,value:we(o),emitter:Y,language:s,top:U,errorRaised:i};throw i}}function highlightAuto(s,o){o=o||L.languages||Object.keys(i);const u=function justTextHighlightResult(s){const o={relevance:0,emitter:new L.__emitter(L),value:we(s),illegal:!1,top:j};return o.emitter.addText(s),o}(s),_=o.filter(getLanguage).filter(autoDetection).map((o=>_highlight(o,s,!1)));_.unshift(u);const w=_.sort(((s,o)=>{if(s.relevance!==o.relevance)return o.relevance-s.relevance;if(s.language&&o.language){if(getLanguage(s.language).supersetOf===o.language)return 1;if(getLanguage(o.language).supersetOf===s.language)return-1}return 0})),[x,C]=w,B=x;return B.second_best=C,B}const B={"before:highlightElement":({el:s})=>{L.useBR&&(s.innerHTML=s.innerHTML.replace(/\n/g,"").replace(//g,"\n"))},"after:highlightElement":({result:s})=>{L.useBR&&(s.value=s.value.replace(/\n/g,"
        "))}},$=/^(<[^>]+>|\t)+/gm,V={"after:highlightElement":({result:s})=>{L.tabReplace&&(s.value=s.value.replace($,(s=>s.replace(/\t/g,L.tabReplace))))}};function highlightElement(s){let o=null;const i=function blockLanguage(s){let o=s.className+" ";o+=s.parentNode?s.parentNode.className:"";const i=L.languageDetectRe.exec(o);if(i){const o=getLanguage(i[1]);return o||(warn(C.replace("{}",i[1])),warn("Falling back to no-highlight mode for this block.",s)),o?i[1]:"no-highlight"}return o.split(/\s+/).find((s=>shouldNotHighlight(s)||getLanguage(s)))}(s);if(shouldNotHighlight(i))return;fire("before:highlightElement",{el:s,language:i}),o=s;const _=o.textContent,w=i?highlight(_,{language:i,ignoreIllegals:!0}):highlightAuto(_);fire("after:highlightElement",{el:s,result:w,text:_}),s.innerHTML=w.value,function updateClassName(s,o,i){const _=o?u[o]:i;s.classList.add("hljs"),_&&s.classList.add(_)}(s,i,w.language),s.result={language:w.language,re:w.relevance,relavance:w.relevance},w.second_best&&(s.second_best={language:w.second_best.language,re:w.second_best.relevance,relavance:w.second_best.relevance})}const initHighlighting=()=>{if(initHighlighting.called)return;initHighlighting.called=!0,deprecated("10.6.0","initHighlighting() is deprecated. Use highlightAll() instead.");document.querySelectorAll("pre code").forEach(highlightElement)};let U=!1;function highlightAll(){if("loading"===document.readyState)return void(U=!0);document.querySelectorAll("pre code").forEach(highlightElement)}function getLanguage(s){return s=(s||"").toLowerCase(),i[s]||i[u[s]]}function registerAliases(s,{languageName:o}){"string"==typeof s&&(s=[s]),s.forEach((s=>{u[s.toLowerCase()]=o}))}function autoDetection(s){const o=getLanguage(s);return o&&!o.disableAutodetect}function fire(s,o){const i=s;_.forEach((function(s){s[i]&&s[i](o)}))}"undefined"!=typeof window&&window.addEventListener&&window.addEventListener("DOMContentLoaded",(function boot(){U&&highlightAll()}),!1),Object.assign(s,{highlight,highlightAuto,highlightAll,fixMarkup:function deprecateFixMarkup(s){return deprecated("10.2.0","fixMarkup will be removed entirely in v11.0"),deprecated("10.2.0","Please see https://github.com/highlightjs/highlight.js/issues/2534"),function fixMarkup(s){return L.tabReplace||L.useBR?s.replace(x,(s=>"\n"===s?L.useBR?"
        ":s:L.tabReplace?s.replace(/\t/g,L.tabReplace):s)):s}(s)},highlightElement,highlightBlock:function deprecateHighlightBlock(s){return deprecated("10.7.0","highlightBlock will be removed entirely in v12.0"),deprecated("10.7.0","Please use highlightElement now."),highlightElement(s)},configure:function configure(s){s.useBR&&(deprecated("10.3.0","'useBR' will be removed entirely in v11.0"),deprecated("10.3.0","Please see https://github.com/highlightjs/highlight.js/issues/2559")),L=Se(L,s)},initHighlighting,initHighlightingOnLoad:function initHighlightingOnLoad(){deprecated("10.6.0","initHighlightingOnLoad() is deprecated. Use highlightAll() instead."),U=!0},registerLanguage:function registerLanguage(o,u){let _=null;try{_=u(s)}catch(s){if(error("Language definition for '{}' could not be registered.".replace("{}",o)),!w)throw s;error(s),_=j}_.name||(_.name=o),i[o]=_,_.rawDefinition=u.bind(null,s),_.aliases&®isterAliases(_.aliases,{languageName:o})},unregisterLanguage:function unregisterLanguage(s){delete i[s];for(const o of Object.keys(u))u[o]===s&&delete u[o]},listLanguages:function listLanguages(){return Object.keys(i)},getLanguage,registerAliases,requireLanguage:function requireLanguage(s){deprecated("10.4.0","requireLanguage will be removed entirely in v11."),deprecated("10.4.0","Please see https://github.com/highlightjs/highlight.js/pull/2844");const o=getLanguage(s);if(o)return o;throw new Error("The '{}' language is required, but not loaded.".replace("{}",s))},autoDetection,inherit:Se,addPlugin:function addPlugin(s){!function upgradePluginAPI(s){s["before:highlightBlock"]&&!s["before:highlightElement"]&&(s["before:highlightElement"]=o=>{s["before:highlightBlock"](Object.assign({block:o.el},o))}),s["after:highlightBlock"]&&!s["after:highlightElement"]&&(s["after:highlightElement"]=o=>{s["after:highlightBlock"](Object.assign({block:o.el},o))})}(s),_.push(s)},vuePlugin:BuildVuePlugin(s).VuePlugin}),s.debugMode=function(){w=!1},s.safeMode=function(){w=!0},s.versionString="10.7.3";for(const s in fe)"object"==typeof fe[s]&&o(fe[s]);return Object.assign(s,fe),s.addPlugin(B),s.addPlugin(be),s.addPlugin(V),s}({});s.exports=Pe},35344:s=>{function concat(...s){return s.map((s=>function source(s){return s?"string"==typeof s?s:s.source:null}(s))).join("")}s.exports=function bash(s){const o={},i={begin:/\$\{/,end:/\}/,contains:["self",{begin:/:-/,contains:[o]}]};Object.assign(o,{className:"variable",variants:[{begin:concat(/\$[\w\d#@][\w\d_]*/,"(?![\\w\\d])(?![$])")},i]});const u={className:"subst",begin:/\$\(/,end:/\)/,contains:[s.BACKSLASH_ESCAPE]},_={begin:/<<-?\s*(?=\w+)/,starts:{contains:[s.END_SAME_AS_BEGIN({begin:/(\w+)/,end:/(\w+)/,className:"string"})]}},w={className:"string",begin:/"/,end:/"/,contains:[s.BACKSLASH_ESCAPE,o,u]};u.contains.push(w);const x={begin:/\$\(\(/,end:/\)\)/,contains:[{begin:/\d+#[0-9a-f]+/,className:"number"},s.NUMBER_MODE,o]},C=s.SHEBANG({binary:`(${["fish","bash","zsh","sh","csh","ksh","tcsh","dash","scsh"].join("|")})`,relevance:10}),j={className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0,contains:[s.inherit(s.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0};return{name:"Bash",aliases:["sh","zsh"],keywords:{$pattern:/\b[a-z._-]+\b/,keyword:"if then else elif fi for while in do done case esac function",literal:"true false",built_in:"break cd continue eval exec exit export getopts hash pwd readonly return shift test times trap umask unset alias bind builtin caller command declare echo enable help let local logout mapfile printf read readarray source type typeset ulimit unalias set shopt autoload bg bindkey bye cap chdir clone comparguments compcall compctl compdescribe compfiles compgroups compquote comptags comptry compvalues dirs disable disown echotc echoti emulate fc fg float functions getcap getln history integer jobs kill limit log noglob popd print pushd pushln rehash sched setcap setopt stat suspend ttyctl unfunction unhash unlimit unsetopt vared wait whence where which zcompile zformat zftp zle zmodload zparseopts zprof zpty zregexparse zsocket zstyle ztcp"},contains:[C,s.SHEBANG(),j,x,s.HASH_COMMENT_MODE,_,w,{className:"",begin:/\\"/},{className:"string",begin:/'/,end:/'/},o]}}},73402:s=>{function concat(...s){return s.map((s=>function source(s){return s?"string"==typeof s?s:s.source:null}(s))).join("")}s.exports=function http(s){const o="HTTP/(2|1\\.[01])",i={className:"attribute",begin:concat("^",/[A-Za-z][A-Za-z0-9-]*/,"(?=\\:\\s)"),starts:{contains:[{className:"punctuation",begin:/: /,relevance:0,starts:{end:"$",relevance:0}}]}},u=[i,{begin:"\\n\\n",starts:{subLanguage:[],endsWithParent:!0}}];return{name:"HTTP",aliases:["https"],illegal:/\S/,contains:[{begin:"^(?="+o+" \\d{3})",end:/$/,contains:[{className:"meta",begin:o},{className:"number",begin:"\\b\\d{3}\\b"}],starts:{end:/\b\B/,illegal:/\S/,contains:u}},{begin:"(?=^[A-Z]+ (.*?) "+o+"$)",end:/$/,contains:[{className:"string",begin:" ",end:" ",excludeBegin:!0,excludeEnd:!0},{className:"meta",begin:o},{className:"keyword",begin:"[A-Z]+"}],starts:{end:/\b\B/,illegal:/\S/,contains:u}},s.inherit(i,{relevance:0})]}}},95089:s=>{const o="[A-Za-z$_][0-9A-Za-z$_]*",i=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends"],u=["true","false","null","undefined","NaN","Infinity"],_=[].concat(["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],["arguments","this","super","console","window","document","localStorage","module","global"],["Intl","DataView","Number","Math","Date","String","RegExp","Object","Function","Boolean","Error","Symbol","Set","Map","WeakSet","WeakMap","Proxy","Reflect","JSON","Promise","Float64Array","Int16Array","Int32Array","Int8Array","Uint16Array","Uint32Array","Float32Array","Array","Uint8Array","Uint8ClampedArray","ArrayBuffer","BigInt64Array","BigUint64Array","BigInt"],["EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"]);function lookahead(s){return concat("(?=",s,")")}function concat(...s){return s.map((s=>function source(s){return s?"string"==typeof s?s:s.source:null}(s))).join("")}s.exports=function javascript(s){const w=o,x="<>",C="",j={begin:/<[A-Za-z0-9\\._:-]+/,end:/\/[A-Za-z0-9\\._:-]+>|\/>/,isTrulyOpeningTag:(s,o)=>{const i=s[0].length+s.index,u=s.input[i];"<"!==u?">"===u&&(((s,{after:o})=>{const i="",returnBegin:!0,end:"\\s*=>",contains:[{className:"params",variants:[{begin:s.UNDERSCORE_IDENT_RE,relevance:0},{className:null,begin:/\(\s*\)/,skip:!0},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:L,contains:ce}]}]},{begin:/,/,relevance:0},{className:"",begin:/\s/,end:/\s*/,skip:!0},{variants:[{begin:x,end:C},{begin:j.begin,"on:begin":j.isTrulyOpeningTag,end:j.end}],subLanguage:"xml",contains:[{begin:j.begin,end:j.end,skip:!0,contains:["self"]}]}],relevance:0},{className:"function",beginKeywords:"function",end:/[{;]/,excludeEnd:!0,keywords:L,contains:["self",s.inherit(s.TITLE_MODE,{begin:w}),pe],illegal:/%/},{beginKeywords:"while if switch catch for"},{className:"function",begin:s.UNDERSCORE_IDENT_RE+"\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)\\s*\\{",returnBegin:!0,contains:[pe,s.inherit(s.TITLE_MODE,{begin:w})]},{variants:[{begin:"\\."+w},{begin:"\\$"+w}],relevance:0},{className:"class",beginKeywords:"class",end:/[{;=]/,excludeEnd:!0,illegal:/[:"[\]]/,contains:[{beginKeywords:"extends"},s.UNDERSCORE_TITLE_MODE]},{begin:/\b(?=constructor)/,end:/[{;]/,excludeEnd:!0,contains:[s.inherit(s.TITLE_MODE,{begin:w}),"self",pe]},{begin:"(get|set)\\s+(?="+w+"\\()",end:/\{/,keywords:"get set",contains:[s.inherit(s.TITLE_MODE,{begin:w}),{begin:/\(\)/},pe]},{begin:/\$[(.]/}]}}},65772:s=>{s.exports=function json(s){const o={literal:"true false null"},i=[s.C_LINE_COMMENT_MODE,s.C_BLOCK_COMMENT_MODE],u=[s.QUOTE_STRING_MODE,s.C_NUMBER_MODE],_={end:",",endsWithParent:!0,excludeEnd:!0,contains:u,keywords:o},w={begin:/\{/,end:/\}/,contains:[{className:"attr",begin:/"/,end:/"/,contains:[s.BACKSLASH_ESCAPE],illegal:"\\n"},s.inherit(_,{begin:/:/})].concat(i),illegal:"\\S"},x={begin:"\\[",end:"\\]",contains:[s.inherit(_)],illegal:"\\S"};return u.push(w,x),i.forEach((function(s){u.push(s)})),{name:"JSON",contains:u,keywords:o,illegal:"\\S"}}},26571:s=>{s.exports=function powershell(s){const o={$pattern:/-?[A-z\.\-]+\b/,keyword:"if else foreach return do while until elseif begin for trap data dynamicparam end break throw param continue finally in switch exit filter try process catch hidden static parameter",built_in:"ac asnp cat cd CFS chdir clc clear clhy cli clp cls clv cnsn compare copy cp cpi cpp curl cvpa dbp del diff dir dnsn ebp echo|0 epal epcsv epsn erase etsn exsn fc fhx fl ft fw gal gbp gc gcb gci gcm gcs gdr gerr ghy gi gin gjb gl gm gmo gp gps gpv group gsn gsnp gsv gtz gu gv gwmi h history icm iex ihy ii ipal ipcsv ipmo ipsn irm ise iwmi iwr kill lp ls man md measure mi mount move mp mv nal ndr ni nmo npssc nsn nv ogv oh popd ps pushd pwd r rbp rcjb rcsn rd rdr ren ri rjb rm rmdir rmo rni rnp rp rsn rsnp rujb rv rvpa rwmi sajb sal saps sasv sbp sc scb select set shcm si sl sleep sls sort sp spjb spps spsv start stz sujb sv swmi tee trcm type wget where wjb write"},i={begin:"`[\\s\\S]",relevance:0},u={className:"variable",variants:[{begin:/\$\B/},{className:"keyword",begin:/\$this/},{begin:/\$[\w\d][\w\d_:]*/}]},_={className:"string",variants:[{begin:/"/,end:/"/},{begin:/@"/,end:/^"@/}],contains:[i,u,{className:"variable",begin:/\$[A-z]/,end:/[^A-z]/}]},w={className:"string",variants:[{begin:/'/,end:/'/},{begin:/@'/,end:/^'@/}]},x=s.inherit(s.COMMENT(null,null),{variants:[{begin:/#/,end:/$/},{begin:/<#/,end:/#>/}],contains:[{className:"doctag",variants:[{begin:/\.(synopsis|description|example|inputs|outputs|notes|link|component|role|functionality)/},{begin:/\.(parameter|forwardhelptargetname|forwardhelpcategory|remotehelprunspace|externalhelp)\s+\S+/}]}]}),C={className:"built_in",variants:[{begin:"(".concat("Add|Clear|Close|Copy|Enter|Exit|Find|Format|Get|Hide|Join|Lock|Move|New|Open|Optimize|Pop|Push|Redo|Remove|Rename|Reset|Resize|Search|Select|Set|Show|Skip|Split|Step|Switch|Undo|Unlock|Watch|Backup|Checkpoint|Compare|Compress|Convert|ConvertFrom|ConvertTo|Dismount|Edit|Expand|Export|Group|Import|Initialize|Limit|Merge|Mount|Out|Publish|Restore|Save|Sync|Unpublish|Update|Approve|Assert|Build|Complete|Confirm|Deny|Deploy|Disable|Enable|Install|Invoke|Register|Request|Restart|Resume|Start|Stop|Submit|Suspend|Uninstall|Unregister|Wait|Debug|Measure|Ping|Repair|Resolve|Test|Trace|Connect|Disconnect|Read|Receive|Send|Write|Block|Grant|Protect|Revoke|Unblock|Unprotect|Use|ForEach|Sort|Tee|Where",")+(-)[\\w\\d]+")}]},j={className:"class",beginKeywords:"class enum",end:/\s*[{]/,excludeEnd:!0,relevance:0,contains:[s.TITLE_MODE]},L={className:"function",begin:/function\s+/,end:/\s*\{|$/,excludeEnd:!0,returnBegin:!0,relevance:0,contains:[{begin:"function",relevance:0,className:"keyword"},{className:"title",begin:/\w[\w\d]*((-)[\w\d]+)*/,relevance:0},{begin:/\(/,end:/\)/,className:"params",relevance:0,contains:[u]}]},B={begin:/using\s/,end:/$/,returnBegin:!0,contains:[_,w,{className:"keyword",begin:/(using|assembly|command|module|namespace|type)/}]},$={variants:[{className:"operator",begin:"(".concat("-and|-as|-band|-bnot|-bor|-bxor|-casesensitive|-ccontains|-ceq|-cge|-cgt|-cle|-clike|-clt|-cmatch|-cne|-cnotcontains|-cnotlike|-cnotmatch|-contains|-creplace|-csplit|-eq|-exact|-f|-file|-ge|-gt|-icontains|-ieq|-ige|-igt|-ile|-ilike|-ilt|-imatch|-in|-ine|-inotcontains|-inotlike|-inotmatch|-ireplace|-is|-isnot|-isplit|-join|-le|-like|-lt|-match|-ne|-not|-notcontains|-notin|-notlike|-notmatch|-or|-regex|-replace|-shl|-shr|-split|-wildcard|-xor",")\\b")},{className:"literal",begin:/(-)[\w\d]+/,relevance:0}]},V={className:"function",begin:/\[.*\]\s*[\w]+[ ]??\(/,end:/$/,returnBegin:!0,relevance:0,contains:[{className:"keyword",begin:"(".concat(o.keyword.toString().replace(/\s/g,"|"),")\\b"),endsParent:!0,relevance:0},s.inherit(s.TITLE_MODE,{endsParent:!0})]},U=[V,x,i,s.NUMBER_MODE,_,w,C,u,{className:"literal",begin:/\$(null|true|false)\b/},{className:"selector-tag",begin:/@\B/,relevance:0}],z={begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0,relevance:0,contains:[].concat("self",U,{begin:"("+["string","char","byte","int","long","bool","decimal","single","double","DateTime","xml","array","hashtable","void"].join("|")+")",className:"built_in",relevance:0},{className:"type",begin:/[\.\w\d]+/,relevance:0})};return V.contains.unshift(z),{name:"PowerShell",aliases:["ps","ps1"],case_insensitive:!0,keywords:o,contains:U.concat(j,L,B,$,z)}}},17285:s=>{function source(s){return s?"string"==typeof s?s:s.source:null}function lookahead(s){return concat("(?=",s,")")}function concat(...s){return s.map((s=>source(s))).join("")}function either(...s){return"("+s.map((s=>source(s))).join("|")+")"}s.exports=function xml(s){const o=concat(/[A-Z_]/,function optional(s){return concat("(",s,")?")}(/[A-Z0-9_.-]*:/),/[A-Z0-9_.-]*/),i={className:"symbol",begin:/&[a-z]+;|&#[0-9]+;|&#x[a-f0-9]+;/},u={begin:/\s/,contains:[{className:"meta-keyword",begin:/#?[a-z_][a-z1-9_-]+/,illegal:/\n/}]},_=s.inherit(u,{begin:/\(/,end:/\)/}),w=s.inherit(s.APOS_STRING_MODE,{className:"meta-string"}),x=s.inherit(s.QUOTE_STRING_MODE,{className:"meta-string"}),C={endsWithParent:!0,illegal:/`]+/}]}]}]};return{name:"HTML, XML",aliases:["html","xhtml","rss","atom","xjb","xsd","xsl","plist","wsf","svg"],case_insensitive:!0,contains:[{className:"meta",begin://,relevance:10,contains:[u,x,w,_,{begin:/\[/,end:/\]/,contains:[{className:"meta",begin://,contains:[u,_,x,w]}]}]},s.COMMENT(//,{relevance:10}),{begin://,relevance:10},i,{className:"meta",begin:/<\?xml/,end:/\?>/,relevance:10},{className:"tag",begin:/)/,end:/>/,keywords:{name:"style"},contains:[C],starts:{end:/<\/style>/,returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag",begin:/)/,end:/>/,keywords:{name:"script"},contains:[C],starts:{end:/<\/script>/,returnEnd:!0,subLanguage:["javascript","handlebars","xml"]}},{className:"tag",begin:/<>|<\/>/},{className:"tag",begin:concat(//,/>/,/\s/)))),end:/\/?>/,contains:[{className:"name",begin:o,relevance:0,starts:C}]},{className:"tag",begin:concat(/<\//,lookahead(concat(o,/>/))),contains:[{className:"name",begin:o,relevance:0},{begin:/>/,relevance:0,endsParent:!0}]}]}}},17533:s=>{s.exports=function yaml(s){var o="true false yes no null",i="[\\w#;/?:@&=+$,.~*'()[\\]]+",u={className:"string",relevance:0,variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/\S+/}],contains:[s.BACKSLASH_ESCAPE,{className:"template-variable",variants:[{begin:/\{\{/,end:/\}\}/},{begin:/%\{/,end:/\}/}]}]},_=s.inherit(u,{variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/[^\s,{}[\]]+/}]}),w={className:"number",begin:"\\b[0-9]{4}(-[0-9][0-9]){0,2}([Tt \\t][0-9][0-9]?(:[0-9][0-9]){2})?(\\.[0-9]*)?([ \\t])*(Z|[-+][0-9][0-9]?(:[0-9][0-9])?)?\\b"},x={end:",",endsWithParent:!0,excludeEnd:!0,keywords:o,relevance:0},C={begin:/\{/,end:/\}/,contains:[x],illegal:"\\n",relevance:0},j={begin:"\\[",end:"\\]",contains:[x],illegal:"\\n",relevance:0},L=[{className:"attr",variants:[{begin:"\\w[\\w :\\/.-]*:(?=[ \t]|$)"},{begin:'"\\w[\\w :\\/.-]*":(?=[ \t]|$)'},{begin:"'\\w[\\w :\\/.-]*':(?=[ \t]|$)"}]},{className:"meta",begin:"^---\\s*$",relevance:10},{className:"string",begin:"[\\|>]([1-9]?[+-])?[ ]*\\n( +)[^ ][^\\n]*\\n(\\2[^\\n]+\\n?)*"},{begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0,relevance:0},{className:"type",begin:"!\\w+!"+i},{className:"type",begin:"!<"+i+">"},{className:"type",begin:"!"+i},{className:"type",begin:"!!"+i},{className:"meta",begin:"&"+s.UNDERSCORE_IDENT_RE+"$"},{className:"meta",begin:"\\*"+s.UNDERSCORE_IDENT_RE+"$"},{className:"bullet",begin:"-(?=[ ]|$)",relevance:0},s.HASH_COMMENT_MODE,{beginKeywords:o,keywords:{literal:o}},w,{className:"number",begin:s.C_NUMBER_RE+"\\b",relevance:0},C,j,u],B=[...L];return B.pop(),B.push(_),x.contains=B,{name:"YAML",case_insensitive:!0,aliases:["yml"],contains:L}}},251:(s,o)=>{o.read=function(s,o,i,u,_){var w,x,C=8*_-u-1,j=(1<>1,B=-7,$=i?_-1:0,V=i?-1:1,U=s[o+$];for($+=V,w=U&(1<<-B)-1,U>>=-B,B+=C;B>0;w=256*w+s[o+$],$+=V,B-=8);for(x=w&(1<<-B)-1,w>>=-B,B+=u;B>0;x=256*x+s[o+$],$+=V,B-=8);if(0===w)w=1-L;else{if(w===j)return x?NaN:1/0*(U?-1:1);x+=Math.pow(2,u),w-=L}return(U?-1:1)*x*Math.pow(2,w-u)},o.write=function(s,o,i,u,_,w){var x,C,j,L=8*w-_-1,B=(1<>1,V=23===_?Math.pow(2,-24)-Math.pow(2,-77):0,U=u?0:w-1,z=u?1:-1,Y=o<0||0===o&&1/o<0?1:0;for(o=Math.abs(o),isNaN(o)||o===1/0?(C=isNaN(o)?1:0,x=B):(x=Math.floor(Math.log(o)/Math.LN2),o*(j=Math.pow(2,-x))<1&&(x--,j*=2),(o+=x+$>=1?V/j:V*Math.pow(2,1-$))*j>=2&&(x++,j/=2),x+$>=B?(C=0,x=B):x+$>=1?(C=(o*j-1)*Math.pow(2,_),x+=$):(C=o*Math.pow(2,$-1)*Math.pow(2,_),x=0));_>=8;s[i+U]=255&C,U+=z,C/=256,_-=8);for(x=x<<_|C,L+=_;L>0;s[i+U]=255&x,U+=z,x/=256,L-=8);s[i+U-z]|=128*Y}},9404:function(s){s.exports=function(){"use strict";var s=Array.prototype.slice;function createClass(s,o){o&&(s.prototype=Object.create(o.prototype)),s.prototype.constructor=s}function Iterable(s){return isIterable(s)?s:Seq(s)}function KeyedIterable(s){return isKeyed(s)?s:KeyedSeq(s)}function IndexedIterable(s){return isIndexed(s)?s:IndexedSeq(s)}function SetIterable(s){return isIterable(s)&&!isAssociative(s)?s:SetSeq(s)}function isIterable(s){return!(!s||!s[o])}function isKeyed(s){return!(!s||!s[i])}function isIndexed(s){return!(!s||!s[u])}function isAssociative(s){return isKeyed(s)||isIndexed(s)}function isOrdered(s){return!(!s||!s[_])}createClass(KeyedIterable,Iterable),createClass(IndexedIterable,Iterable),createClass(SetIterable,Iterable),Iterable.isIterable=isIterable,Iterable.isKeyed=isKeyed,Iterable.isIndexed=isIndexed,Iterable.isAssociative=isAssociative,Iterable.isOrdered=isOrdered,Iterable.Keyed=KeyedIterable,Iterable.Indexed=IndexedIterable,Iterable.Set=SetIterable;var o="@@__IMMUTABLE_ITERABLE__@@",i="@@__IMMUTABLE_KEYED__@@",u="@@__IMMUTABLE_INDEXED__@@",_="@@__IMMUTABLE_ORDERED__@@",w="delete",x=5,C=1<>>0;if(""+i!==o||4294967295===i)return NaN;o=i}return o<0?ensureSize(s)+o:o}function returnTrue(){return!0}function wholeSlice(s,o,i){return(0===s||void 0!==i&&s<=-i)&&(void 0===o||void 0!==i&&o>=i)}function resolveBegin(s,o){return resolveIndex(s,o,0)}function resolveEnd(s,o){return resolveIndex(s,o,o)}function resolveIndex(s,o,i){return void 0===s?i:s<0?Math.max(0,o+s):void 0===o?s:Math.min(o,s)}var V=0,U=1,z=2,Y="function"==typeof Symbol&&Symbol.iterator,Z="@@iterator",ee=Y||Z;function Iterator(s){this.next=s}function iteratorValue(s,o,i,u){var _=0===s?o:1===s?i:[o,i];return u?u.value=_:u={value:_,done:!1},u}function iteratorDone(){return{value:void 0,done:!0}}function hasIterator(s){return!!getIteratorFn(s)}function isIterator(s){return s&&"function"==typeof s.next}function getIterator(s){var o=getIteratorFn(s);return o&&o.call(s)}function getIteratorFn(s){var o=s&&(Y&&s[Y]||s[Z]);if("function"==typeof o)return o}function isArrayLike(s){return s&&"number"==typeof s.length}function Seq(s){return null==s?emptySequence():isIterable(s)?s.toSeq():seqFromValue(s)}function KeyedSeq(s){return null==s?emptySequence().toKeyedSeq():isIterable(s)?isKeyed(s)?s.toSeq():s.fromEntrySeq():keyedSeqFromValue(s)}function IndexedSeq(s){return null==s?emptySequence():isIterable(s)?isKeyed(s)?s.entrySeq():s.toIndexedSeq():indexedSeqFromValue(s)}function SetSeq(s){return(null==s?emptySequence():isIterable(s)?isKeyed(s)?s.entrySeq():s:indexedSeqFromValue(s)).toSetSeq()}Iterator.prototype.toString=function(){return"[Iterator]"},Iterator.KEYS=V,Iterator.VALUES=U,Iterator.ENTRIES=z,Iterator.prototype.inspect=Iterator.prototype.toSource=function(){return this.toString()},Iterator.prototype[ee]=function(){return this},createClass(Seq,Iterable),Seq.of=function(){return Seq(arguments)},Seq.prototype.toSeq=function(){return this},Seq.prototype.toString=function(){return this.__toString("Seq {","}")},Seq.prototype.cacheResult=function(){return!this._cache&&this.__iterateUncached&&(this._cache=this.entrySeq().toArray(),this.size=this._cache.length),this},Seq.prototype.__iterate=function(s,o){return seqIterate(this,s,o,!0)},Seq.prototype.__iterator=function(s,o){return seqIterator(this,s,o,!0)},createClass(KeyedSeq,Seq),KeyedSeq.prototype.toKeyedSeq=function(){return this},createClass(IndexedSeq,Seq),IndexedSeq.of=function(){return IndexedSeq(arguments)},IndexedSeq.prototype.toIndexedSeq=function(){return this},IndexedSeq.prototype.toString=function(){return this.__toString("Seq [","]")},IndexedSeq.prototype.__iterate=function(s,o){return seqIterate(this,s,o,!1)},IndexedSeq.prototype.__iterator=function(s,o){return seqIterator(this,s,o,!1)},createClass(SetSeq,Seq),SetSeq.of=function(){return SetSeq(arguments)},SetSeq.prototype.toSetSeq=function(){return this},Seq.isSeq=isSeq,Seq.Keyed=KeyedSeq,Seq.Set=SetSeq,Seq.Indexed=IndexedSeq;var ie,ae,le,ce="@@__IMMUTABLE_SEQ__@@";function ArraySeq(s){this._array=s,this.size=s.length}function ObjectSeq(s){var o=Object.keys(s);this._object=s,this._keys=o,this.size=o.length}function IterableSeq(s){this._iterable=s,this.size=s.length||s.size}function IteratorSeq(s){this._iterator=s,this._iteratorCache=[]}function isSeq(s){return!(!s||!s[ce])}function emptySequence(){return ie||(ie=new ArraySeq([]))}function keyedSeqFromValue(s){var o=Array.isArray(s)?new ArraySeq(s).fromEntrySeq():isIterator(s)?new IteratorSeq(s).fromEntrySeq():hasIterator(s)?new IterableSeq(s).fromEntrySeq():"object"==typeof s?new ObjectSeq(s):void 0;if(!o)throw new TypeError("Expected Array or iterable object of [k, v] entries, or keyed object: "+s);return o}function indexedSeqFromValue(s){var o=maybeIndexedSeqFromValue(s);if(!o)throw new TypeError("Expected Array or iterable object of values: "+s);return o}function seqFromValue(s){var o=maybeIndexedSeqFromValue(s)||"object"==typeof s&&new ObjectSeq(s);if(!o)throw new TypeError("Expected Array or iterable object of values, or keyed object: "+s);return o}function maybeIndexedSeqFromValue(s){return isArrayLike(s)?new ArraySeq(s):isIterator(s)?new IteratorSeq(s):hasIterator(s)?new IterableSeq(s):void 0}function seqIterate(s,o,i,u){var _=s._cache;if(_){for(var w=_.length-1,x=0;x<=w;x++){var C=_[i?w-x:x];if(!1===o(C[1],u?C[0]:x,s))return x+1}return x}return s.__iterateUncached(o,i)}function seqIterator(s,o,i,u){var _=s._cache;if(_){var w=_.length-1,x=0;return new Iterator((function(){var s=_[i?w-x:x];return x++>w?iteratorDone():iteratorValue(o,u?s[0]:x-1,s[1])}))}return s.__iteratorUncached(o,i)}function fromJS(s,o){return o?fromJSWith(o,s,"",{"":s}):fromJSDefault(s)}function fromJSWith(s,o,i,u){return Array.isArray(o)?s.call(u,i,IndexedSeq(o).map((function(i,u){return fromJSWith(s,i,u,o)}))):isPlainObj(o)?s.call(u,i,KeyedSeq(o).map((function(i,u){return fromJSWith(s,i,u,o)}))):o}function fromJSDefault(s){return Array.isArray(s)?IndexedSeq(s).map(fromJSDefault).toList():isPlainObj(s)?KeyedSeq(s).map(fromJSDefault).toMap():s}function isPlainObj(s){return s&&(s.constructor===Object||void 0===s.constructor)}function is(s,o){if(s===o||s!=s&&o!=o)return!0;if(!s||!o)return!1;if("function"==typeof s.valueOf&&"function"==typeof o.valueOf){if((s=s.valueOf())===(o=o.valueOf())||s!=s&&o!=o)return!0;if(!s||!o)return!1}return!("function"!=typeof s.equals||"function"!=typeof o.equals||!s.equals(o))}function deepEqual(s,o){if(s===o)return!0;if(!isIterable(o)||void 0!==s.size&&void 0!==o.size&&s.size!==o.size||void 0!==s.__hash&&void 0!==o.__hash&&s.__hash!==o.__hash||isKeyed(s)!==isKeyed(o)||isIndexed(s)!==isIndexed(o)||isOrdered(s)!==isOrdered(o))return!1;if(0===s.size&&0===o.size)return!0;var i=!isAssociative(s);if(isOrdered(s)){var u=s.entries();return o.every((function(s,o){var _=u.next().value;return _&&is(_[1],s)&&(i||is(_[0],o))}))&&u.next().done}var _=!1;if(void 0===s.size)if(void 0===o.size)"function"==typeof s.cacheResult&&s.cacheResult();else{_=!0;var w=s;s=o,o=w}var x=!0,C=o.__iterate((function(o,u){if(i?!s.has(o):_?!is(o,s.get(u,L)):!is(s.get(u,L),o))return x=!1,!1}));return x&&s.size===C}function Repeat(s,o){if(!(this instanceof Repeat))return new Repeat(s,o);if(this._value=s,this.size=void 0===o?1/0:Math.max(0,o),0===this.size){if(ae)return ae;ae=this}}function invariant(s,o){if(!s)throw new Error(o)}function Range(s,o,i){if(!(this instanceof Range))return new Range(s,o,i);if(invariant(0!==i,"Cannot step a Range by 0"),s=s||0,void 0===o&&(o=1/0),i=void 0===i?1:Math.abs(i),ou?iteratorDone():iteratorValue(s,_,i[o?u-_++:_++])}))},createClass(ObjectSeq,KeyedSeq),ObjectSeq.prototype.get=function(s,o){return void 0===o||this.has(s)?this._object[s]:o},ObjectSeq.prototype.has=function(s){return this._object.hasOwnProperty(s)},ObjectSeq.prototype.__iterate=function(s,o){for(var i=this._object,u=this._keys,_=u.length-1,w=0;w<=_;w++){var x=u[o?_-w:w];if(!1===s(i[x],x,this))return w+1}return w},ObjectSeq.prototype.__iterator=function(s,o){var i=this._object,u=this._keys,_=u.length-1,w=0;return new Iterator((function(){var x=u[o?_-w:w];return w++>_?iteratorDone():iteratorValue(s,x,i[x])}))},ObjectSeq.prototype[_]=!0,createClass(IterableSeq,IndexedSeq),IterableSeq.prototype.__iterateUncached=function(s,o){if(o)return this.cacheResult().__iterate(s,o);var i=getIterator(this._iterable),u=0;if(isIterator(i))for(var _;!(_=i.next()).done&&!1!==s(_.value,u++,this););return u},IterableSeq.prototype.__iteratorUncached=function(s,o){if(o)return this.cacheResult().__iterator(s,o);var i=getIterator(this._iterable);if(!isIterator(i))return new Iterator(iteratorDone);var u=0;return new Iterator((function(){var o=i.next();return o.done?o:iteratorValue(s,u++,o.value)}))},createClass(IteratorSeq,IndexedSeq),IteratorSeq.prototype.__iterateUncached=function(s,o){if(o)return this.cacheResult().__iterate(s,o);for(var i,u=this._iterator,_=this._iteratorCache,w=0;w<_.length;)if(!1===s(_[w],w++,this))return w;for(;!(i=u.next()).done;){var x=i.value;if(_[w]=x,!1===s(x,w++,this))break}return w},IteratorSeq.prototype.__iteratorUncached=function(s,o){if(o)return this.cacheResult().__iterator(s,o);var i=this._iterator,u=this._iteratorCache,_=0;return new Iterator((function(){if(_>=u.length){var o=i.next();if(o.done)return o;u[_]=o.value}return iteratorValue(s,_,u[_++])}))},createClass(Repeat,IndexedSeq),Repeat.prototype.toString=function(){return 0===this.size?"Repeat []":"Repeat [ "+this._value+" "+this.size+" times ]"},Repeat.prototype.get=function(s,o){return this.has(s)?this._value:o},Repeat.prototype.includes=function(s){return is(this._value,s)},Repeat.prototype.slice=function(s,o){var i=this.size;return wholeSlice(s,o,i)?this:new Repeat(this._value,resolveEnd(o,i)-resolveBegin(s,i))},Repeat.prototype.reverse=function(){return this},Repeat.prototype.indexOf=function(s){return is(this._value,s)?0:-1},Repeat.prototype.lastIndexOf=function(s){return is(this._value,s)?this.size:-1},Repeat.prototype.__iterate=function(s,o){for(var i=0;i=0&&o=0&&ii?iteratorDone():iteratorValue(s,w++,x)}))},Range.prototype.equals=function(s){return s instanceof Range?this._start===s._start&&this._end===s._end&&this._step===s._step:deepEqual(this,s)},createClass(Collection,Iterable),createClass(KeyedCollection,Collection),createClass(IndexedCollection,Collection),createClass(SetCollection,Collection),Collection.Keyed=KeyedCollection,Collection.Indexed=IndexedCollection,Collection.Set=SetCollection;var pe="function"==typeof Math.imul&&-2===Math.imul(4294967295,2)?Math.imul:function imul(s,o){var i=65535&(s|=0),u=65535&(o|=0);return i*u+((s>>>16)*u+i*(o>>>16)<<16>>>0)|0};function smi(s){return s>>>1&1073741824|3221225471&s}function hash(s){if(!1===s||null==s)return 0;if("function"==typeof s.valueOf&&(!1===(s=s.valueOf())||null==s))return 0;if(!0===s)return 1;var o=typeof s;if("number"===o){if(s!=s||s===1/0)return 0;var i=0|s;for(i!==s&&(i^=4294967295*s);s>4294967295;)i^=s/=4294967295;return smi(i)}if("string"===o)return s.length>Se?cachedHashString(s):hashString(s);if("function"==typeof s.hashCode)return s.hashCode();if("object"===o)return hashJSObj(s);if("function"==typeof s.toString)return hashString(s.toString());throw new Error("Value type "+o+" cannot be hashed.")}function cachedHashString(s){var o=Te[s];return void 0===o&&(o=hashString(s),Pe===xe&&(Pe=0,Te={}),Pe++,Te[s]=o),o}function hashString(s){for(var o=0,i=0;i0)switch(s.nodeType){case 1:return s.uniqueID;case 9:return s.documentElement&&s.documentElement.uniqueID}}var ye,be="function"==typeof WeakMap;be&&(ye=new WeakMap);var _e=0,we="__immutablehash__";"function"==typeof Symbol&&(we=Symbol(we));var Se=16,xe=255,Pe=0,Te={};function assertNotInfinite(s){invariant(s!==1/0,"Cannot perform this action with an infinite size.")}function Map(s){return null==s?emptyMap():isMap(s)&&!isOrdered(s)?s:emptyMap().withMutations((function(o){var i=KeyedIterable(s);assertNotInfinite(i.size),i.forEach((function(s,i){return o.set(i,s)}))}))}function isMap(s){return!(!s||!s[qe])}createClass(Map,KeyedCollection),Map.of=function(){var o=s.call(arguments,0);return emptyMap().withMutations((function(s){for(var i=0;i=o.length)throw new Error("Missing value for key: "+o[i]);s.set(o[i],o[i+1])}}))},Map.prototype.toString=function(){return this.__toString("Map {","}")},Map.prototype.get=function(s,o){return this._root?this._root.get(0,void 0,s,o):o},Map.prototype.set=function(s,o){return updateMap(this,s,o)},Map.prototype.setIn=function(s,o){return this.updateIn(s,L,(function(){return o}))},Map.prototype.remove=function(s){return updateMap(this,s,L)},Map.prototype.deleteIn=function(s){return this.updateIn(s,(function(){return L}))},Map.prototype.update=function(s,o,i){return 1===arguments.length?s(this):this.updateIn([s],o,i)},Map.prototype.updateIn=function(s,o,i){i||(i=o,o=void 0);var u=updateInDeepMap(this,forceIterator(s),o,i);return u===L?void 0:u},Map.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._root=null,this.__hash=void 0,this.__altered=!0,this):emptyMap()},Map.prototype.merge=function(){return mergeIntoMapWith(this,void 0,arguments)},Map.prototype.mergeWith=function(o){return mergeIntoMapWith(this,o,s.call(arguments,1))},Map.prototype.mergeIn=function(o){var i=s.call(arguments,1);return this.updateIn(o,emptyMap(),(function(s){return"function"==typeof s.merge?s.merge.apply(s,i):i[i.length-1]}))},Map.prototype.mergeDeep=function(){return mergeIntoMapWith(this,deepMerger,arguments)},Map.prototype.mergeDeepWith=function(o){var i=s.call(arguments,1);return mergeIntoMapWith(this,deepMergerWith(o),i)},Map.prototype.mergeDeepIn=function(o){var i=s.call(arguments,1);return this.updateIn(o,emptyMap(),(function(s){return"function"==typeof s.mergeDeep?s.mergeDeep.apply(s,i):i[i.length-1]}))},Map.prototype.sort=function(s){return OrderedMap(sortFactory(this,s))},Map.prototype.sortBy=function(s,o){return OrderedMap(sortFactory(this,o,s))},Map.prototype.withMutations=function(s){var o=this.asMutable();return s(o),o.wasAltered()?o.__ensureOwner(this.__ownerID):this},Map.prototype.asMutable=function(){return this.__ownerID?this:this.__ensureOwner(new OwnerID)},Map.prototype.asImmutable=function(){return this.__ensureOwner()},Map.prototype.wasAltered=function(){return this.__altered},Map.prototype.__iterator=function(s,o){return new MapIterator(this,s,o)},Map.prototype.__iterate=function(s,o){var i=this,u=0;return this._root&&this._root.iterate((function(o){return u++,s(o[1],o[0],i)}),o),u},Map.prototype.__ensureOwner=function(s){return s===this.__ownerID?this:s?makeMap(this.size,this._root,s,this.__hash):(this.__ownerID=s,this.__altered=!1,this)},Map.isMap=isMap;var Re,qe="@@__IMMUTABLE_MAP__@@",$e=Map.prototype;function ArrayMapNode(s,o){this.ownerID=s,this.entries=o}function BitmapIndexedNode(s,o,i){this.ownerID=s,this.bitmap=o,this.nodes=i}function HashArrayMapNode(s,o,i){this.ownerID=s,this.count=o,this.nodes=i}function HashCollisionNode(s,o,i){this.ownerID=s,this.keyHash=o,this.entries=i}function ValueNode(s,o,i){this.ownerID=s,this.keyHash=o,this.entry=i}function MapIterator(s,o,i){this._type=o,this._reverse=i,this._stack=s._root&&mapIteratorFrame(s._root)}function mapIteratorValue(s,o){return iteratorValue(s,o[0],o[1])}function mapIteratorFrame(s,o){return{node:s,index:0,__prev:o}}function makeMap(s,o,i,u){var _=Object.create($e);return _.size=s,_._root=o,_.__ownerID=i,_.__hash=u,_.__altered=!1,_}function emptyMap(){return Re||(Re=makeMap(0))}function updateMap(s,o,i){var u,_;if(s._root){var w=MakeRef(B),x=MakeRef($);if(u=updateNode(s._root,s.__ownerID,0,void 0,o,i,w,x),!x.value)return s;_=s.size+(w.value?i===L?-1:1:0)}else{if(i===L)return s;_=1,u=new ArrayMapNode(s.__ownerID,[[o,i]])}return s.__ownerID?(s.size=_,s._root=u,s.__hash=void 0,s.__altered=!0,s):u?makeMap(_,u):emptyMap()}function updateNode(s,o,i,u,_,w,x,C){return s?s.update(o,i,u,_,w,x,C):w===L?s:(SetRef(C),SetRef(x),new ValueNode(o,u,[_,w]))}function isLeafNode(s){return s.constructor===ValueNode||s.constructor===HashCollisionNode}function mergeIntoNode(s,o,i,u,_){if(s.keyHash===u)return new HashCollisionNode(o,u,[s.entry,_]);var w,C=(0===i?s.keyHash:s.keyHash>>>i)&j,L=(0===i?u:u>>>i)&j;return new BitmapIndexedNode(o,1<>>=1)x[j]=1&i?o[w++]:void 0;return x[u]=_,new HashArrayMapNode(s,w+1,x)}function mergeIntoMapWith(s,o,i){for(var u=[],_=0;_>1&1431655765))+(s>>2&858993459))+(s>>4)&252645135,s+=s>>8,127&(s+=s>>16)}function setIn(s,o,i,u){var _=u?s:arrCopy(s);return _[o]=i,_}function spliceIn(s,o,i,u){var _=s.length+1;if(u&&o+1===_)return s[o]=i,s;for(var w=new Array(_),x=0,C=0;C<_;C++)C===o?(w[C]=i,x=-1):w[C]=s[C+x];return w}function spliceOut(s,o,i){var u=s.length-1;if(i&&o===u)return s.pop(),s;for(var _=new Array(u),w=0,x=0;x=ze)return createNodes(s,j,u,_);var U=s&&s===this.ownerID,z=U?j:arrCopy(j);return V?C?B===$-1?z.pop():z[B]=z.pop():z[B]=[u,_]:z.push([u,_]),U?(this.entries=z,this):new ArrayMapNode(s,z)}},BitmapIndexedNode.prototype.get=function(s,o,i,u){void 0===o&&(o=hash(i));var _=1<<((0===s?o:o>>>s)&j),w=this.bitmap;return w&_?this.nodes[popCount(w&_-1)].get(s+x,o,i,u):u},BitmapIndexedNode.prototype.update=function(s,o,i,u,_,w,C){void 0===i&&(i=hash(u));var B=(0===o?i:i>>>o)&j,$=1<=We)return expandNodes(s,Y,V,B,ee);if(U&&!ee&&2===Y.length&&isLeafNode(Y[1^z]))return Y[1^z];if(U&&ee&&1===Y.length&&isLeafNode(ee))return ee;var ie=s&&s===this.ownerID,ae=U?ee?V:V^$:V|$,le=U?ee?setIn(Y,z,ee,ie):spliceOut(Y,z,ie):spliceIn(Y,z,ee,ie);return ie?(this.bitmap=ae,this.nodes=le,this):new BitmapIndexedNode(s,ae,le)},HashArrayMapNode.prototype.get=function(s,o,i,u){void 0===o&&(o=hash(i));var _=(0===s?o:o>>>s)&j,w=this.nodes[_];return w?w.get(s+x,o,i,u):u},HashArrayMapNode.prototype.update=function(s,o,i,u,_,w,C){void 0===i&&(i=hash(u));var B=(0===o?i:i>>>o)&j,$=_===L,V=this.nodes,U=V[B];if($&&!U)return this;var z=updateNode(U,s,o+x,i,u,_,w,C);if(z===U)return this;var Y=this.count;if(U){if(!z&&--Y0&&u=0&&s>>o&j;if(u>=this.array.length)return new VNode([],s);var _,w=0===u;if(o>0){var C=this.array[u];if((_=C&&C.removeBefore(s,o-x,i))===C&&w)return this}if(w&&!_)return this;var L=editableVNode(this,s);if(!w)for(var B=0;B>>o&j;if(_>=this.array.length)return this;if(o>0){var w=this.array[_];if((u=w&&w.removeAfter(s,o-x,i))===w&&_===this.array.length-1)return this}var C=editableVNode(this,s);return C.array.splice(_+1),u&&(C.array[_]=u),C};var Qe,et,tt={};function iterateList(s,o){var i=s._origin,u=s._capacity,_=getTailOffset(u),w=s._tail;return iterateNodeOrLeaf(s._root,s._level,0);function iterateNodeOrLeaf(s,o,i){return 0===o?iterateLeaf(s,i):iterateNode(s,o,i)}function iterateLeaf(s,x){var j=x===_?w&&w.array:s&&s.array,L=x>i?0:i-x,B=u-x;return B>C&&(B=C),function(){if(L===B)return tt;var s=o?--B:L++;return j&&j[s]}}function iterateNode(s,_,w){var j,L=s&&s.array,B=w>i?0:i-w>>_,$=1+(u-w>>_);return $>C&&($=C),function(){for(;;){if(j){var s=j();if(s!==tt)return s;j=null}if(B===$)return tt;var i=o?--$:B++;j=iterateNodeOrLeaf(L&&L[i],_-x,w+(i<<_))}}}}function makeList(s,o,i,u,_,w,x){var C=Object.create(Xe);return C.size=o-s,C._origin=s,C._capacity=o,C._level=i,C._root=u,C._tail=_,C.__ownerID=w,C.__hash=x,C.__altered=!1,C}function emptyList(){return Qe||(Qe=makeList(0,0,x))}function updateList(s,o,i){if((o=wrapIndex(s,o))!=o)return s;if(o>=s.size||o<0)return s.withMutations((function(s){o<0?setListBounds(s,o).set(0,i):setListBounds(s,0,o+1).set(o,i)}));o+=s._origin;var u=s._tail,_=s._root,w=MakeRef($);return o>=getTailOffset(s._capacity)?u=updateVNode(u,s.__ownerID,0,o,i,w):_=updateVNode(_,s.__ownerID,s._level,o,i,w),w.value?s.__ownerID?(s._root=_,s._tail=u,s.__hash=void 0,s.__altered=!0,s):makeList(s._origin,s._capacity,s._level,_,u):s}function updateVNode(s,o,i,u,_,w){var C,L=u>>>i&j,B=s&&L0){var $=s&&s.array[L],V=updateVNode($,o,i-x,u,_,w);return V===$?s:((C=editableVNode(s,o)).array[L]=V,C)}return B&&s.array[L]===_?s:(SetRef(w),C=editableVNode(s,o),void 0===_&&L===C.array.length-1?C.array.pop():C.array[L]=_,C)}function editableVNode(s,o){return o&&s&&o===s.ownerID?s:new VNode(s?s.array.slice():[],o)}function listNodeFor(s,o){if(o>=getTailOffset(s._capacity))return s._tail;if(o<1<0;)i=i.array[o>>>u&j],u-=x;return i}}function setListBounds(s,o,i){void 0!==o&&(o|=0),void 0!==i&&(i|=0);var u=s.__ownerID||new OwnerID,_=s._origin,w=s._capacity,C=_+o,L=void 0===i?w:i<0?w+i:_+i;if(C===_&&L===w)return s;if(C>=L)return s.clear();for(var B=s._level,$=s._root,V=0;C+V<0;)$=new VNode($&&$.array.length?[void 0,$]:[],u),V+=1<<(B+=x);V&&(C+=V,_+=V,L+=V,w+=V);for(var U=getTailOffset(w),z=getTailOffset(L);z>=1<U?new VNode([],u):Y;if(Y&&z>U&&Cx;ie-=x){var ae=U>>>ie&j;ee=ee.array[ae]=editableVNode(ee.array[ae],u)}ee.array[U>>>x&j]=Y}if(L=z)C-=z,L-=z,B=x,$=null,Z=Z&&Z.removeBefore(u,0,C);else if(C>_||z>>B&j;if(le!==z>>>B&j)break;le&&(V+=(1<_&&($=$.removeBefore(u,B,C-V)),$&&z_&&(_=C.size),isIterable(x)||(C=C.map((function(s){return fromJS(s)}))),u.push(C)}return _>s.size&&(s=s.setSize(_)),mergeIntoCollectionWith(s,o,u)}function getTailOffset(s){return s>>x<=C&&x.size>=2*w.size?(u=(_=x.filter((function(s,o){return void 0!==s&&j!==o}))).toKeyedSeq().map((function(s){return s[0]})).flip().toMap(),s.__ownerID&&(u.__ownerID=_.__ownerID=s.__ownerID)):(u=w.remove(o),_=j===x.size-1?x.pop():x.set(j,void 0))}else if(B){if(i===x.get(j)[1])return s;u=w,_=x.set(j,[o,i])}else u=w.set(o,x.size),_=x.set(x.size,[o,i]);return s.__ownerID?(s.size=u.size,s._map=u,s._list=_,s.__hash=void 0,s):makeOrderedMap(u,_)}function ToKeyedSequence(s,o){this._iter=s,this._useKeys=o,this.size=s.size}function ToIndexedSequence(s){this._iter=s,this.size=s.size}function ToSetSequence(s){this._iter=s,this.size=s.size}function FromEntriesSequence(s){this._iter=s,this.size=s.size}function flipFactory(s){var o=makeSequence(s);return o._iter=s,o.size=s.size,o.flip=function(){return s},o.reverse=function(){var o=s.reverse.apply(this);return o.flip=function(){return s.reverse()},o},o.has=function(o){return s.includes(o)},o.includes=function(o){return s.has(o)},o.cacheResult=cacheResultThrough,o.__iterateUncached=function(o,i){var u=this;return s.__iterate((function(s,i){return!1!==o(i,s,u)}),i)},o.__iteratorUncached=function(o,i){if(o===z){var u=s.__iterator(o,i);return new Iterator((function(){var s=u.next();if(!s.done){var o=s.value[0];s.value[0]=s.value[1],s.value[1]=o}return s}))}return s.__iterator(o===U?V:U,i)},o}function mapFactory(s,o,i){var u=makeSequence(s);return u.size=s.size,u.has=function(o){return s.has(o)},u.get=function(u,_){var w=s.get(u,L);return w===L?_:o.call(i,w,u,s)},u.__iterateUncached=function(u,_){var w=this;return s.__iterate((function(s,_,x){return!1!==u(o.call(i,s,_,x),_,w)}),_)},u.__iteratorUncached=function(u,_){var w=s.__iterator(z,_);return new Iterator((function(){var _=w.next();if(_.done)return _;var x=_.value,C=x[0];return iteratorValue(u,C,o.call(i,x[1],C,s),_)}))},u}function reverseFactory(s,o){var i=makeSequence(s);return i._iter=s,i.size=s.size,i.reverse=function(){return s},s.flip&&(i.flip=function(){var o=flipFactory(s);return o.reverse=function(){return s.flip()},o}),i.get=function(i,u){return s.get(o?i:-1-i,u)},i.has=function(i){return s.has(o?i:-1-i)},i.includes=function(o){return s.includes(o)},i.cacheResult=cacheResultThrough,i.__iterate=function(o,i){var u=this;return s.__iterate((function(s,i){return o(s,i,u)}),!i)},i.__iterator=function(o,i){return s.__iterator(o,!i)},i}function filterFactory(s,o,i,u){var _=makeSequence(s);return u&&(_.has=function(u){var _=s.get(u,L);return _!==L&&!!o.call(i,_,u,s)},_.get=function(u,_){var w=s.get(u,L);return w!==L&&o.call(i,w,u,s)?w:_}),_.__iterateUncached=function(_,w){var x=this,C=0;return s.__iterate((function(s,w,j){if(o.call(i,s,w,j))return C++,_(s,u?w:C-1,x)}),w),C},_.__iteratorUncached=function(_,w){var x=s.__iterator(z,w),C=0;return new Iterator((function(){for(;;){var w=x.next();if(w.done)return w;var j=w.value,L=j[0],B=j[1];if(o.call(i,B,L,s))return iteratorValue(_,u?L:C++,B,w)}}))},_}function countByFactory(s,o,i){var u=Map().asMutable();return s.__iterate((function(_,w){u.update(o.call(i,_,w,s),0,(function(s){return s+1}))})),u.asImmutable()}function groupByFactory(s,o,i){var u=isKeyed(s),_=(isOrdered(s)?OrderedMap():Map()).asMutable();s.__iterate((function(w,x){_.update(o.call(i,w,x,s),(function(s){return(s=s||[]).push(u?[x,w]:w),s}))}));var w=iterableClass(s);return _.map((function(o){return reify(s,w(o))}))}function sliceFactory(s,o,i,u){var _=s.size;if(void 0!==o&&(o|=0),void 0!==i&&(i===1/0?i=_:i|=0),wholeSlice(o,i,_))return s;var w=resolveBegin(o,_),x=resolveEnd(i,_);if(w!=w||x!=x)return sliceFactory(s.toSeq().cacheResult(),o,i,u);var C,j=x-w;j==j&&(C=j<0?0:j);var L=makeSequence(s);return L.size=0===C?C:s.size&&C||void 0,!u&&isSeq(s)&&C>=0&&(L.get=function(o,i){return(o=wrapIndex(this,o))>=0&&oC)return iteratorDone();var s=_.next();return u||o===U?s:iteratorValue(o,j-1,o===V?void 0:s.value[1],s)}))},L}function takeWhileFactory(s,o,i){var u=makeSequence(s);return u.__iterateUncached=function(u,_){var w=this;if(_)return this.cacheResult().__iterate(u,_);var x=0;return s.__iterate((function(s,_,C){return o.call(i,s,_,C)&&++x&&u(s,_,w)})),x},u.__iteratorUncached=function(u,_){var w=this;if(_)return this.cacheResult().__iterator(u,_);var x=s.__iterator(z,_),C=!0;return new Iterator((function(){if(!C)return iteratorDone();var s=x.next();if(s.done)return s;var _=s.value,j=_[0],L=_[1];return o.call(i,L,j,w)?u===z?s:iteratorValue(u,j,L,s):(C=!1,iteratorDone())}))},u}function skipWhileFactory(s,o,i,u){var _=makeSequence(s);return _.__iterateUncached=function(_,w){var x=this;if(w)return this.cacheResult().__iterate(_,w);var C=!0,j=0;return s.__iterate((function(s,w,L){if(!C||!(C=o.call(i,s,w,L)))return j++,_(s,u?w:j-1,x)})),j},_.__iteratorUncached=function(_,w){var x=this;if(w)return this.cacheResult().__iterator(_,w);var C=s.__iterator(z,w),j=!0,L=0;return new Iterator((function(){var s,w,B;do{if((s=C.next()).done)return u||_===U?s:iteratorValue(_,L++,_===V?void 0:s.value[1],s);var $=s.value;w=$[0],B=$[1],j&&(j=o.call(i,B,w,x))}while(j);return _===z?s:iteratorValue(_,w,B,s)}))},_}function concatFactory(s,o){var i=isKeyed(s),u=[s].concat(o).map((function(s){return isIterable(s)?i&&(s=KeyedIterable(s)):s=i?keyedSeqFromValue(s):indexedSeqFromValue(Array.isArray(s)?s:[s]),s})).filter((function(s){return 0!==s.size}));if(0===u.length)return s;if(1===u.length){var _=u[0];if(_===s||i&&isKeyed(_)||isIndexed(s)&&isIndexed(_))return _}var w=new ArraySeq(u);return i?w=w.toKeyedSeq():isIndexed(s)||(w=w.toSetSeq()),(w=w.flatten(!0)).size=u.reduce((function(s,o){if(void 0!==s){var i=o.size;if(void 0!==i)return s+i}}),0),w}function flattenFactory(s,o,i){var u=makeSequence(s);return u.__iterateUncached=function(u,_){var w=0,x=!1;function flatDeep(s,C){var j=this;s.__iterate((function(s,_){return(!o||C0}function zipWithFactory(s,o,i){var u=makeSequence(s);return u.size=new ArraySeq(i).map((function(s){return s.size})).min(),u.__iterate=function(s,o){for(var i,u=this.__iterator(U,o),_=0;!(i=u.next()).done&&!1!==s(i.value,_++,this););return _},u.__iteratorUncached=function(s,u){var _=i.map((function(s){return s=Iterable(s),getIterator(u?s.reverse():s)})),w=0,x=!1;return new Iterator((function(){var i;return x||(i=_.map((function(s){return s.next()})),x=i.some((function(s){return s.done}))),x?iteratorDone():iteratorValue(s,w++,o.apply(null,i.map((function(s){return s.value}))))}))},u}function reify(s,o){return isSeq(s)?o:s.constructor(o)}function validateEntry(s){if(s!==Object(s))throw new TypeError("Expected [K, V] tuple: "+s)}function resolveSize(s){return assertNotInfinite(s.size),ensureSize(s)}function iterableClass(s){return isKeyed(s)?KeyedIterable:isIndexed(s)?IndexedIterable:SetIterable}function makeSequence(s){return Object.create((isKeyed(s)?KeyedSeq:isIndexed(s)?IndexedSeq:SetSeq).prototype)}function cacheResultThrough(){return this._iter.cacheResult?(this._iter.cacheResult(),this.size=this._iter.size,this):Seq.prototype.cacheResult.call(this)}function defaultComparator(s,o){return s>o?1:s=0;i--)o={value:arguments[i],next:o};return this.__ownerID?(this.size=s,this._head=o,this.__hash=void 0,this.__altered=!0,this):makeStack(s,o)},Stack.prototype.pushAll=function(s){if(0===(s=IndexedIterable(s)).size)return this;assertNotInfinite(s.size);var o=this.size,i=this._head;return s.reverse().forEach((function(s){o++,i={value:s,next:i}})),this.__ownerID?(this.size=o,this._head=i,this.__hash=void 0,this.__altered=!0,this):makeStack(o,i)},Stack.prototype.pop=function(){return this.slice(1)},Stack.prototype.unshift=function(){return this.push.apply(this,arguments)},Stack.prototype.unshiftAll=function(s){return this.pushAll(s)},Stack.prototype.shift=function(){return this.pop.apply(this,arguments)},Stack.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._head=void 0,this.__hash=void 0,this.__altered=!0,this):emptyStack()},Stack.prototype.slice=function(s,o){if(wholeSlice(s,o,this.size))return this;var i=resolveBegin(s,this.size);if(resolveEnd(o,this.size)!==this.size)return IndexedCollection.prototype.slice.call(this,s,o);for(var u=this.size-i,_=this._head;i--;)_=_.next;return this.__ownerID?(this.size=u,this._head=_,this.__hash=void 0,this.__altered=!0,this):makeStack(u,_)},Stack.prototype.__ensureOwner=function(s){return s===this.__ownerID?this:s?makeStack(this.size,this._head,s,this.__hash):(this.__ownerID=s,this.__altered=!1,this)},Stack.prototype.__iterate=function(s,o){if(o)return this.reverse().__iterate(s);for(var i=0,u=this._head;u&&!1!==s(u.value,i++,this);)u=u.next;return i},Stack.prototype.__iterator=function(s,o){if(o)return this.reverse().__iterator(s);var i=0,u=this._head;return new Iterator((function(){if(u){var o=u.value;return u=u.next,iteratorValue(s,i++,o)}return iteratorDone()}))},Stack.isStack=isStack;var lt,ct="@@__IMMUTABLE_STACK__@@",ut=Stack.prototype;function makeStack(s,o,i,u){var _=Object.create(ut);return _.size=s,_._head=o,_.__ownerID=i,_.__hash=u,_.__altered=!1,_}function emptyStack(){return lt||(lt=makeStack(0))}function mixin(s,o){var keyCopier=function(i){s.prototype[i]=o[i]};return Object.keys(o).forEach(keyCopier),Object.getOwnPropertySymbols&&Object.getOwnPropertySymbols(o).forEach(keyCopier),s}ut[ct]=!0,ut.withMutations=$e.withMutations,ut.asMutable=$e.asMutable,ut.asImmutable=$e.asImmutable,ut.wasAltered=$e.wasAltered,Iterable.Iterator=Iterator,mixin(Iterable,{toArray:function(){assertNotInfinite(this.size);var s=new Array(this.size||0);return this.valueSeq().__iterate((function(o,i){s[i]=o})),s},toIndexedSeq:function(){return new ToIndexedSequence(this)},toJS:function(){return this.toSeq().map((function(s){return s&&"function"==typeof s.toJS?s.toJS():s})).__toJS()},toJSON:function(){return this.toSeq().map((function(s){return s&&"function"==typeof s.toJSON?s.toJSON():s})).__toJS()},toKeyedSeq:function(){return new ToKeyedSequence(this,!0)},toMap:function(){return Map(this.toKeyedSeq())},toObject:function(){assertNotInfinite(this.size);var s={};return this.__iterate((function(o,i){s[i]=o})),s},toOrderedMap:function(){return OrderedMap(this.toKeyedSeq())},toOrderedSet:function(){return OrderedSet(isKeyed(this)?this.valueSeq():this)},toSet:function(){return Set(isKeyed(this)?this.valueSeq():this)},toSetSeq:function(){return new ToSetSequence(this)},toSeq:function(){return isIndexed(this)?this.toIndexedSeq():isKeyed(this)?this.toKeyedSeq():this.toSetSeq()},toStack:function(){return Stack(isKeyed(this)?this.valueSeq():this)},toList:function(){return List(isKeyed(this)?this.valueSeq():this)},toString:function(){return"[Iterable]"},__toString:function(s,o){return 0===this.size?s+o:s+" "+this.toSeq().map(this.__toStringMapper).join(", ")+" "+o},concat:function(){return reify(this,concatFactory(this,s.call(arguments,0)))},includes:function(s){return this.some((function(o){return is(o,s)}))},entries:function(){return this.__iterator(z)},every:function(s,o){assertNotInfinite(this.size);var i=!0;return this.__iterate((function(u,_,w){if(!s.call(o,u,_,w))return i=!1,!1})),i},filter:function(s,o){return reify(this,filterFactory(this,s,o,!0))},find:function(s,o,i){var u=this.findEntry(s,o);return u?u[1]:i},forEach:function(s,o){return assertNotInfinite(this.size),this.__iterate(o?s.bind(o):s)},join:function(s){assertNotInfinite(this.size),s=void 0!==s?""+s:",";var o="",i=!0;return this.__iterate((function(u){i?i=!1:o+=s,o+=null!=u?u.toString():""})),o},keys:function(){return this.__iterator(V)},map:function(s,o){return reify(this,mapFactory(this,s,o))},reduce:function(s,o,i){var u,_;return assertNotInfinite(this.size),arguments.length<2?_=!0:u=o,this.__iterate((function(o,w,x){_?(_=!1,u=o):u=s.call(i,u,o,w,x)})),u},reduceRight:function(s,o,i){var u=this.toKeyedSeq().reverse();return u.reduce.apply(u,arguments)},reverse:function(){return reify(this,reverseFactory(this,!0))},slice:function(s,o){return reify(this,sliceFactory(this,s,o,!0))},some:function(s,o){return!this.every(not(s),o)},sort:function(s){return reify(this,sortFactory(this,s))},values:function(){return this.__iterator(U)},butLast:function(){return this.slice(0,-1)},isEmpty:function(){return void 0!==this.size?0===this.size:!this.some((function(){return!0}))},count:function(s,o){return ensureSize(s?this.toSeq().filter(s,o):this)},countBy:function(s,o){return countByFactory(this,s,o)},equals:function(s){return deepEqual(this,s)},entrySeq:function(){var s=this;if(s._cache)return new ArraySeq(s._cache);var o=s.toSeq().map(entryMapper).toIndexedSeq();return o.fromEntrySeq=function(){return s.toSeq()},o},filterNot:function(s,o){return this.filter(not(s),o)},findEntry:function(s,o,i){var u=i;return this.__iterate((function(i,_,w){if(s.call(o,i,_,w))return u=[_,i],!1})),u},findKey:function(s,o){var i=this.findEntry(s,o);return i&&i[0]},findLast:function(s,o,i){return this.toKeyedSeq().reverse().find(s,o,i)},findLastEntry:function(s,o,i){return this.toKeyedSeq().reverse().findEntry(s,o,i)},findLastKey:function(s,o){return this.toKeyedSeq().reverse().findKey(s,o)},first:function(){return this.find(returnTrue)},flatMap:function(s,o){return reify(this,flatMapFactory(this,s,o))},flatten:function(s){return reify(this,flattenFactory(this,s,!0))},fromEntrySeq:function(){return new FromEntriesSequence(this)},get:function(s,o){return this.find((function(o,i){return is(i,s)}),void 0,o)},getIn:function(s,o){for(var i,u=this,_=forceIterator(s);!(i=_.next()).done;){var w=i.value;if((u=u&&u.get?u.get(w,L):L)===L)return o}return u},groupBy:function(s,o){return groupByFactory(this,s,o)},has:function(s){return this.get(s,L)!==L},hasIn:function(s){return this.getIn(s,L)!==L},isSubset:function(s){return s="function"==typeof s.includes?s:Iterable(s),this.every((function(o){return s.includes(o)}))},isSuperset:function(s){return(s="function"==typeof s.isSubset?s:Iterable(s)).isSubset(this)},keyOf:function(s){return this.findKey((function(o){return is(o,s)}))},keySeq:function(){return this.toSeq().map(keyMapper).toIndexedSeq()},last:function(){return this.toSeq().reverse().first()},lastKeyOf:function(s){return this.toKeyedSeq().reverse().keyOf(s)},max:function(s){return maxFactory(this,s)},maxBy:function(s,o){return maxFactory(this,o,s)},min:function(s){return maxFactory(this,s?neg(s):defaultNegComparator)},minBy:function(s,o){return maxFactory(this,o?neg(o):defaultNegComparator,s)},rest:function(){return this.slice(1)},skip:function(s){return this.slice(Math.max(0,s))},skipLast:function(s){return reify(this,this.toSeq().reverse().skip(s).reverse())},skipWhile:function(s,o){return reify(this,skipWhileFactory(this,s,o,!0))},skipUntil:function(s,o){return this.skipWhile(not(s),o)},sortBy:function(s,o){return reify(this,sortFactory(this,o,s))},take:function(s){return this.slice(0,Math.max(0,s))},takeLast:function(s){return reify(this,this.toSeq().reverse().take(s).reverse())},takeWhile:function(s,o){return reify(this,takeWhileFactory(this,s,o))},takeUntil:function(s,o){return this.takeWhile(not(s),o)},valueSeq:function(){return this.toIndexedSeq()},hashCode:function(){return this.__hash||(this.__hash=hashIterable(this))}});var pt=Iterable.prototype;pt[o]=!0,pt[ee]=pt.values,pt.__toJS=pt.toArray,pt.__toStringMapper=quoteString,pt.inspect=pt.toSource=function(){return this.toString()},pt.chain=pt.flatMap,pt.contains=pt.includes,mixin(KeyedIterable,{flip:function(){return reify(this,flipFactory(this))},mapEntries:function(s,o){var i=this,u=0;return reify(this,this.toSeq().map((function(_,w){return s.call(o,[w,_],u++,i)})).fromEntrySeq())},mapKeys:function(s,o){var i=this;return reify(this,this.toSeq().flip().map((function(u,_){return s.call(o,u,_,i)})).flip())}});var ht=KeyedIterable.prototype;function keyMapper(s,o){return o}function entryMapper(s,o){return[o,s]}function not(s){return function(){return!s.apply(this,arguments)}}function neg(s){return function(){return-s.apply(this,arguments)}}function quoteString(s){return"string"==typeof s?JSON.stringify(s):String(s)}function defaultZipper(){return arrCopy(arguments)}function defaultNegComparator(s,o){return so?-1:0}function hashIterable(s){if(s.size===1/0)return 0;var o=isOrdered(s),i=isKeyed(s),u=o?1:0;return murmurHashOfSize(s.__iterate(i?o?function(s,o){u=31*u+hashMerge(hash(s),hash(o))|0}:function(s,o){u=u+hashMerge(hash(s),hash(o))|0}:o?function(s){u=31*u+hash(s)|0}:function(s){u=u+hash(s)|0}),u)}function murmurHashOfSize(s,o){return o=pe(o,3432918353),o=pe(o<<15|o>>>-15,461845907),o=pe(o<<13|o>>>-13,5),o=pe((o=o+3864292196^s)^o>>>16,2246822507),o=smi((o=pe(o^o>>>13,3266489909))^o>>>16)}function hashMerge(s,o){return s^o+2654435769+(s<<6)+(s>>2)}return ht[i]=!0,ht[ee]=pt.entries,ht.__toJS=pt.toObject,ht.__toStringMapper=function(s,o){return JSON.stringify(o)+": "+quoteString(s)},mixin(IndexedIterable,{toKeyedSeq:function(){return new ToKeyedSequence(this,!1)},filter:function(s,o){return reify(this,filterFactory(this,s,o,!1))},findIndex:function(s,o){var i=this.findEntry(s,o);return i?i[0]:-1},indexOf:function(s){var o=this.keyOf(s);return void 0===o?-1:o},lastIndexOf:function(s){var o=this.lastKeyOf(s);return void 0===o?-1:o},reverse:function(){return reify(this,reverseFactory(this,!1))},slice:function(s,o){return reify(this,sliceFactory(this,s,o,!1))},splice:function(s,o){var i=arguments.length;if(o=Math.max(0|o,0),0===i||2===i&&!o)return this;s=resolveBegin(s,s<0?this.count():this.size);var u=this.slice(0,s);return reify(this,1===i?u:u.concat(arrCopy(arguments,2),this.slice(s+o)))},findLastIndex:function(s,o){var i=this.findLastEntry(s,o);return i?i[0]:-1},first:function(){return this.get(0)},flatten:function(s){return reify(this,flattenFactory(this,s,!1))},get:function(s,o){return(s=wrapIndex(this,s))<0||this.size===1/0||void 0!==this.size&&s>this.size?o:this.find((function(o,i){return i===s}),void 0,o)},has:function(s){return(s=wrapIndex(this,s))>=0&&(void 0!==this.size?this.size===1/0||s{"function"==typeof Object.create?s.exports=function inherits(s,o){o&&(s.super_=o,s.prototype=Object.create(o.prototype,{constructor:{value:s,enumerable:!1,writable:!0,configurable:!0}}))}:s.exports=function inherits(s,o){if(o){s.super_=o;var TempCtor=function(){};TempCtor.prototype=o.prototype,s.prototype=new TempCtor,s.prototype.constructor=s}}},5419:s=>{s.exports=function(s,o,i,u){var _=new Blob(void 0!==u?[u,s]:[s],{type:i||"application/octet-stream"});if(void 0!==window.navigator.msSaveBlob)window.navigator.msSaveBlob(_,o);else{var w=window.URL&&window.URL.createObjectURL?window.URL.createObjectURL(_):window.webkitURL.createObjectURL(_),x=document.createElement("a");x.style.display="none",x.href=w,x.setAttribute("download",o),void 0===x.download&&x.setAttribute("target","_blank"),document.body.appendChild(x),x.click(),setTimeout((function(){document.body.removeChild(x),window.URL.revokeObjectURL(w)}),200)}}},20181:(s,o,i)=>{var u=/^\s+|\s+$/g,_=/^[-+]0x[0-9a-f]+$/i,w=/^0b[01]+$/i,x=/^0o[0-7]+$/i,C=parseInt,j="object"==typeof i.g&&i.g&&i.g.Object===Object&&i.g,L="object"==typeof self&&self&&self.Object===Object&&self,B=j||L||Function("return this")(),$=Object.prototype.toString,V=Math.max,U=Math.min,now=function(){return B.Date.now()};function isObject(s){var o=typeof s;return!!s&&("object"==o||"function"==o)}function toNumber(s){if("number"==typeof s)return s;if(function isSymbol(s){return"symbol"==typeof s||function isObjectLike(s){return!!s&&"object"==typeof s}(s)&&"[object Symbol]"==$.call(s)}(s))return NaN;if(isObject(s)){var o="function"==typeof s.valueOf?s.valueOf():s;s=isObject(o)?o+"":o}if("string"!=typeof s)return 0===s?s:+s;s=s.replace(u,"");var i=w.test(s);return i||x.test(s)?C(s.slice(2),i?2:8):_.test(s)?NaN:+s}s.exports=function debounce(s,o,i){var u,_,w,x,C,j,L=0,B=!1,$=!1,z=!0;if("function"!=typeof s)throw new TypeError("Expected a function");function invokeFunc(o){var i=u,w=_;return u=_=void 0,L=o,x=s.apply(w,i)}function shouldInvoke(s){var i=s-j;return void 0===j||i>=o||i<0||$&&s-L>=w}function timerExpired(){var s=now();if(shouldInvoke(s))return trailingEdge(s);C=setTimeout(timerExpired,function remainingWait(s){var i=o-(s-j);return $?U(i,w-(s-L)):i}(s))}function trailingEdge(s){return C=void 0,z&&u?invokeFunc(s):(u=_=void 0,x)}function debounced(){var s=now(),i=shouldInvoke(s);if(u=arguments,_=this,j=s,i){if(void 0===C)return function leadingEdge(s){return L=s,C=setTimeout(timerExpired,o),B?invokeFunc(s):x}(j);if($)return C=setTimeout(timerExpired,o),invokeFunc(j)}return void 0===C&&(C=setTimeout(timerExpired,o)),x}return o=toNumber(o)||0,isObject(i)&&(B=!!i.leading,w=($="maxWait"in i)?V(toNumber(i.maxWait)||0,o):w,z="trailing"in i?!!i.trailing:z),debounced.cancel=function cancel(){void 0!==C&&clearTimeout(C),L=0,u=j=_=C=void 0},debounced.flush=function flush(){return void 0===C?x:trailingEdge(now())},debounced}},55580:(s,o,i)=>{var u=i(56110)(i(9325),"DataView");s.exports=u},21549:(s,o,i)=>{var u=i(22032),_=i(63862),w=i(66721),x=i(12749),C=i(35749);function Hash(s){var o=-1,i=null==s?0:s.length;for(this.clear();++o{var u=i(39344),_=i(94033);function LazyWrapper(s){this.__wrapped__=s,this.__actions__=[],this.__dir__=1,this.__filtered__=!1,this.__iteratees__=[],this.__takeCount__=4294967295,this.__views__=[]}LazyWrapper.prototype=u(_.prototype),LazyWrapper.prototype.constructor=LazyWrapper,s.exports=LazyWrapper},80079:(s,o,i)=>{var u=i(63702),_=i(70080),w=i(24739),x=i(48655),C=i(31175);function ListCache(s){var o=-1,i=null==s?0:s.length;for(this.clear();++o{var u=i(39344),_=i(94033);function LodashWrapper(s,o){this.__wrapped__=s,this.__actions__=[],this.__chain__=!!o,this.__index__=0,this.__values__=void 0}LodashWrapper.prototype=u(_.prototype),LodashWrapper.prototype.constructor=LodashWrapper,s.exports=LodashWrapper},68223:(s,o,i)=>{var u=i(56110)(i(9325),"Map");s.exports=u},53661:(s,o,i)=>{var u=i(63040),_=i(17670),w=i(90289),x=i(4509),C=i(72949);function MapCache(s){var o=-1,i=null==s?0:s.length;for(this.clear();++o{var u=i(56110)(i(9325),"Promise");s.exports=u},76545:(s,o,i)=>{var u=i(56110)(i(9325),"Set");s.exports=u},38859:(s,o,i)=>{var u=i(53661),_=i(31380),w=i(51459);function SetCache(s){var o=-1,i=null==s?0:s.length;for(this.__data__=new u;++o{var u=i(80079),_=i(51420),w=i(90938),x=i(63605),C=i(29817),j=i(80945);function Stack(s){var o=this.__data__=new u(s);this.size=o.size}Stack.prototype.clear=_,Stack.prototype.delete=w,Stack.prototype.get=x,Stack.prototype.has=C,Stack.prototype.set=j,s.exports=Stack},51873:(s,o,i)=>{var u=i(9325).Symbol;s.exports=u},37828:(s,o,i)=>{var u=i(9325).Uint8Array;s.exports=u},28303:(s,o,i)=>{var u=i(56110)(i(9325),"WeakMap");s.exports=u},91033:s=>{s.exports=function apply(s,o,i){switch(i.length){case 0:return s.call(o);case 1:return s.call(o,i[0]);case 2:return s.call(o,i[0],i[1]);case 3:return s.call(o,i[0],i[1],i[2])}return s.apply(o,i)}},83729:s=>{s.exports=function arrayEach(s,o){for(var i=-1,u=null==s?0:s.length;++i{s.exports=function arrayFilter(s,o){for(var i=-1,u=null==s?0:s.length,_=0,w=[];++i{var u=i(96131);s.exports=function arrayIncludes(s,o){return!!(null==s?0:s.length)&&u(s,o,0)>-1}},70695:(s,o,i)=>{var u=i(78096),_=i(72428),w=i(56449),x=i(3656),C=i(30361),j=i(37167),L=Object.prototype.hasOwnProperty;s.exports=function arrayLikeKeys(s,o){var i=w(s),B=!i&&_(s),$=!i&&!B&&x(s),V=!i&&!B&&!$&&j(s),U=i||B||$||V,z=U?u(s.length,String):[],Y=z.length;for(var Z in s)!o&&!L.call(s,Z)||U&&("length"==Z||$&&("offset"==Z||"parent"==Z)||V&&("buffer"==Z||"byteLength"==Z||"byteOffset"==Z)||C(Z,Y))||z.push(Z);return z}},34932:s=>{s.exports=function arrayMap(s,o){for(var i=-1,u=null==s?0:s.length,_=Array(u);++i{s.exports=function arrayPush(s,o){for(var i=-1,u=o.length,_=s.length;++i{s.exports=function arrayReduce(s,o,i,u){var _=-1,w=null==s?0:s.length;for(u&&w&&(i=s[++_]);++_{s.exports=function arraySome(s,o){for(var i=-1,u=null==s?0:s.length;++i{s.exports=function asciiToArray(s){return s.split("")}},1733:s=>{var o=/[^\x00-\x2f\x3a-\x40\x5b-\x60\x7b-\x7f]+/g;s.exports=function asciiWords(s){return s.match(o)||[]}},87805:(s,o,i)=>{var u=i(43360),_=i(75288);s.exports=function assignMergeValue(s,o,i){(void 0!==i&&!_(s[o],i)||void 0===i&&!(o in s))&&u(s,o,i)}},16547:(s,o,i)=>{var u=i(43360),_=i(75288),w=Object.prototype.hasOwnProperty;s.exports=function assignValue(s,o,i){var x=s[o];w.call(s,o)&&_(x,i)&&(void 0!==i||o in s)||u(s,o,i)}},26025:(s,o,i)=>{var u=i(75288);s.exports=function assocIndexOf(s,o){for(var i=s.length;i--;)if(u(s[i][0],o))return i;return-1}},74733:(s,o,i)=>{var u=i(21791),_=i(95950);s.exports=function baseAssign(s,o){return s&&u(o,_(o),s)}},43838:(s,o,i)=>{var u=i(21791),_=i(37241);s.exports=function baseAssignIn(s,o){return s&&u(o,_(o),s)}},43360:(s,o,i)=>{var u=i(93243);s.exports=function baseAssignValue(s,o,i){"__proto__"==o&&u?u(s,o,{configurable:!0,enumerable:!0,value:i,writable:!0}):s[o]=i}},9999:(s,o,i)=>{var u=i(37217),_=i(83729),w=i(16547),x=i(74733),C=i(43838),j=i(93290),L=i(23007),B=i(92271),$=i(48948),V=i(50002),U=i(83349),z=i(5861),Y=i(76189),Z=i(77199),ee=i(35529),ie=i(56449),ae=i(3656),le=i(87730),ce=i(23805),pe=i(38440),de=i(95950),fe=i(37241),ye="[object Arguments]",be="[object Function]",_e="[object Object]",we={};we[ye]=we["[object Array]"]=we["[object ArrayBuffer]"]=we["[object DataView]"]=we["[object Boolean]"]=we["[object Date]"]=we["[object Float32Array]"]=we["[object Float64Array]"]=we["[object Int8Array]"]=we["[object Int16Array]"]=we["[object Int32Array]"]=we["[object Map]"]=we["[object Number]"]=we[_e]=we["[object RegExp]"]=we["[object Set]"]=we["[object String]"]=we["[object Symbol]"]=we["[object Uint8Array]"]=we["[object Uint8ClampedArray]"]=we["[object Uint16Array]"]=we["[object Uint32Array]"]=!0,we["[object Error]"]=we[be]=we["[object WeakMap]"]=!1,s.exports=function baseClone(s,o,i,Se,xe,Pe){var Te,Re=1&o,qe=2&o,$e=4&o;if(i&&(Te=xe?i(s,Se,xe,Pe):i(s)),void 0!==Te)return Te;if(!ce(s))return s;var ze=ie(s);if(ze){if(Te=Y(s),!Re)return L(s,Te)}else{var We=z(s),He=We==be||"[object GeneratorFunction]"==We;if(ae(s))return j(s,Re);if(We==_e||We==ye||He&&!xe){if(Te=qe||He?{}:ee(s),!Re)return qe?$(s,C(Te,s)):B(s,x(Te,s))}else{if(!we[We])return xe?s:{};Te=Z(s,We,Re)}}Pe||(Pe=new u);var Ye=Pe.get(s);if(Ye)return Ye;Pe.set(s,Te),pe(s)?s.forEach((function(u){Te.add(baseClone(u,o,i,u,s,Pe))})):le(s)&&s.forEach((function(u,_){Te.set(_,baseClone(u,o,i,_,s,Pe))}));var Xe=ze?void 0:($e?qe?U:V:qe?fe:de)(s);return _(Xe||s,(function(u,_){Xe&&(u=s[_=u]),w(Te,_,baseClone(u,o,i,_,s,Pe))})),Te}},39344:(s,o,i)=>{var u=i(23805),_=Object.create,w=function(){function object(){}return function(s){if(!u(s))return{};if(_)return _(s);object.prototype=s;var o=new object;return object.prototype=void 0,o}}();s.exports=w},80909:(s,o,i)=>{var u=i(30641),_=i(38329)(u);s.exports=_},2523:s=>{s.exports=function baseFindIndex(s,o,i,u){for(var _=s.length,w=i+(u?1:-1);u?w--:++w<_;)if(o(s[w],w,s))return w;return-1}},83120:(s,o,i)=>{var u=i(14528),_=i(45891);s.exports=function baseFlatten(s,o,i,w,x){var C=-1,j=s.length;for(i||(i=_),x||(x=[]);++C0&&i(L)?o>1?baseFlatten(L,o-1,i,w,x):u(x,L):w||(x[x.length]=L)}return x}},86649:(s,o,i)=>{var u=i(83221)();s.exports=u},30641:(s,o,i)=>{var u=i(86649),_=i(95950);s.exports=function baseForOwn(s,o){return s&&u(s,o,_)}},47422:(s,o,i)=>{var u=i(31769),_=i(77797);s.exports=function baseGet(s,o){for(var i=0,w=(o=u(o,s)).length;null!=s&&i{var u=i(14528),_=i(56449);s.exports=function baseGetAllKeys(s,o,i){var w=o(s);return _(s)?w:u(w,i(s))}},72552:(s,o,i)=>{var u=i(51873),_=i(659),w=i(59350),x=u?u.toStringTag:void 0;s.exports=function baseGetTag(s){return null==s?void 0===s?"[object Undefined]":"[object Null]":x&&x in Object(s)?_(s):w(s)}},20426:s=>{var o=Object.prototype.hasOwnProperty;s.exports=function baseHas(s,i){return null!=s&&o.call(s,i)}},28077:s=>{s.exports=function baseHasIn(s,o){return null!=s&&o in Object(s)}},96131:(s,o,i)=>{var u=i(2523),_=i(85463),w=i(76959);s.exports=function baseIndexOf(s,o,i){return o==o?w(s,o,i):u(s,_,i)}},27534:(s,o,i)=>{var u=i(72552),_=i(40346);s.exports=function baseIsArguments(s){return _(s)&&"[object Arguments]"==u(s)}},60270:(s,o,i)=>{var u=i(87068),_=i(40346);s.exports=function baseIsEqual(s,o,i,w,x){return s===o||(null==s||null==o||!_(s)&&!_(o)?s!=s&&o!=o:u(s,o,i,w,baseIsEqual,x))}},87068:(s,o,i)=>{var u=i(37217),_=i(25911),w=i(21986),x=i(50689),C=i(5861),j=i(56449),L=i(3656),B=i(37167),$="[object Arguments]",V="[object Array]",U="[object Object]",z=Object.prototype.hasOwnProperty;s.exports=function baseIsEqualDeep(s,o,i,Y,Z,ee){var ie=j(s),ae=j(o),le=ie?V:C(s),ce=ae?V:C(o),pe=(le=le==$?U:le)==U,de=(ce=ce==$?U:ce)==U,fe=le==ce;if(fe&&L(s)){if(!L(o))return!1;ie=!0,pe=!1}if(fe&&!pe)return ee||(ee=new u),ie||B(s)?_(s,o,i,Y,Z,ee):w(s,o,le,i,Y,Z,ee);if(!(1&i)){var ye=pe&&z.call(s,"__wrapped__"),be=de&&z.call(o,"__wrapped__");if(ye||be){var _e=ye?s.value():s,we=be?o.value():o;return ee||(ee=new u),Z(_e,we,i,Y,ee)}}return!!fe&&(ee||(ee=new u),x(s,o,i,Y,Z,ee))}},29172:(s,o,i)=>{var u=i(5861),_=i(40346);s.exports=function baseIsMap(s){return _(s)&&"[object Map]"==u(s)}},41799:(s,o,i)=>{var u=i(37217),_=i(60270);s.exports=function baseIsMatch(s,o,i,w){var x=i.length,C=x,j=!w;if(null==s)return!C;for(s=Object(s);x--;){var L=i[x];if(j&&L[2]?L[1]!==s[L[0]]:!(L[0]in s))return!1}for(;++x{s.exports=function baseIsNaN(s){return s!=s}},45083:(s,o,i)=>{var u=i(1882),_=i(87296),w=i(23805),x=i(47473),C=/^\[object .+?Constructor\]$/,j=Function.prototype,L=Object.prototype,B=j.toString,$=L.hasOwnProperty,V=RegExp("^"+B.call($).replace(/[\\^$.*+?()[\]{}|]/g,"\\$&").replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g,"$1.*?")+"$");s.exports=function baseIsNative(s){return!(!w(s)||_(s))&&(u(s)?V:C).test(x(s))}},16038:(s,o,i)=>{var u=i(5861),_=i(40346);s.exports=function baseIsSet(s){return _(s)&&"[object Set]"==u(s)}},4901:(s,o,i)=>{var u=i(72552),_=i(30294),w=i(40346),x={};x["[object Float32Array]"]=x["[object Float64Array]"]=x["[object Int8Array]"]=x["[object Int16Array]"]=x["[object Int32Array]"]=x["[object Uint8Array]"]=x["[object Uint8ClampedArray]"]=x["[object Uint16Array]"]=x["[object Uint32Array]"]=!0,x["[object Arguments]"]=x["[object Array]"]=x["[object ArrayBuffer]"]=x["[object Boolean]"]=x["[object DataView]"]=x["[object Date]"]=x["[object Error]"]=x["[object Function]"]=x["[object Map]"]=x["[object Number]"]=x["[object Object]"]=x["[object RegExp]"]=x["[object Set]"]=x["[object String]"]=x["[object WeakMap]"]=!1,s.exports=function baseIsTypedArray(s){return w(s)&&_(s.length)&&!!x[u(s)]}},15389:(s,o,i)=>{var u=i(93663),_=i(87978),w=i(83488),x=i(56449),C=i(50583);s.exports=function baseIteratee(s){return"function"==typeof s?s:null==s?w:"object"==typeof s?x(s)?_(s[0],s[1]):u(s):C(s)}},88984:(s,o,i)=>{var u=i(55527),_=i(3650),w=Object.prototype.hasOwnProperty;s.exports=function baseKeys(s){if(!u(s))return _(s);var o=[];for(var i in Object(s))w.call(s,i)&&"constructor"!=i&&o.push(i);return o}},72903:(s,o,i)=>{var u=i(23805),_=i(55527),w=i(90181),x=Object.prototype.hasOwnProperty;s.exports=function baseKeysIn(s){if(!u(s))return w(s);var o=_(s),i=[];for(var C in s)("constructor"!=C||!o&&x.call(s,C))&&i.push(C);return i}},94033:s=>{s.exports=function baseLodash(){}},93663:(s,o,i)=>{var u=i(41799),_=i(10776),w=i(67197);s.exports=function baseMatches(s){var o=_(s);return 1==o.length&&o[0][2]?w(o[0][0],o[0][1]):function(i){return i===s||u(i,s,o)}}},87978:(s,o,i)=>{var u=i(60270),_=i(58156),w=i(80631),x=i(28586),C=i(30756),j=i(67197),L=i(77797);s.exports=function baseMatchesProperty(s,o){return x(s)&&C(o)?j(L(s),o):function(i){var x=_(i,s);return void 0===x&&x===o?w(i,s):u(o,x,3)}}},85250:(s,o,i)=>{var u=i(37217),_=i(87805),w=i(86649),x=i(42824),C=i(23805),j=i(37241),L=i(14974);s.exports=function baseMerge(s,o,i,B,$){s!==o&&w(o,(function(w,j){if($||($=new u),C(w))x(s,o,j,i,baseMerge,B,$);else{var V=B?B(L(s,j),w,j+"",s,o,$):void 0;void 0===V&&(V=w),_(s,j,V)}}),j)}},42824:(s,o,i)=>{var u=i(87805),_=i(93290),w=i(71961),x=i(23007),C=i(35529),j=i(72428),L=i(56449),B=i(83693),$=i(3656),V=i(1882),U=i(23805),z=i(11331),Y=i(37167),Z=i(14974),ee=i(69884);s.exports=function baseMergeDeep(s,o,i,ie,ae,le,ce){var pe=Z(s,i),de=Z(o,i),fe=ce.get(de);if(fe)u(s,i,fe);else{var ye=le?le(pe,de,i+"",s,o,ce):void 0,be=void 0===ye;if(be){var _e=L(de),we=!_e&&$(de),Se=!_e&&!we&&Y(de);ye=de,_e||we||Se?L(pe)?ye=pe:B(pe)?ye=x(pe):we?(be=!1,ye=_(de,!0)):Se?(be=!1,ye=w(de,!0)):ye=[]:z(de)||j(de)?(ye=pe,j(pe)?ye=ee(pe):U(pe)&&!V(pe)||(ye=C(de))):be=!1}be&&(ce.set(de,ye),ae(ye,de,ie,le,ce),ce.delete(de)),u(s,i,ye)}}},47237:s=>{s.exports=function baseProperty(s){return function(o){return null==o?void 0:o[s]}}},17255:(s,o,i)=>{var u=i(47422);s.exports=function basePropertyDeep(s){return function(o){return u(o,s)}}},54552:s=>{s.exports=function basePropertyOf(s){return function(o){return null==s?void 0:s[o]}}},85558:s=>{s.exports=function baseReduce(s,o,i,u,_){return _(s,(function(s,_,w){i=u?(u=!1,s):o(i,s,_,w)})),i}},69302:(s,o,i)=>{var u=i(83488),_=i(56757),w=i(32865);s.exports=function baseRest(s,o){return w(_(s,o,u),s+"")}},73170:(s,o,i)=>{var u=i(16547),_=i(31769),w=i(30361),x=i(23805),C=i(77797);s.exports=function baseSet(s,o,i,j){if(!x(s))return s;for(var L=-1,B=(o=_(o,s)).length,$=B-1,V=s;null!=V&&++L{var u=i(83488),_=i(48152),w=_?function(s,o){return _.set(s,o),s}:u;s.exports=w},19570:(s,o,i)=>{var u=i(37334),_=i(93243),w=i(83488),x=_?function(s,o){return _(s,"toString",{configurable:!0,enumerable:!1,value:u(o),writable:!0})}:w;s.exports=x},25160:s=>{s.exports=function baseSlice(s,o,i){var u=-1,_=s.length;o<0&&(o=-o>_?0:_+o),(i=i>_?_:i)<0&&(i+=_),_=o>i?0:i-o>>>0,o>>>=0;for(var w=Array(_);++u<_;)w[u]=s[u+o];return w}},90916:(s,o,i)=>{var u=i(80909);s.exports=function baseSome(s,o){var i;return u(s,(function(s,u,_){return!(i=o(s,u,_))})),!!i}},78096:s=>{s.exports=function baseTimes(s,o){for(var i=-1,u=Array(s);++i{var u=i(51873),_=i(34932),w=i(56449),x=i(44394),C=u?u.prototype:void 0,j=C?C.toString:void 0;s.exports=function baseToString(s){if("string"==typeof s)return s;if(w(s))return _(s,baseToString)+"";if(x(s))return j?j.call(s):"";var o=s+"";return"0"==o&&1/s==-1/0?"-0":o}},54128:(s,o,i)=>{var u=i(31800),_=/^\s+/;s.exports=function baseTrim(s){return s?s.slice(0,u(s)+1).replace(_,""):s}},27301:s=>{s.exports=function baseUnary(s){return function(o){return s(o)}}},19931:(s,o,i)=>{var u=i(31769),_=i(68090),w=i(68969),x=i(77797);s.exports=function baseUnset(s,o){return o=u(o,s),null==(s=w(s,o))||delete s[x(_(o))]}},51234:s=>{s.exports=function baseZipObject(s,o,i){for(var u=-1,_=s.length,w=o.length,x={};++u<_;){var C=u{s.exports=function cacheHas(s,o){return s.has(o)}},31769:(s,o,i)=>{var u=i(56449),_=i(28586),w=i(61802),x=i(13222);s.exports=function castPath(s,o){return u(s)?s:_(s,o)?[s]:w(x(s))}},28754:(s,o,i)=>{var u=i(25160);s.exports=function castSlice(s,o,i){var _=s.length;return i=void 0===i?_:i,!o&&i>=_?s:u(s,o,i)}},49653:(s,o,i)=>{var u=i(37828);s.exports=function cloneArrayBuffer(s){var o=new s.constructor(s.byteLength);return new u(o).set(new u(s)),o}},93290:(s,o,i)=>{s=i.nmd(s);var u=i(9325),_=o&&!o.nodeType&&o,w=_&&s&&!s.nodeType&&s,x=w&&w.exports===_?u.Buffer:void 0,C=x?x.allocUnsafe:void 0;s.exports=function cloneBuffer(s,o){if(o)return s.slice();var i=s.length,u=C?C(i):new s.constructor(i);return s.copy(u),u}},76169:(s,o,i)=>{var u=i(49653);s.exports=function cloneDataView(s,o){var i=o?u(s.buffer):s.buffer;return new s.constructor(i,s.byteOffset,s.byteLength)}},73201:s=>{var o=/\w*$/;s.exports=function cloneRegExp(s){var i=new s.constructor(s.source,o.exec(s));return i.lastIndex=s.lastIndex,i}},93736:(s,o,i)=>{var u=i(51873),_=u?u.prototype:void 0,w=_?_.valueOf:void 0;s.exports=function cloneSymbol(s){return w?Object(w.call(s)):{}}},71961:(s,o,i)=>{var u=i(49653);s.exports=function cloneTypedArray(s,o){var i=o?u(s.buffer):s.buffer;return new s.constructor(i,s.byteOffset,s.length)}},91596:s=>{var o=Math.max;s.exports=function composeArgs(s,i,u,_){for(var w=-1,x=s.length,C=u.length,j=-1,L=i.length,B=o(x-C,0),$=Array(L+B),V=!_;++j{var o=Math.max;s.exports=function composeArgsRight(s,i,u,_){for(var w=-1,x=s.length,C=-1,j=u.length,L=-1,B=i.length,$=o(x-j,0),V=Array($+B),U=!_;++w<$;)V[w]=s[w];for(var z=w;++L{s.exports=function copyArray(s,o){var i=-1,u=s.length;for(o||(o=Array(u));++i{var u=i(16547),_=i(43360);s.exports=function copyObject(s,o,i,w){var x=!i;i||(i={});for(var C=-1,j=o.length;++C{var u=i(21791),_=i(4664);s.exports=function copySymbols(s,o){return u(s,_(s),o)}},48948:(s,o,i)=>{var u=i(21791),_=i(86375);s.exports=function copySymbolsIn(s,o){return u(s,_(s),o)}},55481:(s,o,i)=>{var u=i(9325)["__core-js_shared__"];s.exports=u},58523:s=>{s.exports=function countHolders(s,o){for(var i=s.length,u=0;i--;)s[i]===o&&++u;return u}},20999:(s,o,i)=>{var u=i(69302),_=i(36800);s.exports=function createAssigner(s){return u((function(o,i){var u=-1,w=i.length,x=w>1?i[w-1]:void 0,C=w>2?i[2]:void 0;for(x=s.length>3&&"function"==typeof x?(w--,x):void 0,C&&_(i[0],i[1],C)&&(x=w<3?void 0:x,w=1),o=Object(o);++u{var u=i(64894);s.exports=function createBaseEach(s,o){return function(i,_){if(null==i)return i;if(!u(i))return s(i,_);for(var w=i.length,x=o?w:-1,C=Object(i);(o?x--:++x{s.exports=function createBaseFor(s){return function(o,i,u){for(var _=-1,w=Object(o),x=u(o),C=x.length;C--;){var j=x[s?C:++_];if(!1===i(w[j],j,w))break}return o}}},11842:(s,o,i)=>{var u=i(82819),_=i(9325);s.exports=function createBind(s,o,i){var w=1&o,x=u(s);return function wrapper(){return(this&&this!==_&&this instanceof wrapper?x:s).apply(w?i:this,arguments)}}},12507:(s,o,i)=>{var u=i(28754),_=i(49698),w=i(63912),x=i(13222);s.exports=function createCaseFirst(s){return function(o){o=x(o);var i=_(o)?w(o):void 0,C=i?i[0]:o.charAt(0),j=i?u(i,1).join(""):o.slice(1);return C[s]()+j}}},45539:(s,o,i)=>{var u=i(40882),_=i(50828),w=i(66645),x=RegExp("['’]","g");s.exports=function createCompounder(s){return function(o){return u(w(_(o).replace(x,"")),s,"")}}},82819:(s,o,i)=>{var u=i(39344),_=i(23805);s.exports=function createCtor(s){return function(){var o=arguments;switch(o.length){case 0:return new s;case 1:return new s(o[0]);case 2:return new s(o[0],o[1]);case 3:return new s(o[0],o[1],o[2]);case 4:return new s(o[0],o[1],o[2],o[3]);case 5:return new s(o[0],o[1],o[2],o[3],o[4]);case 6:return new s(o[0],o[1],o[2],o[3],o[4],o[5]);case 7:return new s(o[0],o[1],o[2],o[3],o[4],o[5],o[6])}var i=u(s.prototype),w=s.apply(i,o);return _(w)?w:i}}},77078:(s,o,i)=>{var u=i(91033),_=i(82819),w=i(37471),x=i(18073),C=i(11287),j=i(36306),L=i(9325);s.exports=function createCurry(s,o,i){var B=_(s);return function wrapper(){for(var _=arguments.length,$=Array(_),V=_,U=C(wrapper);V--;)$[V]=arguments[V];var z=_<3&&$[0]!==U&&$[_-1]!==U?[]:j($,U);return(_-=z.length){var u=i(15389),_=i(64894),w=i(95950);s.exports=function createFind(s){return function(o,i,x){var C=Object(o);if(!_(o)){var j=u(i,3);o=w(o),i=function(s){return j(C[s],s,C)}}var L=s(o,i,x);return L>-1?C[j?o[L]:L]:void 0}}},37471:(s,o,i)=>{var u=i(91596),_=i(53320),w=i(58523),x=i(82819),C=i(18073),j=i(11287),L=i(68294),B=i(36306),$=i(9325);s.exports=function createHybrid(s,o,i,V,U,z,Y,Z,ee,ie){var ae=128&o,le=1&o,ce=2&o,pe=24&o,de=512&o,fe=ce?void 0:x(s);return function wrapper(){for(var ye=arguments.length,be=Array(ye),_e=ye;_e--;)be[_e]=arguments[_e];if(pe)var we=j(wrapper),Se=w(be,we);if(V&&(be=u(be,V,U,pe)),z&&(be=_(be,z,Y,pe)),ye-=Se,pe&&ye1&&be.reverse(),ae&&ee{var u=i(91033),_=i(82819),w=i(9325);s.exports=function createPartial(s,o,i,x){var C=1&o,j=_(s);return function wrapper(){for(var o=-1,_=arguments.length,L=-1,B=x.length,$=Array(B+_),V=this&&this!==w&&this instanceof wrapper?j:s;++L{var u=i(85087),_=i(54641),w=i(70981);s.exports=function createRecurry(s,o,i,x,C,j,L,B,$,V){var U=8&o;o|=U?32:64,4&(o&=~(U?64:32))||(o&=-4);var z=[s,o,C,U?j:void 0,U?L:void 0,U?void 0:j,U?void 0:L,B,$,V],Y=i.apply(void 0,z);return u(s)&&_(Y,z),Y.placeholder=x,w(Y,s,o)}},66977:(s,o,i)=>{var u=i(68882),_=i(11842),w=i(77078),x=i(37471),C=i(24168),j=i(37381),L=i(3209),B=i(54641),$=i(70981),V=i(61489),U=Math.max;s.exports=function createWrap(s,o,i,z,Y,Z,ee,ie){var ae=2&o;if(!ae&&"function"!=typeof s)throw new TypeError("Expected a function");var le=z?z.length:0;if(le||(o&=-97,z=Y=void 0),ee=void 0===ee?ee:U(V(ee),0),ie=void 0===ie?ie:V(ie),le-=Y?Y.length:0,64&o){var ce=z,pe=Y;z=Y=void 0}var de=ae?void 0:j(s),fe=[s,o,i,z,Y,ce,pe,Z,ee,ie];if(de&&L(fe,de),s=fe[0],o=fe[1],i=fe[2],z=fe[3],Y=fe[4],!(ie=fe[9]=void 0===fe[9]?ae?0:s.length:U(fe[9]-le,0))&&24&o&&(o&=-25),o&&1!=o)ye=8==o||16==o?w(s,o,ie):32!=o&&33!=o||Y.length?x.apply(void 0,fe):C(s,o,i,z);else var ye=_(s,o,i);return $((de?u:B)(ye,fe),s,o)}},53138:(s,o,i)=>{var u=i(11331);s.exports=function customOmitClone(s){return u(s)?void 0:s}},24647:(s,o,i)=>{var u=i(54552)({À:"A",Á:"A",Â:"A",Ã:"A",Ä:"A",Å:"A",à:"a",á:"a",â:"a",ã:"a",ä:"a",å:"a",Ç:"C",ç:"c",Ð:"D",ð:"d",È:"E",É:"E",Ê:"E",Ë:"E",è:"e",é:"e",ê:"e",ë:"e",Ì:"I",Í:"I",Î:"I",Ï:"I",ì:"i",í:"i",î:"i",ï:"i",Ñ:"N",ñ:"n",Ò:"O",Ó:"O",Ô:"O",Õ:"O",Ö:"O",Ø:"O",ò:"o",ó:"o",ô:"o",õ:"o",ö:"o",ø:"o",Ù:"U",Ú:"U",Û:"U",Ü:"U",ù:"u",ú:"u",û:"u",ü:"u",Ý:"Y",ý:"y",ÿ:"y",Æ:"Ae",æ:"ae",Þ:"Th",þ:"th",ß:"ss",Ā:"A",Ă:"A",Ą:"A",ā:"a",ă:"a",ą:"a",Ć:"C",Ĉ:"C",Ċ:"C",Č:"C",ć:"c",ĉ:"c",ċ:"c",č:"c",Ď:"D",Đ:"D",ď:"d",đ:"d",Ē:"E",Ĕ:"E",Ė:"E",Ę:"E",Ě:"E",ē:"e",ĕ:"e",ė:"e",ę:"e",ě:"e",Ĝ:"G",Ğ:"G",Ġ:"G",Ģ:"G",ĝ:"g",ğ:"g",ġ:"g",ģ:"g",Ĥ:"H",Ħ:"H",ĥ:"h",ħ:"h",Ĩ:"I",Ī:"I",Ĭ:"I",Į:"I",İ:"I",ĩ:"i",ī:"i",ĭ:"i",į:"i",ı:"i",Ĵ:"J",ĵ:"j",Ķ:"K",ķ:"k",ĸ:"k",Ĺ:"L",Ļ:"L",Ľ:"L",Ŀ:"L",Ł:"L",ĺ:"l",ļ:"l",ľ:"l",ŀ:"l",ł:"l",Ń:"N",Ņ:"N",Ň:"N",Ŋ:"N",ń:"n",ņ:"n",ň:"n",ŋ:"n",Ō:"O",Ŏ:"O",Ő:"O",ō:"o",ŏ:"o",ő:"o",Ŕ:"R",Ŗ:"R",Ř:"R",ŕ:"r",ŗ:"r",ř:"r",Ś:"S",Ŝ:"S",Ş:"S",Š:"S",ś:"s",ŝ:"s",ş:"s",š:"s",Ţ:"T",Ť:"T",Ŧ:"T",ţ:"t",ť:"t",ŧ:"t",Ũ:"U",Ū:"U",Ŭ:"U",Ů:"U",Ű:"U",Ų:"U",ũ:"u",ū:"u",ŭ:"u",ů:"u",ű:"u",ų:"u",Ŵ:"W",ŵ:"w",Ŷ:"Y",ŷ:"y",Ÿ:"Y",Ź:"Z",Ż:"Z",Ž:"Z",ź:"z",ż:"z",ž:"z",IJ:"IJ",ij:"ij",Œ:"Oe",œ:"oe",ʼn:"'n",ſ:"s"});s.exports=u},93243:(s,o,i)=>{var u=i(56110),_=function(){try{var s=u(Object,"defineProperty");return s({},"",{}),s}catch(s){}}();s.exports=_},25911:(s,o,i)=>{var u=i(38859),_=i(14248),w=i(19219);s.exports=function equalArrays(s,o,i,x,C,j){var L=1&i,B=s.length,$=o.length;if(B!=$&&!(L&&$>B))return!1;var V=j.get(s),U=j.get(o);if(V&&U)return V==o&&U==s;var z=-1,Y=!0,Z=2&i?new u:void 0;for(j.set(s,o),j.set(o,s);++z{var u=i(51873),_=i(37828),w=i(75288),x=i(25911),C=i(20317),j=i(84247),L=u?u.prototype:void 0,B=L?L.valueOf:void 0;s.exports=function equalByTag(s,o,i,u,L,$,V){switch(i){case"[object DataView]":if(s.byteLength!=o.byteLength||s.byteOffset!=o.byteOffset)return!1;s=s.buffer,o=o.buffer;case"[object ArrayBuffer]":return!(s.byteLength!=o.byteLength||!$(new _(s),new _(o)));case"[object Boolean]":case"[object Date]":case"[object Number]":return w(+s,+o);case"[object Error]":return s.name==o.name&&s.message==o.message;case"[object RegExp]":case"[object String]":return s==o+"";case"[object Map]":var U=C;case"[object Set]":var z=1&u;if(U||(U=j),s.size!=o.size&&!z)return!1;var Y=V.get(s);if(Y)return Y==o;u|=2,V.set(s,o);var Z=x(U(s),U(o),u,L,$,V);return V.delete(s),Z;case"[object Symbol]":if(B)return B.call(s)==B.call(o)}return!1}},50689:(s,o,i)=>{var u=i(50002),_=Object.prototype.hasOwnProperty;s.exports=function equalObjects(s,o,i,w,x,C){var j=1&i,L=u(s),B=L.length;if(B!=u(o).length&&!j)return!1;for(var $=B;$--;){var V=L[$];if(!(j?V in o:_.call(o,V)))return!1}var U=C.get(s),z=C.get(o);if(U&&z)return U==o&&z==s;var Y=!0;C.set(s,o),C.set(o,s);for(var Z=j;++${var u=i(35970),_=i(56757),w=i(32865);s.exports=function flatRest(s){return w(_(s,void 0,u),s+"")}},34840:(s,o,i)=>{var u="object"==typeof i.g&&i.g&&i.g.Object===Object&&i.g;s.exports=u},50002:(s,o,i)=>{var u=i(82199),_=i(4664),w=i(95950);s.exports=function getAllKeys(s){return u(s,w,_)}},83349:(s,o,i)=>{var u=i(82199),_=i(86375),w=i(37241);s.exports=function getAllKeysIn(s){return u(s,w,_)}},37381:(s,o,i)=>{var u=i(48152),_=i(63950),w=u?function(s){return u.get(s)}:_;s.exports=w},62284:(s,o,i)=>{var u=i(84629),_=Object.prototype.hasOwnProperty;s.exports=function getFuncName(s){for(var o=s.name+"",i=u[o],w=_.call(u,o)?i.length:0;w--;){var x=i[w],C=x.func;if(null==C||C==s)return x.name}return o}},11287:s=>{s.exports=function getHolder(s){return s.placeholder}},12651:(s,o,i)=>{var u=i(74218);s.exports=function getMapData(s,o){var i=s.__data__;return u(o)?i["string"==typeof o?"string":"hash"]:i.map}},10776:(s,o,i)=>{var u=i(30756),_=i(95950);s.exports=function getMatchData(s){for(var o=_(s),i=o.length;i--;){var w=o[i],x=s[w];o[i]=[w,x,u(x)]}return o}},56110:(s,o,i)=>{var u=i(45083),_=i(10392);s.exports=function getNative(s,o){var i=_(s,o);return u(i)?i:void 0}},28879:(s,o,i)=>{var u=i(74335)(Object.getPrototypeOf,Object);s.exports=u},659:(s,o,i)=>{var u=i(51873),_=Object.prototype,w=_.hasOwnProperty,x=_.toString,C=u?u.toStringTag:void 0;s.exports=function getRawTag(s){var o=w.call(s,C),i=s[C];try{s[C]=void 0;var u=!0}catch(s){}var _=x.call(s);return u&&(o?s[C]=i:delete s[C]),_}},4664:(s,o,i)=>{var u=i(79770),_=i(63345),w=Object.prototype.propertyIsEnumerable,x=Object.getOwnPropertySymbols,C=x?function(s){return null==s?[]:(s=Object(s),u(x(s),(function(o){return w.call(s,o)})))}:_;s.exports=C},86375:(s,o,i)=>{var u=i(14528),_=i(28879),w=i(4664),x=i(63345),C=Object.getOwnPropertySymbols?function(s){for(var o=[];s;)u(o,w(s)),s=_(s);return o}:x;s.exports=C},5861:(s,o,i)=>{var u=i(55580),_=i(68223),w=i(32804),x=i(76545),C=i(28303),j=i(72552),L=i(47473),B="[object Map]",$="[object Promise]",V="[object Set]",U="[object WeakMap]",z="[object DataView]",Y=L(u),Z=L(_),ee=L(w),ie=L(x),ae=L(C),le=j;(u&&le(new u(new ArrayBuffer(1)))!=z||_&&le(new _)!=B||w&&le(w.resolve())!=$||x&&le(new x)!=V||C&&le(new C)!=U)&&(le=function(s){var o=j(s),i="[object Object]"==o?s.constructor:void 0,u=i?L(i):"";if(u)switch(u){case Y:return z;case Z:return B;case ee:return $;case ie:return V;case ae:return U}return o}),s.exports=le},10392:s=>{s.exports=function getValue(s,o){return null==s?void 0:s[o]}},75251:s=>{var o=/\{\n\/\* \[wrapped with (.+)\] \*/,i=/,? & /;s.exports=function getWrapDetails(s){var u=s.match(o);return u?u[1].split(i):[]}},49326:(s,o,i)=>{var u=i(31769),_=i(72428),w=i(56449),x=i(30361),C=i(30294),j=i(77797);s.exports=function hasPath(s,o,i){for(var L=-1,B=(o=u(o,s)).length,$=!1;++L{var o=RegExp("[\\u200d\\ud800-\\udfff\\u0300-\\u036f\\ufe20-\\ufe2f\\u20d0-\\u20ff\\ufe0e\\ufe0f]");s.exports=function hasUnicode(s){return o.test(s)}},45434:s=>{var o=/[a-z][A-Z]|[A-Z]{2}[a-z]|[0-9][a-zA-Z]|[a-zA-Z][0-9]|[^a-zA-Z0-9 ]/;s.exports=function hasUnicodeWord(s){return o.test(s)}},22032:(s,o,i)=>{var u=i(81042);s.exports=function hashClear(){this.__data__=u?u(null):{},this.size=0}},63862:s=>{s.exports=function hashDelete(s){var o=this.has(s)&&delete this.__data__[s];return this.size-=o?1:0,o}},66721:(s,o,i)=>{var u=i(81042),_=Object.prototype.hasOwnProperty;s.exports=function hashGet(s){var o=this.__data__;if(u){var i=o[s];return"__lodash_hash_undefined__"===i?void 0:i}return _.call(o,s)?o[s]:void 0}},12749:(s,o,i)=>{var u=i(81042),_=Object.prototype.hasOwnProperty;s.exports=function hashHas(s){var o=this.__data__;return u?void 0!==o[s]:_.call(o,s)}},35749:(s,o,i)=>{var u=i(81042);s.exports=function hashSet(s,o){var i=this.__data__;return this.size+=this.has(s)?0:1,i[s]=u&&void 0===o?"__lodash_hash_undefined__":o,this}},76189:s=>{var o=Object.prototype.hasOwnProperty;s.exports=function initCloneArray(s){var i=s.length,u=new s.constructor(i);return i&&"string"==typeof s[0]&&o.call(s,"index")&&(u.index=s.index,u.input=s.input),u}},77199:(s,o,i)=>{var u=i(49653),_=i(76169),w=i(73201),x=i(93736),C=i(71961);s.exports=function initCloneByTag(s,o,i){var j=s.constructor;switch(o){case"[object ArrayBuffer]":return u(s);case"[object Boolean]":case"[object Date]":return new j(+s);case"[object DataView]":return _(s,i);case"[object Float32Array]":case"[object Float64Array]":case"[object Int8Array]":case"[object Int16Array]":case"[object Int32Array]":case"[object Uint8Array]":case"[object Uint8ClampedArray]":case"[object Uint16Array]":case"[object Uint32Array]":return C(s,i);case"[object Map]":case"[object Set]":return new j;case"[object Number]":case"[object String]":return new j(s);case"[object RegExp]":return w(s);case"[object Symbol]":return x(s)}}},35529:(s,o,i)=>{var u=i(39344),_=i(28879),w=i(55527);s.exports=function initCloneObject(s){return"function"!=typeof s.constructor||w(s)?{}:u(_(s))}},62060:s=>{var o=/\{(?:\n\/\* \[wrapped with .+\] \*\/)?\n?/;s.exports=function insertWrapDetails(s,i){var u=i.length;if(!u)return s;var _=u-1;return i[_]=(u>1?"& ":"")+i[_],i=i.join(u>2?", ":" "),s.replace(o,"{\n/* [wrapped with "+i+"] */\n")}},45891:(s,o,i)=>{var u=i(51873),_=i(72428),w=i(56449),x=u?u.isConcatSpreadable:void 0;s.exports=function isFlattenable(s){return w(s)||_(s)||!!(x&&s&&s[x])}},30361:s=>{var o=/^(?:0|[1-9]\d*)$/;s.exports=function isIndex(s,i){var u=typeof s;return!!(i=null==i?9007199254740991:i)&&("number"==u||"symbol"!=u&&o.test(s))&&s>-1&&s%1==0&&s{var u=i(75288),_=i(64894),w=i(30361),x=i(23805);s.exports=function isIterateeCall(s,o,i){if(!x(i))return!1;var C=typeof o;return!!("number"==C?_(i)&&w(o,i.length):"string"==C&&o in i)&&u(i[o],s)}},28586:(s,o,i)=>{var u=i(56449),_=i(44394),w=/\.|\[(?:[^[\]]*|(["'])(?:(?!\1)[^\\]|\\.)*?\1)\]/,x=/^\w*$/;s.exports=function isKey(s,o){if(u(s))return!1;var i=typeof s;return!("number"!=i&&"symbol"!=i&&"boolean"!=i&&null!=s&&!_(s))||(x.test(s)||!w.test(s)||null!=o&&s in Object(o))}},74218:s=>{s.exports=function isKeyable(s){var o=typeof s;return"string"==o||"number"==o||"symbol"==o||"boolean"==o?"__proto__"!==s:null===s}},85087:(s,o,i)=>{var u=i(30980),_=i(37381),w=i(62284),x=i(53758);s.exports=function isLaziable(s){var o=w(s),i=x[o];if("function"!=typeof i||!(o in u.prototype))return!1;if(s===i)return!0;var C=_(i);return!!C&&s===C[0]}},87296:(s,o,i)=>{var u,_=i(55481),w=(u=/[^.]+$/.exec(_&&_.keys&&_.keys.IE_PROTO||""))?"Symbol(src)_1."+u:"";s.exports=function isMasked(s){return!!w&&w in s}},55527:s=>{var o=Object.prototype;s.exports=function isPrototype(s){var i=s&&s.constructor;return s===("function"==typeof i&&i.prototype||o)}},30756:(s,o,i)=>{var u=i(23805);s.exports=function isStrictComparable(s){return s==s&&!u(s)}},63702:s=>{s.exports=function listCacheClear(){this.__data__=[],this.size=0}},70080:(s,o,i)=>{var u=i(26025),_=Array.prototype.splice;s.exports=function listCacheDelete(s){var o=this.__data__,i=u(o,s);return!(i<0)&&(i==o.length-1?o.pop():_.call(o,i,1),--this.size,!0)}},24739:(s,o,i)=>{var u=i(26025);s.exports=function listCacheGet(s){var o=this.__data__,i=u(o,s);return i<0?void 0:o[i][1]}},48655:(s,o,i)=>{var u=i(26025);s.exports=function listCacheHas(s){return u(this.__data__,s)>-1}},31175:(s,o,i)=>{var u=i(26025);s.exports=function listCacheSet(s,o){var i=this.__data__,_=u(i,s);return _<0?(++this.size,i.push([s,o])):i[_][1]=o,this}},63040:(s,o,i)=>{var u=i(21549),_=i(80079),w=i(68223);s.exports=function mapCacheClear(){this.size=0,this.__data__={hash:new u,map:new(w||_),string:new u}}},17670:(s,o,i)=>{var u=i(12651);s.exports=function mapCacheDelete(s){var o=u(this,s).delete(s);return this.size-=o?1:0,o}},90289:(s,o,i)=>{var u=i(12651);s.exports=function mapCacheGet(s){return u(this,s).get(s)}},4509:(s,o,i)=>{var u=i(12651);s.exports=function mapCacheHas(s){return u(this,s).has(s)}},72949:(s,o,i)=>{var u=i(12651);s.exports=function mapCacheSet(s,o){var i=u(this,s),_=i.size;return i.set(s,o),this.size+=i.size==_?0:1,this}},20317:s=>{s.exports=function mapToArray(s){var o=-1,i=Array(s.size);return s.forEach((function(s,u){i[++o]=[u,s]})),i}},67197:s=>{s.exports=function matchesStrictComparable(s,o){return function(i){return null!=i&&(i[s]===o&&(void 0!==o||s in Object(i)))}}},62224:(s,o,i)=>{var u=i(50104);s.exports=function memoizeCapped(s){var o=u(s,(function(s){return 500===i.size&&i.clear(),s})),i=o.cache;return o}},3209:(s,o,i)=>{var u=i(91596),_=i(53320),w=i(36306),x="__lodash_placeholder__",C=128,j=Math.min;s.exports=function mergeData(s,o){var i=s[1],L=o[1],B=i|L,$=B<131,V=L==C&&8==i||L==C&&256==i&&s[7].length<=o[8]||384==L&&o[7].length<=o[8]&&8==i;if(!$&&!V)return s;1&L&&(s[2]=o[2],B|=1&i?0:4);var U=o[3];if(U){var z=s[3];s[3]=z?u(z,U,o[4]):U,s[4]=z?w(s[3],x):o[4]}return(U=o[5])&&(z=s[5],s[5]=z?_(z,U,o[6]):U,s[6]=z?w(s[5],x):o[6]),(U=o[7])&&(s[7]=U),L&C&&(s[8]=null==s[8]?o[8]:j(s[8],o[8])),null==s[9]&&(s[9]=o[9]),s[0]=o[0],s[1]=B,s}},48152:(s,o,i)=>{var u=i(28303),_=u&&new u;s.exports=_},81042:(s,o,i)=>{var u=i(56110)(Object,"create");s.exports=u},3650:(s,o,i)=>{var u=i(74335)(Object.keys,Object);s.exports=u},90181:s=>{s.exports=function nativeKeysIn(s){var o=[];if(null!=s)for(var i in Object(s))o.push(i);return o}},86009:(s,o,i)=>{s=i.nmd(s);var u=i(34840),_=o&&!o.nodeType&&o,w=_&&s&&!s.nodeType&&s,x=w&&w.exports===_&&u.process,C=function(){try{var s=w&&w.require&&w.require("util").types;return s||x&&x.binding&&x.binding("util")}catch(s){}}();s.exports=C},59350:s=>{var o=Object.prototype.toString;s.exports=function objectToString(s){return o.call(s)}},74335:s=>{s.exports=function overArg(s,o){return function(i){return s(o(i))}}},56757:(s,o,i)=>{var u=i(91033),_=Math.max;s.exports=function overRest(s,o,i){return o=_(void 0===o?s.length-1:o,0),function(){for(var w=arguments,x=-1,C=_(w.length-o,0),j=Array(C);++x{var u=i(47422),_=i(25160);s.exports=function parent(s,o){return o.length<2?s:u(s,_(o,0,-1))}},84629:s=>{s.exports={}},68294:(s,o,i)=>{var u=i(23007),_=i(30361),w=Math.min;s.exports=function reorder(s,o){for(var i=s.length,x=w(o.length,i),C=u(s);x--;){var j=o[x];s[x]=_(j,i)?C[j]:void 0}return s}},36306:s=>{var o="__lodash_placeholder__";s.exports=function replaceHolders(s,i){for(var u=-1,_=s.length,w=0,x=[];++u<_;){var C=s[u];C!==i&&C!==o||(s[u]=o,x[w++]=u)}return x}},9325:(s,o,i)=>{var u=i(34840),_="object"==typeof self&&self&&self.Object===Object&&self,w=u||_||Function("return this")();s.exports=w},14974:s=>{s.exports=function safeGet(s,o){if(("constructor"!==o||"function"!=typeof s[o])&&"__proto__"!=o)return s[o]}},31380:s=>{s.exports=function setCacheAdd(s){return this.__data__.set(s,"__lodash_hash_undefined__"),this}},51459:s=>{s.exports=function setCacheHas(s){return this.__data__.has(s)}},54641:(s,o,i)=>{var u=i(68882),_=i(51811)(u);s.exports=_},84247:s=>{s.exports=function setToArray(s){var o=-1,i=Array(s.size);return s.forEach((function(s){i[++o]=s})),i}},32865:(s,o,i)=>{var u=i(19570),_=i(51811)(u);s.exports=_},70981:(s,o,i)=>{var u=i(75251),_=i(62060),w=i(32865),x=i(75948);s.exports=function setWrapToString(s,o,i){var C=o+"";return w(s,_(C,x(u(C),i)))}},51811:s=>{var o=Date.now;s.exports=function shortOut(s){var i=0,u=0;return function(){var _=o(),w=16-(_-u);if(u=_,w>0){if(++i>=800)return arguments[0]}else i=0;return s.apply(void 0,arguments)}}},51420:(s,o,i)=>{var u=i(80079);s.exports=function stackClear(){this.__data__=new u,this.size=0}},90938:s=>{s.exports=function stackDelete(s){var o=this.__data__,i=o.delete(s);return this.size=o.size,i}},63605:s=>{s.exports=function stackGet(s){return this.__data__.get(s)}},29817:s=>{s.exports=function stackHas(s){return this.__data__.has(s)}},80945:(s,o,i)=>{var u=i(80079),_=i(68223),w=i(53661);s.exports=function stackSet(s,o){var i=this.__data__;if(i instanceof u){var x=i.__data__;if(!_||x.length<199)return x.push([s,o]),this.size=++i.size,this;i=this.__data__=new w(x)}return i.set(s,o),this.size=i.size,this}},76959:s=>{s.exports=function strictIndexOf(s,o,i){for(var u=i-1,_=s.length;++u<_;)if(s[u]===o)return u;return-1}},63912:(s,o,i)=>{var u=i(61074),_=i(49698),w=i(42054);s.exports=function stringToArray(s){return _(s)?w(s):u(s)}},61802:(s,o,i)=>{var u=i(62224),_=/[^.[\]]+|\[(?:(-?\d+(?:\.\d+)?)|(["'])((?:(?!\2)[^\\]|\\.)*?)\2)\]|(?=(?:\.|\[\])(?:\.|\[\]|$))/g,w=/\\(\\)?/g,x=u((function(s){var o=[];return 46===s.charCodeAt(0)&&o.push(""),s.replace(_,(function(s,i,u,_){o.push(u?_.replace(w,"$1"):i||s)})),o}));s.exports=x},77797:(s,o,i)=>{var u=i(44394);s.exports=function toKey(s){if("string"==typeof s||u(s))return s;var o=s+"";return"0"==o&&1/s==-1/0?"-0":o}},47473:s=>{var o=Function.prototype.toString;s.exports=function toSource(s){if(null!=s){try{return o.call(s)}catch(s){}try{return s+""}catch(s){}}return""}},31800:s=>{var o=/\s/;s.exports=function trimmedEndIndex(s){for(var i=s.length;i--&&o.test(s.charAt(i)););return i}},42054:s=>{var o="\\ud800-\\udfff",i="["+o+"]",u="[\\u0300-\\u036f\\ufe20-\\ufe2f\\u20d0-\\u20ff]",_="\\ud83c[\\udffb-\\udfff]",w="[^"+o+"]",x="(?:\\ud83c[\\udde6-\\uddff]){2}",C="[\\ud800-\\udbff][\\udc00-\\udfff]",j="(?:"+u+"|"+_+")"+"?",L="[\\ufe0e\\ufe0f]?",B=L+j+("(?:\\u200d(?:"+[w,x,C].join("|")+")"+L+j+")*"),$="(?:"+[w+u+"?",u,x,C,i].join("|")+")",V=RegExp(_+"(?="+_+")|"+$+B,"g");s.exports=function unicodeToArray(s){return s.match(V)||[]}},22225:s=>{var o="\\ud800-\\udfff",i="\\u2700-\\u27bf",u="a-z\\xdf-\\xf6\\xf8-\\xff",_="A-Z\\xc0-\\xd6\\xd8-\\xde",w="\\xac\\xb1\\xd7\\xf7\\x00-\\x2f\\x3a-\\x40\\x5b-\\x60\\x7b-\\xbf\\u2000-\\u206f \\t\\x0b\\f\\xa0\\ufeff\\n\\r\\u2028\\u2029\\u1680\\u180e\\u2000\\u2001\\u2002\\u2003\\u2004\\u2005\\u2006\\u2007\\u2008\\u2009\\u200a\\u202f\\u205f\\u3000",x="["+w+"]",C="\\d+",j="["+i+"]",L="["+u+"]",B="[^"+o+w+C+i+u+_+"]",$="(?:\\ud83c[\\udde6-\\uddff]){2}",V="[\\ud800-\\udbff][\\udc00-\\udfff]",U="["+_+"]",z="(?:"+L+"|"+B+")",Y="(?:"+U+"|"+B+")",Z="(?:['’](?:d|ll|m|re|s|t|ve))?",ee="(?:['’](?:D|LL|M|RE|S|T|VE))?",ie="(?:[\\u0300-\\u036f\\ufe20-\\ufe2f\\u20d0-\\u20ff]|\\ud83c[\\udffb-\\udfff])?",ae="[\\ufe0e\\ufe0f]?",le=ae+ie+("(?:\\u200d(?:"+["[^"+o+"]",$,V].join("|")+")"+ae+ie+")*"),ce="(?:"+[j,$,V].join("|")+")"+le,pe=RegExp([U+"?"+L+"+"+Z+"(?="+[x,U,"$"].join("|")+")",Y+"+"+ee+"(?="+[x,U+z,"$"].join("|")+")",U+"?"+z+"+"+Z,U+"+"+ee,"\\d*(?:1ST|2ND|3RD|(?![123])\\dTH)(?=\\b|[a-z_])","\\d*(?:1st|2nd|3rd|(?![123])\\dth)(?=\\b|[A-Z_])",C,ce].join("|"),"g");s.exports=function unicodeWords(s){return s.match(pe)||[]}},75948:(s,o,i)=>{var u=i(83729),_=i(15325),w=[["ary",128],["bind",1],["bindKey",2],["curry",8],["curryRight",16],["flip",512],["partial",32],["partialRight",64],["rearg",256]];s.exports=function updateWrapDetails(s,o){return u(w,(function(i){var u="_."+i[0];o&i[1]&&!_(s,u)&&s.push(u)})),s.sort()}},80257:(s,o,i)=>{var u=i(30980),_=i(56017),w=i(23007);s.exports=function wrapperClone(s){if(s instanceof u)return s.clone();var o=new _(s.__wrapped__,s.__chain__);return o.__actions__=w(s.__actions__),o.__index__=s.__index__,o.__values__=s.__values__,o}},64626:(s,o,i)=>{var u=i(66977);s.exports=function ary(s,o,i){return o=i?void 0:o,o=s&&null==o?s.length:o,u(s,128,void 0,void 0,void 0,void 0,o)}},84058:(s,o,i)=>{var u=i(14792),_=i(45539)((function(s,o,i){return o=o.toLowerCase(),s+(i?u(o):o)}));s.exports=_},14792:(s,o,i)=>{var u=i(13222),_=i(55808);s.exports=function capitalize(s){return _(u(s).toLowerCase())}},32629:(s,o,i)=>{var u=i(9999);s.exports=function clone(s){return u(s,4)}},37334:s=>{s.exports=function constant(s){return function(){return s}}},49747:(s,o,i)=>{var u=i(66977);function curry(s,o,i){var _=u(s,8,void 0,void 0,void 0,void 0,void 0,o=i?void 0:o);return _.placeholder=curry.placeholder,_}curry.placeholder={},s.exports=curry},38221:(s,o,i)=>{var u=i(23805),_=i(10124),w=i(99374),x=Math.max,C=Math.min;s.exports=function debounce(s,o,i){var j,L,B,$,V,U,z=0,Y=!1,Z=!1,ee=!0;if("function"!=typeof s)throw new TypeError("Expected a function");function invokeFunc(o){var i=j,u=L;return j=L=void 0,z=o,$=s.apply(u,i)}function shouldInvoke(s){var i=s-U;return void 0===U||i>=o||i<0||Z&&s-z>=B}function timerExpired(){var s=_();if(shouldInvoke(s))return trailingEdge(s);V=setTimeout(timerExpired,function remainingWait(s){var i=o-(s-U);return Z?C(i,B-(s-z)):i}(s))}function trailingEdge(s){return V=void 0,ee&&j?invokeFunc(s):(j=L=void 0,$)}function debounced(){var s=_(),i=shouldInvoke(s);if(j=arguments,L=this,U=s,i){if(void 0===V)return function leadingEdge(s){return z=s,V=setTimeout(timerExpired,o),Y?invokeFunc(s):$}(U);if(Z)return clearTimeout(V),V=setTimeout(timerExpired,o),invokeFunc(U)}return void 0===V&&(V=setTimeout(timerExpired,o)),$}return o=w(o)||0,u(i)&&(Y=!!i.leading,B=(Z="maxWait"in i)?x(w(i.maxWait)||0,o):B,ee="trailing"in i?!!i.trailing:ee),debounced.cancel=function cancel(){void 0!==V&&clearTimeout(V),z=0,j=U=L=V=void 0},debounced.flush=function flush(){return void 0===V?$:trailingEdge(_())},debounced}},50828:(s,o,i)=>{var u=i(24647),_=i(13222),w=/[\xc0-\xd6\xd8-\xf6\xf8-\xff\u0100-\u017f]/g,x=RegExp("[\\u0300-\\u036f\\ufe20-\\ufe2f\\u20d0-\\u20ff]","g");s.exports=function deburr(s){return(s=_(s))&&s.replace(w,u).replace(x,"")}},75288:s=>{s.exports=function eq(s,o){return s===o||s!=s&&o!=o}},60680:(s,o,i)=>{var u=i(13222),_=/[\\^$.*+?()[\]{}|]/g,w=RegExp(_.source);s.exports=function escapeRegExp(s){return(s=u(s))&&w.test(s)?s.replace(_,"\\$&"):s}},7309:(s,o,i)=>{var u=i(62006)(i(24713));s.exports=u},24713:(s,o,i)=>{var u=i(2523),_=i(15389),w=i(61489),x=Math.max;s.exports=function findIndex(s,o,i){var C=null==s?0:s.length;if(!C)return-1;var j=null==i?0:w(i);return j<0&&(j=x(C+j,0)),u(s,_(o,3),j)}},35970:(s,o,i)=>{var u=i(83120);s.exports=function flatten(s){return(null==s?0:s.length)?u(s,1):[]}},73424:(s,o,i)=>{var u=i(16962),_=i(2874),w=Array.prototype.push;function baseAry(s,o){return 2==o?function(o,i){return s(o,i)}:function(o){return s(o)}}function cloneArray(s){for(var o=s?s.length:0,i=Array(o);o--;)i[o]=s[o];return i}function wrapImmutable(s,o){return function(){var i=arguments.length;if(i){for(var u=Array(i);i--;)u[i]=arguments[i];var _=u[0]=o.apply(void 0,u);return s.apply(void 0,u),_}}}s.exports=function baseConvert(s,o,i,x){var C="function"==typeof o,j=o===Object(o);if(j&&(x=i,i=o,o=void 0),null==i)throw new TypeError;x||(x={});var L=!("cap"in x)||x.cap,B=!("curry"in x)||x.curry,$=!("fixed"in x)||x.fixed,V=!("immutable"in x)||x.immutable,U=!("rearg"in x)||x.rearg,z=C?i:_,Y="curry"in x&&x.curry,Z="fixed"in x&&x.fixed,ee="rearg"in x&&x.rearg,ie=C?i.runInContext():void 0,ae=C?i:{ary:s.ary,assign:s.assign,clone:s.clone,curry:s.curry,forEach:s.forEach,isArray:s.isArray,isError:s.isError,isFunction:s.isFunction,isWeakMap:s.isWeakMap,iteratee:s.iteratee,keys:s.keys,rearg:s.rearg,toInteger:s.toInteger,toPath:s.toPath},le=ae.ary,ce=ae.assign,pe=ae.clone,de=ae.curry,fe=ae.forEach,ye=ae.isArray,be=ae.isError,_e=ae.isFunction,we=ae.isWeakMap,Se=ae.keys,xe=ae.rearg,Pe=ae.toInteger,Te=ae.toPath,Re=Se(u.aryMethod),qe={castArray:function(s){return function(){var o=arguments[0];return ye(o)?s(cloneArray(o)):s.apply(void 0,arguments)}},iteratee:function(s){return function(){var o=arguments[1],i=s(arguments[0],o),u=i.length;return L&&"number"==typeof o?(o=o>2?o-2:1,u&&u<=o?i:baseAry(i,o)):i}},mixin:function(s){return function(o){var i=this;if(!_e(i))return s(i,Object(o));var u=[];return fe(Se(o),(function(s){_e(o[s])&&u.push([s,i.prototype[s]])})),s(i,Object(o)),fe(u,(function(s){var o=s[1];_e(o)?i.prototype[s[0]]=o:delete i.prototype[s[0]]})),i}},nthArg:function(s){return function(o){var i=o<0?1:Pe(o)+1;return de(s(o),i)}},rearg:function(s){return function(o,i){var u=i?i.length:0;return de(s(o,i),u)}},runInContext:function(o){return function(i){return baseConvert(s,o(i),x)}}};function castCap(s,o){if(L){var i=u.iterateeRearg[s];if(i)return function iterateeRearg(s,o){return overArg(s,(function(s){var i=o.length;return function baseArity(s,o){return 2==o?function(o,i){return s.apply(void 0,arguments)}:function(o){return s.apply(void 0,arguments)}}(xe(baseAry(s,i),o),i)}))}(o,i);var _=!C&&u.iterateeAry[s];if(_)return function iterateeAry(s,o){return overArg(s,(function(s){return"function"==typeof s?baseAry(s,o):s}))}(o,_)}return o}function castFixed(s,o,i){if($&&(Z||!u.skipFixed[s])){var _=u.methodSpread[s],x=_&&_.start;return void 0===x?le(o,i):function flatSpread(s,o){return function(){for(var i=arguments.length,u=i-1,_=Array(i);i--;)_[i]=arguments[i];var x=_[o],C=_.slice(0,o);return x&&w.apply(C,x),o!=u&&w.apply(C,_.slice(o+1)),s.apply(this,C)}}(o,x)}return o}function castRearg(s,o,i){return U&&i>1&&(ee||!u.skipRearg[s])?xe(o,u.methodRearg[s]||u.aryRearg[i]):o}function cloneByPath(s,o){for(var i=-1,u=(o=Te(o)).length,_=u-1,w=pe(Object(s)),x=w;null!=x&&++i1?de(o,i):o}(0,_=castCap(w,_),s),!1}})),!_})),_||(_=x),_==o&&(_=Y?de(_,1):function(){return o.apply(this,arguments)}),_.convert=createConverter(w,o),_.placeholder=o.placeholder=i,_}if(!j)return wrap(o,i,z);var $e=i,ze=[];return fe(Re,(function(s){fe(u.aryMethod[s],(function(s){var o=$e[u.remap[s]||s];o&&ze.push([s,wrap(s,o,$e)])}))})),fe(Se($e),(function(s){var o=$e[s];if("function"==typeof o){for(var i=ze.length;i--;)if(ze[i][0]==s)return;o.convert=createConverter(s,o),ze.push([s,o])}})),fe(ze,(function(s){$e[s[0]]=s[1]})),$e.convert=function convertLib(s){return $e.runInContext.convert(s)(void 0)},$e.placeholder=$e,fe(Se($e),(function(s){fe(u.realToAlias[s]||[],(function(o){$e[o]=$e[s]}))})),$e}},16962:(s,o)=>{o.aliasToReal={each:"forEach",eachRight:"forEachRight",entries:"toPairs",entriesIn:"toPairsIn",extend:"assignIn",extendAll:"assignInAll",extendAllWith:"assignInAllWith",extendWith:"assignInWith",first:"head",conforms:"conformsTo",matches:"isMatch",property:"get",__:"placeholder",F:"stubFalse",T:"stubTrue",all:"every",allPass:"overEvery",always:"constant",any:"some",anyPass:"overSome",apply:"spread",assoc:"set",assocPath:"set",complement:"negate",compose:"flowRight",contains:"includes",dissoc:"unset",dissocPath:"unset",dropLast:"dropRight",dropLastWhile:"dropRightWhile",equals:"isEqual",identical:"eq",indexBy:"keyBy",init:"initial",invertObj:"invert",juxt:"over",omitAll:"omit",nAry:"ary",path:"get",pathEq:"matchesProperty",pathOr:"getOr",paths:"at",pickAll:"pick",pipe:"flow",pluck:"map",prop:"get",propEq:"matchesProperty",propOr:"getOr",props:"at",symmetricDifference:"xor",symmetricDifferenceBy:"xorBy",symmetricDifferenceWith:"xorWith",takeLast:"takeRight",takeLastWhile:"takeRightWhile",unapply:"rest",unnest:"flatten",useWith:"overArgs",where:"conformsTo",whereEq:"isMatch",zipObj:"zipObject"},o.aryMethod={1:["assignAll","assignInAll","attempt","castArray","ceil","create","curry","curryRight","defaultsAll","defaultsDeepAll","floor","flow","flowRight","fromPairs","invert","iteratee","memoize","method","mergeAll","methodOf","mixin","nthArg","over","overEvery","overSome","rest","reverse","round","runInContext","spread","template","trim","trimEnd","trimStart","uniqueId","words","zipAll"],2:["add","after","ary","assign","assignAllWith","assignIn","assignInAllWith","at","before","bind","bindAll","bindKey","chunk","cloneDeepWith","cloneWith","concat","conformsTo","countBy","curryN","curryRightN","debounce","defaults","defaultsDeep","defaultTo","delay","difference","divide","drop","dropRight","dropRightWhile","dropWhile","endsWith","eq","every","filter","find","findIndex","findKey","findLast","findLastIndex","findLastKey","flatMap","flatMapDeep","flattenDepth","forEach","forEachRight","forIn","forInRight","forOwn","forOwnRight","get","groupBy","gt","gte","has","hasIn","includes","indexOf","intersection","invertBy","invoke","invokeMap","isEqual","isMatch","join","keyBy","lastIndexOf","lt","lte","map","mapKeys","mapValues","matchesProperty","maxBy","meanBy","merge","mergeAllWith","minBy","multiply","nth","omit","omitBy","overArgs","pad","padEnd","padStart","parseInt","partial","partialRight","partition","pick","pickBy","propertyOf","pull","pullAll","pullAt","random","range","rangeRight","rearg","reject","remove","repeat","restFrom","result","sampleSize","some","sortBy","sortedIndex","sortedIndexOf","sortedLastIndex","sortedLastIndexOf","sortedUniqBy","split","spreadFrom","startsWith","subtract","sumBy","take","takeRight","takeRightWhile","takeWhile","tap","throttle","thru","times","trimChars","trimCharsEnd","trimCharsStart","truncate","union","uniqBy","uniqWith","unset","unzipWith","without","wrap","xor","zip","zipObject","zipObjectDeep"],3:["assignInWith","assignWith","clamp","differenceBy","differenceWith","findFrom","findIndexFrom","findLastFrom","findLastIndexFrom","getOr","includesFrom","indexOfFrom","inRange","intersectionBy","intersectionWith","invokeArgs","invokeArgsMap","isEqualWith","isMatchWith","flatMapDepth","lastIndexOfFrom","mergeWith","orderBy","padChars","padCharsEnd","padCharsStart","pullAllBy","pullAllWith","rangeStep","rangeStepRight","reduce","reduceRight","replace","set","slice","sortedIndexBy","sortedLastIndexBy","transform","unionBy","unionWith","update","xorBy","xorWith","zipWith"],4:["fill","setWith","updateWith"]},o.aryRearg={2:[1,0],3:[2,0,1],4:[3,2,0,1]},o.iterateeAry={dropRightWhile:1,dropWhile:1,every:1,filter:1,find:1,findFrom:1,findIndex:1,findIndexFrom:1,findKey:1,findLast:1,findLastFrom:1,findLastIndex:1,findLastIndexFrom:1,findLastKey:1,flatMap:1,flatMapDeep:1,flatMapDepth:1,forEach:1,forEachRight:1,forIn:1,forInRight:1,forOwn:1,forOwnRight:1,map:1,mapKeys:1,mapValues:1,partition:1,reduce:2,reduceRight:2,reject:1,remove:1,some:1,takeRightWhile:1,takeWhile:1,times:1,transform:2},o.iterateeRearg={mapKeys:[1],reduceRight:[1,0]},o.methodRearg={assignInAllWith:[1,0],assignInWith:[1,2,0],assignAllWith:[1,0],assignWith:[1,2,0],differenceBy:[1,2,0],differenceWith:[1,2,0],getOr:[2,1,0],intersectionBy:[1,2,0],intersectionWith:[1,2,0],isEqualWith:[1,2,0],isMatchWith:[2,1,0],mergeAllWith:[1,0],mergeWith:[1,2,0],padChars:[2,1,0],padCharsEnd:[2,1,0],padCharsStart:[2,1,0],pullAllBy:[2,1,0],pullAllWith:[2,1,0],rangeStep:[1,2,0],rangeStepRight:[1,2,0],setWith:[3,1,2,0],sortedIndexBy:[2,1,0],sortedLastIndexBy:[2,1,0],unionBy:[1,2,0],unionWith:[1,2,0],updateWith:[3,1,2,0],xorBy:[1,2,0],xorWith:[1,2,0],zipWith:[1,2,0]},o.methodSpread={assignAll:{start:0},assignAllWith:{start:0},assignInAll:{start:0},assignInAllWith:{start:0},defaultsAll:{start:0},defaultsDeepAll:{start:0},invokeArgs:{start:2},invokeArgsMap:{start:2},mergeAll:{start:0},mergeAllWith:{start:0},partial:{start:1},partialRight:{start:1},without:{start:1},zipAll:{start:0}},o.mutate={array:{fill:!0,pull:!0,pullAll:!0,pullAllBy:!0,pullAllWith:!0,pullAt:!0,remove:!0,reverse:!0},object:{assign:!0,assignAll:!0,assignAllWith:!0,assignIn:!0,assignInAll:!0,assignInAllWith:!0,assignInWith:!0,assignWith:!0,defaults:!0,defaultsAll:!0,defaultsDeep:!0,defaultsDeepAll:!0,merge:!0,mergeAll:!0,mergeAllWith:!0,mergeWith:!0},set:{set:!0,setWith:!0,unset:!0,update:!0,updateWith:!0}},o.realToAlias=function(){var s=Object.prototype.hasOwnProperty,i=o.aliasToReal,u={};for(var _ in i){var w=i[_];s.call(u,w)?u[w].push(_):u[w]=[_]}return u}(),o.remap={assignAll:"assign",assignAllWith:"assignWith",assignInAll:"assignIn",assignInAllWith:"assignInWith",curryN:"curry",curryRightN:"curryRight",defaultsAll:"defaults",defaultsDeepAll:"defaultsDeep",findFrom:"find",findIndexFrom:"findIndex",findLastFrom:"findLast",findLastIndexFrom:"findLastIndex",getOr:"get",includesFrom:"includes",indexOfFrom:"indexOf",invokeArgs:"invoke",invokeArgsMap:"invokeMap",lastIndexOfFrom:"lastIndexOf",mergeAll:"merge",mergeAllWith:"mergeWith",padChars:"pad",padCharsEnd:"padEnd",padCharsStart:"padStart",propertyOf:"get",rangeStep:"range",rangeStepRight:"rangeRight",restFrom:"rest",spreadFrom:"spread",trimChars:"trim",trimCharsEnd:"trimEnd",trimCharsStart:"trimStart",zipAll:"zip"},o.skipFixed={castArray:!0,flow:!0,flowRight:!0,iteratee:!0,mixin:!0,rearg:!0,runInContext:!0},o.skipRearg={add:!0,assign:!0,assignIn:!0,bind:!0,bindKey:!0,concat:!0,difference:!0,divide:!0,eq:!0,gt:!0,gte:!0,isEqual:!0,lt:!0,lte:!0,matchesProperty:!0,merge:!0,multiply:!0,overArgs:!0,partial:!0,partialRight:!0,propertyOf:!0,random:!0,range:!0,rangeRight:!0,subtract:!0,zip:!0,zipObject:!0,zipObjectDeep:!0}},47934:(s,o,i)=>{s.exports={ary:i(64626),assign:i(74733),clone:i(32629),curry:i(49747),forEach:i(83729),isArray:i(56449),isError:i(23546),isFunction:i(1882),isWeakMap:i(47886),iteratee:i(33855),keys:i(88984),rearg:i(84195),toInteger:i(61489),toPath:i(42072)}},56367:(s,o,i)=>{s.exports=i(77731)},79920:(s,o,i)=>{var u=i(73424),_=i(47934);s.exports=function convert(s,o,i){return u(_,s,o,i)}},2874:s=>{s.exports={}},77731:(s,o,i)=>{var u=i(79920)("set",i(63560));u.placeholder=i(2874),s.exports=u},58156:(s,o,i)=>{var u=i(47422);s.exports=function get(s,o,i){var _=null==s?void 0:u(s,o);return void 0===_?i:_}},61448:(s,o,i)=>{var u=i(20426),_=i(49326);s.exports=function has(s,o){return null!=s&&_(s,o,u)}},80631:(s,o,i)=>{var u=i(28077),_=i(49326);s.exports=function hasIn(s,o){return null!=s&&_(s,o,u)}},83488:s=>{s.exports=function identity(s){return s}},72428:(s,o,i)=>{var u=i(27534),_=i(40346),w=Object.prototype,x=w.hasOwnProperty,C=w.propertyIsEnumerable,j=u(function(){return arguments}())?u:function(s){return _(s)&&x.call(s,"callee")&&!C.call(s,"callee")};s.exports=j},56449:s=>{var o=Array.isArray;s.exports=o},64894:(s,o,i)=>{var u=i(1882),_=i(30294);s.exports=function isArrayLike(s){return null!=s&&_(s.length)&&!u(s)}},83693:(s,o,i)=>{var u=i(64894),_=i(40346);s.exports=function isArrayLikeObject(s){return _(s)&&u(s)}},53812:(s,o,i)=>{var u=i(72552),_=i(40346);s.exports=function isBoolean(s){return!0===s||!1===s||_(s)&&"[object Boolean]"==u(s)}},3656:(s,o,i)=>{s=i.nmd(s);var u=i(9325),_=i(89935),w=o&&!o.nodeType&&o,x=w&&s&&!s.nodeType&&s,C=x&&x.exports===w?u.Buffer:void 0,j=(C?C.isBuffer:void 0)||_;s.exports=j},62193:(s,o,i)=>{var u=i(88984),_=i(5861),w=i(72428),x=i(56449),C=i(64894),j=i(3656),L=i(55527),B=i(37167),$=Object.prototype.hasOwnProperty;s.exports=function isEmpty(s){if(null==s)return!0;if(C(s)&&(x(s)||"string"==typeof s||"function"==typeof s.splice||j(s)||B(s)||w(s)))return!s.length;var o=_(s);if("[object Map]"==o||"[object Set]"==o)return!s.size;if(L(s))return!u(s).length;for(var i in s)if($.call(s,i))return!1;return!0}},2404:(s,o,i)=>{var u=i(60270);s.exports=function isEqual(s,o){return u(s,o)}},23546:(s,o,i)=>{var u=i(72552),_=i(40346),w=i(11331);s.exports=function isError(s){if(!_(s))return!1;var o=u(s);return"[object Error]"==o||"[object DOMException]"==o||"string"==typeof s.message&&"string"==typeof s.name&&!w(s)}},1882:(s,o,i)=>{var u=i(72552),_=i(23805);s.exports=function isFunction(s){if(!_(s))return!1;var o=u(s);return"[object Function]"==o||"[object GeneratorFunction]"==o||"[object AsyncFunction]"==o||"[object Proxy]"==o}},30294:s=>{s.exports=function isLength(s){return"number"==typeof s&&s>-1&&s%1==0&&s<=9007199254740991}},87730:(s,o,i)=>{var u=i(29172),_=i(27301),w=i(86009),x=w&&w.isMap,C=x?_(x):u;s.exports=C},5187:s=>{s.exports=function isNull(s){return null===s}},98023:(s,o,i)=>{var u=i(72552),_=i(40346);s.exports=function isNumber(s){return"number"==typeof s||_(s)&&"[object Number]"==u(s)}},23805:s=>{s.exports=function isObject(s){var o=typeof s;return null!=s&&("object"==o||"function"==o)}},40346:s=>{s.exports=function isObjectLike(s){return null!=s&&"object"==typeof s}},11331:(s,o,i)=>{var u=i(72552),_=i(28879),w=i(40346),x=Function.prototype,C=Object.prototype,j=x.toString,L=C.hasOwnProperty,B=j.call(Object);s.exports=function isPlainObject(s){if(!w(s)||"[object Object]"!=u(s))return!1;var o=_(s);if(null===o)return!0;var i=L.call(o,"constructor")&&o.constructor;return"function"==typeof i&&i instanceof i&&j.call(i)==B}},38440:(s,o,i)=>{var u=i(16038),_=i(27301),w=i(86009),x=w&&w.isSet,C=x?_(x):u;s.exports=C},85015:(s,o,i)=>{var u=i(72552),_=i(56449),w=i(40346);s.exports=function isString(s){return"string"==typeof s||!_(s)&&w(s)&&"[object String]"==u(s)}},44394:(s,o,i)=>{var u=i(72552),_=i(40346);s.exports=function isSymbol(s){return"symbol"==typeof s||_(s)&&"[object Symbol]"==u(s)}},37167:(s,o,i)=>{var u=i(4901),_=i(27301),w=i(86009),x=w&&w.isTypedArray,C=x?_(x):u;s.exports=C},47886:(s,o,i)=>{var u=i(5861),_=i(40346);s.exports=function isWeakMap(s){return _(s)&&"[object WeakMap]"==u(s)}},33855:(s,o,i)=>{var u=i(9999),_=i(15389);s.exports=function iteratee(s){return _("function"==typeof s?s:u(s,1))}},95950:(s,o,i)=>{var u=i(70695),_=i(88984),w=i(64894);s.exports=function keys(s){return w(s)?u(s):_(s)}},37241:(s,o,i)=>{var u=i(70695),_=i(72903),w=i(64894);s.exports=function keysIn(s){return w(s)?u(s,!0):_(s)}},68090:s=>{s.exports=function last(s){var o=null==s?0:s.length;return o?s[o-1]:void 0}},50104:(s,o,i)=>{var u=i(53661);function memoize(s,o){if("function"!=typeof s||null!=o&&"function"!=typeof o)throw new TypeError("Expected a function");var memoized=function(){var i=arguments,u=o?o.apply(this,i):i[0],_=memoized.cache;if(_.has(u))return _.get(u);var w=s.apply(this,i);return memoized.cache=_.set(u,w)||_,w};return memoized.cache=new(memoize.Cache||u),memoized}memoize.Cache=u,s.exports=memoize},55364:(s,o,i)=>{var u=i(85250),_=i(20999)((function(s,o,i){u(s,o,i)}));s.exports=_},6048:s=>{s.exports=function negate(s){if("function"!=typeof s)throw new TypeError("Expected a function");return function(){var o=arguments;switch(o.length){case 0:return!s.call(this);case 1:return!s.call(this,o[0]);case 2:return!s.call(this,o[0],o[1]);case 3:return!s.call(this,o[0],o[1],o[2])}return!s.apply(this,o)}}},63950:s=>{s.exports=function noop(){}},10124:(s,o,i)=>{var u=i(9325);s.exports=function(){return u.Date.now()}},90179:(s,o,i)=>{var u=i(34932),_=i(9999),w=i(19931),x=i(31769),C=i(21791),j=i(53138),L=i(38816),B=i(83349),$=L((function(s,o){var i={};if(null==s)return i;var L=!1;o=u(o,(function(o){return o=x(o,s),L||(L=o.length>1),o})),C(s,B(s),i),L&&(i=_(i,7,j));for(var $=o.length;$--;)w(i,o[$]);return i}));s.exports=$},50583:(s,o,i)=>{var u=i(47237),_=i(17255),w=i(28586),x=i(77797);s.exports=function property(s){return w(s)?u(x(s)):_(s)}},84195:(s,o,i)=>{var u=i(66977),_=i(38816),w=_((function(s,o){return u(s,256,void 0,void 0,void 0,o)}));s.exports=w},40860:(s,o,i)=>{var u=i(40882),_=i(80909),w=i(15389),x=i(85558),C=i(56449);s.exports=function reduce(s,o,i){var j=C(s)?u:x,L=arguments.length<3;return j(s,w(o,4),i,L,_)}},63560:(s,o,i)=>{var u=i(73170);s.exports=function set(s,o,i){return null==s?s:u(s,o,i)}},42426:(s,o,i)=>{var u=i(14248),_=i(15389),w=i(90916),x=i(56449),C=i(36800);s.exports=function some(s,o,i){var j=x(s)?u:w;return i&&C(s,o,i)&&(o=void 0),j(s,_(o,3))}},63345:s=>{s.exports=function stubArray(){return[]}},89935:s=>{s.exports=function stubFalse(){return!1}},17400:(s,o,i)=>{var u=i(99374),_=1/0;s.exports=function toFinite(s){return s?(s=u(s))===_||s===-1/0?17976931348623157e292*(s<0?-1:1):s==s?s:0:0===s?s:0}},61489:(s,o,i)=>{var u=i(17400);s.exports=function toInteger(s){var o=u(s),i=o%1;return o==o?i?o-i:o:0}},80218:(s,o,i)=>{var u=i(13222);s.exports=function toLower(s){return u(s).toLowerCase()}},99374:(s,o,i)=>{var u=i(54128),_=i(23805),w=i(44394),x=/^[-+]0x[0-9a-f]+$/i,C=/^0b[01]+$/i,j=/^0o[0-7]+$/i,L=parseInt;s.exports=function toNumber(s){if("number"==typeof s)return s;if(w(s))return NaN;if(_(s)){var o="function"==typeof s.valueOf?s.valueOf():s;s=_(o)?o+"":o}if("string"!=typeof s)return 0===s?s:+s;s=u(s);var i=C.test(s);return i||j.test(s)?L(s.slice(2),i?2:8):x.test(s)?NaN:+s}},42072:(s,o,i)=>{var u=i(34932),_=i(23007),w=i(56449),x=i(44394),C=i(61802),j=i(77797),L=i(13222);s.exports=function toPath(s){return w(s)?u(s,j):x(s)?[s]:_(C(L(s)))}},69884:(s,o,i)=>{var u=i(21791),_=i(37241);s.exports=function toPlainObject(s){return u(s,_(s))}},13222:(s,o,i)=>{var u=i(77556);s.exports=function toString(s){return null==s?"":u(s)}},55808:(s,o,i)=>{var u=i(12507)("toUpperCase");s.exports=u},66645:(s,o,i)=>{var u=i(1733),_=i(45434),w=i(13222),x=i(22225);s.exports=function words(s,o,i){return s=w(s),void 0===(o=i?void 0:o)?_(s)?x(s):u(s):s.match(o)||[]}},53758:(s,o,i)=>{var u=i(30980),_=i(56017),w=i(94033),x=i(56449),C=i(40346),j=i(80257),L=Object.prototype.hasOwnProperty;function lodash(s){if(C(s)&&!x(s)&&!(s instanceof u)){if(s instanceof _)return s;if(L.call(s,"__wrapped__"))return j(s)}return new _(s)}lodash.prototype=w.prototype,lodash.prototype.constructor=lodash,s.exports=lodash},47248:(s,o,i)=>{var u=i(16547),_=i(51234);s.exports=function zipObject(s,o){return _(s||[],o||[],u)}},43768:(s,o,i)=>{"use strict";var u=i(45981),_=i(85587);o.highlight=highlight,o.highlightAuto=function highlightAuto(s,o){var i,x,C,j,L=o||{},B=L.subset||u.listLanguages(),$=L.prefix,V=B.length,U=-1;null==$&&($=w);if("string"!=typeof s)throw _("Expected `string` for value, got `%s`",s);x={relevance:0,language:null,value:[]},i={relevance:0,language:null,value:[]};for(;++Ux.relevance&&(x=C),C.relevance>i.relevance&&(x=i,i=C));x.language&&(i.secondBest=x);return i},o.registerLanguage=function registerLanguage(s,o){u.registerLanguage(s,o)},o.listLanguages=function listLanguages(){return u.listLanguages()},o.registerAlias=function registerAlias(s,o){var i,_=s;o&&((_={})[s]=o);for(i in _)u.registerAliases(_[i],{languageName:i})},Emitter.prototype.addText=function text(s){var o,i,u=this.stack;if(""===s)return;o=u[u.length-1],(i=o.children[o.children.length-1])&&"text"===i.type?i.value+=s:o.children.push({type:"text",value:s})},Emitter.prototype.addKeyword=function addKeyword(s,o){this.openNode(o),this.addText(s),this.closeNode()},Emitter.prototype.addSublanguage=function addSublanguage(s,o){var i=this.stack,u=i[i.length-1],_=s.rootNode.children,w=o?{type:"element",tagName:"span",properties:{className:[o]},children:_}:_;u.children=u.children.concat(w)},Emitter.prototype.openNode=function open(s){var o=this.stack,i=this.options.classPrefix+s,u=o[o.length-1],_={type:"element",tagName:"span",properties:{className:[i]},children:[]};u.children.push(_),o.push(_)},Emitter.prototype.closeNode=function close(){this.stack.pop()},Emitter.prototype.closeAllNodes=noop,Emitter.prototype.finalize=noop,Emitter.prototype.toHTML=function toHtmlNoop(){return""};var w="hljs-";function highlight(s,o,i){var x,C=u.configure({}),j=(i||{}).prefix;if("string"!=typeof s)throw _("Expected `string` for name, got `%s`",s);if(!u.getLanguage(s))throw _("Unknown language: `%s` is not registered",s);if("string"!=typeof o)throw _("Expected `string` for value, got `%s`",o);if(null==j&&(j=w),u.configure({__emitter:Emitter,classPrefix:j}),x=u.highlight(o,{language:s,ignoreIllegals:!0}),u.configure(C||{}),x.errorRaised)throw x.errorRaised;return{relevance:x.relevance,language:x.language,value:x.emitter.rootNode.children}}function Emitter(s){this.options=s,this.rootNode={children:[]},this.stack=[this.rootNode]}function noop(){}},92340:(s,o,i)=>{const u=i(6048);function coerceElementMatchingCallback(s){return"string"==typeof s?o=>o.element===s:s.constructor&&s.extend?o=>o instanceof s:s}class ArraySlice{constructor(s){this.elements=s||[]}toValue(){return this.elements.map((s=>s.toValue()))}map(s,o){return this.elements.map(s,o)}flatMap(s,o){return this.map(s,o).reduce(((s,o)=>s.concat(o)),[])}compactMap(s,o){const i=[];return this.forEach((u=>{const _=s.bind(o)(u);_&&i.push(_)})),i}filter(s,o){return s=coerceElementMatchingCallback(s),new ArraySlice(this.elements.filter(s,o))}reject(s,o){return s=coerceElementMatchingCallback(s),new ArraySlice(this.elements.filter(u(s),o))}find(s,o){return s=coerceElementMatchingCallback(s),this.elements.find(s,o)}forEach(s,o){this.elements.forEach(s,o)}reduce(s,o){return this.elements.reduce(s,o)}includes(s){return this.elements.some((o=>o.equals(s)))}shift(){return this.elements.shift()}unshift(s){this.elements.unshift(this.refract(s))}push(s){return this.elements.push(this.refract(s)),this}add(s){this.push(s)}get(s){return this.elements[s]}getValue(s){const o=this.elements[s];if(o)return o.toValue()}get length(){return this.elements.length}get isEmpty(){return 0===this.elements.length}get first(){return this.elements[0]}}"undefined"!=typeof Symbol&&(ArraySlice.prototype[Symbol.iterator]=function symbol(){return this.elements[Symbol.iterator]()}),s.exports=ArraySlice},55973:s=>{class KeyValuePair{constructor(s,o){this.key=s,this.value=o}clone(){const s=new KeyValuePair;return this.key&&(s.key=this.key.clone()),this.value&&(s.value=this.value.clone()),s}}s.exports=KeyValuePair},3110:(s,o,i)=>{const u=i(5187),_=i(85015),w=i(98023),x=i(53812),C=i(23805),j=i(85105),L=i(86804);class Namespace{constructor(s){this.elementMap={},this.elementDetection=[],this.Element=L.Element,this.KeyValuePair=L.KeyValuePair,s&&s.noDefault||this.useDefault(),this._attributeElementKeys=[],this._attributeElementArrayKeys=[]}use(s){return s.namespace&&s.namespace({base:this}),s.load&&s.load({base:this}),this}useDefault(){return this.register("null",L.NullElement).register("string",L.StringElement).register("number",L.NumberElement).register("boolean",L.BooleanElement).register("array",L.ArrayElement).register("object",L.ObjectElement).register("member",L.MemberElement).register("ref",L.RefElement).register("link",L.LinkElement),this.detect(u,L.NullElement,!1).detect(_,L.StringElement,!1).detect(w,L.NumberElement,!1).detect(x,L.BooleanElement,!1).detect(Array.isArray,L.ArrayElement,!1).detect(C,L.ObjectElement,!1),this}register(s,o){return this._elements=void 0,this.elementMap[s]=o,this}unregister(s){return this._elements=void 0,delete this.elementMap[s],this}detect(s,o,i){return void 0===i||i?this.elementDetection.unshift([s,o]):this.elementDetection.push([s,o]),this}toElement(s){if(s instanceof this.Element)return s;let o;for(let i=0;i{const o=s[0].toUpperCase()+s.substr(1);this._elements[o]=this.elementMap[s]}))),this._elements}get serialiser(){return new j(this)}}j.prototype.Namespace=Namespace,s.exports=Namespace},10866:(s,o,i)=>{const u=i(6048),_=i(92340);class ObjectSlice extends _{map(s,o){return this.elements.map((i=>s.bind(o)(i.value,i.key,i)))}filter(s,o){return new ObjectSlice(this.elements.filter((i=>s.bind(o)(i.value,i.key,i))))}reject(s,o){return this.filter(u(s.bind(o)))}forEach(s,o){return this.elements.forEach(((i,u)=>{s.bind(o)(i.value,i.key,i,u)}))}keys(){return this.map(((s,o)=>o.toValue()))}values(){return this.map((s=>s.toValue()))}}s.exports=ObjectSlice},86804:(s,o,i)=>{const u=i(10316),_=i(41067),w=i(71167),x=i(40239),C=i(12242),j=i(6233),L=i(87726),B=i(61045),$=i(86303),V=i(14540),U=i(92340),z=i(10866),Y=i(55973);function refract(s){if(s instanceof u)return s;if("string"==typeof s)return new w(s);if("number"==typeof s)return new x(s);if("boolean"==typeof s)return new C(s);if(null===s)return new _;if(Array.isArray(s))return new j(s.map(refract));if("object"==typeof s){return new B(s)}return s}u.prototype.ObjectElement=B,u.prototype.RefElement=V,u.prototype.MemberElement=L,u.prototype.refract=refract,U.prototype.refract=refract,s.exports={Element:u,NullElement:_,StringElement:w,NumberElement:x,BooleanElement:C,ArrayElement:j,MemberElement:L,ObjectElement:B,LinkElement:$,RefElement:V,refract,ArraySlice:U,ObjectSlice:z,KeyValuePair:Y}},86303:(s,o,i)=>{const u=i(10316);s.exports=class LinkElement extends u{constructor(s,o,i){super(s||[],o,i),this.element="link"}get relation(){return this.attributes.get("relation")}set relation(s){this.attributes.set("relation",s)}get href(){return this.attributes.get("href")}set href(s){this.attributes.set("href",s)}}},14540:(s,o,i)=>{const u=i(10316);s.exports=class RefElement extends u{constructor(s,o,i){super(s||[],o,i),this.element="ref",this.path||(this.path="element")}get path(){return this.attributes.get("path")}set path(s){this.attributes.set("path",s)}}},34035:(s,o,i)=>{const u=i(3110),_=i(86804);o.g$=u,o.KeyValuePair=i(55973),o.G6=_.ArraySlice,o.ot=_.ObjectSlice,o.Hg=_.Element,o.Om=_.StringElement,o.kT=_.NumberElement,o.bd=_.BooleanElement,o.Os=_.NullElement,o.wE=_.ArrayElement,o.Sh=_.ObjectElement,o.Pr=_.MemberElement,o.sI=_.RefElement,o.Ft=_.LinkElement,o.e=_.refract,i(85105),i(75147)},6233:(s,o,i)=>{const u=i(6048),_=i(10316),w=i(92340);class ArrayElement extends _{constructor(s,o,i){super(s||[],o,i),this.element="array"}primitive(){return"array"}get(s){return this.content[s]}getValue(s){const o=this.get(s);if(o)return o.toValue()}getIndex(s){return this.content[s]}set(s,o){return this.content[s]=this.refract(o),this}remove(s){const o=this.content.splice(s,1);return o.length?o[0]:null}map(s,o){return this.content.map(s,o)}flatMap(s,o){return this.map(s,o).reduce(((s,o)=>s.concat(o)),[])}compactMap(s,o){const i=[];return this.forEach((u=>{const _=s.bind(o)(u);_&&i.push(_)})),i}filter(s,o){return new w(this.content.filter(s,o))}reject(s,o){return this.filter(u(s),o)}reduce(s,o){let i,u;void 0!==o?(i=0,u=this.refract(o)):(i=1,u="object"===this.primitive()?this.first.value:this.first);for(let o=i;o{s.bind(o)(i,this.refract(u))}))}shift(){return this.content.shift()}unshift(s){this.content.unshift(this.refract(s))}push(s){return this.content.push(this.refract(s)),this}add(s){this.push(s)}findElements(s,o){const i=o||{},u=!!i.recursive,_=void 0===i.results?[]:i.results;return this.forEach(((o,i,w)=>{u&&void 0!==o.findElements&&o.findElements(s,{results:_,recursive:u}),s(o,i,w)&&_.push(o)})),_}find(s){return new w(this.findElements(s,{recursive:!0}))}findByElement(s){return this.find((o=>o.element===s))}findByClass(s){return this.find((o=>o.classes.includes(s)))}getById(s){return this.find((o=>o.id.toValue()===s)).first}includes(s){return this.content.some((o=>o.equals(s)))}contains(s){return this.includes(s)}empty(){return new this.constructor([])}"fantasy-land/empty"(){return this.empty()}concat(s){return new this.constructor(this.content.concat(s.content))}"fantasy-land/concat"(s){return this.concat(s)}"fantasy-land/map"(s){return new this.constructor(this.map(s))}"fantasy-land/chain"(s){return this.map((o=>s(o)),this).reduce(((s,o)=>s.concat(o)),this.empty())}"fantasy-land/filter"(s){return new this.constructor(this.content.filter(s))}"fantasy-land/reduce"(s,o){return this.content.reduce(s,o)}get length(){return this.content.length}get isEmpty(){return 0===this.content.length}get first(){return this.getIndex(0)}get second(){return this.getIndex(1)}get last(){return this.getIndex(this.length-1)}}ArrayElement.empty=function empty(){return new this},ArrayElement["fantasy-land/empty"]=ArrayElement.empty,"undefined"!=typeof Symbol&&(ArrayElement.prototype[Symbol.iterator]=function symbol(){return this.content[Symbol.iterator]()}),s.exports=ArrayElement},12242:(s,o,i)=>{const u=i(10316);s.exports=class BooleanElement extends u{constructor(s,o,i){super(s,o,i),this.element="boolean"}primitive(){return"boolean"}}},10316:(s,o,i)=>{const u=i(2404),_=i(55973),w=i(92340);class Element{constructor(s,o,i){o&&(this.meta=o),i&&(this.attributes=i),this.content=s}freeze(){Object.isFrozen(this)||(this._meta&&(this.meta.parent=this,this.meta.freeze()),this._attributes&&(this.attributes.parent=this,this.attributes.freeze()),this.children.forEach((s=>{s.parent=this,s.freeze()}),this),this.content&&Array.isArray(this.content)&&Object.freeze(this.content),Object.freeze(this))}primitive(){}clone(){const s=new this.constructor;return s.element=this.element,this.meta.length&&(s._meta=this.meta.clone()),this.attributes.length&&(s._attributes=this.attributes.clone()),this.content?this.content.clone?s.content=this.content.clone():Array.isArray(this.content)?s.content=this.content.map((s=>s.clone())):s.content=this.content:s.content=this.content,s}toValue(){return this.content instanceof Element?this.content.toValue():this.content instanceof _?{key:this.content.key.toValue(),value:this.content.value?this.content.value.toValue():void 0}:this.content&&this.content.map?this.content.map((s=>s.toValue()),this):this.content}toRef(s){if(""===this.id.toValue())throw Error("Cannot create reference to an element that does not contain an ID");const o=new this.RefElement(this.id.toValue());return s&&(o.path=s),o}findRecursive(...s){if(arguments.length>1&&!this.isFrozen)throw new Error("Cannot find recursive with multiple element names without first freezing the element. Call `element.freeze()`");const o=s.pop();let i=new w;const append=(s,o)=>(s.push(o),s),checkElement=(s,i)=>{i.element===o&&s.push(i);const u=i.findRecursive(o);return u&&u.reduce(append,s),i.content instanceof _&&(i.content.key&&checkElement(s,i.content.key),i.content.value&&checkElement(s,i.content.value)),s};return this.content&&(this.content.element&&checkElement(i,this.content),Array.isArray(this.content)&&this.content.reduce(checkElement,i)),s.isEmpty||(i=i.filter((o=>{let i=o.parents.map((s=>s.element));for(const o in s){const u=s[o],_=i.indexOf(u);if(-1===_)return!1;i=i.splice(0,_)}return!0}))),i}set(s){return this.content=s,this}equals(s){return u(this.toValue(),s)}getMetaProperty(s,o){if(!this.meta.hasKey(s)){if(this.isFrozen){const s=this.refract(o);return s.freeze(),s}this.meta.set(s,o)}return this.meta.get(s)}setMetaProperty(s,o){this.meta.set(s,o)}get element(){return this._storedElement||"element"}set element(s){this._storedElement=s}get content(){return this._content}set content(s){if(s instanceof Element)this._content=s;else if(s instanceof w)this.content=s.elements;else if("string"==typeof s||"number"==typeof s||"boolean"==typeof s||"null"===s||null==s)this._content=s;else if(s instanceof _)this._content=s;else if(Array.isArray(s))this._content=s.map(this.refract);else{if("object"!=typeof s)throw new Error("Cannot set content to given value");this._content=Object.keys(s).map((o=>new this.MemberElement(o,s[o])))}}get meta(){if(!this._meta){if(this.isFrozen){const s=new this.ObjectElement;return s.freeze(),s}this._meta=new this.ObjectElement}return this._meta}set meta(s){s instanceof this.ObjectElement?this._meta=s:this.meta.set(s||{})}get attributes(){if(!this._attributes){if(this.isFrozen){const s=new this.ObjectElement;return s.freeze(),s}this._attributes=new this.ObjectElement}return this._attributes}set attributes(s){s instanceof this.ObjectElement?this._attributes=s:this.attributes.set(s||{})}get id(){return this.getMetaProperty("id","")}set id(s){this.setMetaProperty("id",s)}get classes(){return this.getMetaProperty("classes",[])}set classes(s){this.setMetaProperty("classes",s)}get title(){return this.getMetaProperty("title","")}set title(s){this.setMetaProperty("title",s)}get description(){return this.getMetaProperty("description","")}set description(s){this.setMetaProperty("description",s)}get links(){return this.getMetaProperty("links",[])}set links(s){this.setMetaProperty("links",s)}get isFrozen(){return Object.isFrozen(this)}get parents(){let{parent:s}=this;const o=new w;for(;s;)o.push(s),s=s.parent;return o}get children(){if(Array.isArray(this.content))return new w(this.content);if(this.content instanceof _){const s=new w([this.content.key]);return this.content.value&&s.push(this.content.value),s}return this.content instanceof Element?new w([this.content]):new w}get recursiveChildren(){const s=new w;return this.children.forEach((o=>{s.push(o),o.recursiveChildren.forEach((o=>{s.push(o)}))})),s}}s.exports=Element},87726:(s,o,i)=>{const u=i(55973),_=i(10316);s.exports=class MemberElement extends _{constructor(s,o,i,_){super(new u,i,_),this.element="member",this.key=s,this.value=o}get key(){return this.content.key}set key(s){this.content.key=this.refract(s)}get value(){return this.content.value}set value(s){this.content.value=this.refract(s)}}},41067:(s,o,i)=>{const u=i(10316);s.exports=class NullElement extends u{constructor(s,o,i){super(s||null,o,i),this.element="null"}primitive(){return"null"}set(){return new Error("Cannot set the value of null")}}},40239:(s,o,i)=>{const u=i(10316);s.exports=class NumberElement extends u{constructor(s,o,i){super(s,o,i),this.element="number"}primitive(){return"number"}}},61045:(s,o,i)=>{const u=i(6048),_=i(23805),w=i(6233),x=i(87726),C=i(10866);s.exports=class ObjectElement extends w{constructor(s,o,i){super(s||[],o,i),this.element="object"}primitive(){return"object"}toValue(){return this.content.reduce(((s,o)=>(s[o.key.toValue()]=o.value?o.value.toValue():void 0,s)),{})}get(s){const o=this.getMember(s);if(o)return o.value}getMember(s){if(void 0!==s)return this.content.find((o=>o.key.toValue()===s))}remove(s){let o=null;return this.content=this.content.filter((i=>i.key.toValue()!==s||(o=i,!1))),o}getKey(s){const o=this.getMember(s);if(o)return o.key}set(s,o){if(_(s))return Object.keys(s).forEach((o=>{this.set(o,s[o])})),this;const i=s,u=this.getMember(i);return u?u.value=o:this.content.push(new x(i,o)),this}keys(){return this.content.map((s=>s.key.toValue()))}values(){return this.content.map((s=>s.value.toValue()))}hasKey(s){return this.content.some((o=>o.key.equals(s)))}items(){return this.content.map((s=>[s.key.toValue(),s.value.toValue()]))}map(s,o){return this.content.map((i=>s.bind(o)(i.value,i.key,i)))}compactMap(s,o){const i=[];return this.forEach(((u,_,w)=>{const x=s.bind(o)(u,_,w);x&&i.push(x)})),i}filter(s,o){return new C(this.content).filter(s,o)}reject(s,o){return this.filter(u(s),o)}forEach(s,o){return this.content.forEach((i=>s.bind(o)(i.value,i.key,i)))}}},71167:(s,o,i)=>{const u=i(10316);s.exports=class StringElement extends u{constructor(s,o,i){super(s,o,i),this.element="string"}primitive(){return"string"}get length(){return this.content.length}}},75147:(s,o,i)=>{const u=i(85105);s.exports=class JSON06Serialiser extends u{serialise(s){if(!(s instanceof this.namespace.elements.Element))throw new TypeError(`Given element \`${s}\` is not an Element instance`);let o;s._attributes&&s.attributes.get("variable")&&(o=s.attributes.get("variable"));const i={element:s.element};s._meta&&s._meta.length>0&&(i.meta=this.serialiseObject(s.meta));const u="enum"===s.element||-1!==s.attributes.keys().indexOf("enumerations");if(u){const o=this.enumSerialiseAttributes(s);o&&(i.attributes=o)}else if(s._attributes&&s._attributes.length>0){let{attributes:u}=s;u.get("metadata")&&(u=u.clone(),u.set("meta",u.get("metadata")),u.remove("metadata")),"member"===s.element&&o&&(u=u.clone(),u.remove("variable")),u.length>0&&(i.attributes=this.serialiseObject(u))}if(u)i.content=this.enumSerialiseContent(s,i);else if(this[`${s.element}SerialiseContent`])i.content=this[`${s.element}SerialiseContent`](s,i);else if(void 0!==s.content){let u;o&&s.content.key?(u=s.content.clone(),u.key.attributes.set("variable",o),u=this.serialiseContent(u)):u=this.serialiseContent(s.content),this.shouldSerialiseContent(s,u)&&(i.content=u)}else this.shouldSerialiseContent(s,s.content)&&s instanceof this.namespace.elements.Array&&(i.content=[]);return i}shouldSerialiseContent(s,o){return"parseResult"===s.element||"httpRequest"===s.element||"httpResponse"===s.element||"category"===s.element||"link"===s.element||void 0!==o&&(!Array.isArray(o)||0!==o.length)}refSerialiseContent(s,o){return delete o.attributes,{href:s.toValue(),path:s.path.toValue()}}sourceMapSerialiseContent(s){return s.toValue()}dataStructureSerialiseContent(s){return[this.serialiseContent(s.content)]}enumSerialiseAttributes(s){const o=s.attributes.clone(),i=o.remove("enumerations")||new this.namespace.elements.Array([]),u=o.get("default");let _=o.get("samples")||new this.namespace.elements.Array([]);if(u&&u.content&&(u.content.attributes&&u.content.attributes.remove("typeAttributes"),o.set("default",new this.namespace.elements.Array([u.content]))),_.forEach((s=>{s.content&&s.content.element&&s.content.attributes.remove("typeAttributes")})),s.content&&0!==i.length&&_.unshift(s.content),_=_.map((s=>s instanceof this.namespace.elements.Array?[s]:new this.namespace.elements.Array([s.content]))),_.length&&o.set("samples",_),o.length>0)return this.serialiseObject(o)}enumSerialiseContent(s){if(s._attributes){const o=s.attributes.get("enumerations");if(o&&o.length>0)return o.content.map((s=>{const o=s.clone();return o.attributes.remove("typeAttributes"),this.serialise(o)}))}if(s.content){const o=s.content.clone();return o.attributes.remove("typeAttributes"),[this.serialise(o)]}return[]}deserialise(s){if("string"==typeof s)return new this.namespace.elements.String(s);if("number"==typeof s)return new this.namespace.elements.Number(s);if("boolean"==typeof s)return new this.namespace.elements.Boolean(s);if(null===s)return new this.namespace.elements.Null;if(Array.isArray(s))return new this.namespace.elements.Array(s.map(this.deserialise,this));const o=this.namespace.getElementClass(s.element),i=new o;i.element!==s.element&&(i.element=s.element),s.meta&&this.deserialiseObject(s.meta,i.meta),s.attributes&&this.deserialiseObject(s.attributes,i.attributes);const u=this.deserialiseContent(s.content);if(void 0===u&&null!==i.content||(i.content=u),"enum"===i.element){i.content&&i.attributes.set("enumerations",i.content);let s=i.attributes.get("samples");if(i.attributes.remove("samples"),s){const u=s;s=new this.namespace.elements.Array,u.forEach((u=>{u.forEach((u=>{const _=new o(u);_.element=i.element,s.push(_)}))}));const _=s.shift();i.content=_?_.content:void 0,i.attributes.set("samples",s)}else i.content=void 0;let u=i.attributes.get("default");if(u&&u.length>0){u=u.get(0);const s=new o(u);s.element=i.element,i.attributes.set("default",s)}}else if("dataStructure"===i.element&&Array.isArray(i.content))[i.content]=i.content;else if("category"===i.element){const s=i.attributes.get("meta");s&&(i.attributes.set("metadata",s),i.attributes.remove("meta"))}else"member"===i.element&&i.key&&i.key._attributes&&i.key._attributes.getValue("variable")&&(i.attributes.set("variable",i.key.attributes.get("variable")),i.key.attributes.remove("variable"));return i}serialiseContent(s){if(s instanceof this.namespace.elements.Element)return this.serialise(s);if(s instanceof this.namespace.KeyValuePair){const o={key:this.serialise(s.key)};return s.value&&(o.value=this.serialise(s.value)),o}return s&&s.map?s.map(this.serialise,this):s}deserialiseContent(s){if(s){if(s.element)return this.deserialise(s);if(s.key){const o=new this.namespace.KeyValuePair(this.deserialise(s.key));return s.value&&(o.value=this.deserialise(s.value)),o}if(s.map)return s.map(this.deserialise,this)}return s}shouldRefract(s){return!!(s._attributes&&s.attributes.keys().length||s._meta&&s.meta.keys().length)||"enum"!==s.element&&(s.element!==s.primitive()||"member"===s.element)}convertKeyToRefract(s,o){return this.shouldRefract(o)?this.serialise(o):"enum"===o.element?this.serialiseEnum(o):"array"===o.element?o.map((o=>this.shouldRefract(o)||"default"===s?this.serialise(o):"array"===o.element||"object"===o.element||"enum"===o.element?o.children.map((s=>this.serialise(s))):o.toValue())):"object"===o.element?(o.content||[]).map(this.serialise,this):o.toValue()}serialiseEnum(s){return s.children.map((s=>this.serialise(s)))}serialiseObject(s){const o={};return s.forEach(((s,i)=>{if(s){const u=i.toValue();o[u]=this.convertKeyToRefract(u,s)}})),o}deserialiseObject(s,o){Object.keys(s).forEach((i=>{o.set(i,this.deserialise(s[i]))}))}}},85105:s=>{s.exports=class JSONSerialiser{constructor(s){this.namespace=s||new this.Namespace}serialise(s){if(!(s instanceof this.namespace.elements.Element))throw new TypeError(`Given element \`${s}\` is not an Element instance`);const o={element:s.element};s._meta&&s._meta.length>0&&(o.meta=this.serialiseObject(s.meta)),s._attributes&&s._attributes.length>0&&(o.attributes=this.serialiseObject(s.attributes));const i=this.serialiseContent(s.content);return void 0!==i&&(o.content=i),o}deserialise(s){if(!s.element)throw new Error("Given value is not an object containing an element name");const o=new(this.namespace.getElementClass(s.element));o.element!==s.element&&(o.element=s.element),s.meta&&this.deserialiseObject(s.meta,o.meta),s.attributes&&this.deserialiseObject(s.attributes,o.attributes);const i=this.deserialiseContent(s.content);return void 0===i&&null!==o.content||(o.content=i),o}serialiseContent(s){if(s instanceof this.namespace.elements.Element)return this.serialise(s);if(s instanceof this.namespace.KeyValuePair){const o={key:this.serialise(s.key)};return s.value&&(o.value=this.serialise(s.value)),o}if(s&&s.map){if(0===s.length)return;return s.map(this.serialise,this)}return s}deserialiseContent(s){if(s){if(s.element)return this.deserialise(s);if(s.key){const o=new this.namespace.KeyValuePair(this.deserialise(s.key));return s.value&&(o.value=this.deserialise(s.value)),o}if(s.map)return s.map(this.deserialise,this)}return s}serialiseObject(s){const o={};if(s.forEach(((s,i)=>{s&&(o[i.toValue()]=this.serialise(s))})),0!==Object.keys(o).length)return o}deserialiseObject(s,o){Object.keys(s).forEach((i=>{o.set(i,this.deserialise(s[i]))}))}}},65606:s=>{var o,i,u=s.exports={};function defaultSetTimout(){throw new Error("setTimeout has not been defined")}function defaultClearTimeout(){throw new Error("clearTimeout has not been defined")}function runTimeout(s){if(o===setTimeout)return setTimeout(s,0);if((o===defaultSetTimout||!o)&&setTimeout)return o=setTimeout,setTimeout(s,0);try{return o(s,0)}catch(i){try{return o.call(null,s,0)}catch(i){return o.call(this,s,0)}}}!function(){try{o="function"==typeof setTimeout?setTimeout:defaultSetTimout}catch(s){o=defaultSetTimout}try{i="function"==typeof clearTimeout?clearTimeout:defaultClearTimeout}catch(s){i=defaultClearTimeout}}();var _,w=[],x=!1,C=-1;function cleanUpNextTick(){x&&_&&(x=!1,_.length?w=_.concat(w):C=-1,w.length&&drainQueue())}function drainQueue(){if(!x){var s=runTimeout(cleanUpNextTick);x=!0;for(var o=w.length;o;){for(_=w,w=[];++C1)for(var i=1;i{"use strict";var u=i(6925);function emptyFunction(){}function emptyFunctionWithReset(){}emptyFunctionWithReset.resetWarningCache=emptyFunction,s.exports=function(){function shim(s,o,i,_,w,x){if(x!==u){var C=new Error("Calling PropTypes validators directly is not supported by the `prop-types` package. Use PropTypes.checkPropTypes() to call them. Read more at http://fb.me/use-check-prop-types");throw C.name="Invariant Violation",C}}function getShim(){return shim}shim.isRequired=shim;var s={array:shim,bigint:shim,bool:shim,func:shim,number:shim,object:shim,string:shim,symbol:shim,any:shim,arrayOf:getShim,element:shim,elementType:shim,instanceOf:getShim,node:shim,objectOf:getShim,oneOf:getShim,oneOfType:getShim,shape:getShim,exact:getShim,checkPropTypes:emptyFunctionWithReset,resetWarningCache:emptyFunction};return s.PropTypes=s,s}},5556:(s,o,i)=>{s.exports=i(2694)()},6925:s=>{"use strict";s.exports="SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED"},73992:(s,o)=>{"use strict";var i=Object.prototype.hasOwnProperty;function decode(s){try{return decodeURIComponent(s.replace(/\+/g," "))}catch(s){return null}}function encode(s){try{return encodeURIComponent(s)}catch(s){return null}}o.stringify=function querystringify(s,o){o=o||"";var u,_,w=[];for(_ in"string"!=typeof o&&(o="?"),s)if(i.call(s,_)){if((u=s[_])||null!=u&&!isNaN(u)||(u=""),_=encode(_),u=encode(u),null===_||null===u)continue;w.push(_+"="+u)}return w.length?o+w.join("&"):""},o.parse=function querystring(s){for(var o,i=/([^=?#&]+)=?([^&]*)/g,u={};o=i.exec(s);){var _=decode(o[1]),w=decode(o[2]);null===_||null===w||_ in u||(u[_]=w)}return u}},41859:(s,o,i)=>{const u=i(27096),_=i(78004),w=u.types;s.exports=class RandExp{constructor(s,o){if(this._setDefaults(s),s instanceof RegExp)this.ignoreCase=s.ignoreCase,this.multiline=s.multiline,s=s.source;else{if("string"!=typeof s)throw new Error("Expected a regexp or string");this.ignoreCase=o&&-1!==o.indexOf("i"),this.multiline=o&&-1!==o.indexOf("m")}this.tokens=u(s)}_setDefaults(s){this.max=null!=s.max?s.max:null!=RandExp.prototype.max?RandExp.prototype.max:100,this.defaultRange=s.defaultRange?s.defaultRange:this.defaultRange.clone(),s.randInt&&(this.randInt=s.randInt)}gen(){return this._gen(this.tokens,[])}_gen(s,o){var i,u,_,x,C;switch(s.type){case w.ROOT:case w.GROUP:if(s.followedBy||s.notFollowedBy)return"";for(s.remember&&void 0===s.groupNumber&&(s.groupNumber=o.push(null)-1),u="",x=0,C=(i=s.options?this._randSelect(s.options):s.stack).length;x{"use strict";var u=i(65606),_=65536,w=4294967295;var x=i(92861).Buffer,C=i.g.crypto||i.g.msCrypto;C&&C.getRandomValues?s.exports=function randomBytes(s,o){if(s>w)throw new RangeError("requested too many random bytes");var i=x.allocUnsafe(s);if(s>0)if(s>_)for(var j=0;j{"use strict";function _typeof(s){return _typeof="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(s){return typeof s}:function(s){return s&&"function"==typeof Symbol&&s.constructor===Symbol&&s!==Symbol.prototype?"symbol":typeof s},_typeof(s)}Object.defineProperty(o,"__esModule",{value:!0}),o.CopyToClipboard=void 0;var u=_interopRequireDefault(i(96540)),_=_interopRequireDefault(i(17965)),w=["text","onCopy","options","children"];function _interopRequireDefault(s){return s&&s.__esModule?s:{default:s}}function ownKeys(s,o){var i=Object.keys(s);if(Object.getOwnPropertySymbols){var u=Object.getOwnPropertySymbols(s);o&&(u=u.filter((function(o){return Object.getOwnPropertyDescriptor(s,o).enumerable}))),i.push.apply(i,u)}return i}function _objectSpread(s){for(var o=1;o=0||(_[i]=s[i]);return _}(s,o);if(Object.getOwnPropertySymbols){var w=Object.getOwnPropertySymbols(s);for(u=0;u=0||Object.prototype.propertyIsEnumerable.call(s,i)&&(_[i]=s[i])}return _}function _defineProperties(s,o){for(var i=0;i{"use strict";var u=i(25264).CopyToClipboard;u.CopyToClipboard=u,s.exports=u},81214:(s,o,i)=>{"use strict";function _typeof(s){return _typeof="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(s){return typeof s}:function(s){return s&&"function"==typeof Symbol&&s.constructor===Symbol&&s!==Symbol.prototype?"symbol":typeof s},_typeof(s)}Object.defineProperty(o,"__esModule",{value:!0}),o.DebounceInput=void 0;var u=_interopRequireDefault(i(96540)),_=_interopRequireDefault(i(20181)),w=["element","onChange","value","minLength","debounceTimeout","forceNotifyByEnter","forceNotifyOnBlur","onKeyDown","onBlur","inputRef"];function _interopRequireDefault(s){return s&&s.__esModule?s:{default:s}}function _objectWithoutProperties(s,o){if(null==s)return{};var i,u,_=function _objectWithoutPropertiesLoose(s,o){if(null==s)return{};var i,u,_={},w=Object.keys(s);for(u=0;u=0||(_[i]=s[i]);return _}(s,o);if(Object.getOwnPropertySymbols){var w=Object.getOwnPropertySymbols(s);for(u=0;u=0||Object.prototype.propertyIsEnumerable.call(s,i)&&(_[i]=s[i])}return _}function ownKeys(s,o){var i=Object.keys(s);if(Object.getOwnPropertySymbols){var u=Object.getOwnPropertySymbols(s);o&&(u=u.filter((function(o){return Object.getOwnPropertyDescriptor(s,o).enumerable}))),i.push.apply(i,u)}return i}function _objectSpread(s){for(var o=1;o=u?i.notify(s):o.length>_.length&&i.notify(_objectSpread(_objectSpread({},s),{},{target:_objectSpread(_objectSpread({},s.target),{},{value:""})}))}))})),_defineProperty(_assertThisInitialized(i),"onKeyDown",(function(s){"Enter"===s.key&&i.forceNotify(s);var o=i.props.onKeyDown;o&&(s.persist(),o(s))})),_defineProperty(_assertThisInitialized(i),"onBlur",(function(s){i.forceNotify(s);var o=i.props.onBlur;o&&(s.persist(),o(s))})),_defineProperty(_assertThisInitialized(i),"createNotifier",(function(s){if(s<0)i.notify=function(){return null};else if(0===s)i.notify=i.doNotify;else{var o=(0,_.default)((function(s){i.isDebouncing=!1,i.doNotify(s)}),s);i.notify=function(s){i.isDebouncing=!0,o(s)},i.flush=function(){return o.flush()},i.cancel=function(){i.isDebouncing=!1,o.cancel()}}})),_defineProperty(_assertThisInitialized(i),"doNotify",(function(){i.props.onChange.apply(void 0,arguments)})),_defineProperty(_assertThisInitialized(i),"forceNotify",(function(s){var o=i.props.debounceTimeout;if(i.isDebouncing||!(o>0)){i.cancel&&i.cancel();var u=i.state.value,_=i.props.minLength;u.length>=_?i.doNotify(s):i.doNotify(_objectSpread(_objectSpread({},s),{},{target:_objectSpread(_objectSpread({},s.target),{},{value:u})}))}})),i.isDebouncing=!1,i.state={value:void 0===s.value||null===s.value?"":s.value};var u=i.props.debounceTimeout;return i.createNotifier(u),i}return function _createClass(s,o,i){return o&&_defineProperties(s.prototype,o),i&&_defineProperties(s,i),Object.defineProperty(s,"prototype",{writable:!1}),s}(DebounceInput,[{key:"componentDidUpdate",value:function componentDidUpdate(s){if(!this.isDebouncing){var o=this.props,i=o.value,u=o.debounceTimeout,_=s.debounceTimeout,w=s.value,x=this.state.value;void 0!==i&&w!==i&&x!==i&&this.setState({value:i}),u!==_&&this.createNotifier(u)}}},{key:"componentWillUnmount",value:function componentWillUnmount(){this.flush&&this.flush()}},{key:"render",value:function render(){var s,o,i=this.props,_=i.element,x=(i.onChange,i.value,i.minLength,i.debounceTimeout,i.forceNotifyByEnter),C=i.forceNotifyOnBlur,j=i.onKeyDown,L=i.onBlur,B=i.inputRef,$=_objectWithoutProperties(i,w),V=this.state.value;s=x?{onKeyDown:this.onKeyDown}:j?{onKeyDown:j}:{},o=C?{onBlur:this.onBlur}:L?{onBlur:L}:{};var U=B?{ref:B}:{};return u.default.createElement(_,_objectSpread(_objectSpread(_objectSpread(_objectSpread({},$),{},{onChange:this.onChange,value:V},s),o),U))}}]),DebounceInput}(u.default.PureComponent);o.DebounceInput=x,_defineProperty(x,"defaultProps",{element:"input",type:"text",onKeyDown:void 0,onBlur:void 0,value:void 0,minLength:0,debounceTimeout:100,forceNotifyByEnter:!0,forceNotifyOnBlur:!0,inputRef:void 0})},24677:(s,o,i)=>{"use strict";var u=i(81214).DebounceInput;u.DebounceInput=u,s.exports=u},22551:(s,o,i)=>{"use strict";var u=i(96540),_=i(69982);function p(s){for(var o="https://reactjs.org/docs/error-decoder.html?invariant="+s,i=1;i