From 1126d96271b50c284cfb75d1bc6eb44872dfcc4f Mon Sep 17 00:00:00 2001 From: Kacper Date: Tue, 8 Sep 2026 23:14:39 +0200 Subject: [PATCH] ADconsole v1.0.0 First public release. - docs: document the audit log, user offboarding, CSV export and the "require a reason for destructive actions" setting across README, docs/ and CLAUDE/AGENTS.md; note retention for panel_audit - docs: fill the publishing placeholders (repo URL, security contact, issue-template links) and add a semantic-versioning policy - move agent/contributor context into CLAUDE/ (AGENTS.md + a README); root CLAUDE.md is now a thin @-import pointer - add a favicon (shield-lock, matching the login screen) to the login page and the app layout - CHANGELOG: promote [Unreleased] to [1.0.0]; keep the earlier internal build as [0.1.0] Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01UfuvfwexztdS7LDpGuKsuK --- .dockerignore | 41 + .editorconfig | 18 + .env.example | 124 + .gitattributes | 15 + .github/ISSUE_TEMPLATE/bug_report.md | 29 + .github/ISSUE_TEMPLATE/config.yml | 9 + .github/ISSUE_TEMPLATE/feature_request.md | 28 + .github/pull_request_template.md | 19 + .github/workflows/ci.yml | 50 + .gitignore | 43 + .npmrc | 2 + CHANGELOG.md | 110 + CLAUDE.md | 8 + CLAUDE/AGENTS.md | 79 + CLAUDE/README.md | 22 + CODE_OF_CONDUCT.md | 46 + CONTRIBUTING.md | 67 + LICENSE | 21 + README.md | 202 + SECURITY.md | 53 + .../Controllers/Admin/AuditController.php | 46 + .../Controllers/Admin/ComputerController.php | 366 + .../Controllers/Admin/ConfigController.php | 143 + .../Controllers/Admin/DashboardController.php | 349 + app/Http/Controllers/Admin/GpoController.php | 379 + .../Controllers/Admin/GroupController.php | 331 + .../Controllers/Admin/OrgChartController.php | 152 + app/Http/Controllers/Admin/UserController.php | 1665 +++ app/Http/Controllers/Auth/LoginController.php | 114 + app/Http/Controllers/Controller.php | 12 + app/Http/Controllers/LocaleController.php | 25 + app/Http/Controllers/PageController.php | 20 + app/Http/Middleware/SetLocale.php | 35 + app/Ldap/Rules/MemberOfLoginGroup.php | 98 + app/Ldap/User.php | 143 + app/Models/AuditLog.php | 41 + app/Models/PanelAttribute.php | 35 + app/Models/PanelSetting.php | 30 + app/Models/User.php | 32 + app/Providers/AppServiceProvider.php | 30 + app/Services/AttributeCipher.php | 94 + app/Services/AuditLogger.php | 70 + app/Services/LdapAttributeHelper.php | 148 + app/Services/OuTreeService.php | 199 + app/Services/PanelSettingsService.php | 582 ++ artisan | 18 + bootstrap/app.php | 38 + bootstrap/cache/.gitignore | 2 + bootstrap/providers.php | 7 + composer.json | 67 + composer.lock | 8973 +++++++++++++++++ config/app.php | 126 + config/auth.php | 130 + config/cache.php | 136 + config/database.php | 184 + config/filesystems.php | 80 + config/ldap.php | 147 + config/locales.php | 18 + config/logging.php | 140 + config/mail.php | 118 + config/queue.php | 129 + config/services.php | 38 + config/session.php | 233 + database/.gitignore | 1 + database/factories/UserFactory.php | 45 + .../0001_01_01_000000_create_users_table.php | 49 + .../0001_01_01_000001_create_cache_table.php | 35 + .../0001_01_01_000002_create_jobs_table.php | 59 + ..._07_000100_create_panel_settings_table.php | 61 + ...8_000100_create_panel_attributes_table.php | 86 + ...100_add_validation_to_panel_attributes.php | 32 + ..._09_10_000100_create_panel_audit_table.php | 39 + database/seeders/DatabaseSeeder.php | 25 + docker/compose.override.yaml.template | 32 + docker/compose.yaml | 71 + docker/php/Dockerfile | 39 + docker/php/entrypoint.sh | 38 + docs/README.md | 17 + docs/active-directory.md | 118 + docs/architecture.md | 135 + docs/configuration.md | 124 + docs/deployment.md | 97 + docs/development.md | 99 + docs/installation.md | 141 + docs/publishing.md | 97 + docs/troubleshooting.md | 93 + docs/upgrading.md | 72 + lang/en/audit.php | 82 + lang/en/auth.php | 26 + lang/en/common.php | 60 + lang/en/computers.php | 91 + lang/en/config.php | 87 + lang/en/dashboard.php | 67 + lang/en/gpo.php | 65 + lang/en/groups.php | 66 + lang/en/nav.php | 37 + lang/en/org.php | 21 + lang/en/pages.php | 70 + lang/en/users.php | 252 + lang/en/validation.php | 221 + lang/pl/audit.php | 82 + lang/pl/auth.php | 26 + lang/pl/common.php | 60 + lang/pl/computers.php | 91 + lang/pl/config.php | 87 + lang/pl/dashboard.php | 67 + lang/pl/gpo.php | 65 + lang/pl/groups.php | 66 + lang/pl/nav.php | 37 + lang/pl/org.php | 21 + lang/pl/pages.php | 70 + lang/pl/users.php | 252 + lang/pl/validation.php | 204 + package.json | 19 + phpunit.xml | 36 + public/.htaccess | 25 + public/css/style.css | 718 ++ public/favicon.ico | Bin 0 -> 32038 bytes public/favicon.svg | 3 + public/index.php | 20 + public/js/main.js | 432 + public/js/shortcuts.js | 76 + public/robots.txt | 2 + resources/css/app.css | 9 + resources/js/app.js | 1 + resources/views/admin/audit/index.blade.php | 142 + .../views/admin/computers/index.blade.php | 277 + .../computers/partials/results.blade.php | 57 + resources/views/admin/config/index.blade.php | 416 + .../views/admin/dashboard/index.blade.php | 260 + resources/views/admin/gpo/index.blade.php | 151 + .../admin/gpo/partials/results.blade.php | 52 + resources/views/admin/groups/index.blade.php | 232 + .../admin/groups/partials/results.blade.php | 47 + resources/views/admin/org/index.blade.php | 168 + .../admin/partials/pagination-bar.blade.php | 23 + .../admin/partials/reason-field.blade.php | 14 + resources/views/admin/users/_modals.blade.php | 569 ++ resources/views/admin/users/index.blade.php | 534 + .../admin/users/partials/results.blade.php | 87 + resources/views/auth/login.blade.php | 109 + .../views/components/ou-options.blade.php | 8 + resources/views/components/ou-tree.blade.php | 20 + resources/views/layouts/app.blade.php | 197 + resources/views/pages/about.blade.php | 61 + resources/views/partials/footer.blade.php | 13 + .../views/vendor/pagination/buttons.blade.php | 49 + resources/views/welcome.blade.php | 223 + routes/console.php | 8 + routes/web.php | 143 + storage/app/.gitignore | 4 + storage/app/private/.gitignore | 2 + storage/app/public/.gitignore | 2 + storage/framework/.gitignore | 10 + storage/framework/cache/.gitignore | 3 + storage/framework/cache/data/.gitignore | 2 + storage/framework/sessions/.gitignore | 2 + storage/framework/testing/.gitignore | 2 + storage/framework/views/.gitignore | 2 + storage/logs/.gitignore | 2 + tests/Feature/AttributeConfigTest.php | 391 + tests/Feature/AuditLogTest.php | 57 + tests/Feature/Auth/LoginTest.php | 50 + tests/Feature/DashboardTest.php | 179 + tests/Feature/LocalizationTest.php | 53 + tests/Feature/Pages/AboutPageTest.php | 40 + tests/Feature/UserExportTest.php | 15 + tests/TestCase.php | 25 + tests/Unit/AttributeCipherTest.php | 78 + tests/Unit/AuditLoggerTest.php | 50 + tests/Unit/LdapAttributeHelperTest.php | 43 + tests/Unit/MemberOfLoginGroupTest.php | 92 + tests/Unit/OuTreeServiceTest.php | 197 + vite.config.js | 24 + 174 files changed, 27113 insertions(+) create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 CLAUDE/AGENTS.md create mode 100644 CLAUDE/README.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 app/Http/Controllers/Admin/AuditController.php create mode 100644 app/Http/Controllers/Admin/ComputerController.php create mode 100644 app/Http/Controllers/Admin/ConfigController.php create mode 100644 app/Http/Controllers/Admin/DashboardController.php create mode 100644 app/Http/Controllers/Admin/GpoController.php create mode 100644 app/Http/Controllers/Admin/GroupController.php create mode 100644 app/Http/Controllers/Admin/OrgChartController.php create mode 100644 app/Http/Controllers/Admin/UserController.php create mode 100644 app/Http/Controllers/Auth/LoginController.php create mode 100644 app/Http/Controllers/Controller.php create mode 100644 app/Http/Controllers/LocaleController.php create mode 100644 app/Http/Controllers/PageController.php create mode 100644 app/Http/Middleware/SetLocale.php create mode 100644 app/Ldap/Rules/MemberOfLoginGroup.php create mode 100644 app/Ldap/User.php create mode 100644 app/Models/AuditLog.php create mode 100644 app/Models/PanelAttribute.php create mode 100644 app/Models/PanelSetting.php create mode 100644 app/Models/User.php create mode 100644 app/Providers/AppServiceProvider.php create mode 100644 app/Services/AttributeCipher.php create mode 100644 app/Services/AuditLogger.php create mode 100644 app/Services/LdapAttributeHelper.php create mode 100644 app/Services/OuTreeService.php create mode 100644 app/Services/PanelSettingsService.php create mode 100644 artisan create mode 100644 bootstrap/app.php create mode 100644 bootstrap/cache/.gitignore create mode 100644 bootstrap/providers.php create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 config/app.php create mode 100644 config/auth.php create mode 100644 config/cache.php create mode 100644 config/database.php create mode 100644 config/filesystems.php create mode 100644 config/ldap.php create mode 100644 config/locales.php create mode 100644 config/logging.php create mode 100644 config/mail.php create mode 100644 config/queue.php create mode 100644 config/services.php create mode 100644 config/session.php create mode 100644 database/.gitignore create mode 100644 database/factories/UserFactory.php create mode 100644 database/migrations/0001_01_01_000000_create_users_table.php create mode 100644 database/migrations/0001_01_01_000001_create_cache_table.php create mode 100644 database/migrations/0001_01_01_000002_create_jobs_table.php create mode 100644 database/migrations/2026_09_07_000100_create_panel_settings_table.php create mode 100644 database/migrations/2026_09_08_000100_create_panel_attributes_table.php create mode 100644 database/migrations/2026_09_09_000100_add_validation_to_panel_attributes.php create mode 100644 database/migrations/2026_09_10_000100_create_panel_audit_table.php create mode 100644 database/seeders/DatabaseSeeder.php create mode 100644 docker/compose.override.yaml.template create mode 100644 docker/compose.yaml create mode 100644 docker/php/Dockerfile create mode 100644 docker/php/entrypoint.sh create mode 100644 docs/README.md create mode 100644 docs/active-directory.md create mode 100644 docs/architecture.md create mode 100644 docs/configuration.md create mode 100644 docs/deployment.md create mode 100644 docs/development.md create mode 100644 docs/installation.md create mode 100644 docs/publishing.md create mode 100644 docs/troubleshooting.md create mode 100644 docs/upgrading.md create mode 100644 lang/en/audit.php create mode 100644 lang/en/auth.php create mode 100644 lang/en/common.php create mode 100644 lang/en/computers.php create mode 100644 lang/en/config.php create mode 100644 lang/en/dashboard.php create mode 100644 lang/en/gpo.php create mode 100644 lang/en/groups.php create mode 100644 lang/en/nav.php create mode 100644 lang/en/org.php create mode 100644 lang/en/pages.php create mode 100644 lang/en/users.php create mode 100644 lang/en/validation.php create mode 100644 lang/pl/audit.php create mode 100644 lang/pl/auth.php create mode 100644 lang/pl/common.php create mode 100644 lang/pl/computers.php create mode 100644 lang/pl/config.php create mode 100644 lang/pl/dashboard.php create mode 100644 lang/pl/gpo.php create mode 100644 lang/pl/groups.php create mode 100644 lang/pl/nav.php create mode 100644 lang/pl/org.php create mode 100644 lang/pl/pages.php create mode 100644 lang/pl/users.php create mode 100644 lang/pl/validation.php create mode 100644 package.json create mode 100644 phpunit.xml create mode 100644 public/.htaccess create mode 100644 public/css/style.css create mode 100644 public/favicon.ico create mode 100644 public/favicon.svg create mode 100644 public/index.php create mode 100644 public/js/main.js create mode 100644 public/js/shortcuts.js create mode 100644 public/robots.txt create mode 100644 resources/css/app.css create mode 100644 resources/js/app.js create mode 100644 resources/views/admin/audit/index.blade.php create mode 100644 resources/views/admin/computers/index.blade.php create mode 100644 resources/views/admin/computers/partials/results.blade.php create mode 100644 resources/views/admin/config/index.blade.php create mode 100644 resources/views/admin/dashboard/index.blade.php create mode 100644 resources/views/admin/gpo/index.blade.php create mode 100644 resources/views/admin/gpo/partials/results.blade.php create mode 100644 resources/views/admin/groups/index.blade.php create mode 100644 resources/views/admin/groups/partials/results.blade.php create mode 100644 resources/views/admin/org/index.blade.php create mode 100644 resources/views/admin/partials/pagination-bar.blade.php create mode 100644 resources/views/admin/partials/reason-field.blade.php create mode 100644 resources/views/admin/users/_modals.blade.php create mode 100644 resources/views/admin/users/index.blade.php create mode 100644 resources/views/admin/users/partials/results.blade.php create mode 100644 resources/views/auth/login.blade.php create mode 100644 resources/views/components/ou-options.blade.php create mode 100644 resources/views/components/ou-tree.blade.php create mode 100644 resources/views/layouts/app.blade.php create mode 100644 resources/views/pages/about.blade.php create mode 100644 resources/views/partials/footer.blade.php create mode 100644 resources/views/vendor/pagination/buttons.blade.php create mode 100644 resources/views/welcome.blade.php create mode 100644 routes/console.php create mode 100644 routes/web.php create mode 100644 storage/app/.gitignore create mode 100644 storage/app/private/.gitignore create mode 100644 storage/app/public/.gitignore create mode 100644 storage/framework/.gitignore create mode 100644 storage/framework/cache/.gitignore create mode 100644 storage/framework/cache/data/.gitignore create mode 100644 storage/framework/sessions/.gitignore create mode 100644 storage/framework/testing/.gitignore create mode 100644 storage/framework/views/.gitignore create mode 100644 storage/logs/.gitignore create mode 100644 tests/Feature/AttributeConfigTest.php create mode 100644 tests/Feature/AuditLogTest.php create mode 100644 tests/Feature/Auth/LoginTest.php create mode 100644 tests/Feature/DashboardTest.php create mode 100644 tests/Feature/LocalizationTest.php create mode 100644 tests/Feature/Pages/AboutPageTest.php create mode 100644 tests/Feature/UserExportTest.php create mode 100644 tests/TestCase.php create mode 100644 tests/Unit/AttributeCipherTest.php create mode 100644 tests/Unit/AuditLoggerTest.php create mode 100644 tests/Unit/LdapAttributeHelperTest.php create mode 100644 tests/Unit/MemberOfLoginGroupTest.php create mode 100644 tests/Unit/OuTreeServiceTest.php create mode 100644 vite.config.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f1010a4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,41 @@ +# Build-context trimming for `docker build` (docker/php/Dockerfile). +# The compose stack bind-mounts the project at runtime, so the image only needs +# the entrypoint; keep the context small. + +.git +.github +.gitattributes +.gitignore +.editorconfig + +vendor +node_modules +public/build +public/hot +public/storage + +storage/app/* +!storage/app/.gitignore +!storage/app/private +!storage/app/public +storage/framework/cache/data/* +storage/framework/sessions/* +storage/framework/testing/* +storage/framework/views/* +storage/logs/* +bootstrap/cache/* +!bootstrap/cache/.gitignore + +.env +.env.* +!.env.example + +docs +tests +phpunit.xml +.phpunit.result.cache +.phpunit.cache + +*.md +docker/compose.override.yaml +docker/compose.override.yml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6df8428 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[{compose,docker-compose}.{yml,yaml}] +indent_size = 4 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..346f9ea --- /dev/null +++ b/.env.example @@ -0,0 +1,124 @@ +# ============================================================================= +# ADconsole - example environment file +# +# Copy to .env and fill in the values for your environment: +# cp .env.example .env && php artisan key:generate +# +# .env is git-ignored - never commit real hosts, service-account DNs or +# passwords. Every value below is a placeholder. +# ============================================================================= + +# --- Application ------------------------------------------------------------- +APP_NAME="ADconsole" +# Use "local" only on a developer machine (enables verbose errors / debugbar). +APP_ENV=production +APP_KEY= +APP_DEBUG=false +# Public URL the panel is served from (used for absolute links and cookies). +APP_URL=https://adconsole.example.com + +# Hostname Traefik routes to the container (docker/compose.yaml). Ignored on a +# bare-metal install. +APP_DOMAIN=adconsole.example.com + +# Interface language: pl | en. FALLBACK is used for missing translation keys. +APP_LOCALE=pl +APP_FALLBACK_LOCALE=en + +PHP_CLI_SERVER_WORKERS=4 +BCRYPT_ROUNDS=12 + +# --- Logging --------------------------------------------------------------- +LOG_CHANNEL=stack +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +# debug | info | notice | warning | error ... +LOG_LEVEL=warning + +# --- Database ------------------------------------------------------------- +# ADconsole keeps NO Active Directory data in SQL. The database only backs the +# application layer: sessions, cache, queue and the panel configuration +# (table `panel_settings`). Any Laravel-supported driver works; MariaDB/MySQL +# is the default and what docker/compose.yaml provisions. +DB_CONNECTION=mysql +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=adconsole +DB_USERNAME=adconsole +DB_PASSWORD= + +# Only read by docker/compose.yaml when it first initialises the MariaDB +# volume. Changing them later does NOT change an existing database. +DB_ROOT_PASSWORD= + +# --- Session / cache / queue -------------------------------------------- +# "file" keeps sessions on local disk (fine for a single node). Use "database" +# or "redis" when running more than one app container behind a load balancer. +SESSION_DRIVER=file +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null + +CACHE_STORE=database +QUEUE_CONNECTION=database + +# --- Active Directory: connection ------------------------------------------- +# Service account used ONLY to read/modify directory objects. Panel admins log +# in with their own domain account (see AD_LOGIN_GROUP); this account is never +# used for interactive login. +LDAP_CONNECTION=default +LDAP_LOGGING=true + +# One or more domain controllers (comma-separated, tried in order). +LDAP_HOSTS=dc01.corp.example.com,dc02.corp.example.com +LDAP_USERNAME="CN=svc-adconsole,OU=Service Accounts,DC=corp,DC=example,DC=com" +LDAP_PASSWORD= + +# 636 = LDAPS, 389 = plain / STARTTLS. +LDAP_PORT=636 +LDAP_TIMEOUT=5 + +# Transport security. Password reset and every other WRITE to AD REQUIRE an +# encrypted channel - AD rejects unicodePwd over a clear connection. +# LDAP_SSL=true -> LDAPS (port 636) [recommended] +# LDAP_TLS=true -> STARTTLS (port 389) +LDAP_SSL=true +LDAP_TLS=false +# Verify the DC certificate. Set false for a self-signed / internal-CA cert +# that the app server does not trust. +LDAP_TLS_VERIFY=false + +# Base DN for ALL modules. It MUST contain the users, computers AND groups +# branches - if those OUs are siblings, point this at the domain root, or the +# computer/group lists and their OU trees come back empty. +LDAP_BASE_DN="DC=corp,DC=example,DC=com" + +# --- Active Directory: defaults ------------------------------------------ +# OU new objects are created in when no branch is picked in the UI; also caps +# the OU tree shown in each module. +LDAP_DEFAULT_USER_OU="OU=Users,DC=corp,DC=example,DC=com" +LDAP_DEFAULT_COMPUTER_OU="OU=Computers,DC=corp,DC=example,DC=com" +LDAP_DEFAULT_GROUPS_OU="OU=Groups,DC=corp,DC=example,DC=com" + +# Domain name shown in the panel header. +AD_DOMAIN_LABEL=corp.example.com + +# --- Active Directory: who may sign in --------------------------------------- +# Members of this group may log into the panel. Full group DN (recommended) or +# a bare CN. Nested membership and the primary group are honoured. Empty = no +# one can log in. +AD_LOGIN_GROUP="CN=ADconsole Admins,OU=Groups,DC=corp,DC=example,DC=com" + +# --- Mail (optional) ----------------------------------------------------- +# ADconsole does not send mail out of the box; configure only if you add it. +MAIL_MAILER=log +MAIL_HOST=127.0.0.1 +MAIL_PORT=2525 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_FROM_ADDRESS="adconsole@example.com" +MAIL_FROM_NAME="${APP_NAME}" + +# --- Front-end build --------------------------------------------------------- +VITE_APP_NAME="${APP_NAME}" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..45a4ae4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +* text=auto eol=lf + +*.blade.php diff=html +*.css diff=css +*.html diff=html +*.md diff=markdown +*.php diff=php +*.sh text eol=lf + +# Kept out of `git archive` tarballs (does not affect clones). +/.github export-ignore +/.editorconfig export-ignore +/.styleci.yml export-ignore +/phpunit.xml export-ignore +/tests export-ignore diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..89e309e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,29 @@ +--- +name: Bug report +about: Something doesn't work as expected +labels: bug +--- + +**ADconsole version / commit:** +**PHP version:** +**Deployment:** Docker / bare metal (Apache | nginx) +**Database:** MariaDB | MySQL | PostgreSQL | SQLite + +## What happens + + + +## Expected + +## Steps to reproduce + +1. +2. +3. + +## Checked + +- [ ] `docs/troubleshooting.md` +- [ ] `LDAP_BASE_DN` covers users, computers and groups +- [ ] `php artisan optimize:clear` after the last `.env` change diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..8be1483 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,9 @@ +# Contact links shown above the "New issue" templates. +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://gitea.kzbikowski.pl/kzbkowski/ADconsle/src/branch/main/SECURITY.md + about: Do not file security issues publicly — email the maintainer (see SECURITY.md). + - name: Question / help + url: https://gitea.kzbikowski.pl/kzbkowski/ADconsle/issues + about: Deployment and configuration questions — search existing issues first. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..c7580e1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,28 @@ +--- +name: Feature request +about: Suggest an improvement +labels: enhancement +--- + +## Problem + + + +## Proposed solution + + + +## Directory impact + +- [ ] Needs a new AD read/write the service account isn't normally delegated + (which right? see docs/active-directory.md) +- [ ] Read-only + +## Alternatives considered + + + +## Scope check + +- [ ] This is *not* GPO content editing (SYSVOL) or a general LDAP browser — + those are explicit non-goals. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..bc72783 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,19 @@ + + +## What & why + + + +## Impact + +- [ ] New or changed **AD operation** — documented the required right in `docs/active-directory.md` +- [ ] **Database migration** included +- [ ] New **`.env` key** — added to `.env.example` and `docs/configuration.md` +- [ ] New user-facing text — added to **both** `lang/pl` and `lang/en` +- [ ] `CHANGELOG.md` updated (Unreleased) + +## Checks + +- [ ] `php artisan test` green (`DEPR` in the summary is fine) +- [ ] `vendor/bin/pint --test` green +- [ ] Tested manually against a domain controller / with the fake guard diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..66f8fc8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + tests: + name: PHP ${{ matrix.php }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.2', '8.3', '8.4'] + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: ldap, pdo_sqlite, mbstring, curl, zip, openssl + coverage: none + tools: composer:v2 + + - name: Cache Composer packages + uses: actions/cache@v4 + with: + path: ~/.composer/cache + key: composer-${{ matrix.php }}-${{ hashFiles('composer.lock') }} + restore-keys: composer-${{ matrix.php }}- + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Prepare environment + run: | + cp .env.example .env + php artisan key:generate + + - name: Code style (Pint) + run: vendor/bin/pint --test + + - name: Tests + run: php artisan test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f7e87be --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +*.log +.DS_Store +.env +.env.backup +.env.production +.env.*.local +.phpactor.json +.phpunit.result.cache +/.codex +/.cursor/ +/.idea +/.nova +/.phpunit.cache +/.vscode +/.zed +/auth.json +/node_modules +/public/build +/public/fonts-manifest.dev.json +/public/hot +/public/storage +/storage/*.key +/storage/pail +/vendor +_ide_helper.php +_ide_helper_models.php +.phpstorm.meta.php +Homestead.json +Homestead.yaml +Thumbs.db + +# Local Docker overrides (ports, extra services) - not for distribution. +/docker/compose.override.yaml +/docker/compose.override.yml + +# AI assistant / editor local state. +/.claude +/.aider* + +# Legacy panel config file, replaced by the `panel_settings` table. A leftover +# copy (or its *.migrated rename) must never be committed. +/storage/app/panel_settings.json +/storage/app/panel_settings.json.migrated diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..495a6af --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +ignore-scripts=true +audit=true diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6de5ec6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,110 @@ +# Changelog + +All notable changes to ADconsole. Format follows +[Keep a Changelog](https://keepachangelog.com/). + +Versioning (from `v1.0.0` on) is [semantic](https://semver.org/): **MAJOR** for +incompatible changes to configuration, the database schema or AD-permission +requirements; **MINOR** for backwards-compatible features; **PATCH** for +backwards-compatible fixes. Each release is a git tag `vX.Y.Z`; the latest tag on +`main` is the supported version. Upgrade notes live in +[`docs/upgrading.md`](docs/upgrading.md). + +## [Unreleased] + +_Nothing yet._ + +## [1.0.0] - 2026-09-08 + +First public release. Consolidates the prior internal builds (`0.1.0`) into a +tagged, documented release published at +. + +### Added +- **Audit log** (`/audit`, `AuditController`): read-only, filterable record of + every change the panel makes to AD — actor, action slug, target, optional + reason, a `changes` diff and a `context` blob. New `panel_audit` table, written + only through `App\Services\AuditLogger` (best-effort; a log failure never + aborts the AD operation). Viewing a LAPS / BitLocker secret is audited too. +- **User offboarding** (`/users/{sam}/offboard` + `/users/bulk/offboard`): + one action that disables the account and randomises its password, strips group + memberships (optionally keeping the primary group), moves the object to a + chosen OU and sets an expiry date + description note — run in a safe order + (attribute changes → group removal → OU move last, since the move changes the + DN). +- **Settings → Audit log**: a *"require a reason for destructive actions"* + switch (`panel_settings` → `audit.require_reason`) that makes the reason field + mandatory for disable / delete / move / offboard. +- **Users**: CSV export of the current (filtered) list — `GET /users/export`, + streamed, UTF-8 with a BOM for Excel, one column per configured attribute. +- **Dashboard**: tiles are now links to the matching filtered user list; + problem tiles (locked / expired password / expired account) are highlighted + when non-zero; relative "recalculated N ago" indicator that turns amber when + the cache is stale; global empty state when AD returns nothing; password-age + histogram (`<30 / 30–60 / 60–90 / >90 days`). +- **Users**: `filter=` query parameter for the dashboard drill-downs + (`locked`, `pwd_never`, `pwd_expiring`, `pwd_expired`, `inactive`, + `account_expired`) with a clearable filter chip. +- Collapsible OU-tree sidebar (narrow rail with hover-to-expand, or pinned), + remembered per browser. +- `LDAP_SSL`, `LDAP_TLS_VERIFY`, `APP_DOMAIN` environment keys. +- Docker: self-bootstrapping entrypoint (deps, key, migrate, cache), + `docker/compose.override.yaml.template` for running without Traefik, opcache + in the image, `.dockerignore`. +- Full `docs/` set (installation, configuration, Active Directory, architecture, + deployment, development, troubleshooting, upgrading, publishing), plus + `LICENSE`, `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`, GitHub + issue/PR templates, a CI workflow (Pint + PHPUnit on PHP 8.2–8.4), and this + changelog. + +### Changed +- **URL segments are now English** (route *names* were already): `/dashboard`, + `/users`, `/computers`, `/groups`, `/organisation`, `/gpo`, `/settings`, + `/login`, `/logout`, `/language/{locale}`, `/about`; sub-paths too + (`/users/search`, `/users/bulk/…`, `/users/{sam}/reset-password`, + `/computers/{cn}/reset-trust`, `/groups/{cn}/members`, `/settings/attributes`, + …). Bookmarks to the old Polish paths (`/pulpit`, `/uzytkownicy`, …) now 404. + Blade `route()` calls were unaffected; hardcoded fetch/`url()` paths in the + module JS (`/users/{sam}/template`, `.../photo`, `.../groups`, + `/groups/{cn}/members`, single-action templates in the bulk modals) and the + settings-page tab anchors (`#attributes`) were updated to match. +- **`manager` attribute** relabelled to *Kierownik*; in the user edit dialog it + is now a name search only — the raw DN field is hidden (still submitted), with + a clear button to unset the manager. +- **Panel configuration moved out of `storage/app/panel_settings.json` into the + database:** + - general settings (password policy & generator, reset-dialog defaults, + encryption key) → `panel_settings` table (`App\Models\PanelSetting`); + - AD attribute / column definitions → dedicated `panel_attributes` table with + real columns (`key`, `label`, `ldap`, `category`, `type`, `is_column`, + `position`) and one row per attribute (`App\Models\PanelAttribute`). + Two automatic migrations: the first imports the legacy JSON file (renaming it + `*.migrated`), the second moves the attribute list into its own table and + drops the now-unused `attributes` / `domains` / `users_table` keys from the + `panel_settings` row. `PanelSettingsService` is still the only entry point. +- Dashboard aggregation streams every account with `chunk()` instead of one + `paginate(500)` page — lower memory, and unambiguously counts the whole + domain. +- Search inputs, the "search" buttons and the account-status filter restyled as + borderless / outline controls; the navbar search matches. Removed the static + "Domain: …" label from the navbar. +- `config/ldap.php`: `LDAP_SSL` (LDAPS) / `LDAP_TLS` (STARTTLS) now map to the + correct LdapRecord v4 options, plus a `LDAP_TLS_VERIFY` cert-check switch and + tidied comments. +- README rewritten; `.env.example` fully documented with production-safe + defaults (`APP_ENV=production`, `APP_DEBUG=false`). +- `composer.json` package renamed to `adconsole/adconsole`; added `test` / + `lint` / `format` scripts. + +### Fixed +- Dashboard report links used a dead `scope=all` parameter (the list reads + `all_ou`), so drilling into a user from another OU found nothing. + +## [0.1.0] - internal + +Initial internal release: users, computers, groups, read-only GPO, org chart, +dashboard, panel configuration, PL/EN UI, LDAP-group login. Never tagged or +published; superseded by `1.0.0`. + +[Unreleased]: https://gitea.kzbikowski.pl/kzbkowski/ADconsle/compare/v1.0.0...HEAD +[1.0.0]: https://gitea.kzbikowski.pl/kzbkowski/ADconsle/releases/tag/v1.0.0 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c392592 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,8 @@ +# CLAUDE.md + +Project context for AI coding agents and new contributors lives in +**[`CLAUDE/AGENTS.md`](CLAUDE/AGENTS.md)** — read it first. See +[`CLAUDE/README.md`](CLAUDE/README.md) for what else is in that folder. Deeper +human-facing docs are under [`docs/`](docs/). + +@CLAUDE/AGENTS.md diff --git a/CLAUDE/AGENTS.md b/CLAUDE/AGENTS.md new file mode 100644 index 0000000..923aac8 --- /dev/null +++ b/CLAUDE/AGENTS.md @@ -0,0 +1,79 @@ +# AGENTS.md — working on ADconsole + +Context for AI coding agents and new contributors. Keep it accurate when you +change the architecture. + +## What this is + +A Laravel 11 + [LdapRecord](https://ldaprecord.com/) web panel that manages an +Active Directory domain: users, computers, groups and **read-only** GPO, plus a +dashboard and an org chart. PHP 8.2+. Interface is PL/EN. + +## Golden rules + +1. **No SQL storage for AD objects.** Every read/write of a user, computer or + group goes through LdapRecord straight to a domain controller. The database + (`panel_settings` + `panel_attributes` + `panel_audit`, plus Laravel's + `sessions` / `cache` / `jobs`) backs the application layer only. `panel_audit` + records *what the panel did* to AD — it is not a mirror of AD state. +2. **Auth is the `ldap` guard** (LdapRecord plain-bind, no local accounts). + Only members of `AD_LOGIN_GROUP` may sign in + (`App\Ldap\Rules\MemberOfLoginGroup`, honours nested + primary group). Every + panel route is behind `auth:ldap`; the admin profile lives in the session + key `ad_admin`. +3. **i18n everywhere.** User-facing strings come from `lang/{pl,en}/*.php` via + `__('namespace.key')`. Never hard-code copy in Blade/PHP. Add a key to both + locales. Default locale is `pl`; `config/locales.php` is the single source of + the available-language list. +4. **Some `lang/pl/*` values are asserted verbatim by tests** (e.g. + `tests/Feature/AttributeConfigTest`). Changing an existing PL string can + break a test — check before editing. +5. **Docroot is `public/`.** Do not add `.htaccess` deny rules elsewhere as a + substitute. + +## Layout + +| Path | Role | +|---|---| +| `app/Http/Controllers/Admin/*` | one controller per module (Users, Computers, Groups, Gpo, OrgChart, Dashboard, Audit, Config) | +| `app/Ldap/User.php` | AD user model + attribute map + `toPanelProfile()` | +| `app/Ldap/Rules/MemberOfLoginGroup.php` | login authorisation rule | +| `app/Services/` | `PanelSettingsService` (config, DB-backed), `AttributeCipher`, `LdapAttributeHelper` (UAC bits, FileTime), `OuTreeService` (cached OU tree), `AuditLogger` (writes `panel_audit`; never throws — a log failure is only `report()`ed) | +| `app/Models/PanelSetting.php` | key→JSON row: general panel config (`panel_settings`) | +| `app/Models/PanelAttribute.php` | one row per configured AD attribute (`panel_attributes`, ordered by `position`) | +| `app/Models/AuditLog.php` | one row per audited panel action (`panel_audit`); written only via `AuditLogger`, read only by `AuditController` | +| `config/ldap.php` | connection, `auth.login_group`, default OUs, attribute map | +| `resources/views/admin/*` | Blade views; shared bits in `components/` + `partials/` | +| `public/css/style.css`, `public/js/main.js` | hand-written assets loaded directly (no Vite build needed) | +| `routes/web.php` | all routes; English URL segments and route names | + +## Configuration + +- **Deployment config** → `.env` (see `.env.example` for every key). Read-only + at runtime; nothing writes back to it. +- **In-app config** (`/settings`) → via `PanelSettingsService`: + - general settings (password policy / generator, reset-dialog defaults, + attribute-encryption key, `audit.require_reason`) → one JSON row in + `panel_settings`; + - AD attribute / column definitions → one row each in `panel_attributes` + (columns: `key`, `label`, `ldap`, `category`, `type`, `is_column`, + `position`). + Both fall back to `PanelSettingsService::defaults()` / + `defaultAttributes()` when empty. + +## Running things + +```sh +composer install +cp .env.example .env && php artisan key:generate +php artisan migrate +php artisan test # SQLite :memory:, ~40 tests +vendor/bin/pint # code style +``` + +`php artisan test` prints a harmless PHP 8.5 `PDO::MYSQL_ATTR_SSL_CA` +deprecation that PHPUnit flags on every test as `DEPR` — that is **not** a +failure. + +See `docs/` for installation, AD setup, deployment, architecture and +troubleshooting. diff --git a/CLAUDE/README.md b/CLAUDE/README.md new file mode 100644 index 0000000..8c77f44 --- /dev/null +++ b/CLAUDE/README.md @@ -0,0 +1,22 @@ +# `CLAUDE/` — AI agent & contributor context + +This folder holds the machine- and newcomer-oriented context for the project, +kept out of the repo root to keep it tidy. + +| File | What it is | +|---|---| +| [`AGENTS.md`](AGENTS.md) | The architecture, golden rules, layout and dev loop in one page. The single source of truth for "how this project is built and why". | + +The root [`../CLAUDE.md`](../CLAUDE.md) is a thin pointer that `@`-imports +`AGENTS.md` so Claude Code / Cursor / other agents still auto-load the context +from the repository root. + +Human-facing documentation (installation, configuration, deployment, +troubleshooting, …) lives in [`../docs/`](../docs/). Contribution rules are in +[`../CONTRIBUTING.md`](../CONTRIBUTING.md). + +## Keeping it current + +Update `AGENTS.md` whenever you change the architecture, the golden rules, the +top-level layout, or the commands in the dev loop. If you rename or move it, +update the `@`-import in `../CLAUDE.md` and the links in `../CONTRIBUTING.md`. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..67088bc --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,46 @@ +# Code of Conduct + +## Our pledge + +We as members, contributors and maintainers pledge to make participation in the +ADconsole project a harassment-free experience for everyone, regardless of age, +body size, visible or invisible disability, ethnicity, sex characteristics, +gender identity and expression, level of experience, education, socio-economic +status, nationality, personal appearance, race, religion, or sexual identity +and orientation. + +## Our standards + +Examples of behaviour that contributes to a positive environment: + +- Being respectful of differing opinions, viewpoints and experiences +- Giving and gracefully accepting constructive feedback +- Focusing on what is best for the project and its users +- Showing empathy towards other community members + +Examples of unacceptable behaviour: + +- Sexualised language or imagery, and unwelcome sexual attention +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information without explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement + +Instances of abusive, harassing or otherwise unacceptable behaviour may be +reported to the project maintainer through the channel listed in +[`SECURITY.md`](SECURITY.md) (mark it "conduct"). All complaints will be +reviewed and investigated promptly and fairly. Maintainers are obligated to +respect the privacy and security of the reporter. + +Maintainers may remove, edit or reject contributions that violate this Code of +Conduct, and may temporarily or permanently ban any contributor for behaviours +they deem inappropriate, threatening, offensive or harmful. + +## Attribution + +Adapted from the [Contributor Covenant][cc], version 2.1. + +[cc]: https://www.contributor-covenant.org/version/2/1/code_of_conduct/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ce01aa0 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,67 @@ +# Contributing to ADconsole + +Thanks for helping out. This is a small, focused project — a web UI for everyday +Active Directory administration. Contributions that keep it small and sharp are +very welcome. + +## Before you start + +- Read [`CLAUDE/AGENTS.md`](CLAUDE/AGENTS.md) (the architecture in one page) and + [`docs/architecture.md`](docs/architecture.md). +- By participating you agree to the [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md). +- For anything non-trivial, open an issue first so we can agree on the approach. + +## Ground rules + +1. **No SQL storage for AD objects.** All directory reads/writes go through + LdapRecord. The database is for app infrastructure and `panel_settings` only. +2. **Every user-facing string is a translation key** in *both* + `lang/pl/*` and `lang/en/*`. No inline copy. +3. **Keep it dependency-light.** No new runtime Composer/npm package without a + clear reason in the issue. +4. **Least privilege.** New AD operations must be documented in + [`docs/active-directory.md`](docs/active-directory.md) with the exact right + they require. +5. **Security-sensitive by nature** — think about what a hostile panel user or a + compromised service account could do with your change. + +## Dev loop + +```sh +composer install +cp .env.example .env && php artisan key:generate +php artisan migrate +php artisan test +vendor/bin/pint +``` + +See [`docs/development.md`](docs/development.md) for the details (SQLite for dev, +the fake `ldap` guard in tests, the harmless `DEPR` tag, etc.). + +## Pull requests + +- Branch from `main`; keep the PR focused on one thing. +- Include tests for logic changes. Feature tests may seed caches / fake the + guard rather than hitting a real DC (see `tests/Feature/DashboardTest.php`). +- Run `php artisan test` and `vendor/bin/pint --test` — both must be green + (`DEPR` in the test summary is fine; `failed` is not). +- Update the relevant `docs/` page and add a `CHANGELOG.md` entry under + *Unreleased*. +- Describe the change, the motivation, and any AD permission / migration impact + in the PR body. + +## Reporting bugs + +Open an issue with: ADconsole version / commit, PHP version, how it's deployed +(Docker / bare metal), the exact error (with `APP_DEBUG=true` and the relevant +`storage/logs/laravel.log` lines — **redact DNs, hostnames and account names**), +and steps to reproduce. + +## Security issues + +**Do not** open a public issue — see [`SECURITY.md`](SECURITY.md). + +## Licence + +By contributing you agree your work is released under the project's +[MIT Licence](LICENSE). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f33e800 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Kacper Żbikowski + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5601fee --- /dev/null +++ b/README.md @@ -0,0 +1,202 @@ +# ADconsole + +**A self-hosted web panel for day-to-day Active Directory administration — +users, computers, groups and read-only Group Policy — built on Laravel 11 and +[LdapRecord](https://ldaprecord.com/).** + +[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) +![PHP 8.2+](https://img.shields.io/badge/php-8.2%2B-777bb4.svg) +![Laravel 11](https://img.shields.io/badge/laravel-11-ff2d20.svg) + +ADconsole gives helpdesk and sysadmin staff a clean UI for the tasks that +otherwise mean RSAT, `dsa.msc` or PowerShell: reset a password, unlock an +account, move an object between OUs, add someone to a group, check who has a +stale password. It talks **directly to a domain controller** over LDAP — there +is no database copy of your directory to drift out of sync. + +- **No AD data at rest.** Every read and write goes straight to a DC through + LdapRecord. The SQL database only holds the app's own sessions, cache and + panel settings. +- **Domain login, group-gated.** Admins sign in with their own AD account; + access is limited to members of one AD group you nominate. +- **Bilingual UI** — Polish and English, switchable per session. +- **Deploys in minutes** — a `docker compose up` stack, or drop it on any + PHP 8.2+ host. + +> ⚠️ ADconsole performs privileged directory operations (password resets, +> account enable/disable, deletions). Read [`SECURITY.md`](SECURITY.md) and the +> [hardening checklist](docs/deployment.md#hardening-checklist) before exposing it. + +--- + +## Features + +| Module | What it does | +|---|---| +| **Dashboard** (`/dashboard`) | Domain health tiles (disabled / locked / stale-password / inactive / expired accounts, object counts), password-age histogram, "expiring soon / inactive / newly created" mini-reports. Tiles link straight to the matching filtered list. Cached, recalculated on demand. | +| **Users** (`/users`) | OU tree + full-text search, create, view/edit (configurable attribute set), password reset (single **and** bulk), move OU (single and bulk), password-expiry policy, enable/disable, group membership, photo, CSV export of the current (filtered) list, and a one-click **offboarding** flow (disable + randomise password + strip group memberships + move OU + set expiry, in a safe order). | +| **Computers** (`/computers`) | OU tree, OS filter, LAPS (legacy `ms-Mcs-AdmPwd` + Windows LAPS) and BitLocker recovery keys, reset trust account, enable/disable the object, delete. | +| **Groups** (`/groups`) | Type/scope filter, create, edit description & mail, manage members, delete. | +| **Org chart** (`/organisation`) | `manager` → `directReports` hierarchy rendered with d3-org-chart. | +| **GPO** (`/gpo`) | **Read-only** list of Group Policy objects and their OU links, plus a simplified RSoP lookup. Editing policy content (SYSVOL) is out of scope. | +| **Audit log** (`/audit`) | **Read-only** record of every change the panel makes to AD — actor, action, target, optional reason and a before/after diff — filterable by admin, action, object and date. Stored in the `panel_audit` table (application data, never AD). | +| **Configuration** (`/settings`) | Password policy & generator, attribute-encryption key, an optional "require a reason for destructive actions" switch, and the definition of which AD attributes show up as list columns / profile fields. Stored in the `panel_settings` / `panel_attributes` tables. | + +Full walkthrough: [`docs/architecture.md`](docs/architecture.md). + +## Requirements + +- **PHP 8.2+** with `ldap`, `pdo_mysql` (or your DB driver), `mbstring`, `curl`, + `zip`, `openssl` +- **Composer 2** +- A SQL database (MariaDB/MySQL recommended; SQLite/PostgreSQL work too) — for + sessions, cache and panel settings only +- Network reachability to a **domain controller**, ideally over **LDAPS (636)** + or STARTTLS — password writes fail on an unencrypted connection +- An **AD service account** that can read your directory and modify the objects + you want to manage (see [`docs/active-directory.md`](docs/active-directory.md)) +- For the container image: Docker + Docker Compose v2 + +## Quick start — Docker + +```sh +git clone https://gitea.kzbikowski.pl/kzbkowski/ADconsle.git adconsole && cd adconsole +cp .env.example .env +# edit .env: APP_URL/APP_DOMAIN, DB_* passwords, and the whole LDAP_* / AD_* block + +# With an external Traefik proxy (see docker/compose.yaml): +docker compose -f docker/compose.yaml up -d --build + +# Or without a proxy — publish a local port instead: +cp docker/compose.override.yaml.template docker/compose.override.yaml +docker compose -f docker/compose.yaml -f docker/compose.override.yaml up -d --build +# -> http://localhost:8080 (set APP_URL to match) +``` + +The container entrypoint installs dependencies, generates `APP_KEY`, runs +migrations and warms the caches on first boot. Details and production notes: +[`docs/deployment.md`](docs/deployment.md). + +## Quick start — bare metal + +```sh +git clone https://gitea.kzbikowski.pl/kzbkowski/ADconsle.git adconsole && cd adconsole +composer install --no-dev --optimize-autoloader +cp .env.example .env +php artisan key:generate +# edit .env (LDAP_* / AD_* / DB_*) + +php artisan migrate +php artisan config:cache && php artisan route:cache && php artisan view:cache + +# point your web server's document root at ./public +# (dev only:) php artisan serve +``` + +Step-by-step, including Apache/nginx vhosts: [`docs/installation.md`](docs/installation.md). + +## Configuration at a glance + +| Where | What | How | +|---|---|---| +| `.env` | Connection & deployment settings: DC hosts, service account, base DN, default OUs, login group, DB, app URL. | Edit the file, then `php artisan config:cache`. Every key is documented in [`.env.example`](.env.example) and [`docs/configuration.md`](docs/configuration.md). | +| Database (`/settings`) | In-app settings: password policy/generator + encryption key (`panel_settings`), and the AD attribute / column definitions (`panel_attributes`). | Edited in the UI; persisted via `App\Services\PanelSettingsService`. | + +Who can log in is `AD_LOGIN_GROUP`. What the service account may change is set by +**AD delegation**, not by ADconsole — see [`docs/active-directory.md`](docs/active-directory.md). + +## How it works (short version) + +- `config/ldap.php` defines one connection from `LDAP_*`. `App\Ldap\User` + extends the LdapRecord AD user model. +- Login = LdapRecord plain-bind with the typed credentials; then + `App\Ldap\Rules\MemberOfLoginGroup` checks membership of `AD_LOGIN_GROUP` + (nested + primary group aware). Success stores `ad_admin` in the session; all + `/…` panel routes sit behind `auth:ldap`. +- Controllers query/modify AD live. Heavy reads (OU trees, dashboard, org chart) + are cached on the `file` store for a few minutes with a manual "recalculate" + button. +- `LdapAttributeHelper` decodes the awkward bits (userAccountControl bit flags, + Windows FileTime timestamps, `accountExpires` "never" sentinels). +- Sensitive attributes (e.g. cloud extension attributes) can be stored encrypted + with a key set in the panel — `App\Services\AttributeCipher`. + +More: [`docs/architecture.md`](docs/architecture.md). + +## Tests + +```sh +php artisan test # ~40 tests, SQLite :memory: +vendor/bin/pint --test # code style +``` + +A PHP 8.5 `PDO::MYSQL_ATTR_SSL_CA` deprecation makes PHPUnit tag every test +`DEPR` — that is noise, not a failure. + +## Project layout + +``` +app/ + Http/Controllers/Admin/ one controller per module + Ldap/ AD user model + login rule + Services/ PanelSettings, AttributeCipher, LdapAttributeHelper, OuTree + Models/ PanelSetting (JSON config row) + PanelAttribute (one row per attribute) +config/ldap.php connection, login group, default OUs, attribute map +resources/views/admin/ Blade UI (+ components/ + partials/) +public/css/style.css hand-written styles (no build step) +public/js/main.js hand-written JS +routes/web.php English URL segments and route names +lang/{pl,en}/ structured translation keys +docs/ installation / AD / deployment / architecture / … +docker/ compose stack + PHP image +``` + +## Documentation + +Everything lives in [`docs/`](docs/): +[installation](docs/installation.md) · +[configuration](docs/configuration.md) · +[Active Directory setup](docs/active-directory.md) · +[architecture](docs/architecture.md) · +[deployment](docs/deployment.md) · +[development](docs/development.md) · +[troubleshooting](docs/troubleshooting.md) · +[upgrading](docs/upgrading.md) · +[publishing](docs/publishing.md). + +## Roadmap / non-goals + +Planned: server-side password-policy validation before write; optional TOTP for +off-LAN login; export of the audit log. + +Out of scope: editing GPO **content** (SYSVOL/SMB); being a general LDAP browser; +managing non-AD directories. + +## License & legal + +Released under the [MIT License](LICENSE) — provided "as is", without warranty. +`Copyright (c) 2026 Kacper Żbikowski`. + +ADconsole is built on the [Laravel framework](https://laravel.com) (MIT) and the +[laravel/laravel](https://github.com/laravel/laravel) application skeleton (MIT); +the skeleton's MIT terms are satisfied by this repository's own +[`LICENSE`](LICENSE). Third-party PHP and JavaScript dependencies are pulled in +at build time by Composer / a CDN and are **not** vendored in this repository; +each keeps its own licence (all permissive — MIT / BSD / Apache-2.0). Run +`composer licenses` after `composer install` for the authoritative list. + +*Active Directory, Windows, Windows Server and Group Policy are trademarks of +Microsoft Corporation. ADconsole is an independent project and is not affiliated +with, endorsed or sponsored by Microsoft.* + +When you deploy ADconsole your organisation is the **data controller** for the +personal data it reads from AD (names, logins, e-mail, phone, photo, …). You are +responsible for the lawful basis, retention and data-subject rights; restrict +panel access to authorised admins and always use LDAPS/STARTTLS. See the +in-app **About → Legal** page and [`SECURITY.md`](SECURITY.md). + +## Contributing + +Issues and PRs welcome — see [`CONTRIBUTING.md`](CONTRIBUTING.md) and the +[`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md). Security reports: +[`SECURITY.md`](SECURITY.md). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b7384f0 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,53 @@ +# Security policy + +## Reporting a vulnerability + +**Please do not open a public issue for security problems.** + +Email the maintainer at **`mail@kzbikowski.pl`** with the subject prefixed +`[ADconsole security]`. If the host offers private vulnerability reporting, that +channel is fine too. + +Please include: + +- affected version / commit +- a description and impact assessment +- reproduction steps or a proof of concept +- any suggested fix + +You'll get an acknowledgement within a few working days. Please give a +reasonable window for a fix before public disclosure; we'll credit you in the +release notes unless you'd rather stay anonymous. + +## Scope + +In scope: authentication and the `AD_LOGIN_GROUP` gate, authorisation between +modules, LDAP query/filter construction (injection), handling of the service +account credentials, the attribute-encryption feature, session handling, CSRF, +XSS in the Blade views, privilege escalation via the bulk actions. + +Out of scope: issues that require a already-privileged panel account performing +actions it is legitimately delegated to do in AD; missing hardening that is the +operator's responsibility (see below); vulnerabilities in Laravel, LdapRecord or +other dependencies (report those upstream — do tell us so we can pin/patch). + +## Operator responsibilities + +ADconsole is a privileged tool. When you deploy it, **you** are responsible for: + +- Serving it over HTTPS only, reachable from admin networks / VPN only. +- `APP_DEBUG=false`, `APP_ENV=production`. +- An encrypted LDAP channel (`LDAP_SSL` / `LDAP_TLS`) and, ideally, + `LDAP_TLS_VERIFY=true`. +- Delegating the service account **only** the AD rights you actually use + ([docs/active-directory.md](docs/active-directory.md)). +- Keeping `AD_LOGIN_GROUP` small and audited. +- Protecting `.env` (file mode `600`) — it contains the service-account password. +- Applying updates promptly. + +See the [hardening checklist](docs/deployment.md#hardening-checklist). + +## Supported versions + +The latest release on `main` receives security fixes. This is a +community/self-hosted project with no long-term-support branches. diff --git a/app/Http/Controllers/Admin/AuditController.php b/app/Http/Controllers/Admin/AuditController.php new file mode 100644 index 0000000..6562c9c --- /dev/null +++ b/app/Http/Controllers/Admin/AuditController.php @@ -0,0 +1,46 @@ +query('actor', '')); + $action = trim((string) $request->query('action', '')); + $q = trim((string) $request->query('q', '')); + $from = $request->date('from'); + $to = $request->date('to'); + $perPage = max(10, min(200, (int) $request->query('per_page', 25))); + + $entries = AuditLog::query() + ->when($actor !== '', fn ($b) => $b->where('actor', 'like', "%{$actor}%")) + ->when($action !== '', fn ($b) => $b->where('action', $action)) + ->when($q !== '', fn ($b) => $b->where(function ($b) use ($q) { + $b->where('target', 'like', "%{$q}%")->orWhere('target_dn', 'like', "%{$q}%"); + })) + ->when($from, fn ($b) => $b->where('created_at', '>=', $from->startOfDay())) + ->when($to, fn ($b) => $b->where('created_at', '<=', $to->endOfDay())) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->paginate($perPage) + ->withQueryString(); + + return view('admin.audit.index', [ + 'entries' => $entries, + 'actions' => AuditLog::query()->select('action')->distinct()->orderBy('action')->pluck('action'), + 'filters' => compact('actor', 'action', 'q') + [ + 'from' => $request->query('from'), + 'to' => $request->query('to'), + ], + ]); + } +} diff --git a/app/Http/Controllers/Admin/ComputerController.php b/app/Http/Controllers/Admin/ComputerController.php new file mode 100644 index 0000000..56de298 --- /dev/null +++ b/app/Http/Controllers/Admin/ComputerController.php @@ -0,0 +1,366 @@ +boolean('refresh_ou')) { + OuTreeService::flush(); + } + + $allOu = $request->boolean('all_ou'); + $ouDn = $request->query('ou') ?: ($allOu ? null : $ouTree->rootDn('computers')); + + $search = trim((string) $request->query('q', '')); + $os = $request->query('os'); + $inactiveOnly = $request->boolean('inactive'); + $perPage = max(10, min(200, (int) $request->query('per_page', 25))); + + $query = Computer::query(); + + if ($ouDn) { + $query->in($ouDn)->recursive(); + } + + if ($search !== '') { + $searchAttrs = ['cn', 'dnshostname', 'operatingsystem', 'operatingsystemversion', 'description', 'samaccountname']; + $query->orFilter(function ($q) use ($searchAttrs, $search) { + foreach ($searchAttrs as $attr) { + $q->whereContains($attr, $search); + } + }); + } + + if ($os) { + $query->whereContains('operatingsystem', $os); + } + + // 1. Pobieramy dopasowane komputery z LDAP (paged results control). + $allComputers = $query + ->select(['cn', 'dnshostname', 'operatingsystem', 'operatingsystemversion', 'description', 'distinguishedname', 'useraccountcontrol', 'lastlogontimestamp']) + ->orderBy('cn') + ->paginate(500); + + // 2. Mapujemy obiekty LdapRecord do formatu tabeli + $allRows = collect($allComputers)->map(fn (Computer $c) => $this->presentComputer($c)); + + // 2a. Filtr "nieaktywne >= 90 dni" (z kafelka pulpitu) - liczony w PHP. + if ($inactiveOnly) { + $cut = now()->subDays(90); + $allRows = $allRows->filter( + fn (array $r) => ! $r['disabled'] && (! $r['last_logon'] || $r['last_logon']->lt($cut)) + )->values(); + } + + // 3. Budujemy natywny LengthAwarePaginator dla Laravela + $page = LengthAwarePaginator::resolveCurrentPage(); + $total = $allRows->count(); + + $computers = new LengthAwarePaginator( + $allRows->forPage($page, $perPage)->values(), + $total, + $perPage, + $page, + ['path' => LengthAwarePaginator::resolveCurrentPath()] + ); + + $computers->appends(collect($request->query())->except(['partial', 'page'])->all()); + + $viewData = [ + 'currentOu' => $ouDn, + 'allOu' => $allOu, + 'search' => $search, + 'osFilter' => $os, + 'inactiveOnly' => $inactiveOnly, + 'computers' => $computers, + 'rows' => collect($computers->items()), + ]; + + if ($request->boolean('partial')) { + return view('admin.computers.partials.results', $viewData); + } + + return view('admin.computers.index', $viewData + [ + 'ouTree' => $ouTree->tree('computers'), + 'requireReason' => app(PanelSettingsService::class)->requireReason(), + ]); + } + + /** + * Włącza / wyłącza obiekt komputera w AD. + */ + /** + * Szczegóły komputera: LAPS (hasło lokalnego administratora, oba warianty) + * oraz klucze odzyskiwania BitLocker (obiekty msFVE-RecoveryInformation). + */ + public function show(string $cn): JsonResponse + { + $computer = Computer::query() + ->select([ + 'cn', 'dnshostname', 'operatingsystem', 'operatingsystemversion', + 'distinguishedname', 'useraccountcontrol', 'lastlogontimestamp', 'whencreated', 'description', + 'ms-Mcs-AdmPwd', 'ms-Mcs-AdmPwdExpirationTime', + 'msLAPS-Password', 'msLAPS-EncryptedPassword', 'msLAPS-PasswordExpirationTime', + ]) + ->where('cn', '=', $cn) + ->first(); + + if (! $computer) { + throw new ModelNotFoundException("Nie znaleziono komputera: {$cn}"); + } + + // LAPS - wariant klasyczny (ms-Mcs-AdmPwd) i Windows LAPS (msLAPS-Password: JSON). + $lapsLegacy = $computer->getFirstAttribute('ms-mcs-admpwd'); + $lapsLegacyExp = LdapAttributeHelper::windowsTimestampToCarbon($computer->getFirstAttribute('ms-mcs-admpwdexpirationtime')); + + $winLapsRaw = $computer->getFirstAttribute('mslaps-password'); + $winLaps = null; + if ($winLapsRaw) { + $decoded = json_decode($winLapsRaw, true); + $winLaps = is_array($decoded) + ? ['account' => $decoded['n'] ?? null, 'password' => $decoded['p'] ?? null, 'updated' => $decoded['t'] ?? null] + : ['account' => null, 'password' => $winLapsRaw, 'updated' => null]; + } + $winLapsEncrypted = (bool) $computer->getFirstAttribute('mslaps-encryptedpassword'); + $winLapsExp = LdapAttributeHelper::windowsTimestampToCarbon($computer->getFirstAttribute('mslaps-passwordexpirationtime')); + + // BitLocker - obiekty potomne msFVE-RecoveryInformation pod obiektem komputera. + $bitlocker = []; + try { + $recovery = Entry::query() + ->in($computer->getDn()) + ->whereEquals('objectclass', 'msFVE-RecoveryInformation') + ->select(['cn', 'msfve-recoverypassword', 'msfve-recoveryguid', 'whencreated']) + ->paginate(200); + + foreach ($recovery as $rec) { + $guid = $rec->getFirstAttribute('msfve-recoveryguid'); + $bitlocker[] = [ + 'password' => $rec->getFirstAttribute('msfve-recoverypassword'), + 'guid' => $guid ? bin2hex($guid) : ($rec->getFirstAttribute('cn')), + 'created' => $this->parseLdapDate($rec->getFirstAttribute('whencreated')), + ]; + } + } catch (\Throwable) { + // brak dostępu / brak obiektów + } + + $lapsAvailable = (bool) ($lapsLegacy || $winLaps || $winLapsEncrypted); + + // Odczyt wrażliwych danych też trafia do dziennika (kto zobaczył hasło + // lokalnego admina / klucze BitLocker komputera). + if ($lapsAvailable) { + $this->audit->log('computer.laps_view', [ + 'target_type' => 'computer', 'target' => $computer->getFirstAttribute('cn'), 'target_dn' => $computer->getDn(), + ]); + } + if ($bitlocker !== []) { + $this->audit->log('computer.bitlocker_view', [ + 'target_type' => 'computer', 'target' => $computer->getFirstAttribute('cn'), 'target_dn' => $computer->getDn(), + 'context' => ['keys' => count($bitlocker)], + ]); + } + + return response()->json([ + 'cn' => $computer->getFirstAttribute('cn'), + 'dns_hostname' => $computer->getFirstAttribute('dnshostname'), + 'os' => trim(($computer->getFirstAttribute('operatingsystem') ?? '').' '.($computer->getFirstAttribute('operatingsystemversion') ?? '')), + 'dn' => $computer->getDn(), + 'description' => $computer->getFirstAttribute('description'), + 'last_logon' => optional(LdapAttributeHelper::windowsTimestampToCarbon($computer->getFirstAttribute('lastlogontimestamp')))->format('Y-m-d H:i'), + 'created' => $this->parseLdapDate($computer->getFirstAttribute('whencreated')), + 'laps' => [ + 'legacy_password' => $lapsLegacy, + 'legacy_expires' => optional($lapsLegacyExp)->format('Y-m-d H:i'), + 'windows' => $winLaps, + 'windows_encrypted' => $winLapsEncrypted, + 'windows_expires' => optional($winLapsExp)->format('Y-m-d H:i'), + 'available' => $lapsAvailable, + ], + 'bitlocker' => $bitlocker, + ]); + } + + protected function parseLdapDate(?string $ldapDate): ?string + { + if (! $ldapDate) { + return null; + } + + try { + return Carbon::createFromFormat('YmdHis.0Z', $ldapDate)->format('Y-m-d H:i'); + } catch (\Throwable) { + return $ldapDate; + } + } + + public function toggleEnabled(Request $request, string $cn): RedirectResponse + { + $computer = $this->findByCn($cn); + $uac = (int) ($computer->getFirstAttribute('userAccountControl') ?? 0); + + if ($uac & LdapAttributeHelper::UAC_ACCOUNTDISABLE) { + $uac &= ~LdapAttributeHelper::UAC_ACCOUNTDISABLE; + $message = __('computers.flash.enabled', ['cn' => $cn]); + $action = 'computer.enable'; + } else { + $uac |= LdapAttributeHelper::UAC_ACCOUNTDISABLE; + $message = __('computers.flash.disabled', ['cn' => $cn]); + $action = 'computer.disable'; + } + + $computer->userAccountControl = $uac; + $computer->save(); + + $this->audit->log($action, [ + 'target_type' => 'computer', 'target' => $cn, 'target_dn' => $computer->getDn(), + 'reason' => $request->input('reason') ?: null, + ]); + + return back()->with('status', $message); + } + + /** + * Resetuje konto zaufania komputera (nowe losowe hasło konta maszyny). + * Odpowiednik "Reset Account" w ADUC - wymusza ponowne dołączenie do + * domeny przy następnym starcie, jeśli sekret jest niezgodny. + */ + public function resetTrustAccount(Request $request, string $cn): RedirectResponse + { + $computer = $this->findByCn($cn); + + $randomPassword = bin2hex(random_bytes(16)); + $computer->unicodepwd = iconv('UTF-8', 'UTF-16LE', '"'.$randomPassword.'"'); + $computer->save(); + + $this->audit->log('computer.reset_trust', [ + 'target_type' => 'computer', 'target' => $cn, 'target_dn' => $computer->getDn(), + 'reason' => $request->input('reason') ?: null, + ]); + + return back()->with('status', __('computers.flash.trust_reset', ['cn' => $cn])); + } + + public function destroy(Request $request, string $cn): RedirectResponse + { + $computer = $this->findByCn($cn); + $dn = $computer->getDn(); + $computer->delete(); + + $this->audit->log('computer.delete', [ + 'target_type' => 'computer', 'target' => $cn, 'target_dn' => $dn, + 'reason' => $request->input('reason') ?: null, + ]); + + return back()->with('status', __('computers.flash.deleted', ['cn' => $cn])); + } + + /** + * Akcje masowe (wg zaznaczonych wierszy listy komputerów). + */ + public function bulkSetEnabled(Request $request): RedirectResponse + { + $data = $request->validate([ + 'cns' => ['required', 'array', 'min:1'], + 'cns.*' => ['string'], + 'enabled' => ['nullable', 'boolean'], + 'reason' => ['nullable', 'string', 'max:1000'], + ]); + + $enabled = (bool) ($data['enabled'] ?? false); + + foreach ($data['cns'] as $cn) { + $computer = $this->findByCn($cn); + $uac = (int) ($computer->getFirstAttribute('userAccountControl') ?? 0); + + if ($enabled) { + $uac &= ~LdapAttributeHelper::UAC_ACCOUNTDISABLE; + } else { + $uac |= LdapAttributeHelper::UAC_ACCOUNTDISABLE; + } + + $computer->userAccountControl = $uac; + $computer->save(); + } + + $this->audit->log($enabled ? 'computer.bulk_enable' : 'computer.bulk_disable', [ + 'target_type' => 'computer', + 'reason' => $data['reason'] ?? null, + 'context' => ['count' => count($data['cns']), 'computers' => array_slice($data['cns'], 0, 200)], + ]); + + return back()->with('status', __( + $enabled ? 'computers.flash.bulk_enabled' : 'computers.flash.bulk_disabled', + ['count' => count($data['cns'])], + )); + } + + public function bulkMove(Request $request): RedirectResponse + { + $data = $request->validate([ + 'cns' => ['required', 'array', 'min:1'], + 'cns.*' => ['string'], + 'target_ou' => ['required', 'string'], + 'reason' => ['nullable', 'string', 'max:1000'], + ]); + + foreach ($data['cns'] as $cn) { + $this->findByCn($cn)->move($data['target_ou']); + } + + $this->audit->log('computer.bulk_move', [ + 'target_type' => 'computer', + 'reason' => $data['reason'] ?? null, + 'context' => ['count' => count($data['cns']), 'target_ou' => $data['target_ou'], 'computers' => array_slice($data['cns'], 0, 200)], + ]); + + return back()->with('status', __('computers.flash.bulk_moved', ['count' => count($data['cns'])])); + } + + protected function findByCn(string $cn): Computer + { + $computer = Computer::query()->where('cn', '=', $cn)->first(); + + if (! $computer) { + throw new ModelNotFoundException("Nie znaleziono komputera: {$cn}"); + } + + return $computer; + } + + protected function presentComputer(Computer $computer): array + { + $uac = (int) ($computer->getFirstAttribute('userAccountControl') ?? 0); + $disabled = (bool) ($uac & LdapAttributeHelper::UAC_ACCOUNTDISABLE); + + return [ + 'cn' => $computer->getFirstAttribute('cn'), + 'dns_hostname' => $computer->getFirstAttribute('dnshostname'), + 'os' => $computer->getFirstAttribute('operatingsystem'), + 'os_version' => $computer->getFirstAttribute('operatingsystemversion'), + 'dn' => $computer->getDn(), + 'disabled' => $disabled, + 'status_label' => $disabled ? __('common.account_status.disabled') : __('common.account_status.active'), + 'status_css' => $disabled ? 'badge-status-disabled' : 'badge-status-active', + 'last_logon' => LdapAttributeHelper::windowsTimestampToCarbon($computer->getFirstAttribute('lastlogontimestamp')), + ]; + } +} diff --git a/app/Http/Controllers/Admin/ConfigController.php b/app/Http/Controllers/Admin/ConfigController.php new file mode 100644 index 0000000..492b5ce --- /dev/null +++ b/app/Http/Controllers/Admin/ConfigController.php @@ -0,0 +1,143 @@ + $settings->all(), + 'attributes' => $settings->attributes(), + 'attributeCategories' => $settings->categories(), + 'attributeTypeLabels' => PanelSettingsService::ATTRIBUTE_TYPE_LABELS, + 'editableAttributeTypes' => PanelSettingsService::EDITABLE_ATTRIBUTE_TYPES, + 'ldapDefaults' => [ + 'user_ou' => config('ldap.defaults.user_ou'), + 'computer_ou' => config('ldap.defaults.computer_ou'), + 'groups_ou' => config('ldap.defaults.groups_ou'), + 'domain_label' => config('ldap.defaults.domain_label'), + ], + 'ldapConnection' => [ + 'hosts' => config('ldap.connections.default.hosts'), + 'base_dn' => config('ldap.connections.default.base_dn'), + 'encrypted' => (bool) config('ldap.connections.default.use_tls') + || (bool) config('ldap.connections.default.use_starttls'), + 'username' => config('ldap.connections.default.username'), + ], + ]); + } + + public function update(Request $request, PanelSettingsService $settings): RedirectResponse + { + $data = $request->validate([ + 'reset_password.must_change_password' => ['nullable', 'boolean'], + 'reset_password.unlock_account' => ['nullable', 'boolean'], + 'password_policy.max_age_days' => ['required', 'integer', 'min:0', 'max:3650'], + 'password_policy.generator_length' => ['nullable', 'integer', 'min:8', 'max:128'], + 'password_policy.generator_symbols' => ['nullable', 'boolean'], + 'encryption.key' => ['nullable', 'string', 'max:2048'], + 'audit.require_reason' => ['nullable', 'boolean'], + ]); + + $current = $settings->all(); + + $current['reset_password']['must_change_password'] = (bool) ($data['reset_password']['must_change_password'] ?? false); + $current['reset_password']['unlock_account'] = (bool) ($data['reset_password']['unlock_account'] ?? false); + $current['password_policy']['max_age_days'] = (int) $data['password_policy']['max_age_days']; + $current['password_policy']['generator_length'] = (int) ($data['password_policy']['generator_length'] ?? 16); + $current['password_policy']['generator_symbols'] = (bool) ($data['password_policy']['generator_symbols'] ?? false); + $current['encryption']['key'] = trim((string) ($data['encryption']['key'] ?? '')); + $current['audit']['require_reason'] = (bool) ($data['audit']['require_reason'] ?? false); + + $settings->save($current); + + $this->audit->log('config.settings', [ + 'context' => [ + 'max_age_days' => $current['password_policy']['max_age_days'], + 'require_reason' => $current['audit']['require_reason'], + 'encryption_key_set' => $current['encryption']['key'] !== '', + ], + ]); + + return back()->with('status', __('config.flash.saved')); + } + + /** + * Zapisuje graficznie skonfigurowaną listę atrybutów AD (zakładka "Atrybuty"): + * dodanie / edycja / usunięcie, widoczność jako kolumna listy użytkowników + * oraz przypisanie do kategorii w oknie podglądu użytkownika. + */ + public function updateAttributes(Request $request, PanelSettingsService $settings): RedirectResponse + { + $validCategories = array_keys($settings->categories()); + + $data = $request->validate([ + 'attributes' => ['array'], + 'attributes.*.key' => ['nullable', 'string', 'max:64', 'regex:/^[A-Za-z0-9_-]*$/'], + 'attributes.*.label' => ['nullable', 'string', 'max:128'], + 'attributes.*.ldap' => ['nullable', 'string', 'max:128', 'regex:/^[A-Za-z0-9-]*$/'], + 'attributes.*.category' => ['nullable', 'string'], + 'attributes.*.type' => ['nullable', 'string', 'in:'.implode(',', PanelSettingsService::ATTRIBUTE_TYPES)], + 'attributes.*.column' => ['nullable', 'boolean'], + 'attributes.*.regex' => ['nullable', 'string', 'max:191', function (string $attribute, mixed $value, \Closure $fail) { + if (is_string($value) && $value !== '' && @preg_match($value, '') === false) { + $fail(__('config.validation.regex_invalid')); + } + }], + 'attributes.*.required' => ['nullable', 'boolean'], + 'attributes.*.help' => ['nullable', 'string', 'max:255'], + ], [ + 'attributes.*.key.regex' => __('config.validation.key_regex'), + 'attributes.*.ldap.regex' => __('config.validation.ldap_regex'), + ]); + + $computedKeys = PanelSettingsService::COMPUTED_ATTRIBUTE_KEYS; + + $rows = collect($data['attributes'] ?? []) + ->map(function (array $row) use ($validCategories, $computedKeys) { + $key = trim((string) ($row['key'] ?? '')); + $category = $row['category'] ?? 'general'; + $isComputed = in_array($key, $computedKeys, true); + + return [ + 'key' => $key, + 'label' => trim((string) ($row['label'] ?? '')), + // Kolumny wyliczane nie mają atrybutu AD - podstawiamy klucz, + // żeby przeszły przez filtr; PanelSettingsService i tak to wymusi. + 'ldap' => $isComputed ? $key : trim((string) ($row['ldap'] ?? '')), + 'category' => in_array($category, $validCategories, true) ? $category : 'general', + // Typ (tekst / liczba / data / tekst szyfrowany ...) - dla + // pozycji wbudowanych i tak wymusi go PanelSettingsService. + 'type' => (string) ($row['type'] ?? 'text'), + // Reguły walidacji pola profilu - PanelSettingsService czyści + // je dla pozycji nieedytowalnych. + 'regex' => trim((string) ($row['regex'] ?? '')), + 'required' => (bool) ($row['required'] ?? false), + 'help' => trim((string) ($row['help'] ?? '')), + 'column' => (bool) ($row['column'] ?? false), + ]; + }) + ->filter(fn (array $row) => $row['key'] !== '' && $row['ldap'] !== '') + ->values() + ->all(); + + $settings->saveAttributes($rows); + + $this->audit->log('config.attributes', [ + 'context' => ['count' => count($rows)], + ]); + + return back() + ->with('status', __('config.flash.attributes_saved')) + ->withFragment('attributes'); + } +} diff --git a/app/Http/Controllers/Admin/DashboardController.php b/app/Http/Controllers/Admin/DashboardController.php new file mode 100644 index 0000000..777a23e --- /dev/null +++ b/app/Http/Controllers/Admin/DashboardController.php @@ -0,0 +1,349 @@ +remember( + 'dashboard:summary', + self::CACHE_TTL, + fn () => $this->build($settings->passwordMaxAgeDays()), + ); + + $tsRaw = Cache::store('file')->get('dashboard:summary_ts'); + $refreshedAt = $tsRaw ? Carbon::parse($tsRaw) : now(); + + Carbon::setLocale(app()->getLocale()); + + $data['refreshed_at'] = $refreshedAt->format('Y-m-d H:i'); + $data['refreshed_at_human'] = $refreshedAt->diffForHumans(); + // Dane powinny pochodzić z ostatniego cyklu cache; większy odstęp = coś + // nie przelicza się w tle (np. błąd LDAP przy odświeżaniu). + $data['refreshed_stale'] = $refreshedAt->lt(now()->subSeconds(self::CACHE_TTL + 60)); + + // Pusty stan globalny - LDAP nic nie zwrócił (błąd połączenia / złe OU). + $data['empty'] = ($data['stats']['users_total'] ?? 0) === 0 + && ($data['computers_total'] ?? 0) === 0 + && ($data['groups_total'] ?? 0) === 0; + + // Preferencje układu pulpitu (widoczność sekcji + kolejność kart) - cookie. + $data['layout'] = $this->dashboardLayout($request); + + return view('admin.dashboard.index', $data); + } + + public function refresh() + { + Cache::store('file')->forget('dashboard:summary'); + Cache::store('file')->forget('dashboard:summary_ts'); + + return redirect()->route('dashboard.index'); + } + + /** + * Zapisuje preferencje układu pulpitu w cookie (rok ważności). + */ + public function savePreferences(Request $request): RedirectResponse + { + $order = array_values(array_filter( + (array) $request->input('report_order', []), + fn ($k) => in_array($k, self::REPORT_BLOCKS, true), + )); + + // Dołóż karty pominięte w formularzu (np. dodane w nowej wersji). + foreach (self::REPORT_BLOCKS as $k) { + if (! in_array($k, $order, true)) { + $order[] = $k; + } + } + + $visible = array_flip((array) $request->input('visible', [])); + + $payload = [ + 'tiles' => $request->boolean('section_tiles'), + 'pwd_age' => $request->boolean('section_pwd_age'), + 'reports' => array_map( + fn ($k) => ['key' => $k, 'visible' => isset($visible[$k])], + $order, + ), + ]; + + return redirect() + ->route('dashboard.index') + ->withCookie(cookie('dashboard_layout', json_encode($payload), 60 * 24 * 365)); + } + + /** + * Odczytuje preferencje układu z cookie i uzupełnia brakami z domyślnych. + * + * @return array{tiles:bool, pwd_age:bool, reports:list} + */ + protected function dashboardLayout(Request $request): array + { + $stored = json_decode((string) $request->cookie('dashboard_layout'), true); + $stored = is_array($stored) ? $stored : []; + + $layout = [ + 'tiles' => ! array_key_exists('tiles', $stored) || (bool) $stored['tiles'], + 'pwd_age' => ! array_key_exists('pwd_age', $stored) || (bool) $stored['pwd_age'], + 'reports' => [], + ]; + + $seen = []; + foreach ((array) ($stored['reports'] ?? []) as $r) { + $key = is_array($r) ? ($r['key'] ?? null) : null; + if (in_array($key, self::REPORT_BLOCKS, true) && ! isset($seen[$key])) { + $seen[$key] = true; + $layout['reports'][] = ['key' => $key, 'visible' => (bool) ($r['visible'] ?? true)]; + } + } + foreach (self::REPORT_BLOCKS as $key) { + if (! isset($seen[$key])) { + $layout['reports'][] = ['key' => $key, 'visible' => true]; + } + } + + return $layout; + } + + protected function build(int $maxPasswordAgeDays): array + { + $userOu = config('ldap.defaults.user_ou'); + + $now = now(); + $cutInactive = $now->copy()->subDays(90); + $cutRecent = $now->copy()->subDays(30); + $expiryHorizon = $now->copy()->addDays(7); + + $stats = [ + 'users_total' => 0, + 'users_disabled' => 0, + 'users_locked' => 0, + 'pwd_never_expires' => 0, + 'pwd_expiring_7d' => 0, + 'pwd_expired' => 0, + 'inactive_90d' => 0, + 'account_expired' => 0, + ]; + + $stats['users_no_manager'] = 0; + + // Histogram wieku hasła (dni od pwdLastSet). + $pwdAge = ['lt30' => 0, 'd30_60' => 0, 'd60_90' => 0, 'gt90' => 0, 'unknown' => 0]; + + $expiringSoon = []; + $inactive = []; + $recent = []; + $pwdNever = []; + $locked = []; + $noManager = []; + + // chunk() strumieniuje kolejne strony (paged results) - nie trzymamy w + // pamięci wszystkich obiektów naraz i liczymy KAŻDE konto w domenie. + User::query() + ->in($userOu)->recursive() + ->select(['cn', 'samaccountname', 'userAccountControl', 'pwdLastSet', 'lockoutTime', 'lastLogonTimestamp', 'accountExpires', 'whenCreated', 'mail', 'manager', 'directReports']) + ->orderBy('cn') + ->chunk(1000, function ($users) use ( + &$stats, &$pwdAge, &$expiringSoon, &$inactive, &$recent, &$pwdNever, &$locked, &$noManager, + $now, $cutInactive, $cutRecent, $expiryHorizon, $maxPasswordAgeDays + ) { + foreach ($users as $u) { + $stats['users_total']++; + + $disabled = LdapAttributeHelper::isDisabled($u); + if ($disabled) { + $stats['users_disabled']++; + } + if (LdapAttributeHelper::isLocked($u)) { + $stats['users_locked']++; + $lockedSince = LdapAttributeHelper::windowsTimestampToCarbon($u->getFirstAttribute('lockouttime')); + $locked[] = ['cn' => $u->getFirstAttribute('cn'), 'sam' => $u->getFirstAttribute('samaccountname'), 'since' => $lockedSince?->format('Y-m-d H:i') ?? '—']; + } + if (LdapAttributeHelper::passwordNeverExpires($u)) { + $stats['pwd_never_expires']++; + if (! $disabled) { + $pwdNever[] = ['cn' => $u->getFirstAttribute('cn'), 'sam' => $u->getFirstAttribute('samaccountname')]; + } + } + + // "Dziura w org-chart": aktywne konto bez przełożonego i bez podwładnych. + if (! $disabled + && trim((string) $u->getFirstAttribute('manager')) === '' + && count(array_filter(array_map('trim', $u->getAttribute('directreports') ?? []))) === 0) { + $stats['users_no_manager']++; + $noManager[] = ['cn' => $u->getFirstAttribute('cn'), 'sam' => $u->getFirstAttribute('samaccountname')]; + } + + $exp = LdapAttributeHelper::passwordExpiresAt($u, $maxPasswordAgeDays); + if ($exp) { + if ($exp->isPast()) { + $stats['pwd_expired']++; + } elseif ($exp->lte($expiryHorizon)) { + $stats['pwd_expiring_7d']++; + $expiringSoon[] = ['cn' => $u->getFirstAttribute('cn'), 'sam' => $u->getFirstAttribute('samaccountname'), 'date' => $exp->format('Y-m-d')]; + } + } + + $lastSet = LdapAttributeHelper::passwordLastSet($u); + if (! $lastSet) { + $pwdAge['unknown']++; + } else { + $ageDays = (int) abs($lastSet->diffInDays($now)); + if ($ageDays < 30) { + $pwdAge['lt30']++; + } elseif ($ageDays < 60) { + $pwdAge['d30_60']++; + } elseif ($ageDays < 90) { + $pwdAge['d60_90']++; + } else { + $pwdAge['gt90']++; + } + } + + $lastLogon = LdapAttributeHelper::windowsTimestampToCarbon($u->getFirstAttribute('lastlogontimestamp')); + if (! $disabled && (! $lastLogon || $lastLogon->lt($cutInactive))) { + $stats['inactive_90d']++; + $inactive[] = ['cn' => $u->getFirstAttribute('cn'), 'sam' => $u->getFirstAttribute('samaccountname'), 'last' => $lastLogon?->format('Y-m-d') ?? 'nigdy']; + } + + $accExp = LdapAttributeHelper::accountExpiresAt($u); + if ($accExp && $accExp->isPast()) { + $stats['account_expired']++; + } + + $created = $this->parseWhenCreated($u->getFirstAttribute('whencreated')); + if ($created && $created->gt($cutRecent)) { + $recent[] = ['cn' => $u->getFirstAttribute('cn'), 'sam' => $u->getFirstAttribute('samaccountname'), 'created' => $created->format('Y-m-d')]; + } + } + }); + + usort($expiringSoon, fn ($a, $b) => strcmp($a['date'], $b['date'])); + usort($inactive, fn ($a, $b) => strcmp($a['last'], $b['last'])); + usort($recent, fn ($a, $b) => strcmp($b['created'], $a['created'])); + usort($pwdNever, fn ($a, $b) => strcasecmp((string) $a['cn'], (string) $b['cn'])); + usort($locked, fn ($a, $b) => strcmp($b['since'], $a['since'])); + usort($noManager, fn ($a, $b) => strcasecmp((string) $a['cn'], (string) $b['cn'])); + + $computersInactive = $this->scanInactiveComputers($cutInactive); + + Cache::store('file')->put('dashboard:summary_ts', $now->format('Y-m-d H:i:s'), self::CACHE_TTL); + + $stats['computers_inactive_90d'] = $computersInactive['count']; + + return [ + 'stats' => $stats, + 'pwd_age' => $pwdAge, + 'computers_total' => $this->countAll( + Computer::query()->in(config('ldap.defaults.computer_ou'))->recursive()->select(['cn']) + ), + 'groups_total' => $this->countAll( + Group::query()->in(config('ldap.defaults.groups_ou') ?: $userOu)->recursive()->select(['cn']) + ), + 'expiring_soon' => array_slice($expiringSoon, 0, 15), + 'inactive' => array_slice($inactive, 0, 15), + 'recent' => array_slice($recent, 0, 15), + 'pwd_never_list' => array_slice($pwdNever, 0, 50), + 'locked_list' => array_slice($locked, 0, 50), + 'no_manager_list' => array_slice($noManager, 0, 50), + 'computers_inactive' => array_slice($computersInactive['list'], 0, 50), + 'max_password_age_days' => $maxPasswordAgeDays, + ]; + } + + /** + * Skan kont komputerów: aktywne, których lastLogonTimestamp jest starszy niż + * próg (lub w ogóle brak logowania). Strumieniuje strony jak countAll(). + * + * @return array{count:int, list:list} + */ + protected function scanInactiveComputers(Carbon $cutInactive): array + { + $count = 0; + $list = []; + + Computer::query() + ->in(config('ldap.defaults.computer_ou'))->recursive() + ->select(['cn', 'lastLogonTimestamp', 'userAccountControl']) + ->orderBy('cn') + ->chunk(1000, function ($computers) use (&$count, &$list, $cutInactive) { + foreach ($computers as $c) { + if ((int) ($c->getFirstAttribute('useraccountcontrol') ?? 0) & LdapAttributeHelper::UAC_ACCOUNTDISABLE) { + continue; + } + + $last = LdapAttributeHelper::windowsTimestampToCarbon($c->getFirstAttribute('lastlogontimestamp')); + if ($last && $last->gte($cutInactive)) { + continue; + } + + $count++; + $list[] = ['cn' => $c->getFirstAttribute('cn'), 'last' => $last?->format('Y-m-d') ?? '—']; + } + }); + + usort($list, fn ($a, $b) => strcmp($a['last'], $b['last'])); + + return ['count' => $count, 'list' => $list]; + } + + /** + * Liczy wszystkie obiekty pasujące do zapytania, strumieniując strony - + * bez materializowania całej listy modeli w pamięci. + */ + protected function countAll(LdapBuilder $query): int + { + $count = 0; + + $query->chunk(1000, function ($rows) use (&$count) { + $count += count($rows); + }); + + return $count; + } + + protected function parseWhenCreated(?string $value): ?Carbon + { + if (! $value) { + return null; + } + + try { + return Carbon::createFromFormat('YmdHis.0Z', $value); + } catch (\Throwable) { + try { + return Carbon::parse($value); + } catch (\Throwable) { + return null; + } + } + } +} diff --git a/app/Http/Controllers/Admin/GpoController.php b/app/Http/Controllers/Admin/GpoController.php new file mode 100644 index 0000000..03e1ec4 --- /dev/null +++ b/app/Http/Controllers/Admin/GpoController.php @@ -0,0 +1,379 @@ +query('q', '')); + $perPage = max(10, min(200, (int) $request->query('per_page', 25))); + + $baseDn = config('ldap.connections.default.base_dn'); + $policiesContainer = "CN=Policies,CN=System,{$baseDn}"; + + $gpos = Entry::query() + ->in($policiesContainer) + ->whereEquals('objectclass', 'groupPolicyContainer') + ->select(['cn', 'displayname', 'whencreated', 'whenchanged', 'versionnumber', 'flags']) + ->get(); + + $linkedOuMap = $this->buildGpoLinkMap($baseDn); + + $allRows = collect($gpos)->map(function (Entry $gpo) use ($linkedOuMap) { + $guid = trim((string) $gpo->getFirstAttribute('cn'), '{}'); + $flags = (int) ($gpo->getFirstAttribute('flags') ?? 0); + + return [ + 'guid' => $guid, + 'name' => $gpo->getFirstAttribute('displayname') ?: $gpo->getFirstAttribute('cn'), + 'created' => $this->parseLdapDate($gpo->getFirstAttribute('whencreated')), + 'changed' => $this->parseLdapDate($gpo->getFirstAttribute('whenchanged')), + 'version' => $gpo->getFirstAttribute('versionnumber'), + // flags: bit0 = user config disabled, bit1 = computer config disabled + 'user_settings_enabled' => ! ($flags & 1), + 'computer_settings_enabled' => ! ($flags & 2), + 'linked_ous' => $linkedOuMap[strtolower('{'.$guid.'}')] ?? [], + ]; + }); + + if ($search !== '') { + $needle = mb_strtolower($search); + $allRows = $allRows->filter(function (array $row) use ($needle) { + return str_contains(mb_strtolower((string) $row['name']), $needle) + || str_contains(mb_strtolower((string) $row['guid']), $needle) + || str_contains(mb_strtolower(implode(' ', $row['linked_ous'])), $needle); + }); + } + + $allRows = $allRows->sortBy('name')->values(); + + $page = LengthAwarePaginator::resolveCurrentPage(); + $gpos = new LengthAwarePaginator( + $allRows->forPage($page, $perPage)->values(), + $allRows->count(), + $perPage, + $page, + ['path' => LengthAwarePaginator::resolveCurrentPath()] + ); + + $gpos->appends(collect($request->query())->except(['partial', 'page'])->all()); + + $viewData = [ + 'search' => $search, + 'gpos' => $gpos, + 'rows' => collect($gpos->items()), + ]; + + if ($request->boolean('partial')) { + return view('admin.gpo.partials.results', $viewData); + } + + return view('admin.gpo.index', $viewData); + } + + /** + * Szczegóły pojedynczego GPO: wersje, stan konfiguracji, ścieżka SYSVOL, + * filtr WMI, rozszerzenia CSE oraz miejsca podpięcia (z flagą "wymuszone"). + */ + public function show(string $guid): JsonResponse + { + $baseDn = config('ldap.connections.default.base_dn'); + $guid = '{'.trim($guid, '{}').'}'; + + $gpo = Entry::query() + ->in("CN=Policies,CN=System,{$baseDn}") + ->whereEquals('cn', $guid) + ->select(['cn', 'displayname', 'whencreated', 'whenchanged', 'versionnumber', 'flags', 'gpcfilesyspath', 'gpcwqlfilter', 'gpcmachineextensionnames', 'gpcuserextensionnames']) + ->first(); + + if (! $gpo) { + abort(404, __('gpo.not_found')); + } + + $version = (int) ($gpo->getFirstAttribute('versionnumber') ?? 0); + $flags = (int) ($gpo->getFirstAttribute('flags') ?? 0); + + // versionNumber: młodsze 16 bitów = User, starsze 16 bitów = Computer. + $userVer = $version & 0xFFFF; + $computerVer = ($version >> 16) & 0xFFFF; + + // Filtr WMI: gPCWQLFilter = "[domena;{GUID};0]". + $wmi = null; + if ($wql = $gpo->getFirstAttribute('gpcwqlfilter')) { + if (preg_match('/\{[0-9A-Fa-f-]+\}/', $wql, $mm)) { + $filter = Entry::query() + ->in("CN=SOM,CN=WMIPolicy,CN=System,{$baseDn}") + ->whereEquals('cn', $mm[0]) + ->select(['msp-wmi-name']) + ->first(); + $wmi = $filter?->getFirstAttribute('msp-wmi-name') ?? $mm[0]; + } else { + $wmi = $wql; + } + } + + // Podpięcia + flaga enforced dla tego GPO. + $links = []; + foreach ($this->allGpLinkSources($baseDn) as $src) { + foreach ($this->parseGpLink($src['gplink']) as $lnk) { + if (strcasecmp($lnk['guid'], $guid) === 0) { + $links[] = ['location' => $src['name'], 'enforced' => $lnk['enforced'], 'enabled' => $lnk['enabled']]; + } + } + } + + return response()->json([ + 'guid' => trim($guid, '{}'), + 'name' => $gpo->getFirstAttribute('displayname') ?: $gpo->getFirstAttribute('cn'), + 'created' => $this->parseLdapDate($gpo->getFirstAttribute('whencreated')), + 'changed' => $this->parseLdapDate($gpo->getFirstAttribute('whenchanged')), + 'version' => $version, + 'user_version' => $userVer, + 'computer_version' => $computerVer, + 'user_settings_enabled' => ! ($flags & 1), + 'computer_settings_enabled' => ! ($flags & 2), + 'sysvol_path' => $gpo->getFirstAttribute('gpcfilesyspath'), + 'wmi_filter' => $wmi, + 'machine_cse_count' => substr_count((string) $gpo->getFirstAttribute('gpcmachineextensionnames'), '['), + 'user_cse_count' => substr_count((string) $gpo->getFirstAttribute('gpcuserextensionnames'), '['), + 'links' => $links, + ]); + } + + /** + * "Które GPO obowiązują" (RSoP-lite) dla wskazanej OU: idziemy od domeny + * w dół do wybranej OU, respektując blokowanie dziedziczenia (gPOptions + * bit 0) oraz linki wymuszone (enforced - obowiązują mimo blokady). + */ + public function rsop(Request $request): JsonResponse + { + $targetDn = trim((string) $request->query('ou', '')); + $baseDn = config('ldap.connections.default.base_dn'); + + if ($targetDn === '') { + return response()->json(['error' => 'Podaj OU.'], 422); + } + + $names = $this->gpoNameMap($baseDn); + + // Łańcuch DN od domeny w dół do wskazanej OU. + $chain = $this->dnChain($targetDn, $baseDn); + + $applied = []; // finalna lista (w kolejności stosowania: później = wyższy priorytet) + $enforced = []; // linki wymuszone - dokładane na końcu (najwyższy priorytet) + $blockedFrom = null; + + foreach ($chain as $depth => $node) { + $obj = $this->readObject($node['dn']); + if (! $obj) { + continue; + } + + $gpOptions = (int) ($obj->getFirstAttribute('gpoptions') ?? 0); + $blockInheritance = (bool) ($gpOptions & 1); + + $linkList = $this->parseGpLink($obj->getFirstAttribute('gplink') ?? ''); + // Kolejność stosowania na jednym poziomie: od dołu listy do góry. + $linkList = array_reverse($linkList); + + foreach ($linkList as $lnk) { + if (! $lnk['enabled']) { + continue; + } + $entry = [ + 'guid' => trim($lnk['guid'], '{}'), + 'name' => $names[strtolower($lnk['guid'])] ?? $lnk['guid'], + 'from' => $node['name'], + 'enforced' => $lnk['enforced'], + ]; + if ($lnk['enforced']) { + $enforced[] = $entry; + } else { + $applied[] = $entry; + } + } + + if ($blockInheritance && $depth < count($chain) - 1) { + // Blokada na tym poziomie: linki nieenforced z poziomów WYŻEJ + // (już zebrane) przestają obowiązywać. + $applied = []; + $blockedFrom = $node['name']; + } + } + + return response()->json([ + 'target' => $targetDn, + 'blocked_inheritance_at' => $blockedFrom, + 'effective' => array_merge($applied, $enforced), + ]); + } + + /** + * @return list od domeny w dół do $targetDn + */ + protected function dnChain(string $targetDn, string $baseDn): array + { + $parts = array_map('trim', explode(',', $targetDn)); + $chain = []; + for ($i = 0; $i < count($parts); $i++) { + $dn = implode(',', array_slice($parts, $i)); + if (! str_contains(strtolower($dn), 'ou=') && strcasecmp($dn, $baseDn) !== 0) { + continue; + } + [$rdnKey, $rdnVal] = array_pad(explode('=', $parts[$i], 2), 2, ''); + $chain[] = ['dn' => $dn, 'name' => strcasecmp($dn, $baseDn) === 0 ? 'Domena' : $rdnVal]; + } + + return array_reverse($chain); // domena najpierw + } + + protected function readObject(string $dn): ?Entry + { + try { + return Entry::query()->select(['gplink', 'gpoptions'])->find($dn); + } catch (\Throwable) { + return null; + } + } + + /** + * @return list + */ + protected function allGpLinkSources(string $baseDn): array + { + $out = []; + + foreach (OrganizationalUnit::query()->select(['ou', 'distinguishedname', 'gplink'])->paginate(500) as $ou) { + if ($gp = $ou->getFirstAttribute('gplink')) { + $out[] = ['name' => $ou->getFirstAttribute('ou') ?? $ou->getDn(), 'gplink' => $gp]; + } + } + + $root = Container::query()->find($baseDn); + if ($root && ($gp = $root->getFirstAttribute('gplink'))) { + $out[] = ['name' => __('gpo.domain_root'), 'gplink' => $gp]; + } + + return $out; + } + + /** + * @return array strtolower({GUID}) => displayName + */ + protected function gpoNameMap(string $baseDn): array + { + $map = []; + foreach (Entry::query()->in("CN=Policies,CN=System,{$baseDn}")->whereEquals('objectclass', 'groupPolicyContainer')->select(['cn', 'displayname'])->paginate(500) as $g) { + $map[strtolower((string) $g->getFirstAttribute('cn'))] = $g->getFirstAttribute('displayname') ?: $g->getFirstAttribute('cn'); + } + + return $map; + } + + /** + * Parsuje gpLink na listę linków z flagami. + * + * @return list + */ + protected function parseGpLink(string $gpLink): array + { + preg_match_all('/\[LDAP:\/\/(?:cn=)?(\{[0-9A-Fa-f-]+\})[^;]*;\s*(\d+)\]/i', $gpLink, $m, PREG_SET_ORDER); + + return array_map(function ($x) { + $opt = (int) $x[2]; + + return [ + 'guid' => $x[1], + 'enabled' => ! ($opt & 1), // bit0 = link disabled + 'enforced' => (bool) ($opt & 2), // bit1 = enforced + ]; + }, $m); + } + + /** + * Buduje mapę GUID GPO => lista obiektów (OU / Domena), do których jest podpięte, + * na podstawie atrybutu gpLink obecnego na obiektach OU oraz w korzeniu domeny. + * + * Format gpLink: [LDAP://cn={GUID},cn=policies,cn=system,DC=...;][...] + */ + protected function buildGpoLinkMap(string $baseDn): array + { + $map = []; + + // 1. Pobierz podpięcia z obiektów OrganizationalUnit + $ous = OrganizationalUnit::query() + ->select(['ou', 'distinguishedname', 'gplink']) + ->get(); + + foreach ($ous as $ou) { + $gpLink = $ou->getFirstAttribute('gplink'); + if ($gpLink) { + $ouName = $ou->getFirstAttribute('ou') ?? $ou->getDn(); + $this->extractGuidsFromGpLink($gpLink, $ouName, $map); + } + } + + // 2. Pobierz podpięcia bezpośrednio z korzenia domeny (Domain Root) + $domainRoot = Container::query()->find($baseDn); + if ($domainRoot) { + $gpLink = $domainRoot->getFirstAttribute('gplink'); + if ($gpLink) { + $this->extractGuidsFromGpLink($gpLink, __('gpo.domain_root'), $map); + } + } + + return $map; + } + + /** + * Parsuje ciąg gpLink i dodaje odnalezione dopasowania do mapy. + */ + protected function extractGuidsFromGpLink(string $gpLink, string $locationName, array &$map): void + { + preg_match_all('/\[LDAP:\/\/cn=(\{[0-9A-Fa-f-]+\}),/i', $gpLink, $matches); + + foreach ($matches[1] ?? [] as $guid) { + $key = strtolower($guid); + if (! isset($map[$key])) { + $map[$key] = []; + } + if (! in_array($locationName, $map[$key], true)) { + $map[$key][] = $locationName; + } + } + } + + /** + * Konwertuje ciąg daty z LDAP (np. 20231025120000.0Z) na obiekt Carbon/string. + */ + protected function parseLdapDate(?string $ldapDate): ?string + { + if (! $ldapDate) { + return null; + } + + try { + return Carbon::createFromFormat('YmdHis.0Z', $ldapDate)->format('Y-m-d H:i'); + } catch (\Throwable $e) { + return $ldapDate; + } + } +} diff --git a/app/Http/Controllers/Admin/GroupController.php b/app/Http/Controllers/Admin/GroupController.php new file mode 100644 index 0000000..cfaa3d8 --- /dev/null +++ b/app/Http/Controllers/Admin/GroupController.php @@ -0,0 +1,331 @@ +boolean('refresh_ou')) { + OuTreeService::flush(); + } + + $allOu = $request->boolean('all_ou'); + $currentOu = $request->query('ou') ?: ($allOu ? null : $ouTree->rootDn('groups')); + + $search = trim((string) $request->query('q', '')); + $type = $request->query('type'); // security|distribution + $scope = $request->query('scope'); // global|domain_local|universal + $perPage = max(10, min(200, (int) $request->query('per_page', 25))); + + $query = Group::query(); + + if ($currentOu) { + $query->in($currentOu)->recursive(); + } + + if ($search !== '') { + $searchAttrs = ['cn', 'description', 'mail', 'samaccountname', 'info']; + $query->orFilter(function ($q) use ($searchAttrs, $search) { + foreach ($searchAttrs as $attr) { + $q->whereContains($attr, $search); + } + }); + } + + // 1. Pobieramy dopasowane grupy z LDAP (paged results control). + $allGroups = $query + ->select(['cn', 'description', 'mail', 'grouptype', 'member', 'distinguishedname']) + ->orderBy('cn') + ->paginate(500); + + // 2. Mapujemy na DTO/Tablicę i filtrujemy według typu i zasięgu przed stronnicowaniem + $allRows = collect($allGroups)->map(fn (Group $g) => $this->presentGroup($g)) + ->when($type, fn ($rows) => $rows->where('type_key', $type)) + ->when($scope, fn ($rows) => $rows->where('scope_key', $scope)) + ->values(); + + // 3. Budujemy natywny LengthAwarePaginator Laravela + $page = LengthAwarePaginator::resolveCurrentPage(); + $total = $allRows->count(); + + $groups = new LengthAwarePaginator( + $allRows->forPage($page, $perPage)->values(), + $total, + $perPage, + $page, + ['path' => LengthAwarePaginator::resolveCurrentPath()] + ); + + $groups->appends(collect($request->query())->except(['partial', 'page'])->all()); + + $viewData = [ + 'currentOu' => $currentOu, + 'allOu' => $allOu, + 'search' => $search, + 'typeFilter' => $type, + 'scopeFilter' => $scope, + 'groups' => $groups, + 'rows' => collect($groups->items()), + ]; + + if ($request->boolean('partial')) { + return view('admin.groups.partials.results', $viewData); + } + + return view('admin.groups.index', $viewData + [ + 'ouTree' => $ouTree->tree('groups'), + ]); + } + + /** + * Podpowiedzi grup (typeahead) - do dodawania członkostwa z profilu użytkownika. + */ + public function search(Request $request): JsonResponse + { + $q = trim((string) $request->query('q', '')); + + if (mb_strlen($q) < 2) { + return response()->json([]); + } + + $results = Group::query() + ->in(config('ldap.defaults.groups_ou') ?: config('ldap.defaults.user_ou'))->recursive() + ->select(['cn', 'description']) + ->orFilter(function ($sub) use ($q) { + foreach (['cn', 'samaccountname', 'description'] as $attr) { + $sub->whereContains($attr, $q); + } + }) + ->orderBy('cn') + ->limit(25) + ->paginate(25); + + return response()->json( + collect($results)->take(25)->map(fn (Group $g) => [ + 'dn' => $g->getDn(), + 'cn' => $g->getFirstAttribute('cn'), + 'description' => $g->getFirstAttribute('description'), + ])->values() + ); + } + + public function show(string $cn): JsonResponse + { + $group = $this->findByCn($cn); + + $members = collect($group->members()->get())->map(function ($member) { + return [ + 'name' => $member->getFirstAttribute('cn'), + 'samaccountname' => $member->getFirstAttribute('samaccountname'), + 'dn' => $member->getDn(), + ]; + })->values(); + + return response()->json([ + 'cn' => $group->getFirstAttribute('cn'), + 'description' => $group->getFirstAttribute('description'), + 'mail' => $group->getFirstAttribute('mail'), + 'dn' => $group->getDn(), + 'members' => $members, + ]); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'cn' => ['required', 'string', 'max:64'], + 'description' => ['nullable', 'string', 'max:255'], + 'ou' => ['required', 'string'], + 'group_type' => ['required', 'in:security,distribution'], + 'group_scope' => ['required', 'in:global,domain_local,universal'], + ]); + + $groupType = $this->groupTypeValue($data['group_type'], $data['group_scope']); + + $group = new Group; + $group->inside($data['ou']); + $group->cn = $data['cn']; + $group->samaccountname = $data['cn']; + $group->description = $data['description'] ?? null; + $group->grouptype = $groupType; + $group->save(); + + $this->audit->log('group.create', [ + 'target_type' => 'group', 'target' => $data['cn'], 'target_dn' => $group->getDn(), + 'context' => ['ou' => $data['ou'], 'type' => $data['group_type'], 'scope' => $data['group_scope']], + ]); + + return back()->with('status', __('groups.flash.created', ['cn' => $data['cn']])); + } + + public function update(Request $request, string $cn): RedirectResponse + { + $data = $request->validate([ + 'description' => ['nullable', 'string', 'max:255'], + 'mail' => ['nullable', 'email'], + ]); + + $group = $this->findByCn($cn); + $changes = []; + foreach (['description' => 'description', 'mail' => 'mail'] as $field => $attr) { + $old = $group->getFirstAttribute($attr); + $new = $data[$field] ?? null; + if ((string) ($old ?? '') !== (string) ($new ?? '')) { + $changes[$field] = [$old, $new]; + } + } + $group->description = $data['description'] ?? null; + $group->mail = $data['mail'] ?? null; + $group->save(); + + $this->audit->log('group.update', [ + 'target_type' => 'group', 'target' => $cn, 'target_dn' => $group->getDn(), + 'changes' => $changes ?: null, + ]); + + return back()->with('status', __('groups.flash.updated')); + } + + public function addMember(Request $request, string $cn): RedirectResponse + { + $data = $request->validate([ + 'samaccountname' => ['required', 'string'], + ]); + + $group = $this->findByCn($cn); + $user = User::query()->where('samaccountname', '=', $data['samaccountname'])->firstOrFail(); + + $group->members()->attach($user); + + $this->audit->log('group.member_add', [ + 'target_type' => 'group', 'target' => $cn, 'target_dn' => $group->getDn(), + 'context' => ['member' => $data['samaccountname']], + ]); + + return back()->with('status', __('groups.flash.member_added')); + } + + public function removeMember(Request $request, string $cn): RedirectResponse + { + $data = $request->validate([ + 'member_dn' => ['required', 'string'], + 'reason' => ['nullable', 'string', 'max:1000'], + ]); + + $group = $this->findByCn($cn); + $member = Entry::query()->findByDn($data['member_dn']); + + if ($member) { + $group->members()->detach($member); + } + + $this->audit->log('group.member_remove', [ + 'target_type' => 'group', 'target' => $cn, 'target_dn' => $group->getDn(), + 'reason' => $data['reason'] ?? null, + 'context' => ['member_dn' => $data['member_dn']], + ]); + + return back()->with('status', __('groups.flash.member_removed')); + } + + public function destroy(Request $request, string $cn): RedirectResponse + { + $data = $request->validate(['reason' => ['nullable', 'string', 'max:1000']]); + + $group = $this->findByCn($cn); + $dn = $group->getDn(); + $group->delete(); + + $this->audit->log('group.delete', [ + 'target_type' => 'group', 'target' => $cn, 'target_dn' => $dn, + 'reason' => $data['reason'] ?? null, + ]); + + return back()->with('status', __('groups.flash.deleted', ['cn' => $cn])); + } + + protected function findByCn(string $cn): Group + { + $group = Group::query()->where('cn', '=', $cn)->first(); + + if (! $group) { + throw new ModelNotFoundException("Nie znaleziono grupy: {$cn}"); + } + + return $group; + } + + protected function groupTypeValue(string $type, string $scope): int + { + $value = match ($scope) { + 'global' => self::SCOPE_GLOBAL, + 'domain_local' => self::SCOPE_DOMAIN_LOCAL, + 'universal' => self::SCOPE_UNIVERSAL, + }; + + if ($type === 'security') { + // Bitowa suma jako liczba całkowita ze znakiem (32-bit) - AD + // przechowuje groupType jako wartość ujemną dla grup bezpieczeństwa. + $value = $value | self::TYPE_SECURITY; + $value = $value > 0x7FFFFFFF ? $value - 0x100000000 : $value; + } + + return $value; + } + + protected function presentGroup(Group $group): array + { + $groupType = (int) ($group->getFirstAttribute('grouptype') ?? 0); + $isSecurity = ($groupType & self::TYPE_SECURITY) !== 0 || $groupType < 0; + + $scopeKey = match (true) { + (abs($groupType) & self::SCOPE_DOMAIN_LOCAL) !== 0 => 'domain_local', + (abs($groupType) & self::SCOPE_UNIVERSAL) !== 0 => 'universal', + default => 'global', + }; + + $scopeLabel = match ($scopeKey) { + 'domain_local' => 'Domain Local', + 'universal' => 'Universal', + default => 'Global', + }; + + $memberCount = count($group->getAttribute('member') ?? []); + + return [ + 'cn' => $group->getFirstAttribute('cn'), + 'description' => $group->getFirstAttribute('description'), + 'mail' => $group->getFirstAttribute('mail'), + 'dn' => $group->getDn(), + 'member_count' => $memberCount, + 'type_key' => $isSecurity ? 'security' : 'distribution', + 'type_label' => $isSecurity ? 'Security' : 'Distribution', + 'scope_key' => $scopeKey, + 'scope_label' => $scopeLabel, + ]; + } +} diff --git a/app/Http/Controllers/Admin/OrgChartController.php b/app/Http/Controllers/Admin/OrgChartController.php new file mode 100644 index 0000000..698fd60 --- /dev/null +++ b/app/Http/Controllers/Admin/OrgChartController.php @@ -0,0 +1,152 @@ + directReports. + * + * Do wykresu trafiają WYŁĄCZNIE aktywne konta, które faktycznie są w + * hierarchii: mają przypisanego przełożonego LUB są czyimś przełożonym. + * Konta wyłączone oraz konta bez żadnej relacji są pomijane całkowicie. + * + * Cały zbiór pobieramy jednym zapytaniem (paged control) i budujemy drzewo + * w PHP. Wynik w cache pliku na 10 minut ("Przelicz" czyści cache). + */ + public function index(): View + { + $data = Cache::store('file')->remember('org_chart:v3', 600, fn () => $this->build()); + + return view('admin.org.index', [ + 'flat' => $data['flat'], + 'stats' => $data['stats'], + 'refreshed_at' => Cache::store('file')->get('org_chart:v3_ts', now()->format('Y-m-d H:i')), + ]); + } + + public function refresh() + { + Cache::store('file')->forget('org_chart:v3'); + Cache::store('file')->forget('org_chart:v3_ts'); + + return redirect()->route('org.index'); + } + + /** + * Buduje PŁASKĄ listę węzłów (id / parentId) dla biblioteki d3-org-chart. + * Jeden syntetyczny korzeń "__root__" spina osoby bez przełożonego. + * + * @return array{flat: list>, stats: array} + */ + protected function build(): array + { + $users = User::query() + ->in(config('ldap.defaults.user_ou'))->recursive() + ->select(['cn', 'samaccountname', 'title', 'department', 'manager', 'directReports', 'userAccountControl', 'distinguishedName']) + ->orderBy('cn') + ->paginate(500); + + // Węzły kwalifikujące się do wykresu: aktywne + faktycznie w hierarchii. + $byDn = []; // dn(lower) => sam + $nodes = []; // sam => dane węzła + foreach ($users as $u) { + $uac = (int) ($u->getFirstAttribute('useraccountcontrol') ?? 0); + if ($uac & LdapAttributeHelper::UAC_ACCOUNTDISABLE) { + continue; // tylko aktywni + } + + $managerDn = trim((string) $u->getFirstAttribute('manager')); + $reports = array_filter(array_map('trim', $u->getAttribute('directreports') ?? [])); + + if ($managerDn === '' && count($reports) === 0) { + continue; // poza hierarchią - nie pokazujemy wcale + } + + $sam = $u->getFirstAttribute('samaccountname'); + $byDn[strtolower(trim((string) $u->getDn()))] = $sam; + $nodes[$sam] = [ + 'sam' => $sam, + 'cn' => $u->getFirstAttribute('cn') ?: $sam, + 'title' => $u->getFirstAttribute('title'), + 'department' => $u->getFirstAttribute('department'), + 'manager_dn' => $managerDn !== '' ? strtolower($managerDn) : null, + ]; + } + + // parentId = sam przełożonego, jeśli jest w zbiorze; inaczej korzeń. + $parentOf = []; + foreach ($nodes as $sam => $n) { + $pdn = $n['manager_dn']; + $parentOf[$sam] = ($pdn && isset($byDn[$pdn]) && $byDn[$pdn] !== $sam) + ? $byDn[$pdn] + : '__root__'; + } + + // Rozbijanie cykli manager -> ... -> ten sam (d3-org-chart tego nie zniesie). + foreach (array_keys($parentOf) as $sam) { + $seen = [$sam => true]; + $cur = $parentOf[$sam]; + $guard = 0; + while ($cur !== '__root__' && $guard++ < 100) { + if (isset($seen[$cur])) { + $parentOf[$sam] = '__root__'; + break; + } + $seen[$cur] = true; + $cur = $parentOf[$cur] ?? '__root__'; + } + } + + $childCount = array_count_values(array_values($parentOf)); // parent sam => liczba dzieci + + $flat = [[ + 'id' => '__root__', + 'parentId' => '', + 'name' => config('ldap.defaults.domain_label'), + 'title' => __('org.top_level'), + 'department' => null, + 'sam' => null, + 'root' => true, + ]]; + + $cmp = fn ($a, $b) => strcasecmp((string) $a['name'], (string) $b['name']); + $people = []; + foreach ($nodes as $sam => $n) { + $people[] = [ + 'id' => $sam, + 'parentId' => $parentOf[$sam], + 'name' => $n['cn'], + 'title' => $n['title'], + 'department' => $n['department'], + 'sam' => $sam, + ]; + } + usort($people, $cmp); + $flat = array_merge($flat, $people); + + $managers = 0; + foreach ($childCount as $parentSam => $count) { + if ($parentSam !== '__root__' && $count > 0) { + $managers++; + } + } + + Cache::store('file')->put('org_chart:v3_ts', now()->format('Y-m-d H:i'), 600); + + return [ + 'flat' => $flat, + 'stats' => [ + 'people' => count($nodes), + 'managers' => $managers, + 'top' => $childCount['__root__'] ?? 0, + ], + ]; + } +} diff --git a/app/Http/Controllers/Admin/UserController.php b/app/Http/Controllers/Admin/UserController.php new file mode 100644 index 0000000..1fa88d6 --- /dev/null +++ b/app/Http/Controllers/Admin/UserController.php @@ -0,0 +1,1665 @@ +boolean('refresh_ou')) { + OuTreeService::flush(); + } + + $attributeColumns = $settings->attributeColumns(); + + // Domyślnie zaznaczamy najwyższe OU (root) - jak po jego kliknięciu. + // ?all_ou=1 wyłącza filtr i pokazuje całą domenę. + $allOu = $request->boolean('all_ou'); + $rootDn = $allOu ? null : $ouTree->rootDn('users'); + $ouDn = $request->query('ou') ?: $rootDn; + + $search = trim((string) $request->query('q', '')); + $includeSubOus = $request->boolean('sub_ous', true); + $perPage = max(10, min(200, (int) $request->query('per_page', 25))); + + // Filtr "status konta": '' = wszystkie, 'enabled' = aktywne, 'disabled' = wyłączone. + $status = $request->query('status'); + $status = in_array($status, ['enabled', 'disabled'], true) ? $status : ''; + + // Filtry z kafelków pulpitu - warunki nieprzekładalne na filtr LDAP + // (zależą od polityki haseł panelu), więc liczone w PHP po pobraniu. + $filter = $this->tileFilter($request); + + $maxPasswordAgeDays = $settings->passwordMaxAgeDays(); + + // 1. Pobieramy dopasowane rekordy z LDAP (paged results control - bez limitu 1000). + $results = $this->matchingUsersQuery($request, $ouTree)->paginate(500); + + // 1a. Filtr z kafelka pulpitu (locked / hasło / nieaktywne / wygasłe konto). + if ($filter !== '') { + $results = $results + ->filter(fn (User $u) => $this->passesTileFilter($u, $filter, $maxPasswordAgeDays)) + ->values(); + } + + // 2. Ręczne stronnicowanie przy użyciu LengthAwarePaginator Laravela + $page = LengthAwarePaginator::resolveCurrentPage(); + $total = $results->count(); + + $users = new LengthAwarePaginator( + $results->forPage($page, $perPage)->values(), + $total, + $perPage, + $page, + ['path' => LengthAwarePaginator::resolveCurrentPath()] + ); + + // Linki paginacji zachowują filtry, ale nie znacznik partial=1. + $users->appends(collect($request->query())->except(['partial', 'page'])->all()); + + // 3. Mapowanie obiektów na obiekty prezetacji (działa z wycinkiem z Paginatora Laravela) + $rows = collect($users->items())->map(function (User $user) use ($maxPasswordAgeDays, $attributeColumns, $cipher) { + return $this->presentUser($user, $maxPasswordAgeDays, $attributeColumns, $cipher); + }); + + $viewData = [ + 'currentOu' => $ouDn, + 'allOu' => $allOu, + 'search' => $search, + 'status' => $status, + 'filter' => $filter, + 'includeSubOus' => $includeSubOus, + 'users' => $users, + 'rows' => $rows, + 'attributeColumns' => $attributeColumns, + 'attributeCategories' => $settings->categories(), + 'editableAttributesByCategory' => $settings->editableAttributesByCategory(), + 'passwordGenerator' => $settings->passwordGenerator(), + 'requireReason' => $settings->requireReason(), + ]; + + // Dynamiczne wyszukiwanie / paginacja - zwracamy sam region wyników. + if ($request->boolean('partial')) { + return view('admin.users.partials.results', $viewData); + } + + return view('admin.users.index', $viewData + [ + 'ouTree' => $ouTree->tree('users'), + ]); + } + + /** + * Eksport CSV całej dopasowanej listy (respektuje OU / status / szukaj / + * filtr z kafelka pulpitu) - bez paginacji, strumieniowo. + */ + public function export(Request $request, OuTreeService $ouTree, PanelSettingsService $settings, AttributeCipher $cipher): StreamedResponse + { + $filter = $this->tileFilter($request); + $maxPasswordAgeDays = $settings->passwordMaxAgeDays(); + $attributeColumns = $settings->attributeColumns(); + $query = $this->matchingUsersQuery($request, $ouTree); + + $headings = array_merge( + ['sAMAccountName', __('users.view_modal.title')], + array_map(fn ($c) => $c['label'], $attributeColumns), + ); + + return response()->streamDownload(function () use ($query, $filter, $maxPasswordAgeDays, $attributeColumns, $cipher, $headings) { + $out = fopen('php://output', 'w'); + fprintf($out, "\xEF\xBB\xBF"); // BOM - Excel rozpozna UTF-8 + fputcsv($out, $headings); + + $query->chunk(1000, function ($users) use ($out, $filter, $maxPasswordAgeDays, $attributeColumns, $cipher) { + foreach ($users as $user) { + if ($filter !== '' && ! $this->passesTileFilter($user, $filter, $maxPasswordAgeDays)) { + continue; + } + + $row = $this->presentUser($user, $maxPasswordAgeDays, $attributeColumns, $cipher); + $line = [$row['samaccountname'], $row['display_name']]; + + foreach ($attributeColumns as $col) { + $line[] = match ($col['key']) { + 'status' => $row['status_label'], + 'password_expiry' => $row['password_never_expires'] + ? __('users.table.password_never_expires') + : optional($row['password_expires_at'])->format('Y-m-d') ?? '', + 'account_expiry' => optional($row['account_expires_at'] ?? null)->format('Y-m-d') + ?? __('users.table.account_never_expires'), + default => (string) ($row['attrs'][$col['key']] ?? ''), + }; + } + + fputcsv($out, $line); + } + }); + + fclose($out); + }, 'users-'.now()->format('Ymd-His').'.csv', ['Content-Type' => 'text/csv; charset=UTF-8']); + } + + /** + * Nazwa filtra z kafelka pulpitu, jeśli poprawna (inaczej pusty string). + */ + protected function tileFilter(Request $request): string + { + $filter = (string) $request->query('filter', ''); + + return in_array($filter, ['locked', 'pwd_never', 'pwd_expiring', 'pwd_expired', 'inactive', 'account_expired', 'no_manager'], true) + ? $filter + : ''; + } + + /** + * Buduje zapytanie LDAP listy użytkowników na podstawie parametrów żądania + * (OU / status / pełnotekstowe szukanie). Wspólne dla listy i eksportu. + */ + protected function matchingUsersQuery(Request $request, OuTreeService $ouTree): LdapBuilder + { + $allOu = $request->boolean('all_ou'); + $ouDn = $request->query('ou') ?: ($allOu ? null : $ouTree->rootDn('users')); + $includeSubOus = $request->boolean('sub_ous', true); + + $status = $request->query('status'); + $status = in_array($status, ['enabled', 'disabled'], true) ? $status : ''; + + $search = trim((string) $request->query('q', '')); + + $query = User::query(); + + if ($ouDn) { + $query->in($ouDn); + $includeSubOus ? $query->recursive() : $query->listing(); + } + + if ($status === 'enabled') { + $query->whereEnabled(); + } elseif ($status === 'disabled') { + $query->whereDisabled(); + } + + if ($search !== '') { + // Pełnotekstowo po wszystkich atrybutach z konfiguracji + cn. + $searchAttrs = array_values(array_unique(array_merge( + array_map('strtolower', array_values(User::attributeMap())), + ['cn'], + ))); + + $query->orFilter(function ($q) use ($searchAttrs, $search) { + foreach ($searchAttrs as $attr) { + $q->whereContains($attr, $search); + } + }); + } + + $selectAttrs = $this->tileFilter($request) === 'no_manager' + ? array_merge(User::panelAttributes(), ['manager', 'directReports']) + : User::panelAttributes(); + + return $query->select($selectAttrs)->orderBy('cn'); + } + + /** + * Czy konto spełnia warunek filtra z kafelka pulpitu (liczony w PHP). + */ + protected function passesTileFilter(User $u, string $filter, ?int $maxPasswordAgeDays): bool + { + $now = now(); + + switch ($filter) { + case 'locked': + return LdapAttributeHelper::isLocked($u); + case 'pwd_never': + return LdapAttributeHelper::passwordNeverExpires($u); + case 'pwd_expiring': + $e = LdapAttributeHelper::passwordExpiresAt($u, $maxPasswordAgeDays); + + return $e && ! $e->isPast() && $e->lte($now->copy()->addDays(7)); + case 'pwd_expired': + $e = LdapAttributeHelper::passwordExpiresAt($u, $maxPasswordAgeDays); + + return $e && $e->isPast(); + case 'inactive': + if (LdapAttributeHelper::isDisabled($u)) { + return false; + } + $ll = LdapAttributeHelper::windowsTimestampToCarbon($u->getFirstAttribute('lastlogontimestamp')); + + return ! $ll || $ll->lt($now->copy()->subDays(90)); + case 'account_expired': + $a = LdapAttributeHelper::accountExpiresAt($u); + + return $a && $a->isPast(); + case 'no_manager': + if (LdapAttributeHelper::isDisabled($u)) { + return false; + } + + return trim((string) $u->getFirstAttribute('manager')) === '' + && count(array_filter(array_map('trim', $u->getAttribute('directreports') ?? []))) === 0; + default: + return true; + } + } + + /** + * Zwraca pełne dane pojedynczego użytkownika (do modala "Podgląd / Edycja"). + */ + public function show(string $samaccountname, AttributeCipher $cipher): JsonResponse + { + $user = $this->findBySam($samaccountname); + + // Wartości atrybutów z mapy, kluczowane kluczami z konfiguracji panelu + // (np. 'firstName', 'employeeID', 'google') - używane przez formularz profilu. + $attributes = $user->mappedAttributes(); + + // Atrybuty oznaczone jako szyfrowane pokazujemy odszyfrowane. + foreach (User::attributeDefinitions() as $def) { + if (! empty($def['encrypted']) && array_key_exists($def['key'], $attributes)) { + $attributes[$def['key']] = $cipher->decrypt($attributes[$def['key']]); + } + } + + // Wygasanie konta (accountExpires) - do formularza jako czytelna data + // (Y-m-d), nie surowy Windows FileTime; puste = konto nigdy nie wygasa. + // Pole pojawia się w oknie tylko gdy w konfiguracji jest edytowalne. + $attributes['account_expiry'] = optional(LdapAttributeHelper::accountExpiresAt($user))->format('Y-m-d'); + + // Przełożony (manager) + podwładni (directReports - backlink w AD). + $resolveDn = function (string $dn): ?Model { + try { + return User::query()->select(['cn', 'samaccountname'])->find($dn); + } catch (\Throwable) { + return null; + } + }; + + $manager = null; + if ($managerDn = $user->getFirstAttribute('manager')) { + $m = $resolveDn($managerDn); + $manager = [ + 'dn' => $managerDn, + 'name' => $m?->getFirstAttribute('cn') ?? $managerDn, + 'samaccountname' => $m?->getFirstAttribute('samaccountname'), + ]; + } + + $directReports = collect($user->getAttribute('directreports') ?? []) + ->map(function ($dn) use ($resolveDn) { + $r = $resolveDn($dn); + + return [ + 'dn' => $dn, + 'name' => $r?->getFirstAttribute('cn') ?? $dn, + 'samaccountname' => $r?->getFirstAttribute('samaccountname'), + ]; + }) + ->sortBy('name') + ->values(); + + // Zdjęcie (thumbnailPhoto) jako data URI. + $photo = null; + if ($raw = $user->getFirstAttribute('thumbnailphoto')) { + $photo = 'data:image/jpeg;base64,'.base64_encode($raw); + } + + return response()->json([ + // Klucze płaskie zachowane dla zgodności z istniejącym frontendem. + 'samaccountname' => $attributes['login'] ?? $user->getFirstAttribute('samaccountname'), + 'given_name' => $attributes['firstName'] ?? null, + 'surname' => $attributes['lastName'] ?? null, + 'display_name' => $user->getFirstAttribute('cn'), + 'mail' => $attributes['email'] ?? null, + 'telephone' => $attributes['telephoneNumber'] ?? null, + 'title' => $attributes['title'] ?? null, + 'department' => $attributes['department'] ?? null, + 'upn' => $user->getFirstAttribute('userprincipalname'), + 'dn' => $user->getDn(), + 'attributes' => $attributes, + 'password_last_set' => optional(LdapAttributeHelper::passwordLastSet($user))->format('Y-m-d H:i'), + 'account_expires_at' => optional(LdapAttributeHelper::accountExpiresAt($user))->format('Y-m-d'), + 'disabled' => LdapAttributeHelper::isDisabled($user), + 'locked' => LdapAttributeHelper::isLocked($user), + 'password_never_expires' => LdapAttributeHelper::passwordNeverExpires($user), + 'manager' => $manager, + 'direct_reports' => $directReports, + 'photo' => $photo, + 'groups' => collect($user->groups()->get())->map(fn ($g) => [ + 'name' => $g->getFirstAttribute('cn'), + 'dn' => $g->getDn(), + ])->values(), + ]); + } + + /** + * Tworzy nowego użytkownika w wybranej OU. + */ + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'given_name' => ['required', 'string', 'max:64'], + 'surname' => ['required', 'string', 'max:64'], + 'samaccountname' => ['required', 'string', 'max:20', 'regex:/^[^\\/\\\\\\[\\]:;|=,+*?<>"@]+$/'], + 'mail' => ['nullable', 'email'], + 'ou' => ['required', 'string'], + 'password' => ['required', 'string', 'min:8'], + 'must_change_password' => ['nullable', 'boolean'], + 'account_enabled' => ['nullable', 'boolean'], + // Wzorzec ("utwórz jak...") - kopiuje atrybuty organizacyjne i grupy. + 'template_sam' => ['nullable', 'string', 'max:64'], + 'copy_attributes' => ['nullable', 'boolean'], + 'copy_groups' => ['nullable', 'boolean'], + // Onboarding: wybrane grupy. + 'groups' => ['nullable', 'array'], + 'groups.*' => ['string', 'max:256'], + ]); + + $domain = config('ldap.defaults.domain_label'); + $upnPrefix = $data['samaccountname']; + + $template = null; + if (! empty($data['template_sam'])) { + $template = User::query()->where('samaccountname', '=', $data['template_sam'])->first(); + } + + $user = new User; + $user->inside($data['ou']); + $user->cn = $data['given_name'].' '.$data['surname']; + $user->givenname = $data['given_name']; + $user->sn = $data['surname']; + $user->samaccountname = $data['samaccountname']; + $user->userprincipalname = "{$upnPrefix}@{$domain}"; + + if (! empty($data['mail'])) { + $user->mail = $data['mail']; + } + + if ($template && ! empty($data['copy_attributes'])) { + foreach (['title', 'department', 'company', 'physicaldeliveryofficename', 'co', 'st', 'l', 'streetaddress', 'postalcode', 'manager'] as $attr) { + if ($v = $template->getFirstAttribute($attr)) { + $user->setFirstAttribute($attr, $v); + } + } + } + + $user->unicodepwd = $this->encodePassword($data['password']); + + $uac = 512; // NORMAL_ACCOUNT + if (empty($data['account_enabled'])) { + $uac |= LdapAttributeHelper::UAC_ACCOUNTDISABLE; + } + $user->userAccountControl = $uac; + + $user->save(); + + if (! empty($data['must_change_password'])) { + $user->pwdlastset = 0; + $user->save(); + } + + // Grupy: z wzorca (jeśli zaznaczono) + wybrane wprost, bez duplikatów. + $addedGroups = []; + $failedGroups = []; + $seenGroupDn = []; + + $attachGroup = function (?Group $group, string $label) use ($user, &$addedGroups, &$failedGroups, &$seenGroupDn) { + if (! $group) { + $failedGroups[] = $label; + + return; + } + $dn = strtolower($group->getDn()); + if (isset($seenGroupDn[$dn])) { + return; + } + $seenGroupDn[$dn] = true; + try { + $group->members()->attach($user); + $addedGroups[] = $group->getFirstAttribute('cn') ?: $label; + } catch (\Throwable) { + $failedGroups[] = $group->getFirstAttribute('cn') ?: $label; + } + }; + + if ($template && ! empty($data['copy_groups'])) { + foreach ($template->groups()->get() as $group) { + $attachGroup($group, (string) ($group->getFirstAttribute('cn') ?? '')); + } + } + + foreach (array_unique($data['groups'] ?? []) as $cn) { + $cn = trim((string) $cn); + if ($cn === '') { + continue; + } + $attachGroup(Group::query()->where('cn', '=', $cn)->first(), $cn); + } + + $copiedGroups = count($addedGroups); + $suffix = $template + ? __('users.flash.created_suffix', [ + 'template' => $data['template_sam'], + 'groups' => $copiedGroups + ? __('users.flash.created_suffix_groups', ['count' => $copiedGroups]) + : '', + ]) + : ''; + + $this->audit->log('user.create', [ + 'target_type' => 'user', 'target' => $data['samaccountname'], 'target_dn' => $user->getDn(), + 'context' => [ + 'ou' => $data['ou'], + 'template' => $data['template_sam'] ?? null, + 'groups' => $addedGroups, + 'groups_failed' => $failedGroups, + 'enabled' => ! empty($data['account_enabled']), + ], + ]); + + return back() + ->with('status', __('users.flash.created', ['name' => $data['samaccountname'], 'suffix' => $suffix])) + ->with('created_user', [ + 'sam' => $data['samaccountname'], + 'dn' => $user->getDn(), + 'enabled' => ! empty($data['account_enabled']), + 'must_change_password' => ! empty($data['must_change_password']), + 'groups' => $addedGroups, + 'groups_failed' => $failedGroups, + ]); + } + + /** + * Aktualizuje dane ogólne / atrybuty konta. + */ + public function update(Request $request, string $samaccountname, AttributeCipher $cipher): RedirectResponse + { + $user = $this->findBySam($samaccountname); + + // Zestaw pól edytowalnych pochodzi z konfiguracji panelu (zakładka "Atrybuty"). + $editable = User::editableAttributes(); + + // Klucze pól oznaczonych jako szyfrowane oraz mapa klucz => typ pola. + $definitions = collect(User::attributeDefinitions()); + $encryptedFields = $definitions + ->filter(fn ($d) => ! empty($d['encrypted'])) + ->pluck('key') + ->all(); + $fieldTypes = $definitions->pluck('type', 'key')->all(); + // Reguły walidacji konfigurowane per atrybut (zakładka "Atrybuty"). + $fieldRegex = $definitions->pluck('regex', 'key')->all(); + $fieldRequired = $definitions->pluck('required', 'key')->all(); + + // Pola imię/nazwisko/displayName/login oraz wygasanie konta mają + // szczególną obsługę, ale tylko jeśli w konfiguracji są oznaczone jako + // edytowalne. + $special = ['firstName', 'lastName', 'displayName', 'login', 'account_expiry']; + + $rules = []; + + if (isset($editable['firstName'])) { + $rules['firstName'] = ['required', 'string', 'max:64']; + } + if (isset($editable['lastName'])) { + $rules['lastName'] = ['required', 'string', 'max:64']; + } + if (isset($editable['displayName'])) { + $rules['displayName'] = ['nullable', 'string', 'max:256']; + } + if (isset($editable['login'])) { + $rules['login'] = ['required', 'string', 'max:20', 'regex:/^[^\\/\\\\\\[\\]:;|=,+*?<>"@]+$/']; + } + if (isset($editable['account_expiry'])) { + $rules['account_expiry'] = ['nullable', 'date']; + } + + foreach ($editable as $field => $ldapAttribute) { + if (isset($rules[$field]) || in_array($field, $special, true)) { + continue; + } + + $fieldRule = match (true) { + $field === 'email' => ['nullable', 'email', 'max:320'], + ($fieldTypes[$field] ?? 'text') === 'number' => ['nullable', 'numeric'], + ($fieldTypes[$field] ?? 'text') === 'date' => ['nullable', 'date'], + default => ['nullable', 'string', 'max:2048'], + }; + + // Pole wymagane w konfiguracji: zamień "nullable" na "required". + if (! empty($fieldRequired[$field])) { + $fieldRule = array_values(array_diff($fieldRule, ['nullable'])); + array_unshift($fieldRule, 'required'); + } + + // Wzorzec (regex z ogranicznikami) - stosowany tylko do niepustych wartości. + if (! empty($fieldRegex[$field])) { + $fieldRule[] = 'regex:'.$fieldRegex[$field]; + } + + $rules[$field] = $fieldRule; + } + + $data = $request->validate($rules); + + // Imię / nazwisko + pochodne (cn, displayName). + $hasFirst = isset($editable['firstName']); + $hasLast = isset($editable['lastName']); + + if ($hasFirst) { + $user->givenname = $data['firstName']; + } + if ($hasLast) { + $user->sn = $data['lastName']; + } + if ($hasFirst && $hasLast) { + $user->cn = $data['firstName'].' '.$data['lastName']; + } + if (isset($editable['displayName'])) { + $user->displayname = ($data['displayName'] ?? '') !== '' + ? $data['displayName'] + : trim(($data['firstName'] ?? $user->getFirstAttribute('givenname')).' '.($data['lastName'] ?? $user->getFirstAttribute('sn'))); + } + + // sAMAccountName (login) - zmiana nazwy logowania. + if (isset($editable['login']) && ($data['login'] ?? '') !== '') { + $user->setFirstAttribute('samaccountname', $data['login']); + } + + // Wygasanie konta (accountExpires) - realny zapis w formacie Windows + // FileTime. Pusta data = konto nigdy nie wygasa. + if (isset($editable['account_expiry'])) { + $user->accountexpires = ($data['account_expiry'] ?? '') !== '' + ? LdapAttributeHelper::dateToWindowsTimestamp( + Carbon::parse($data['account_expiry'])->endOfDay() + ) + : LdapAttributeHelper::ACCOUNT_EXPIRES_NEVER; + } + + // Pozostałe pola zapisujemy generycznie na bazie mapy atrybutów. + $changes = []; + + foreach ($editable as $field => $ldapAttribute) { + if (in_array($field, $special, true)) { + continue; + } + + $value = ($data[$field] ?? '') !== '' ? $data[$field] : null; + $isEncrypted = in_array($field, $encryptedFields, true); + + $old = $user->getFirstAttribute($ldapAttribute); + if ((string) ($old ?? '') !== (string) ($value ?? '')) { + $changes[$field] = $isEncrypted + ? ['***', '***'] + : [$old, $value]; + } + + if ($value !== null && $isEncrypted) { + $value = $cipher->encrypt($value); + } + + $user->setFirstAttribute($ldapAttribute, $value); + } + + $user->save(); + + Cache::store('file')->forget('org_chart:v3'); + Cache::store('file')->forget('dashboard:summary'); + + $this->audit->log('user.update', [ + 'target_type' => 'user', 'target' => $samaccountname, 'target_dn' => $user->getDn(), + 'changes' => $changes ?: null, + ]); + + return back()->with('status', __('users.flash.updated')); + } + + /** + * Resetuje hasło użytkownika. + */ + public function resetPassword(Request $request, string $samaccountname): RedirectResponse + { + $data = $request->validate([ + 'password' => ['required', 'string', 'min:8'], + 'must_change_password' => ['nullable', 'boolean'], + 'unlock_account' => ['nullable', 'boolean'], + 'reason' => $this->reasonRule(false), + ]); + + $user = $this->findBySam($samaccountname); + $user->unicodepwd = $this->encodePassword($data['password']); + $user->save(); + + if (! empty($data['must_change_password'])) { + $user->pwdlastset = 0; + } else { + $user->pwdlastset = -1; + } + + if (! empty($data['unlock_account'])) { + $user->lockouttime = 0; + } + + $user->save(); + + $this->audit->log('user.reset_password', [ + 'target_type' => 'user', 'target' => $samaccountname, 'target_dn' => $user->getDn(), + 'reason' => $data['reason'] ?? null, + 'context' => [ + 'must_change_password' => (bool) ($data['must_change_password'] ?? false), + 'unlocked' => (bool) ($data['unlock_account'] ?? false), + ], + ]); + + return back()->with('status', __('users.flash.password_reset', ['name' => $samaccountname])); + } + + /** + * Ustawia politykę wygasania hasła dla konta. + */ + public function passwordExpiry(Request $request, string $samaccountname): RedirectResponse + { + $data = $request->validate([ + 'never_expires' => ['nullable', 'boolean'], + 'account_never' => ['nullable', 'boolean'], + 'account_expire_on' => ['nullable', 'date'], + 'reason' => $this->reasonRule(false), + ]); + + $user = $this->findBySam($samaccountname); + $uac = LdapAttributeHelper::userAccountControl($user); + + if (! empty($data['never_expires'])) { + $uac |= LdapAttributeHelper::UAC_DONT_EXPIRE_PASSWORD; + } else { + $uac &= ~LdapAttributeHelper::UAC_DONT_EXPIRE_PASSWORD; + } + + $user->userAccountControl = $uac; + + // Wygaśnięcie konta (accountExpires) - realny zapis, nie hack na "info". + if (! empty($data['account_never'])) { + $user->accountexpires = LdapAttributeHelper::ACCOUNT_EXPIRES_NEVER; + } elseif (! empty($data['account_expire_on'])) { + $user->accountexpires = LdapAttributeHelper::dateToWindowsTimestamp( + Carbon::parse($data['account_expire_on'])->endOfDay() + ); + } + + $user->save(); + + $this->audit->log('user.password_expiry', [ + 'target_type' => 'user', 'target' => $samaccountname, 'target_dn' => $user->getDn(), + 'reason' => $data['reason'] ?? null, + 'context' => [ + 'password_never_expires' => (bool) ($data['never_expires'] ?? false), + 'account_never' => (bool) ($data['account_never'] ?? false), + 'account_expire_on' => $data['account_expire_on'] ?? null, + ], + ]); + + return back()->with('status', __('users.flash.expiry_updated')); + } + + /** + * Przenosi użytkownika do innej jednostki organizacyjnej. + */ + public function move(Request $request, string $samaccountname): RedirectResponse + { + $data = $request->validate([ + 'target_ou' => ['required', 'string'], + 'reason' => $this->reasonRule(true), + ]); + + $user = $this->findBySam($samaccountname); + $fromDn = $user->getDn(); + $user->move($data['target_ou']); + + $this->audit->log('user.move', [ + 'target_type' => 'user', 'target' => $samaccountname, 'target_dn' => $fromDn, + 'reason' => $data['reason'] ?? null, + 'changes' => ['ou' => [$fromDn, $data['target_ou']]], + ]); + + return back()->with('status', __('users.flash.moved')); + } + + /** + * Włącza / wyłącza konto i czyści lockoutTime. + */ + public function toggleEnabled(Request $request, string $samaccountname): RedirectResponse + { + $user = $this->findBySam($samaccountname); + $uac = LdapAttributeHelper::userAccountControl($user); + + if (LdapAttributeHelper::isDisabled($user)) { + $uac &= ~LdapAttributeHelper::UAC_ACCOUNTDISABLE; + $message = __('users.flash.unlocked'); + $action = 'user.enable'; + } else { + $uac |= LdapAttributeHelper::UAC_ACCOUNTDISABLE; + $message = __('users.flash.locked'); + $action = 'user.disable'; + } + + $user->userAccountControl = $uac; + $user->lockouttime = 0; + $user->save(); + + $this->audit->log($action, [ + 'target_type' => 'user', 'target' => $samaccountname, 'target_dn' => $user->getDn(), + 'reason' => $request->input('reason') ?: null, + ]); + + return back()->with('status', $message); + } + + public function destroy(Request $request, string $samaccountname): RedirectResponse + { + $data = $request->validate(['reason' => $this->reasonRule(true)]); + + $user = $this->findBySam($samaccountname); + $dn = $user->getDn(); + $user->delete(); + + $this->audit->log('user.delete', [ + 'target_type' => 'user', 'target' => $samaccountname, 'target_dn' => $dn, + 'reason' => $data['reason'] ?? null, + ]); + + return back()->with('status', __('users.flash.deleted')); + } + + /** + * Wgrywa / usuwa zdjęcie (thumbnailPhoto) - skalowane do 96x96 JPEG. + */ + public function photo(Request $request, string $samaccountname): RedirectResponse + { + $data = $request->validate([ + 'photo' => ['nullable', 'image', 'mimes:jpeg,jpg,png', 'max:2048'], + 'remove_photo' => ['nullable', 'boolean'], + ]); + + $user = $this->findBySam($samaccountname); + + if (! empty($data['remove_photo'])) { + $user->thumbnailphoto = null; + $user->save(); + $this->audit->log('user.photo', [ + 'target_type' => 'user', 'target' => $samaccountname, 'target_dn' => $user->getDn(), + 'context' => ['removed' => true], + ]); + + return back()->with('status', __('users.flash.photo_removed')); + } + + if ($request->hasFile('photo')) { + $user->thumbnailphoto = $this->makeThumbnail($request->file('photo')->get()); + $user->save(); + $this->audit->log('user.photo', [ + 'target_type' => 'user', 'target' => $samaccountname, 'target_dn' => $user->getDn(), + ]); + + return back()->with('status', __('users.flash.photo_saved')); + } + + return back(); + } + + /** + * Kadruje do kwadratu i skaluje obraz do miniatury JPEG (dla thumbnailPhoto). + */ + protected function makeThumbnail(string $raw, int $size = 96): string + { + if (! function_exists('imagecreatefromstring')) { + return $raw; // brak rozszerzenia GD - zapisujemy oryginał + } + + $src = @imagecreatefromstring($raw); + if ($src === false) { + return $raw; + } + + $w = imagesx($src); + $h = imagesy($src); + $side = min($w, $h); + + $dst = imagecreatetruecolor($size, $size); + imagecopyresampled( + $dst, $src, + 0, 0, + (int) (($w - $side) / 2), (int) (($h - $side) / 2), + $size, $size, $side, $side + ); + + ob_start(); + imagejpeg($dst, null, 82); + $out = ob_get_clean(); + + imagedestroy($src); + imagedestroy($dst); + + return $out !== false && $out !== '' ? $out : $raw; + } + + /** + * Akcje masowe. + */ + public function bulkResetPassword(Request $request): RedirectResponse + { + $data = $request->validate([ + 'samaccountnames' => ['required', 'array', 'min:1'], + 'samaccountnames.*' => ['string'], + 'password' => ['required', 'string', 'min:8'], + 'must_change_password' => ['nullable', 'boolean'], + 'unlock_account' => ['nullable', 'boolean'], + 'reason' => $this->reasonRule(false), + ]); + + foreach ($data['samaccountnames'] as $sam) { + $user = $this->findBySam($sam); + $user->unicodepwd = $this->encodePassword($data['password']); + $user->pwdlastset = empty($data['must_change_password']) ? -1 : 0; + + if (! empty($data['unlock_account'])) { + $user->lockouttime = 0; + } + + $user->save(); + } + + $this->auditBulk('user.bulk_reset_password', $data['samaccountnames'], $data['reason'] ?? null); + + return back()->with('status', __('users.flash.bulk_password_reset', ['count' => count($data['samaccountnames'])])); + } + + public function bulkMove(Request $request): RedirectResponse + { + $data = $request->validate([ + 'samaccountnames' => ['required', 'array', 'min:1'], + 'samaccountnames.*' => ['string'], + 'target_ou' => ['required', 'string'], + 'reason' => $this->reasonRule(true), + ]); + + foreach ($data['samaccountnames'] as $sam) { + $this->findBySam($sam)->move($data['target_ou']); + } + + $this->auditBulk('user.bulk_move', $data['samaccountnames'], $data['reason'] ?? null, [ + 'target_ou' => $data['target_ou'], + ]); + + return back()->with('status', __('users.flash.bulk_moved', ['count' => count($data['samaccountnames'])])); + } + + public function bulkPasswordExpiry(Request $request): RedirectResponse + { + $data = $request->validate([ + 'samaccountnames' => ['required', 'array', 'min:1'], + 'samaccountnames.*' => ['string'], + 'never_expires' => ['nullable', 'boolean'], + 'reason' => $this->reasonRule(false), + ]); + + foreach ($data['samaccountnames'] as $sam) { + $user = $this->findBySam($sam); + $uac = LdapAttributeHelper::userAccountControl($user); + + if (! empty($data['never_expires'])) { + $uac |= LdapAttributeHelper::UAC_DONT_EXPIRE_PASSWORD; + } else { + $uac &= ~LdapAttributeHelper::UAC_DONT_EXPIRE_PASSWORD; + } + + $user->userAccountControl = $uac; + $user->save(); + } + + $this->auditBulk('user.bulk_password_expiry', $data['samaccountnames'], $data['reason'] ?? null, [ + 'password_never_expires' => (bool) ($data['never_expires'] ?? false), + ]); + + return back()->with('status', __('users.flash.bulk_expiry_updated', ['count' => count($data['samaccountnames'])])); + } + + /** + * Włącza lub wyłącza wskazane konto (bez przełączania - stan wprost). + * Przy włączaniu czyści też blokadę logowania (lockoutTime). + */ + public function setEnabled(Request $request, string $samaccountname): RedirectResponse + { + $data = $request->validate([ + 'enabled' => ['nullable', 'boolean'], + 'reason' => $this->reasonRule(true), + ]); + + $enabled = (bool) ($data['enabled'] ?? false); + $user = $this->findBySam($samaccountname); + $this->applyEnabled($user, $enabled); + + $this->audit->log($enabled ? 'user.enable' : 'user.disable', [ + 'target_type' => 'user', 'target' => $samaccountname, 'target_dn' => $user->getDn(), + 'reason' => $data['reason'] ?? null, + ]); + + return back()->with('status', __($enabled ? 'users.flash.unlocked' : 'users.flash.locked')); + } + + public function bulkSetEnabled(Request $request): RedirectResponse + { + $data = $request->validate([ + 'samaccountnames' => ['required', 'array', 'min:1'], + 'samaccountnames.*' => ['string'], + 'enabled' => ['nullable', 'boolean'], + 'reason' => $this->reasonRule(true), + ]); + + $enabled = (bool) ($data['enabled'] ?? false); + + foreach ($data['samaccountnames'] as $sam) { + $this->applyEnabled($this->findBySam($sam), $enabled); + } + + Cache::store('file')->forget('dashboard:summary'); + + $this->auditBulk($enabled ? 'user.bulk_enable' : 'user.bulk_disable', $data['samaccountnames'], $data['reason'] ?? null); + + return back()->with('status', __( + $enabled ? 'users.flash.bulk_enabled' : 'users.flash.bulk_disabled', + ['count' => count($data['samaccountnames'])], + )); + } + + protected function applyEnabled(User $user, bool $enabled): void + { + $uac = LdapAttributeHelper::userAccountControl($user); + + if ($enabled) { + $uac &= ~LdapAttributeHelper::UAC_ACCOUNTDISABLE; + $user->lockouttime = 0; + } else { + $uac |= LdapAttributeHelper::UAC_ACCOUNTDISABLE; + } + + $user->userAccountControl = $uac; + $user->save(); + } + + /** + * Dodaje albo usuwa wskazanych użytkowników z jednej grupy. + */ + public function bulkGroup(Request $request): RedirectResponse + { + $data = $request->validate([ + 'samaccountnames' => ['required', 'array', 'min:1'], + 'samaccountnames.*' => ['string'], + 'group_cn' => ['required', 'string'], + 'mode' => ['required', 'in:add,remove'], + 'reason' => $this->reasonRule($request->input('mode') === 'remove'), + ]); + + $group = Group::query()->where('cn', '=', $data['group_cn'])->firstOrFail(); + $add = $data['mode'] === 'add'; + $done = 0; + + foreach ($data['samaccountnames'] as $sam) { + try { + $user = $this->findBySam($sam); + $add ? $group->members()->attach($user) : $group->members()->detach($user); + $done++; + } catch (\Throwable) { + // np. brak uprawnień / grupa podstawowa - pomijamy. + } + } + + $this->auditBulk('user.bulk_group', $data['samaccountnames'], $data['reason'] ?? null, [ + 'group' => $data['group_cn'], + 'mode' => $data['mode'], + 'applied' => $done, + ]); + + return back()->with('status', __( + $add ? 'users.flash.bulk_group_added' : 'users.flash.bulk_group_removed', + ['count' => $done, 'group' => $data['group_cn']], + )); + } + + /** + * Wymusza zmianę hasła przy następnym logowaniu (pwdLastSet = 0), + * bez zmiany samego hasła. + */ + public function forcePasswordChange(Request $request, string $samaccountname): RedirectResponse + { + $user = $this->findBySam($samaccountname); + $user->pwdlastset = 0; + $user->save(); + + $this->audit->log('user.force_password_change', [ + 'target_type' => 'user', 'target' => $samaccountname, 'target_dn' => $user->getDn(), + 'reason' => $request->input('reason') ?: null, + ]); + + return back()->with('status', __('users.flash.force_pwd_change', ['name' => $samaccountname])); + } + + public function bulkForcePasswordChange(Request $request): RedirectResponse + { + $data = $request->validate([ + 'samaccountnames' => ['required', 'array', 'min:1'], + 'samaccountnames.*' => ['string'], + 'reason' => $this->reasonRule(false), + ]); + + foreach ($data['samaccountnames'] as $sam) { + $user = $this->findBySam($sam); + $user->pwdlastset = 0; + $user->save(); + } + + $this->auditBulk('user.bulk_force_password_change', $data['samaccountnames'], $data['reason'] ?? null); + + return back()->with('status', __('users.flash.bulk_force_pwd_change', ['count' => count($data['samaccountnames'])])); + } + + /** + * Odblokowuje konto (czyści lockoutTime) - bez zmiany stanu włączenia. + */ + public function unlock(Request $request, string $samaccountname): RedirectResponse + { + $user = $this->findBySam($samaccountname); + $user->lockouttime = 0; + $user->save(); + + Cache::store('file')->forget('dashboard:summary'); + + $this->audit->log('user.unlock', [ + 'target_type' => 'user', 'target' => $samaccountname, 'target_dn' => $user->getDn(), + 'reason' => $request->input('reason') ?: null, + ]); + + return back()->with('status', __('users.flash.unlocked_one', ['name' => $samaccountname])); + } + + public function bulkUnlock(Request $request): RedirectResponse + { + $data = $request->validate([ + 'samaccountnames' => ['required', 'array', 'min:1'], + 'samaccountnames.*' => ['string'], + 'reason' => $this->reasonRule(false), + ]); + + foreach ($data['samaccountnames'] as $sam) { + $user = $this->findBySam($sam); + $user->lockouttime = 0; + $user->save(); + } + + Cache::store('file')->forget('dashboard:summary'); + + $this->auditBulk('user.bulk_unlock', $data['samaccountnames'], $data['reason'] ?? null); + + return back()->with('status', __('users.flash.bulk_unlocked', ['count' => count($data['samaccountnames'])])); + } + + /** + * Klucze pól tożsamości - nie edytujemy ich masowo. + * + * @var list + */ + protected const BULK_ATTR_EXCLUDED = ['login', 'firstName', 'lastName', 'displayName']; + + /** + * Masowa edycja atrybutów: dla zaznaczonych kont ustawia tylko te pola, + * które administrator zaznaczył checkboxem ("apply"). Puste + zaznaczone + * czyści atrybut (chyba że w konfiguracji jest wymagany). + */ + public function bulkAttributes(Request $request, AttributeCipher $cipher): RedirectResponse + { + $editable = User::editableAttributes(); + $definitions = collect(User::attributeDefinitions()); + $fieldTypes = $definitions->pluck('type', 'key')->all(); + $fieldRegex = $definitions->pluck('regex', 'key')->all(); + $fieldRequired = $definitions->pluck('required', 'key')->all(); + $encryptedFields = $definitions->filter(fn ($d) => ! empty($d['encrypted']))->pluck('key')->all(); + + $allowed = array_values(array_diff(array_keys($editable), self::BULK_ATTR_EXCLUDED)); + $applied = array_values(array_intersect((array) $request->input('apply', []), $allowed)); + + $rules = [ + 'samaccountnames' => ['required', 'array', 'min:1'], + 'samaccountnames.*' => ['string'], + 'apply' => ['required', 'array', 'min:1'], + 'reason' => $this->reasonRule(false), + ]; + + foreach ($applied as $key) { + if ($key === 'manager') { + $rules['attributes.manager'] = ['nullable', 'string']; + + continue; + } + if ($key === 'account_expiry') { + $rules['attributes.account_expiry'] = ['nullable', 'date']; + + continue; + } + + $type = $fieldTypes[$key] ?? 'text'; + $rule = match (true) { + $key === 'email' => ['nullable', 'email', 'max:320'], + $type === 'number' => ['nullable', 'numeric'], + $type === 'date' => ['nullable', 'date'], + default => ['nullable', 'string', 'max:2048'], + }; + if (! empty($fieldRequired[$key])) { + $rule = array_values(array_diff($rule, ['nullable'])); + array_unshift($rule, 'required'); + } + if (! empty($fieldRegex[$key])) { + $rule[] = 'regex:'.$fieldRegex[$key]; + } + $rules['attributes.'.$key] = $rule; + } + + $data = $request->validate($rules); + $values = $data['attributes'] ?? []; + + if ($applied === []) { + return back()->with('status', __('users.flash.bulk_attributes', ['count' => 0, 'fields' => 0])); + } + + foreach ($data['samaccountnames'] as $sam) { + $user = $this->findBySam($sam); + + foreach ($applied as $key) { + if ($key === 'manager') { + $user->setFirstAttribute('manager', ($values['manager'] ?? '') !== '' ? $values['manager'] : null); + + continue; + } + if ($key === 'account_expiry') { + $user->accountexpires = ($values['account_expiry'] ?? '') !== '' + ? LdapAttributeHelper::dateToWindowsTimestamp(Carbon::parse($values['account_expiry'])->endOfDay()) + : LdapAttributeHelper::ACCOUNT_EXPIRES_NEVER; + + continue; + } + + $value = ($values[$key] ?? '') !== '' ? $values[$key] : null; + if ($value !== null && in_array($key, $encryptedFields, true)) { + $value = $cipher->encrypt($value); + } + $user->setFirstAttribute($editable[$key], $value); + } + + $user->save(); + } + + Cache::store('file')->forget('org_chart:v3'); + Cache::store('file')->forget('dashboard:summary'); + + $this->auditBulk('user.bulk_attributes', $data['samaccountnames'], $data['reason'] ?? null, [ + 'fields' => $applied, + ]); + + return back()->with('status', __('users.flash.bulk_attributes', [ + 'count' => count($data['samaccountnames']), + 'fields' => count($applied), + ])); + } + + /** + * Offboarding: wykonuje tylko zaznaczone kroki na jednym lub wielu kontach. + */ + public function offboardOne(Request $request, string $samaccountname): RedirectResponse + { + $data = $this->validateOffboard($request, single: true); + $result = $this->runOffboard($this->findBySam($samaccountname), $data['steps'], $data['target_ou'] ?? null, $data['expire_on'] ?? null); + + $this->finishOffboard([$samaccountname], [$samaccountname => $result], $data); + + return back()->with('status', __('users.flash.offboarded', ['name' => $samaccountname])); + } + + public function bulkOffboard(Request $request): RedirectResponse + { + $data = $this->validateOffboard($request, single: false); + + $results = []; + foreach ($data['samaccountnames'] as $sam) { + $results[$sam] = $this->runOffboard($this->findBySam($sam), $data['steps'], $data['target_ou'] ?? null, $data['expire_on'] ?? null); + } + + $this->finishOffboard($data['samaccountnames'], $results, $data); + + return back()->with('status', __('users.flash.bulk_offboarded', ['count' => count($data['samaccountnames'])])); + } + + /** + * @return array{samaccountnames: list, steps: array, target_ou: ?string, expire_on: ?string, reason: ?string} + */ + protected function validateOffboard(Request $request, bool $single): array + { + $rules = [ + 'step_disable' => ['nullable', 'boolean'], + 'step_groups' => ['nullable', 'boolean'], + 'step_groups_keep_primary' => ['nullable', 'boolean'], + 'step_move' => ['nullable', 'boolean'], + 'step_expire' => ['nullable', 'boolean'], + 'target_ou' => ['nullable', 'required_if:step_move,1', 'string'], + 'expire_on' => ['nullable', 'date'], + 'reason' => $this->reasonRule(true), + ]; + if (! $single) { + $rules['samaccountnames'] = ['required', 'array', 'min:1']; + $rules['samaccountnames.*'] = ['string']; + } + + $v = $request->validate($rules); + + return [ + 'samaccountnames' => $v['samaccountnames'] ?? [], + 'steps' => [ + 'disable' => (bool) ($v['step_disable'] ?? false), + 'groups' => (bool) ($v['step_groups'] ?? false), + 'groups_keep_primary' => (bool) ($v['step_groups_keep_primary'] ?? false), + 'move' => (bool) ($v['step_move'] ?? false), + 'expire' => (bool) ($v['step_expire'] ?? false), + ], + 'target_ou' => $v['target_ou'] ?? null, + 'expire_on' => $v['expire_on'] ?? null, + 'reason' => $v['reason'] ?? null, + ]; + } + + /** + * Wykonuje kroki offboardingu w bezpiecznej kolejności (przeniesienie OU na + * końcu - zmienia DN). Każdy krok w try/catch; zwraca wynik per krok. + * + * @param array $steps + * @return array + */ + protected function runOffboard(User $user, array $steps, ?string $targetOu, ?string $expireOn): array + { + $result = []; + $adminName = auth('ldap')->user()?->getFirstAttribute('samaccountname') ?? (session('ad_admin')['login'] ?? 'panel'); + $touched = false; + + if ($steps['disable'] ?? false) { + try { + $user->unicodepwd = $this->encodePassword(bin2hex(random_bytes(24))); + $user->pwdlastset = -1; + $uac = LdapAttributeHelper::userAccountControl($user) | LdapAttributeHelper::UAC_ACCOUNTDISABLE; + $user->userAccountControl = $uac; + $touched = true; + $result['disable'] = 'ok'; + } catch (\Throwable $e) { + $result['disable'] = 'error'; + } + } + + if ($steps['expire'] ?? false) { + try { + $date = $expireOn ? Carbon::parse($expireOn) : now(); + $user->accountexpires = LdapAttributeHelper::dateToWindowsTimestamp($date->copy()->endOfDay()); + $note = 'Offboarded '.$date->format('Y-m-d').' by '.$adminName; + $desc = trim((string) $user->getFirstAttribute('description')); + $user->description = $desc !== '' ? $desc.' | '.$note : $note; + $touched = true; + $result['expire'] = 'ok'; + } catch (\Throwable $e) { + $result['expire'] = 'error'; + } + } + + if ($touched) { + try { + $user->save(); + } catch (\Throwable $e) { + $result['save'] = 'error'; + + return $result; + } + } + + if ($steps['groups'] ?? false) { + $removed = 0; + $failed = 0; + foreach ($user->groups()->get() as $group) { + try { + $group->members()->detach($user); + $removed++; + } catch (\Throwable $e) { + $failed++; + } + } + $result['groups'] = "removed:{$removed}".($failed ? " failed:{$failed}" : ''); + } + + if (($steps['move'] ?? false) && $targetOu) { + try { + $user->move($targetOu); + $result['move'] = 'ok'; + } catch (\Throwable $e) { + $result['move'] = 'error'; + } + } + + return $result; + } + + /** + * Wpis(y) w dzienniku + czyszczenie cache po offboardingu. + * + * @param list $sams + * @param array> $results + * @param array{steps: array, target_ou: ?string, expire_on: ?string, reason: ?string} $data + */ + protected function finishOffboard(array $sams, array $results, array $data): void + { + Cache::store('file')->forget('org_chart:v3'); + Cache::store('file')->forget('dashboard:summary'); + + foreach ($sams as $sam) { + $this->audit->log('user.offboard', [ + 'target_type' => 'user', + 'target' => $sam, + 'reason' => $data['reason'], + 'context' => [ + 'steps' => array_keys(array_filter($data['steps'])), + 'target_ou' => $data['target_ou'], + 'expire_on' => $data['expire_on'], + 'results' => $results[$sam] ?? [], + ], + ]); + } + } + + /** + * Podpowiedzi użytkowników (typeahead) - do pól "przełożony", "kopiuj z" + * oraz dodawania członków grupy. + */ + public function search(Request $request): JsonResponse + { + $q = trim((string) $request->query('q', '')); + + if (mb_strlen($q) < 2) { + return response()->json([]); + } + + $results = User::query() + ->in(config('ldap.defaults.user_ou'))->recursive() + ->select(['cn', 'samaccountname', 'mail', 'title', 'department']) + ->orFilter(function ($sub) use ($q) { + foreach (['cn', 'samaccountname', 'mail', 'givenname', 'sn'] as $attr) { + $sub->whereContains($attr, $q); + } + }) + ->orderBy('cn') + ->limit(25) + ->paginate(25); + + return response()->json( + collect($results)->take(25)->map(fn (User $u) => [ + 'dn' => $u->getDn(), + 'cn' => $u->getFirstAttribute('cn'), + 'sam' => $u->getFirstAttribute('samaccountname'), + 'title' => $u->getFirstAttribute('title'), + 'department' => $u->getFirstAttribute('department'), + ])->values() + ); + } + + /** + * Struktura organizacyjna wokół użytkownika: łańcuch przełożonych w górę + * oraz drzewo podwładnych w dół (2 poziomy) - relacja manager / directReports. + */ + public function structure(string $samaccountname): JsonResponse + { + $user = $this->findBySam($samaccountname); + + // Łańcuch w górę (ochrona przed pętlą). + $chain = []; + $seen = []; + $current = $user; + for ($i = 0; $i < 12; $i++) { + $mgrDn = $current->getFirstAttribute('manager'); + if (! $mgrDn || isset($seen[strtolower($mgrDn)])) { + break; + } + $seen[strtolower($mgrDn)] = true; + $m = $this->safeFindDn($mgrDn, ['cn', 'samaccountname', 'title', 'manager']); + if (! $m) { + break; + } + array_unshift($chain, $this->orgNode($m)); + $current = $m; + } + + return response()->json([ + 'self' => $this->orgNode($user), + 'chain' => $chain, + 'reports' => $this->reportsTree($user, 2), + ]); + } + + /** + * Dane wzorca do "utwórz jak..." - atrybuty organizacyjne + lista grup. + */ + public function template(string $samaccountname): JsonResponse + { + $user = $this->findBySam($samaccountname); + + return response()->json([ + 'sam' => $user->getFirstAttribute('samaccountname'), + 'cn' => $user->getFirstAttribute('cn'), + 'ou' => implode(',', array_slice(explode(',', $user->getDn()), 1)), + 'attributes' => [ + 'title' => $user->getFirstAttribute('title'), + 'department' => $user->getFirstAttribute('department'), + 'company' => $user->getFirstAttribute('company'), + 'physicalDeliveryOfficeName' => $user->getFirstAttribute('physicaldeliveryofficename'), + 'co' => $user->getFirstAttribute('co'), + 'st' => $user->getFirstAttribute('st'), + 'l' => $user->getFirstAttribute('l'), + 'streetAddress' => $user->getFirstAttribute('streetaddress'), + 'postalCode' => $user->getFirstAttribute('postalcode'), + 'manager' => $user->getFirstAttribute('manager'), + ], + 'groups' => collect($user->groups()->get())->map(fn ($g) => [ + 'cn' => $g->getFirstAttribute('cn'), + 'dn' => $g->getDn(), + ])->values(), + ]); + } + + /** + * Dodaje użytkownika do grupy (z poziomu profilu użytkownika). + */ + public function addGroup(Request $request, string $samaccountname): RedirectResponse + { + $data = $request->validate(['group_cn' => ['required', 'string']]); + + $user = $this->findBySam($samaccountname); + $group = Group::query()->where('cn', '=', $data['group_cn'])->firstOrFail(); + $group->members()->attach($user); + + $this->audit->log('user.group_add', [ + 'target_type' => 'user', 'target' => $samaccountname, 'target_dn' => $user->getDn(), + 'context' => ['group' => $data['group_cn']], + ]); + + return back()->with('status', __('users.flash.group_added', ['group' => $data['group_cn']])); + } + + /** + * Usuwa użytkownika z grupy (z poziomu profilu użytkownika). + */ + public function removeGroup(Request $request, string $samaccountname): RedirectResponse + { + $data = $request->validate(['group_dn' => ['required', 'string']]); + + $user = $this->findBySam($samaccountname); + $group = $this->safeFindDn($data['group_dn']); + if ($group) { + $group->members()->detach($user); + } + + $this->audit->log('user.group_remove', [ + 'target_type' => 'user', 'target' => $samaccountname, 'target_dn' => $user->getDn(), + 'context' => ['group_dn' => $data['group_dn']], + ]); + + return back()->with('status', __('users.flash.group_removed')); + } + + /** + * Reguła walidacji pola "powód": wymagane tylko dla akcji niszczących, + * gdy w konfiguracji włączono "Wymagaj powodu". + * + * @return list + */ + protected function reasonRule(bool $destructive): array + { + return $destructive && app(PanelSettingsService::class)->requireReason() + ? ['required', 'string', 'max:1000'] + : ['nullable', 'string', 'max:1000']; + } + + /** + * Jeden wpis dziennika dla akcji masowej (lista kont w kontekście). + * + * @param list $sams + * @param array $extra + */ + protected function auditBulk(string $action, array $sams, ?string $reason = null, array $extra = []): void + { + $this->audit->log($action, [ + 'target_type' => 'user', + 'reason' => $reason, + 'context' => ['count' => count($sams), 'accounts' => array_slice(array_values($sams), 0, 200)] + $extra, + ]); + } + + protected function safeFindDn(string $dn, array $selects = ['*']): ?Model + { + try { + return User::query()->select($selects)->find($dn); + } catch (\Throwable) { + return null; + } + } + + /** + * @return array + */ + protected function orgNode(Model $u): array + { + return [ + 'dn' => $u->getDn(), + 'cn' => $u->getFirstAttribute('cn'), + 'sam' => $u->getFirstAttribute('samaccountname'), + 'title' => $u->getFirstAttribute('title'), + ]; + } + + /** + * Rekursywne drzewo podwładnych do zadanej głębokości. + * + * @return list> + */ + protected function reportsTree(Model $user, int $depth): array + { + if ($depth <= 0) { + return []; + } + + return collect($user->getAttribute('directreports') ?? []) + ->map(function ($dn) use ($depth) { + $r = $this->safeFindDn($dn, ['cn', 'samaccountname', 'title', 'directreports']); + if (! $r) { + return null; + } + + return $this->orgNode($r) + ['reports' => $this->reportsTree($r, $depth - 1)]; + }) + ->filter() + ->sortBy('cn') + ->values() + ->all(); + } + + protected function findBySam(string $samaccountname): User + { + $user = User::query()->where('samaccountname', '=', $samaccountname)->first(); + + if (! $user) { + throw new ModelNotFoundException("Nie znaleziono użytkownika: {$samaccountname}"); + } + + return $user; + } + + protected function presentUser(User $user, ?int $maxPasswordAgeDays, array $attributeColumns = [], ?AttributeCipher $cipher = null): array + { + $cipher ??= app(AttributeCipher::class); + $status = LdapAttributeHelper::statusBadge($user); + $expiresAt = LdapAttributeHelper::passwordExpiresAt($user, $maxPasswordAgeDays); + + $attrs = []; + foreach ($attributeColumns as $attr) { + if (! empty($attr['computed']) || $attr['key'] === 'account_expiry') { + continue; // status / wygasanie hasła / konta - liczone niżej + } + $value = $user->getFirstAttribute(strtolower($attr['ldap'])); + $attrs[$attr['key']] = ! empty($attr['encrypted']) ? $cipher->decrypt($value) : $value; + } + + return [ + 'attrs' => $attrs, + 'samaccountname' => $user->getFirstAttribute('samaccountname'), + 'display_name' => $user->getFirstAttribute('cn'), + 'given_name' => $user->getFirstAttribute('givenname'), + 'surname' => $user->getFirstAttribute('sn'), + 'mail' => $user->getFirstAttribute('mail'), + 'title' => $user->getFirstAttribute('title'), + 'department' => $user->getFirstAttribute('department'), + 'status_label' => $status['label'], + 'status_css' => $status['css'], + 'password_never_expires' => LdapAttributeHelper::passwordNeverExpires($user), + 'password_expires_at' => $expiresAt, + 'password_expires_soon' => $expiresAt && $expiresAt->lessThan(now()->addDays(7)), + 'account_expires_at' => LdapAttributeHelper::accountExpiresAt($user), + 'disabled' => LdapAttributeHelper::isDisabled($user), + 'locked' => LdapAttributeHelper::isLocked($user), + ]; + } + + protected function encodePassword(string $password): string + { + return iconv('UTF-8', 'UTF-16LE', '"'.$password.'"'); + } +} diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php new file mode 100644 index 0000000..82a6178 --- /dev/null +++ b/app/Http/Controllers/Auth/LoginController.php @@ -0,0 +1,114 @@ +validate([ + 'login' => ['required', 'string', 'max:255'], + 'password' => ['required', 'string'], + ], attributes: [ + 'login' => __('auth.attributes.login'), + 'password' => __('auth.attributes.password'), + ]); + + $this->ensureIsNotRateLimited($request); + + try { + $authenticated = Auth::guard('ldap')->attempt([ + 'samaccountname' => $credentials['login'], + 'password' => $credentials['password'], + ]); + } catch (LdapRecordException $e) { + report($e); + + throw ValidationException::withMessages([ + 'login' => __('auth.failed_dc'), + ]); + } + + if (! $authenticated) { + RateLimiter::hit($this->throttleKey($request)); + + throw ValidationException::withMessages([ + 'login' => __('auth.failed_credentials'), + ]); + } + + RateLimiter::clear($this->throttleKey($request)); + + $request->session()->regenerate(); + + $request->session()->put( + 'ad_admin', + Auth::guard('ldap')->user()->toPanelProfile() + ); + + return redirect()->route('dashboard.index'); + } + + public function logout(Request $request): RedirectResponse + { + Auth::guard('ldap')->logout(); + + $request->session()->forget('ad_admin'); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect()->route('login')->with('status', __('auth.logged_out')); + } + + /** + * @throws ValidationException + */ + protected function ensureIsNotRateLimited(Request $request): void + { + if (! RateLimiter::tooManyAttempts($this->throttleKey($request), self::MAX_ATTEMPTS)) { + return; + } + + $seconds = RateLimiter::availableIn($this->throttleKey($request)); + + throw ValidationException::withMessages([ + 'login' => __('auth.throttled', ['seconds' => $seconds]), + ]); + } + + protected function throttleKey(Request $request): string + { + return Str::transliterate( + Str::lower((string) $request->input('login')).'|'.$request->ip() + ); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..77ec359 --- /dev/null +++ b/app/Http/Controllers/Controller.php @@ -0,0 +1,12 @@ +session()->put('locale', $locale); + + return redirect() + ->back() + ->withCookie(cookie('locale', $locale, 60 * 24 * 365, sameSite: Cookie::SAMESITE_LAX)); + } +} diff --git a/app/Http/Controllers/PageController.php b/app/Http/Controllers/PageController.php new file mode 100644 index 0000000..f19be03 --- /dev/null +++ b/app/Http/Controllers/PageController.php @@ -0,0 +1,20 @@ + cookie ('locale') -> domyślny + * z config('app.locale'). Wartość musi być jednym z config('locales'), + * w przeciwnym razie jest ignorowana (zostaje język domyślny). + */ +class SetLocale +{ + public function handle(Request $request, Closure $next): Response + { + $supported = array_keys(config('locales', [])); + + $locale = $request->session()->get('locale') + ?? $request->cookie('locale') + ?? config('app.locale'); + + if (in_array($locale, $supported, true)) { + App::setLocale($locale); + // Utrwal wybór w sesji, gdy przyszedł tylko z cookie. + $request->session()->put('locale', $locale); + } + + return $next($request); + } +} diff --git a/app/Ldap/Rules/MemberOfLoginGroup.php b/app/Ldap/Rules/MemberOfLoginGroup.php new file mode 100644 index 0000000..e9f1c7d --- /dev/null +++ b/app/Ldap/Rules/MemberOfLoginGroup.php @@ -0,0 +1,98 @@ + nikt się nie zaloguje (fail-closed). + */ +class MemberOfLoginGroup implements Rule +{ + public function passes(LdapRecord $user, ?Eloquent $model = null): bool + { + $required = trim((string) config('ldap.auth.login_group')); + + if ($required === '') { + return false; + } + + $isDn = str_contains($required, '='); + $needle = $isDn ? $this->normalizeDn($required) : $this->normalizeCn($required); + + // Szybka ścieżka: bezpośrednie członkostwo z atrybutu memberOf. + foreach ((array) $user->getAttribute('memberof') as $dn) { + if ($this->matches((string) $dn, $needle, $isDn)) { + return true; + } + } + + // Pełna ścieżka: członkostwo zagnieżdżone + grupa podstawowa. + foreach ($this->nestedGroupDns($user) as $dn) { + if ($this->matches($dn, $needle, $isDn)) { + return true; + } + } + + return false; + } + + /** + * DN wszystkich grup użytkownika z uwzględnieniem zagnieżdżenia. + * + * @return list + */ + protected function nestedGroupDns(LdapRecord $user): array + { + if (! method_exists($user, 'groups')) { + return []; + } + + return $user->groups()->recursive()->get(['cn']) + ->map(fn ($group) => (string) $group->getDn()) + ->all(); + } + + private function matches(string $groupDn, string $needle, bool $isDn): bool + { + if ($groupDn === '') { + return false; + } + + return $isDn + ? $this->normalizeDn($groupDn) === $needle + : $this->normalizeCn($this->firstRdn($groupDn)) === $needle; + } + + private function firstRdn(string $dn): string + { + return explode(',', $dn, 2)[0]; + } + + private function normalizeCn(string $value): string + { + $value = trim($value); + + if (stripos($value, 'cn=') === 0) { + $value = substr($value, 3); + } + + return mb_strtolower(trim($value)); + } + + private function normalizeDn(string $dn): string + { + return mb_strtolower(preg_replace('/\s*,\s*/', ',', trim($dn))); + } +} diff --git a/app/Ldap/User.php b/app/Ldap/User.php new file mode 100644 index 0000000..9dbea2a --- /dev/null +++ b/app/Ldap/User.php @@ -0,0 +1,143 @@ +> + */ + public static function attributeDefinitions(): array + { + return app(PanelSettingsService::class)->attributes(); + } + + /** + * Mapa: klucz pola panelu => nazwa atrybutu AD. + * + * @return array + */ + public static function attributeMap(): array + { + $map = []; + + foreach (self::attributeDefinitions() as $attr) { + if (! empty($attr['computed'])) { + continue; // kolumny wyliczane nie są atrybutami AD + } + + if (! empty($attr['fixed'])) { + continue; // np. accountExpires - odczyt / zapis ma dedykowaną obsługę + } + + if ($attr['key'] !== '' && $attr['ldap'] !== '') { + $map[$attr['key']] = $attr['ldap']; + } + } + + return $map; + } + + /** + * Mapa pól, które panel pozwala edytować w formularzu profilu. + * Klucz = nazwa pola formularza, wartość = nazwa atrybutu AD w formie + * akceptowanej przez LdapRecord przy zapisie (małe litery). + * + * @return array + */ + public static function editableAttributes(): array + { + $map = []; + + foreach (self::attributeDefinitions() as $attr) { + if (! empty($attr['editable']) && $attr['key'] !== '' && $attr['ldap'] !== '') { + $map[$attr['key']] = strtolower($attr['ldap']); + } + } + + return $map; + } + + /** + * Wszystkie atrybuty AD, które panel powinien pobrać dla użytkownika: + * wartości z mapy atrybutów + atrybuty techniczne potrzebne do wyliczenia + * statusu konta, dat haseł i członkostwa w grupach. + * + * @return list + */ + public static function panelAttributes(): array + { + return array_values(array_unique(array_merge( + array_values(self::attributeMap()), + [ + 'cn', + 'userPrincipalName', + 'userAccountControl', + 'pwdLastSet', + 'lockoutTime', + 'lastLogonTimestamp', + 'memberOf', + 'accountExpires', + ], + ))); + } + + /** + * Zwraca wartości atrybutów z mapy jako płaską tablicę kluczowaną + * kluczami mapy (np. 'firstName', 'employeeID', 'google'). + * + * @return array + */ + public function mappedAttributes(): array + { + $values = []; + + foreach (self::attributeMap() as $key => $ldapAttribute) { + $values[$key] = $this->getFirstAttribute(strtolower($ldapAttribute)); + } + + return $values; + } + + /** + * Lekki profil zalogowanego administratora trzymany w sesji pod kluczem + * 'ad_admin' (zgodnie z konwencją projektu - patrz config/ldap.php). + * Buduje się na statycznym mapowaniu config('ldap.attributes.user'). + * + * @return array{login: ?string, display_name: string, mail: ?string, dn: string} + */ + public function toPanelProfile(): array + { + $map = config('ldap.attributes.user', []); + + $get = fn (string $key, string $fallback): ?string => $this->getFirstAttribute($map[$key] ?? $fallback); + + $login = $get('login', 'samaccountname'); + $display = $get('display_name', 'displayname') + ?: trim(($get('first_name', 'givenname') ?? '').' '.($get('last_name', 'sn') ?? '')) + ?: $this->getFirstAttribute('cn'); + + return [ + 'login' => $login, + 'display_name' => $display ?: ($login ?? 'Administrator'), + 'mail' => $get('mail', 'mail'), + 'dn' => (string) $this->getDn(), + ]; + } +} diff --git a/app/Models/AuditLog.php b/app/Models/AuditLog.php new file mode 100644 index 0000000..bce6d1b --- /dev/null +++ b/app/Models/AuditLog.php @@ -0,0 +1,41 @@ + 'array', + 'context' => 'array', + 'created_at' => 'datetime', + ]; +} diff --git a/app/Models/PanelAttribute.php b/app/Models/PanelAttribute.php new file mode 100644 index 0000000..3ea58a2 --- /dev/null +++ b/app/Models/PanelAttribute.php @@ -0,0 +1,35 @@ + 'boolean', + 'is_column' => 'boolean', + 'position' => 'integer', + ]; +} diff --git a/app/Models/PanelSetting.php b/app/Models/PanelSetting.php new file mode 100644 index 0000000..b304588 --- /dev/null +++ b/app/Models/PanelSetting.php @@ -0,0 +1,30 @@ + wartość JSON). + * Jedyny obecnie używany klucz to "panel" - trzyma całą strukturę ustawień + * (patrz App\Services\PanelSettingsService). + * + * @property string $key + * @property array|null $value + */ +class PanelSetting extends Model +{ + protected $table = 'panel_settings'; + + protected $primaryKey = 'key'; + + public $incrementing = false; + + protected $keyType = 'string'; + + protected $fillable = ['key', 'value']; + + protected $casts = [ + 'value' => 'array', + ]; +} diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 0000000..f6ba1d2 --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,32 @@ + */ + use HasFactory, Notifiable; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + ]; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..70a6132 --- /dev/null +++ b/app/Providers/AppServiceProvider.php @@ -0,0 +1,30 @@ +server('HTTP_X_FORWARDED_PROTO') === 'https') { + URL::forceScheme('https'); + } + } +} diff --git a/app/Services/AttributeCipher.php b/app/Services/AttributeCipher.php new file mode 100644 index 0000000..829d5ff --- /dev/null +++ b/app/Services/AttributeCipher.php @@ -0,0 +1,94 @@ + użyty wprost + * (tu wklej wartość APP_KEY, jeśli atrybuty były nią szyfrowane), + * - dowolny inny tekst (hasło) -> klucz = SHA-256 z tego tekstu (32 B). + * Puste = szyfrowanie wyłączone (wartości przechodzą bez zmian). + */ +class AttributeCipher +{ + protected ?Encrypter $encrypter = null; + + public function __construct(protected PanelSettingsService $settings) {} + + public function isConfigured(): bool + { + return $this->settings->encryptionKey() !== ''; + } + + protected function keyBytes(): string + { + $key = $this->settings->encryptionKey(); + + if (str_starts_with($key, 'base64:')) { + $decoded = base64_decode(substr($key, 7), true); + + if ($decoded !== false && in_array(strlen($decoded), [16, 32], true)) { + return $decoded; + } + } + + return hash('sha256', $key, true); // 32 bajty + } + + protected function encrypter(): Encrypter + { + if ($this->encrypter === null) { + $key = $this->keyBytes(); + $this->encrypter = new Encrypter($key, strlen($key) === 16 ? 'aes-128-cbc' : 'aes-256-cbc'); + } + + return $this->encrypter; + } + + /** + * Zwraca odszyfrowaną wartość. Jeśli szyfrowanie jest wyłączone albo + * wartość nie jest naszym szyfrogramem (np. zły klucz, dane wpisane + * ręcznie) - zwraca ją bez zmian. + */ + public function decrypt(?string $value): ?string + { + if ($value === null || $value === '' || ! $this->isConfigured()) { + return $value; + } + + try { + return $this->encrypter()->decryptString($value); + } catch (\Throwable) { + return $value; + } + } + + /** + * Zwraca zaszyfrowaną wartość. Jeśli szyfrowanie jest wyłączone - zwraca + * bez zmian. Jeśli wartość jest już naszym szyfrogramem - nie szyfruje + * jej ponownie. + */ + public function encrypt(?string $value): ?string + { + if ($value === null || $value === '' || ! $this->isConfigured()) { + return $value; + } + + try { + $this->encrypter()->decryptString($value); + + return $value; // już zaszyfrowane naszym schematem + } catch (\Throwable) { + // wartość jawna - szyfrujemy + } + + return $this->encrypter()->encryptString($value); + } +} diff --git a/app/Services/AuditLogger.php b/app/Services/AuditLogger.php new file mode 100644 index 0000000..4ee21b9 --- /dev/null +++ b/app/Services/AuditLogger.php @@ -0,0 +1,70 @@ +, + * context?: ?array + * } $opts + */ + public function log(string $action, array $opts = []): void + { + try { + [$actor, $actorName] = $this->resolveActor(); + + AuditLog::create([ + 'actor' => $actor, + 'actor_name' => $actorName, + 'action' => $action, + 'target_type' => $opts['target_type'] ?? null, + 'target' => $opts['target'] ?? null, + 'target_dn' => $opts['target_dn'] ?? null, + 'reason' => ($opts['reason'] ?? null) ?: null, + 'changes' => ! empty($opts['changes']) ? $opts['changes'] : null, + 'context' => ! empty($opts['context']) ? $opts['context'] : null, + 'ip' => request()?->ip(), + 'created_at' => now(), + ]); + } catch (\Throwable $e) { + report($e); + } + } + + /** + * @return array{0: string, 1: string} [login, wyświetlana nazwa] + */ + protected function resolveActor(): array + { + $user = Auth::guard('ldap')->user(); + + if ($user) { + $login = (string) ($user->getFirstAttribute('samaccountname') ?? 'unknown'); + $name = (string) ($user->getFirstAttribute('cn') ?? $login); + + return [$login, $name]; + } + + $profile = session('ad_admin'); + if (is_array($profile) && ! empty($profile['login'])) { + return [(string) $profile['login'], (string) ($profile['display_name'] ?? $profile['login'])]; + } + + return ['system', 'system']; + } +} diff --git a/app/Services/LdapAttributeHelper.php b/app/Services/LdapAttributeHelper.php new file mode 100644 index 0000000..56dfec8 --- /dev/null +++ b/app/Services/LdapAttributeHelper.php @@ -0,0 +1,148 @@ +getFirstAttribute('userAccountControl') ?? 0); + } + + public static function isDisabled(User $user): bool + { + return (bool) (self::userAccountControl($user) & self::UAC_ACCOUNTDISABLE); + } + + public static function passwordNeverExpires(User $user): bool + { + return (bool) (self::userAccountControl($user) & self::UAC_DONT_EXPIRE_PASSWORD); + } + + /** + * Konto jest zablokowane (lockout), jeśli lockoutTime jest różne od zera. + */ + public static function isLocked(User $user): bool + { + $lockoutTime = $user->getFirstAttribute('lockouttime'); + + return $lockoutTime !== null && (int) $lockoutTime !== 0; + } + + /** + * Konwersja znacznika czasu Windows FileTime (100-nanosekundowe + * interwały od 1601-01-01) na obiekt Carbon. Wartości 0 lub + * 9223372036854775807 (nigdy) zwracają null. + */ + public static function windowsTimestampToCarbon(mixed $value): ?Carbon + { + // LdapRecord dla modeli AD sam rzutuje pola typu pwdLastSet na obiekty + // daty - wtedy nie ma czego konwertować. + if ($value instanceof \DateTimeInterface) { + return Carbon::instance($value); + } + + if (is_array($value)) { + $value = $value[0] ?? null; + } + + $value = (string) $value; + + if ($value === '' || $value === '0' || $value === '9223372036854775807') { + return null; + } + + // Różnica w sekundach między 1601-01-01 a 1970-01-01 (epoka Unix). + $unixTimestamp = intdiv((int) $value, 10_000_000) - 11_644_473_600; + + if ($unixTimestamp < 0) { + return null; + } + + return Carbon::createFromTimestamp($unixTimestamp); + } + + public static function passwordLastSet(User $user): ?Carbon + { + return self::windowsTimestampToCarbon($user->getFirstAttribute('pwdlastset')); + } + + /** + * Data wygaśnięcia KONTA (accountExpires). Wartości 0 i 0x7FFFFFFFFFFFFFFF + * oznaczają "nigdy". Czytamy surowy atrybut, bo rzutowanie LdapRecord + * potrafi zamienić "nigdy" na datę z roku 1601 / 9999. + */ + public static function accountExpiresAt(User $user): ?Carbon + { + $raw = $user->getAttributes()['accountexpires'][0] ?? null; + + if ($raw === null || $raw === '' || $raw === '0' || $raw === '9223372036854775807') { + return null; + } + + $carbon = self::windowsTimestampToCarbon($raw); + + return ($carbon && $carbon->year > 1601 && $carbon->year < 9000) ? $carbon : null; + } + + public const ACCOUNT_EXPIRES_NEVER = '9223372036854775807'; + + /** + * Zamienia datę na wartość accountExpires w formacie Windows FileTime + * (18-19 cyfr - LdapRecord zapisze ją bez modyfikacji). + */ + public static function dateToWindowsTimestamp(\DateTimeInterface $date): string + { + return (string) (($date->getTimestamp() + 11_644_473_600) * 10_000_000); + } + + /** + * Wylicza planowaną datę wygaśnięcia hasła na podstawie polityki + * maksymalnego wieku hasła (w dniach), pobranej z domeny. + */ + public static function passwordExpiresAt(User $user, ?int $maxPasswordAgeDays): ?Carbon + { + if (self::passwordNeverExpires($user) || ! $maxPasswordAgeDays) { + return null; + } + + $lastSet = self::passwordLastSet($user); + + return $lastSet?->copy()->addDays($maxPasswordAgeDays); + } + + /** + * Zwraca krótki, tekstowy status konta do wyświetlenia w tabeli. + * + * @return array{label: string, css: string} + */ + public static function statusBadge(User $user): array + { + if (self::isLocked($user)) { + return ['label' => __('common.account_status.locked'), 'css' => 'badge-status-locked']; + } + + if (self::isDisabled($user)) { + return ['label' => __('common.account_status.disabled'), 'css' => 'badge-status-disabled']; + } + + return ['label' => __('common.account_status.active'), 'css' => 'badge-status-active']; + } +} diff --git a/app/Services/OuTreeService.php b/app/Services/OuTreeService.php new file mode 100644 index 0000000..bca8b54 --- /dev/null +++ b/app/Services/OuTreeService.php @@ -0,0 +1,199 @@ +forget("ou_tree:{$scope}"); + } + } + + /** + * DN najwyższego węzła (rootu) drzewa dla danego scope - używane do + * domyślnego zaznaczenia OU po wejściu na stronę. + */ + public function rootDn(string $scope = 'users'): ?string + { + $tree = $this->tree($scope); + + return $tree[0]['dn'] ?? null; + } + + /** + * Zwraca drzewo OU dla konkretnego modułu (users, computers, groups, gpo). + */ + public function tree(string $scope = 'users'): array + { + $scopeKey = strtolower($scope); + + if (isset($this->tree[$scopeKey])) { + return $this->tree[$scopeKey]; + } + + // json_encode/decode spłaszcza referencje wewnątrz drzewa (children są + // dopinane przez &$node) - bez tego cache serializowałby dziwne grafy. + $build = fn () => json_decode(json_encode($this->build($scopeKey)), true); + + $tree = (self::CACHE_TTL > 0 && ! app()->runningUnitTests()) + ? Cache::store('file')->remember("ou_tree:{$scopeKey}", self::CACHE_TTL, $build) + : $build(); + + return $this->tree[$scopeKey] = $tree; + } + + /** + * Właściwe zbudowanie drzewa z LDAP (bez cache). + */ + protected function build(string $scopeKey): array + { + $allowedRoots = $this->allowedRootsForScope($scopeKey); + + $ous = OrganizationalUnit::query() + ->select(['ou', 'distinguishedname']) + ->get(); + + $byDn = []; + + foreach ($ous as $ou) { + $dn = trim((string) $ou->getDn()); + + if (! $this->isWithinAllowedRoots($dn, $allowedRoots)) { + continue; + } + + $normalizedDn = strtolower($dn); + $byDn[$normalizedDn] = [ + 'dn' => $dn, + 'name' => $ou->getFirstAttribute('ou') ?? $dn, + 'children' => [], + 'count' => 0, + ]; + } + + $roots = []; + + foreach ($byDn as $normalizedDn => &$node) { + $parentDn = $this->parentDn($normalizedDn); + + while ($parentDn && ! isset($byDn[$parentDn])) { + $parentDn = $this->parentDn($parentDn); + } + + if ($parentDn && isset($byDn[$parentDn])) { + $byDn[$parentDn]['children'][] = &$node; + } else { + $roots[] = &$node; + } + } + unset($node); + + foreach ($roots as &$root) { + $this->attachCounts($root, $scopeKey); + } + unset($root); + + return $roots; + } + + protected function attachCounts(array &$node, string $scope): int + { + foreach ($node['children'] as &$child) { + $this->attachCounts($child, $scope); + } + unset($child); + + // countObjectsInOu() liczy już rekursywnie (całe poddrzewo danego OU), + // więc NIE wolno dodawać sum z dzieci - to powodowało wielokrotne + // zliczanie tych samych obiektów (np. root 313 zamiast 121). + $node['count'] = $this->countObjectsInOu($node['dn'], $scope); + + return $node['count']; + } + + protected function countObjectsInOu(string $ouDn, string $scope): int + { + $query = match ($scope) { + 'users' => User::query()->in($ouDn)->recursive(), + 'computers' => Computer::query()->in($ouDn)->recursive(), + 'groups' => Group::query()->in($ouDn)->recursive(), + default => null, + }; + + if (! $query) { + return 0; + } + + return (int) $query->select(['distinguishedname'])->get()->count(); + } + + protected function allowedRootsForScope(string $scope): array + { + $userOu = trim((string) config('ldap.defaults.user_ou', '')); + $computerOu = trim((string) config('ldap.defaults.computer_ou', '')); + $groupsOu = trim((string) config('ldap.defaults.groups_ou', '')); + $baseDn = trim((string) config('ldap.connections.default.base_dn', '')); + + return match ($scope) { + 'users' => array_values(array_filter([$userOu], fn ($value) => $value !== '')), + 'computers' => array_values(array_filter([$computerOu], fn ($value) => $value !== '')), + 'groups' => array_values(array_filter([$groupsOu ?: $userOu], fn ($value) => $value !== '')), + 'gpo' => array_values(array_filter([$baseDn], fn ($value) => $value !== '')), + default => array_values(array_filter([$userOu, $computerOu, $groupsOu], fn ($value) => $value !== '')), + }; + } + + protected function isWithinAllowedRoots(string $dn, array $allowedRoots): bool + { + $normalizedDn = strtolower(trim($dn)); + + foreach ($allowedRoots as $root) { + $normalizedRoot = strtolower(trim((string) $root)); + if ($normalizedRoot === '') { + continue; + } + + if ($normalizedDn === $normalizedRoot || str_ends_with($normalizedDn, ','.$normalizedRoot)) { + return true; + } + } + + return false; + } + + protected function parentDn(string $dn): ?string + { + $parts = explode(',', $dn); + array_shift($parts); + + $parent = implode(',', $parts); + + return $parent === '' ? null : strtolower($parent); + } +} diff --git a/app/Services/PanelSettingsService.php b/app/Services/PanelSettingsService.php new file mode 100644 index 0000000..c067995 --- /dev/null +++ b/app/Services/PanelSettingsService.php @@ -0,0 +1,582 @@ + tabela `panel_settings`, jeden wiersz 'panel' + * (klucz -> wartość JSON), + * - definicje atrybutów AD (kolumny listy + pola profilu) -> tabela + * `panel_attributes`, po jednym wierszu na atrybut, kolejność wg `position`. + */ +class PanelSettingsService +{ + /** + * Kategorie (zakładki) w oknie "Podgląd / Edycja" użytkownika, do których + * przypisywane są poszczególne atrybuty. Klucz = identyfikator zapisywany + * w konfiguracji, wartość = etykieta wyświetlana w interfejsie. + */ + public const ATTRIBUTE_CATEGORIES = [ + 'general' => 'Ogólne', + 'org' => 'Organizacja', + 'address' => 'Adres i lokalizacja', + 'hr' => 'Kadry i chmura', + 'account' => 'Konto AD', + ]; + + /** + * Pozycje "na stałe": sAMAccountName (identyfikuje wiersze) i DN (ścieżka + * obiektu). Nie można ich usunąć ani zmienić ich klucza / atrybutu AD. + * Etykieta, kategoria i widoczność jako kolumna są konfigurowalne, ale typ + * jest wymuszony ("systemowy") - takich pozycji panel nie edytuje. + * + * @var list + */ + public const SYSTEM_ATTRIBUTE_KEYS = ['login', 'distinguishedName']; + + /** + * Kolumny wyliczane przez panel (nie są to atrybuty AD): status konta + * oraz wygasanie hasła. W zakładce "Atrybuty" można nimi zarządzać jak + * kolumnami (kolejność, widoczność), ale nie da się ich edytować, usunąć + * ani zmienić ich klucza / typu. + * + * @var list + */ + public const COMPUTED_ATTRIBUTE_KEYS = ['status', 'password_expiry']; + + /** + * Pozycje "na stałe", ale będące realnymi atrybutami AD: klucza ani nazwy + * atrybutu AD nie da się zmienić i nie można ich usunąć, natomiast kategorię + * ustawia się normalnie. Wartość mapy to kanoniczna nazwa atrybutu AD + * (zapis / odczyt ma dedykowaną obsługę w kontrolerze - accountExpires to + * Windows FileTime, nie zwykły string). Typ takiej pozycji jest wymuszony. + * + * @var array + */ + public const FIXED_ATTRIBUTE_KEYS = ['account_expiry' => 'accountExpires']; + + /** + * Wymuszony typ pozycji "na stałe" (patrz FIXED_ATTRIBUTE_KEYS). + * + * @var array + */ + public const FIXED_ATTRIBUTE_TYPES = ['account_expiry' => 'date']; + + /** + * Typ atrybutu w oknie "Podgląd / Edycja". Zastępuje dawne osobne flagi + * "edytowalny" i "szyfrowany" - jeden wybór z listy w zakładce "Atrybuty". + * + * Edytowalne: text | number | date | encrypted + * Nieedytowalne: system | account_status | password_expiry + * + * @var array + */ + public const ATTRIBUTE_TYPE_LABELS = [ + 'text' => 'Tekst', + 'number' => 'Liczba', + 'date' => 'Data', + 'encrypted' => 'Tekst szyfrowany', + 'system' => 'Systemowy', + 'account_status' => 'Status konta', + 'password_expiry' => 'Wygaśnięcie hasła', + ]; + + /** Typy, które użytkownik może wybrać dla zwykłego atrybutu AD. */ + public const EDITABLE_ATTRIBUTE_TYPES = ['text', 'number', 'date', 'encrypted']; + + /** Wszystkie dopuszczalne wartości typu. */ + public const ATTRIBUTE_TYPES = [ + 'text', 'number', 'date', 'encrypted', 'system', 'account_status', 'password_expiry', + ]; + + /** Klucz wiersza w tabeli `panel_settings`, pod którym zapisujemy całą strukturę. */ + protected const STORE_KEY = 'panel'; + + public function __construct() + { + // + } + + /** + * Domyślna, wbudowana konfiguracja panelu. + * + * @return array + */ + public function defaults(): array + { + return [ + 'reset_password' => [ + 'must_change_password' => true, + 'unlock_account' => true, + ], + 'password_policy' => [ + // Maksymalny wiek hasła w dniach (domyślne AD = 42). + 'max_age_days' => 42, + // Generator haseł w panelu (nie zmienia polityki w AD). + 'generator_length' => 16, + 'generator_symbols' => true, + ], + 'encryption' => [ + // Klucz (hasło) do odszyfrowania / zaszyfrowania atrybutów + // oznaczonych jako "szyfrowany". Puste = szyfrowanie wyłączone. + 'key' => '', + ], + 'audit' => [ + // Wymuś podanie powodu przy akcjach niszczących (wyłącz / usuń / + // przenieś / offboarding). Wpis w dzienniku powstaje zawsze. + 'require_reason' => false, + ], + ]; + } + + /** + * Czy panel wymaga podania powodu przy akcjach niszczących. + */ + public function requireReason(): bool + { + return (bool) ($this->all()['audit']['require_reason'] ?? false); + } + + /** + * Maksymalny wiek hasła w dniach (0 = brak polityki wygasania). + */ + public function passwordMaxAgeDays(): int + { + $value = (int) ($this->all()['password_policy']['max_age_days'] ?? 42); + + return max(0, $value); + } + + /** + * Ustawienia generatora haseł (tylko w panelu - nie dotyka polityki AD). + * + * @return array{length:int,symbols:bool} + */ + public function passwordGenerator(): array + { + $policy = $this->all()['password_policy'] ?? []; + + return [ + 'length' => max(8, min(128, (int) ($policy['generator_length'] ?? 16))), + 'symbols' => (bool) ($policy['generator_symbols'] ?? true), + ]; + } + + /** + * Klucz szyfrujący atrybuty (pusty = szyfrowanie wyłączone). + */ + public function encryptionKey(): string + { + return trim((string) ($this->all()['encryption']['key'] ?? '')); + } + + /** + * Wbudowana lista atrybutów widocznych w panelu. Stanowi punkt wyjścia + * dla konfiguracji użytkownika (zakładka "Atrybuty"). + * + * @return list> + */ + protected function defaultAttributes(): array + { + return [ + // --- Tożsamość (domyślnie widoczne kolumny listy) -------------- + ['key' => 'login', 'label' => 'sAMAccountName', 'ldap' => 'sAMAccountName', 'category' => 'account', 'type' => 'system', 'column' => true], + ['key' => 'displayName', 'label' => 'Imię i Nazwisko', 'ldap' => 'displayName', 'category' => 'general', 'type' => 'text', 'column' => true], + ['key' => 'email', 'label' => 'Adres e-mail', 'ldap' => 'mail', 'category' => 'general', 'type' => 'text', 'column' => true], + + // --- Ogólne ----------------------------------------------------- + ['key' => 'firstName', 'label' => 'Imię', 'ldap' => 'givenName', 'category' => 'general', 'type' => 'text', 'column' => false], + ['key' => 'lastName', 'label' => 'Nazwisko', 'ldap' => 'sn', 'category' => 'general', 'type' => 'text', 'column' => false], + ['key' => 'telephoneNumber', 'label' => 'Telefon stacjonarny', 'ldap' => 'telephoneNumber', 'category' => 'general', 'type' => 'text', 'column' => false], + ['key' => 'mobile', 'label' => 'Telefon komórkowy', 'ldap' => 'mobile', 'category' => 'general', 'type' => 'text', 'column' => false], + + // --- Organizacja --------------------------------------------------- + ['key' => 'title', 'label' => 'Stanowisko', 'ldap' => 'title', 'category' => 'org', 'type' => 'text', 'column' => false], + ['key' => 'department', 'label' => 'Dział / Departament', 'ldap' => 'department', 'category' => 'org', 'type' => 'text', 'column' => false], + ['key' => 'company', 'label' => 'Firma / Spółka', 'ldap' => 'company', 'category' => 'org', 'type' => 'text', 'column' => false], + ['key' => 'manager', 'label' => 'Kierownik', 'ldap' => 'manager', 'category' => 'org', 'type' => 'text', 'column' => false], + + // --- Adres i lokalizacja ---------------------------------------- + ['key' => 'co', 'label' => 'Kraj (co)', 'ldap' => 'co', 'category' => 'address', 'type' => 'text', 'column' => false], + ['key' => 'st', 'label' => 'Województwo (st)', 'ldap' => 'st', 'category' => 'address', 'type' => 'text', 'column' => false], + ['key' => 'l', 'label' => 'Miasto (l)', 'ldap' => 'l', 'category' => 'address', 'type' => 'text', 'column' => false], + ['key' => 'postalCode', 'label' => 'Kod pocztowy', 'ldap' => 'postalCode', 'category' => 'address', 'type' => 'text', 'column' => false], + ['key' => 'streetAddress', 'label' => 'Ulica i numer domu', 'ldap' => 'streetAddress', 'category' => 'address', 'type' => 'text', 'column' => false], + ['key' => 'roomNumber', 'label' => 'Numer pokoju / biurka', 'ldap' => 'roomNumber', 'category' => 'address', 'type' => 'text', 'column' => false], + ['key' => 'physicalDeliveryOfficeName', 'label' => 'Główna lokalizacja', 'ldap' => 'physicalDeliveryOfficeName', 'category' => 'address', 'type' => 'text', 'column' => false], + + // --- Kadry i chmura ------------------------------------------------- + ['key' => 'employeeID', 'label' => 'ID pracownika (Snipe-IT)', 'ldap' => 'employeeID', 'category' => 'hr', 'type' => 'text', 'column' => false], + ['key' => 'employeeNumber', 'label' => 'Kod pracownika (PIN)', 'ldap' => 'employeeNumber', 'category' => 'hr', 'type' => 'text', 'column' => false], + ['key' => 'microsoft', 'label' => 'Microsoft Cloud', 'ldap' => 'msDS-cloudExtensionAttribute1', 'category' => 'hr', 'type' => 'encrypted', 'column' => false], + ['key' => 'google', 'label' => 'Google Cloud', 'ldap' => 'msDS-cloudExtensionAttribute2', 'category' => 'hr', 'type' => 'encrypted', 'column' => false], + + // --- Konto AD ---------------------------------------------------- + ['key' => 'distinguishedName', 'label' => 'Ścieżka (DN)', 'ldap' => 'distinguishedName', 'category' => 'account', 'type' => 'system', 'column' => false], + ['key' => 'account_expiry', 'label' => 'Wygasanie konta', 'ldap' => 'accountExpires', 'category' => 'account', 'type' => 'date', 'column' => false], + + // --- Kolumny wyliczane (nie są atrybutami AD) --------------------- + ['key' => 'status', 'label' => 'Status konta', 'ldap' => 'status', 'category' => 'account', 'type' => 'account_status', 'column' => true], + ['key' => 'password_expiry', 'label' => 'Wygasanie hasła', 'ldap' => 'password_expiry', 'category' => 'account', 'type' => 'password_expiry', 'column' => true], + ]; + } + + /** + * Ustawienia ogólne (bez definicji atrybutów - te ma `attributes()`). + * + * @return array + */ + public function all(): array + { + return array_replace_recursive($this->defaults(), $this->readStored()); + } + + /** + * Surowy wiersz 'panel' z tabeli `panel_settings` (bez scalania z + * domyślnymi). Gdy tabeli jeszcze nie ma (np. przed migracją) - pusto. + * + * @return array + */ + protected function readStored(): array + { + try { + $value = PanelSetting::query()->find(self::STORE_KEY)?->value; + + return is_array($value) ? $value : []; + } catch (\Throwable) { + return []; + } + } + + public function save(array $settings): void + { + PanelSetting::query()->updateOrCreate( + ['key' => self::STORE_KEY], + ['value' => $settings], + ); + } + + // ===================================================================== + // Atrybuty / kolumny + // ===================================================================== + + /** + * Klucze, których nie wolno usunąć (systemowe + wyliczane). + * + * @return list + */ + protected function lockedKeys(): array + { + return array_merge( + self::SYSTEM_ATTRIBUTE_KEYS, + self::COMPUTED_ATTRIBUTE_KEYS, + array_keys(self::FIXED_ATTRIBUTE_KEYS), + ); + } + + /** + * Znormalizowana lista wszystkich zdefiniowanych atrybutów, z gwarancją + * obecności pozycji systemowych i wyliczanych oraz bez duplikatów kluczy. + * + * @return list + */ + public function attributes(): array + { + // Zapisana konfiguracja z tabeli `panel_attributes`; brak wierszy = + // użytkownik jeszcze nic nie zmieniał -> wbudowana lista domyślna. + $list = $this->storedAttributeRows() ?: $this->defaultAttributes(); + + $normalized = []; + $seen = []; + + foreach ($list as $item) { + if (! is_array($item)) { + continue; + } + + $attr = $this->normalizeAttribute($item); + + if ($attr['key'] === '') { + continue; + } + + $lower = strtolower($attr['key']); + if (isset($seen[$lower])) { + continue; + } + + $seen[$lower] = true; + $normalized[] = $attr; + } + + // Pozycje systemowe i wyliczane muszą istnieć zawsze - odtwarzamy brakujące. + $lockedKeys = $this->lockedKeys(); + foreach ($this->defaultAttributes() as $default) { + if (! in_array($default['key'], $lockedKeys, true)) { + continue; + } + + if (isset($seen[strtolower($default['key'])])) { + continue; + } + + $normalized[] = $this->normalizeAttribute($default); + $seen[strtolower($default['key'])] = true; + } + + return $normalized; + } + + /** + * Wiersze z tabeli `panel_attributes` w kolejności `position`, sprowadzone + * do kształtu przyjmowanego przez normalizeAttribute(). Brak tabeli + * (np. przed migracją) traktujemy jak brak konfiguracji. + * + * @return list> + */ + protected function storedAttributeRows(): array + { + try { + return PanelAttribute::query() + ->orderBy('position') + ->orderBy('id') + ->get() + ->map(fn (PanelAttribute $a) => [ + 'key' => $a->key, + 'label' => $a->label, + 'ldap' => $a->ldap, + 'category' => $a->category, + 'type' => $a->type, + 'regex' => $a->regex, + 'required' => $a->required, + 'help' => $a->help, + 'column' => $a->is_column, + ]) + ->all(); + } catch (\Throwable) { + return []; + } + } + + /** + * Zapisuje przesłaną listę atrybutów, wymuszając reguły dla pozycji + * systemowych i wyliczanych oraz odtwarzając te, które ktoś próbował usunąć. + * + * @param iterable> $attributes + */ + public function saveAttributes(iterable $attributes): void + { + $lockedDefaults = []; + foreach ($this->defaultAttributes() as $default) { + if (in_array($default['key'], $this->lockedKeys(), true)) { + $lockedDefaults[$default['key']] = $this->normalizeAttribute($default); + } + } + + $clean = []; + $seen = []; + + foreach ($attributes as $item) { + if (! is_array($item)) { + continue; + } + + $attr = $this->normalizeAttribute($item); + + if ($attr['key'] === '') { + continue; + } + + $lower = strtolower($attr['key']); + if (isset($seen[$lower])) { + continue; + } + + if (isset($lockedDefaults[$attr['key']])) { + $def = $lockedDefaults[$attr['key']]; + // Typ, editable/encrypted oraz ldap dla kolumn wyliczanych / + // "na stałe" wymusza już normalizeAttribute po kluczu. Zostaje + // przywrócenie nazwy atrybutu AD dla pozycji systemowych oraz + // stałej kategorii dla kolumn wyliczanych. + $attr['ldap'] = $def['ldap']; + if ($def['computed']) { + $attr['category'] = $def['category']; + } + } + + $seen[$lower] = true; + $clean[] = $attr; + } + + foreach ($lockedDefaults as $key => $def) { + if (isset($seen[strtolower($key)])) { + continue; + } + + $clean[] = $def; + } + + // Nadpisujemy całą tabelę - lista jest krótka, a to najprościej trzyma + // kolejność (position) i usunięcia w zgodzie z wynikiem powyżej. + DB::transaction(function () use ($clean) { + PanelAttribute::query()->delete(); + + $now = now(); + $rows = []; + + foreach (array_values($clean) as $i => $attr) { + $rows[] = [ + 'key' => $attr['key'], + 'label' => $attr['label'], + 'ldap' => $attr['ldap'], + 'category' => $attr['category'], + 'type' => $attr['type'], + 'regex' => $attr['regex'] !== '' ? $attr['regex'] : null, + 'required' => $attr['required'], + 'help' => $attr['help'], + 'is_column' => $attr['column'], + 'position' => $i, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + if ($rows !== []) { + PanelAttribute::query()->insert($rows); + } + }); + } + + /** + * Atrybuty / kolumny wyliczane oznaczone do wyświetlenia jako kolumna + * listy użytkowników - w kolejności zapisanej w konfiguracji. + * + * @return list> + */ + public function attributeColumns(): array + { + return array_values(array_filter($this->attributes(), static fn ($a) => $a['column'])); + } + + /** + * Atrybuty pogrupowane wg kategorii (zakładek podglądu użytkownika). + * Pozycje wyliczane są pomijane - nie są polami profilu. + * + * @return array>> + */ + public function attributesByCategory(): array + { + $grouped = []; + + foreach (array_keys(self::ATTRIBUTE_CATEGORIES) as $catId) { + $grouped[$catId] = []; + } + + foreach ($this->attributes() as $attr) { + if ($attr['computed']) { + continue; + } + + $grouped[$attr['category']][] = $attr; + } + + return $grouped; + } + + /** + * Wyłącznie atrybuty edytowalne (bez wyliczanych), pogrupowane wg kategorii. + * Zwraca tylko te kategorie, które mają co najmniej jedno pole - to jest + * dokładnie zestaw pól pokazywanych w oknie "Podgląd / Edycja". + * + * @return array>> + */ + public function editableAttributesByCategory(): array + { + $grouped = []; + + foreach ($this->attributes() as $attr) { + if ($attr['computed'] || ! $attr['editable']) { + continue; + } + + $grouped[$attr['category']][] = $attr; + } + + $ordered = []; + foreach (array_keys(self::ATTRIBUTE_CATEGORIES) as $catId) { + if (! empty($grouped[$catId])) { + $ordered[$catId] = $grouped[$catId]; + } + } + + return $ordered; + } + + /** + * @return array + */ + public function categories(): array + { + return self::ATTRIBUTE_CATEGORIES; + } + + /** + * @param array $a + * @return array{key:string,label:string,ldap:string,category:string,type:string,input:string,editable:bool,column:bool,system:bool,computed:bool,fixed:bool,encrypted:bool} + */ + protected function normalizeAttribute(array $a): array + { + $key = preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($a['key'] ?? '')); + $ldap = trim((string) ($a['ldap'] ?? '')); + $category = (string) ($a['category'] ?? 'general'); + + $computed = in_array($key, self::COMPUTED_ATTRIBUTE_KEYS, true); + $system = in_array($key, self::SYSTEM_ATTRIBUTE_KEYS, true); + $fixedLdap = self::FIXED_ATTRIBUTE_KEYS[$key] ?? null; + + // Typ pozycji wbudowanych jest wymuszony; dla zwykłych atrybutów + // pochodzi z wyboru użytkownika (lista w zakładce "Atrybuty") i musi + // być jednym z typów edytowalnych. + if ($system) { + $type = 'system'; + } elseif ($computed) { + $type = $key === 'status' ? 'account_status' : 'password_expiry'; + } elseif ($fixedLdap !== null) { + $type = self::FIXED_ATTRIBUTE_TYPES[$key] ?? 'text'; + } else { + $type = (string) ($a['type'] ?? 'text'); + if (! in_array($type, self::EDITABLE_ATTRIBUTE_TYPES, true)) { + $type = 'text'; + } + } + + $editable = in_array($type, self::EDITABLE_ATTRIBUTE_TYPES, true); + + return [ + 'key' => $key, + 'label' => trim((string) ($a['label'] ?? '')) ?: $key, + // Klucz i atrybut AD pozycji "na stałe" są niezmienne; kolumny + // wyliczane nie są atrybutami AD. + 'ldap' => $fixedLdap ?? ($computed ? $key : ($ldap !== '' ? $ldap : $key)), + 'category' => array_key_exists($category, self::ATTRIBUTE_CATEGORIES) ? $category : 'general', + 'type' => $type, + // Rodzaj pola formularza w oknie "Podgląd / Edycja". + 'input' => in_array($type, ['number', 'date'], true) ? $type : 'text', + 'editable' => $editable, + // Reguły walidacji pola profilu - tylko dla atrybutów edytowalnych. + 'regex' => $editable ? trim((string) ($a['regex'] ?? '')) : '', + 'required' => $editable ? filter_var($a['required'] ?? false, FILTER_VALIDATE_BOOL) : false, + 'help' => $editable ? trim((string) ($a['help'] ?? '')) : '', + 'column' => filter_var($a['column'] ?? false, FILTER_VALIDATE_BOOL), + 'system' => $system, + 'computed' => $computed, + 'fixed' => $fixedLdap !== null, + 'encrypted' => $type === 'encrypted', + ]; + } +} diff --git a/artisan b/artisan new file mode 100644 index 0000000..c35e31d --- /dev/null +++ b/artisan @@ -0,0 +1,18 @@ +#!/usr/bin/env php +handleCommand(new ArgvInput); + +exit($status); diff --git a/bootstrap/app.php b/bootstrap/app.php new file mode 100644 index 0000000..c7bc899 --- /dev/null +++ b/bootstrap/app.php @@ -0,0 +1,38 @@ +withRouting( + web: __DIR__.'/../routes/web.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware): void { + // Niezalogowany -> ekran logowania; zalogowany wchodzący na /logowanie -> pulpit. + $middleware->redirectGuestsTo(fn () => route('login')); + $middleware->redirectUsersTo('/dashboard'); + + // Język interfejsu (sesja / cookie) - po StartSession w grupie 'web'. + $middleware->web(append: [ + SetLocale::class, + ]); + }) + ->withExceptions(function (Exceptions $exceptions): void { + $exceptions->shouldRenderJsonWhen( + fn (Request $request) => $request->is('api/*') || $request->expectsJson(), + ); + })->create(); diff --git a/bootstrap/cache/.gitignore b/bootstrap/cache/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/bootstrap/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/bootstrap/providers.php b/bootstrap/providers.php new file mode 100644 index 0000000..fc94ae6 --- /dev/null +++ b/bootstrap/providers.php @@ -0,0 +1,7 @@ +=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.1" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2026-09-06T14:11:38+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": "directorytree/ldaprecord", + "version": "v4.0.6", + "source": { + "type": "git", + "url": "https://github.com/DirectoryTree/LdapRecord.git", + "reference": "75f25c84f8ffc4b10029be8a310c72fc0d46bf71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DirectoryTree/LdapRecord/zipball/75f25c84f8ffc4b10029be8a310c72fc0d46bf71", + "reference": "75f25c84f8ffc4b10029be8a310c72fc0d46bf71", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "ext-json": "*", + "ext-ldap": "*", + "illuminate/collections": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "nesbot/carbon": "*", + "php": ">=8.1", + "psr/log": "*", + "psr/simple-cache": "^1.0|^2.0|^3.0" + }, + "require-dev": { + "fakerphp/faker": "^1.21", + "laravel/pint": "^1.6", + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^9.0", + "spatie/ray": "^1.24" + }, + "type": "library", + "autoload": { + "psr-4": { + "LdapRecord\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Steve Bauman", + "email": "steven_bauman@outlook.com", + "role": "Developer" + } + ], + "description": "A fully-featured LDAP ORM.", + "homepage": "https://www.ldaprecord.com", + "keywords": [ + "active directory", + "ad", + "adLDAP", + "adldap2", + "directory", + "ldap", + "ldaprecord", + "orm", + "windows" + ], + "support": { + "docs": "https://ldaprecord.com", + "email": "steven_bauman@outlook.com", + "issues": "https://github.com/DirectoryTree/LdapRecord/issues", + "source": "https://github.com/DirectoryTree/LdapRecord" + }, + "funding": [ + { + "url": "https://github.com/stevebauman", + "type": "github" + } + ], + "time": "2026-05-28T13:39:48+00:00" + }, + { + "name": "directorytree/ldaprecord-laravel", + "version": "v4.0.5", + "source": { + "type": "git", + "url": "https://github.com/DirectoryTree/LdapRecord-Laravel.git", + "reference": "e52ee08744327317cef17abcdb8a22bb079085da" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DirectoryTree/LdapRecord-Laravel/zipball/e52ee08744327317cef17abcdb8a22bb079085da", + "reference": "e52ee08744327317cef17abcdb8a22bb079085da", + "shasum": "" + }, + "require": { + "directorytree/ldaprecord": "^4.0", + "ext-json": "*", + "ext-ldap": "*", + "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": ">=8.1", + "ramsey/uuid": "*" + }, + "require-dev": { + "laravel/pint": "^1.9", + "laravel/sanctum": "*", + "mockery/mockery": "^1.0", + "orchestra/testbench": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", + "phpunit/phpunit": "^8.0|^9.0|^10.0|^11.0", + "spatie/ray": "^1.28" + }, + "type": "project", + "extra": { + "laravel": { + "providers": [ + "LdapRecord\\Laravel\\LdapServiceProvider", + "LdapRecord\\Laravel\\LdapAuthServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "LdapRecord\\Laravel\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "LDAP Authentication & Management for Laravel.", + "keywords": [ + "adldap2", + "laravel", + "ldap", + "ldaprecord" + ], + "support": { + "issues": "https://github.com/DirectoryTree/LdapRecord-Laravel/issues", + "source": "https://github.com/DirectoryTree/LdapRecord-Laravel/tree/v4.0.5" + }, + "funding": [ + { + "url": "https://github.com/stevebauman", + "type": "github" + } + ], + "time": "2026-08-10T14:24:38+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2025-08-10T19:31:58+00:00" + }, + { + "name": "doctrine/lexer", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5", + "psalm/plugin-phpunit": "^0.18.3", + "vimeo/psalm": "^5.21" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2024-02-05T11:56:58+00:00" + }, + { + "name": "dragonmantank/cron-expression", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "shasum": "" + }, + "require": { + "php": "^8.2|^8.3|^8.4|^8.5" + }, + "replace": { + "mtdowling/cron-expression": "^1.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.32|^2.1.31", + "phpunit/phpunit": "^8.5.48|^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "support": { + "issues": "https://github.com/dragonmantank/cron-expression/issues", + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ], + "time": "2025-10-31T18:51:33+00:00" + }, + { + "name": "egulias/email-validator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^2.0 || ^3.0", + "php": ">=8.1", + "symfony/polyfill-intl-idn": "^1.26" + }, + "require-dev": { + "phpunit/phpunit": "^10.2", + "vimeo/psalm": "^5.12" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/egulias/EmailValidator/issues", + "source": "https://github.com/egulias/EmailValidator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/egulias", + "type": "github" + } + ], + "time": "2025-03-06T22:45:56+00:00" + }, + { + "name": "fruitcake/php-cors", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/fruitcake/php-cors.git", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "shasum": "" + }, + "require": { + "php": "^8.1", + "symfony/http-foundation": "^5.4|^6.4|^7.3|^8" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Fruitcake\\Cors\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fruitcake", + "homepage": "https://fruitcake.nl" + }, + { + "name": "Barryvdh", + "email": "barryvdh@gmail.com" + } + ], + "description": "Cross-origin resource sharing library for the Symfony HttpFoundation", + "homepage": "https://github.com/fruitcake/php-cors", + "keywords": [ + "cors", + "laravel", + "symfony" + ], + "support": { + "issues": "https://github.com/fruitcake/php-cors/issues", + "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2025-12-03T09:33:47+00:00" + }, + { + "name": "graham-campbell/result-type", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "adccca3324eece92ca35463648c12b9e6293c05b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/adccca3324eece92ca35463648c12b9e6293c05b", + "reference": "adccca3324eece92ca35463648c12b9e6293c05b", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.10" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.52 || ^9.6.34 || ^10.5.63 || ^11.5.55 || ^12.5.14" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2026-08-24T09:06:52+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.15.5", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ee80339fd9177ba44c49cdb653ff02a4d1106b9a", + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.5.3", + "guzzlehttp/psr7": "^2.13.1", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.3", + "guzzlehttp/test-server": "^0.7", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.15.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2026-08-24T09:21:06+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.5.3", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/cde49999552d185d64715fe9c1f77a2aadd2f9f1", + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.5.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2026-08-24T09:11:28+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.13.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "95e7828100de18b4e269fb1703be530082d5166d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/95e7828100de18b4e269fb1703be530082d5166d", + "reference": "95e7828100de18b4e269fb1703be530082d5166d", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.13.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-08-24T09:13:11+00:00" + }, + { + "name": "guzzlehttp/uri-template", + "version": "v1.0.11", + "source": { + "type": "git", + "url": "https://github.com/guzzle/uri-template.git", + "reference": "d0058dccf4299d70c3d9da3378b8908b32780368" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/d0058dccf4299d70c3d9da3378b8908b32780368", + "reference": "d0058dccf4299d70c3d9da3378b8908b32780368", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-php80": "^1.25" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "uri-template/tests": "1.0.0" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\UriTemplate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + } + ], + "description": "A polyfill class for uri_template of PHP", + "keywords": [ + "guzzlehttp", + "uri-template" + ], + "support": { + "issues": "https://github.com/guzzle/uri-template/issues", + "source": "https://github.com/guzzle/uri-template/tree/v1.0.11" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/uri-template", + "type": "tidelift" + } + ], + "time": "2026-08-24T09:15:32+00:00" + }, + { + "name": "laravel/framework", + "version": "v11.56.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/framework.git", + "reference": "d5a6525f18dc8374167c9faa890715bed7e29283" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/framework/zipball/d5a6525f18dc8374167c9faa890715bed7e29283", + "reference": "d5a6525f18dc8374167c9faa890715bed7e29283", + "shasum": "" + }, + "require": { + "brick/math": "^0.9.3|^0.10.2|^0.11|^0.12|^0.13|^0.14", + "composer-runtime-api": "^2.2", + "doctrine/inflector": "^2.0.5", + "dragonmantank/cron-expression": "^3.4", + "egulias/email-validator": "^3.2.1|^4.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-session": "*", + "ext-tokenizer": "*", + "fruitcake/php-cors": "^1.3", + "guzzlehttp/guzzle": "^7.8.2", + "guzzlehttp/uri-template": "^1.0", + "laravel/prompts": "^0.1.18|^0.2.0|^0.3.0", + "laravel/serializable-closure": "^1.3|^2.0", + "league/commonmark": "^2.7", + "league/flysystem": "^3.25.1", + "league/flysystem-local": "^3.25.1", + "league/uri": "^7.5.1", + "monolog/monolog": "^3.0", + "nesbot/carbon": "^2.72.6|^3.8.4", + "nunomaduro/termwind": "^2.0", + "php": "^8.2", + "psr/container": "^1.1.1|^2.0.1", + "psr/log": "^1.0|^2.0|^3.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "ramsey/uuid": "^4.7", + "symfony/console": "^7.0.3", + "symfony/error-handler": "^7.0.3", + "symfony/finder": "^7.0.3", + "symfony/http-foundation": "^7.2.0", + "symfony/http-kernel": "^7.0.3", + "symfony/mailer": "^7.0.3", + "symfony/mime": "^7.0.3", + "symfony/polyfill-php83": "^1.31", + "symfony/process": "^7.0.3", + "symfony/routing": "^7.0.3", + "symfony/uid": "^7.0.3", + "symfony/var-dumper": "^7.0.3", + "tijsverkoyen/css-to-inline-styles": "^2.2.5", + "vlucas/phpdotenv": "^5.6.1", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "psr/log-implementation": "1.0|2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0" + }, + "replace": { + "illuminate/auth": "self.version", + "illuminate/broadcasting": "self.version", + "illuminate/bus": "self.version", + "illuminate/cache": "self.version", + "illuminate/collections": "self.version", + "illuminate/concurrency": "self.version", + "illuminate/conditionable": "self.version", + "illuminate/config": "self.version", + "illuminate/console": "self.version", + "illuminate/container": "self.version", + "illuminate/contracts": "self.version", + "illuminate/cookie": "self.version", + "illuminate/database": "self.version", + "illuminate/encryption": "self.version", + "illuminate/events": "self.version", + "illuminate/filesystem": "self.version", + "illuminate/hashing": "self.version", + "illuminate/http": "self.version", + "illuminate/log": "self.version", + "illuminate/macroable": "self.version", + "illuminate/mail": "self.version", + "illuminate/notifications": "self.version", + "illuminate/pagination": "self.version", + "illuminate/pipeline": "self.version", + "illuminate/process": "self.version", + "illuminate/queue": "self.version", + "illuminate/redis": "self.version", + "illuminate/routing": "self.version", + "illuminate/session": "self.version", + "illuminate/support": "self.version", + "illuminate/testing": "self.version", + "illuminate/translation": "self.version", + "illuminate/validation": "self.version", + "illuminate/view": "self.version", + "spatie/once": "*" + }, + "require-dev": { + "ably/ably-php": "^1.0", + "aws/aws-sdk-php": "^3.322.9", + "ext-gmp": "*", + "fakerphp/faker": "^1.24", + "guzzlehttp/promises": "^2.0.3", + "guzzlehttp/psr7": "^2.4", + "laravel/pint": "^1.18", + "league/flysystem-aws-s3-v3": "^3.25.1", + "league/flysystem-ftp": "^3.25.1", + "league/flysystem-path-prefixing": "^3.25.1", + "league/flysystem-read-only": "^3.25.1", + "league/flysystem-sftp-v3": "^3.25.1", + "mockery/mockery": "^1.6.10", + "orchestra/testbench-core": "^9.18.0", + "pda/pheanstalk": "^5.0.6", + "php-http/discovery": "^1.15", + "phpstan/phpstan": "2.1.41", + "phpunit/phpunit": "^10.5.35|^11.3.6|^12.0.1", + "predis/predis": "^2.3", + "resend/resend-php": "^0.10.0", + "symfony/cache": "^7.0.3", + "symfony/http-client": "^7.0.3", + "symfony/psr-http-message-bridge": "^7.0.3", + "symfony/translation": "^7.0.3" + }, + "suggest": { + "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", + "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.322.9).", + "brianium/paratest": "Required to run tests in parallel (^7.0|^8.0).", + "ext-apcu": "Required to use the APC cache driver.", + "ext-fileinfo": "Required to use the Filesystem class.", + "ext-ftp": "Required to use the Flysystem FTP driver.", + "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image().", + "ext-memcached": "Required to use the memcache cache driver.", + "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", + "ext-pdo": "Required to use all database features.", + "ext-posix": "Required to use all features of the queue worker.", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0|^6.0).", + "fakerphp/faker": "Required to use the eloquent factory builder (^1.9.1).", + "filp/whoops": "Required for friendly error pages in development (^2.14.3).", + "laravel/tinker": "Required to use the tinker console command (^2.0).", + "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", + "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", + "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.25.1).", + "league/flysystem-read-only": "Required to use read-only disks (^3.25.1)", + "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", + "mockery/mockery": "Required to use mocking (^1.6).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (^5.0).", + "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", + "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.3.6|^12.0.1).", + "predis/predis": "Required to use the predis connector (^2.3).", + "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.0).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.0).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.0).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.0).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.0).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.0)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "11.x-dev" + } + }, + "autoload": { + "files": [ + "src/Illuminate/Collections/functions.php", + "src/Illuminate/Collections/helpers.php", + "src/Illuminate/Events/functions.php", + "src/Illuminate/Filesystem/functions.php", + "src/Illuminate/Foundation/helpers.php", + "src/Illuminate/Log/functions.php", + "src/Illuminate/Support/functions.php", + "src/Illuminate/Support/helpers.php" + ], + "psr-4": { + "Illuminate\\": "src/Illuminate/", + "Illuminate\\Support\\": [ + "src/Illuminate/Macroable/", + "src/Illuminate/Collections/", + "src/Illuminate/Conditionable/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Laravel Framework.", + "homepage": "https://laravel.com", + "keywords": [ + "framework", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-08-25T14:29:04+00:00" + }, + { + "name": "laravel/prompts", + "version": "v0.3.24", + "source": { + "type": "git", + "url": "https://github.com/laravel/prompts.git", + "reference": "5d3cdef29e93ca3b62b1871359db3078cd99908b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/prompts/zipball/5d3cdef29e93ca3b62b1871359db3078cd99908b", + "reference": "5d3cdef29e93ca3b62b1871359db3078cd99908b", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "ext-mbstring": "*", + "php": "^8.1", + "symfony/console": "^6.2|^7.0|^8.0" + }, + "conflict": { + "illuminate/console": ">=10.17.0 <10.25.0", + "laravel/framework": ">=10.17.0 <10.25.0" + }, + "require-dev": { + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", + "mockery/mockery": "^1.5", + "pestphp/pest": "^2.3|^3.4|^4.0", + "phpstan/phpstan": "^1.12.28", + "phpstan/phpstan-mockery": "^1.1.3" + }, + "suggest": { + "ext-pcntl": "Required for the spinner to be animated." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Laravel\\Prompts\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Add beautiful and user-friendly forms to your command-line applications.", + "support": { + "issues": "https://github.com/laravel/prompts/issues", + "source": "https://github.com/laravel/prompts/tree/v0.3.24" + }, + "time": "2026-08-20T12:55:36+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.0.16", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", + "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2026-08-18T20:28:54+00:00" + }, + { + "name": "laravel/tinker", + "version": "v2.11.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/tinker.git", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/tinker/zipball/c9f80cc835649b5c1842898fb043f8cc098dd741", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741", + "shasum": "" + }, + "require": { + "illuminate/console": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "php": "^7.2.5|^8.0", + "psy/psysh": "^0.11.1|^0.12.0", + "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "~1.3.3|^1.4.2", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^8.5.8|^9.3.3|^10.0" + }, + "suggest": { + "illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Tinker\\TinkerServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Tinker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Powerful REPL for the Laravel framework.", + "keywords": [ + "REPL", + "Tinker", + "laravel", + "psysh" + ], + "support": { + "issues": "https://github.com/laravel/tinker/issues", + "source": "https://github.com/laravel/tinker/tree/v2.11.1" + }, + "time": "2026-02-06T14:12:35+00:00" + }, + { + "name": "league/commonmark", + "version": "2.10.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "9d489ab67a02960fd8ffe624d93f751daf95439e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/9d489ab67a02960fd8ffe624d93f751daf95439e", + "reference": "9d489ab67a02960fd8ffe624d93f751daf95439e", + "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": "^2.0.0", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.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.11-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": "2026-09-07T13:44:26+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": "league/flysystem", + "version": "3.36.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "f7fb152932f30072d573510cbd4dd657d6475b25" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f7fb152932f30072d573510cbd4dd657d6475b25", + "reference": "f7fb152932f30072d573510cbd4dd657d6475b25", + "shasum": "" + }, + "require": { + "league/flysystem-local": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "async-aws/core": "<1.19.0", + "async-aws/s3": "<1.14.0", + "aws/aws-sdk-php": "3.209.31 || 3.210.0", + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1", + "phpseclib/phpseclib": "3.0.15", + "symfony/http-client": "<5.2" + }, + "require-dev": { + "async-aws/s3": "^1.5 || ^2.0", + "async-aws/simple-s3": "^1.1 || ^2.0", + "aws/aws-sdk-php": "^3.295.10", + "composer/semver": "^3.0", + "ext-fileinfo": "*", + "ext-ftp": "*", + "ext-mongodb": "^1.3|^2", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "google/cloud-storage": "^1.23", + "guzzlehttp/psr7": "^2.6", + "microsoft/azure-storage-blob": "^1.1", + "mongodb/mongodb": "^1.2|^2", + "phpseclib/phpseclib": "^3.0.36", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.11|^10.0", + "sabre/dav": "^4.6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "File storage abstraction for PHP", + "keywords": [ + "WebDAV", + "aws", + "cloud", + "file", + "files", + "filesystem", + "filesystems", + "ftp", + "s3", + "sftp", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/3.36.0" + }, + "time": "2026-09-02T08:00:27+00:00" + }, + { + "name": "league/flysystem-local", + "version": "3.35.3", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-local.git", + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/a099b24dce160f3b2239043d13d47c4a1a214ea4", + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\Local\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Local filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "file", + "files", + "filesystem", + "local" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-local/tree/3.35.3" + }, + "time": "2026-08-12T13:29:21+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.17.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0 || ^11.0 || ^12.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.17.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2026-07-09T11:49:27+00:00" + }, + { + "name": "league/uri", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.8.1", + "php": "^8.1", + "psr/http-factory": "^1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-15T20:22:25+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-08T20:05:35+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.11.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "147f303310f06334f03f409e49d7ad1e275ff05a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/147f303310f06334f03f409e49d7ad1e275ff05a", + "reference": "147f303310f06334f03f409e49d7ad1e275ff05a", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.11.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2026-09-02T12:39:56+00:00" + }, + { + "name": "nesbot/carbon", + "version": "3.13.2", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/a1c54919f5fff9800cd03c32bd01defd5a4061cb", + "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbonphp.github.io/carbon/", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2026-08-08T11:40:35+00:00" + }, + { + "name": "nette/schema", + "version": "v1.3.6", + "source": { + "type": "git", + "url": "https://github.com/nette/schema.git", + "reference": "c54350438cd6914616f790a49cb424605f421562" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/schema/zipball/c54350438cd6914616f790a49cb424605f421562", + "reference": "c54350438cd6914616f790a49cb424605f421562", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0", + "php": "8.1 - 8.5" + }, + "require-dev": { + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@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.6" + }, + "time": "2026-08-16T21:58:41+00:00" + }, + { + "name": "nette/utils", + "version": "v4.1.5", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "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::chr(), ord() 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.5" + }, + "time": "2026-07-17T23:02:45+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + }, + "time": "2026-07-04T14:30:18+00:00" + }, + { + "name": "nunomaduro/termwind", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/termwind.git", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^8.2", + "symfony/console": "^7.4.4 || ^8.0.4" + }, + "require-dev": { + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", + "mockery/mockery": "^1.6.12", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", + "phpstan/phpstan": "^1.12.32", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", + "thecodingmachine/phpstan-strict-rules": "^1.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Termwind\\Laravel\\TermwindServiceProvider" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "files": [ + "src/Functions.php" + ], + "psr-4": { + "Termwind\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "It's like Tailwind CSS, but for the console.", + "keywords": [ + "cli", + "console", + "css", + "package", + "php", + "style" + ], + "support": { + "issues": "https://github.com/nunomaduro/termwind/issues", + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://github.com/xiCO2k", + "type": "github" + } + ], + "time": "2026-02-16T23:10:27+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.10.0", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/67b192b6a42ec03944b972d6e633ddec78ad2c6d", + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.54 || ^9.6.36 || ^10.5.64 || ^11.5.56 || ^12.5.33" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.10.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2026-08-24T00:54:40+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+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/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": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+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": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "psy/psysh", + "version": "v0.12.24", + "source": { + "type": "git", + "url": "https://github.com/bobthecow/psysh.git", + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "nikic/php-parser": "^5.0 || ^4.0", + "php": "^8.0 || ^7.4", + "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + }, + "conflict": { + "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.2", + "composer/class-map-generator": "^1.6" + }, + "suggest": { + "composer/class-map-generator": "Improved tab completion performance with better class discovery.", + "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", + "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." + }, + "bin": [ + "bin/psysh" + ], + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": false, + "forward-command": false + }, + "branch-alias": { + "dev-main": "0.12.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Psy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Justin Hileman", + "email": "justin@justinhileman.info" + } + ], + "description": "An interactive shell for modern PHP.", + "homepage": "https://psysh.org", + "keywords": [ + "REPL", + "console", + "interactive", + "shell" + ], + "support": { + "issues": "https://github.com/bobthecow/psysh/issues", + "source": "https://github.com/bobthecow/psysh/tree/v0.12.24" + }, + "time": "2026-06-29T15:41:09+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "ramsey/collection", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" + }, + "time": "2025-03-22T05:38:12+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.9.3", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", + "shasum": "" + }, + "require": { + "brick/math": ">=0.8.16 <=0.18", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.3" + }, + "time": "2026-06-18T03:57:49+00:00" + }, + { + "name": "symfony/clock", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/701ef4de9705d6c32292ebee5e8044094a09fbf6", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/clock": "^1.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "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": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v8.1.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": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/23d6f88a29f6d0eac45bd77d70307adf83ba7ab0", + "reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^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/v7.4.18" + }, + "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-08-25T14:18:37+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v8.1.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "08e2905152a39cf3fd1745d83f8c483e258887d9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/08e2905152a39cf3fd1745d83f8c483e258887d9", + "reference": "08e2905152a39cf3fd1745d83f8c483e258887d9", + "shasum": "" + }, + "require": { + "php": ">=8.4.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v8.1.6" + }, + "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-08-23T10:06:25+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-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.7.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": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "8373921e231e190a88e2ad526951bbaa791576fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8373921e231e190a88e2ad526951bbaa791576fa", + "reference": "8373921e231e190a88e2ad526951bbaa791576fa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "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 tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v7.4.17" + }, + "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-08-21T17:40:08+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "7458da64220376b2e0dc2d8451bf43382c1ad297" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/7458da64220376b2e0dc2d8451bf43382c1ad297", + "reference": "7458da64220376b2e0dc2d8451bf43382c1ad297", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/security-http": "<7.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "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/error-handler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "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 tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.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-08-21T17:47:34+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "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 dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.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": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/5ce28827081f6d1f0c32eaf3882750f19cb5bbe6", + "reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0|^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/v7.4.17" + }, + "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-08-21T12:09:28+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "d070b716a32fbe3bf04204db0f58ace73b86d133" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/d070b716a32fbe3bf04204db0f58ace73b86d133", + "reference": "d070b716a32fbe3bf04204db0f58ace73b86d133", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + }, + "require-dev": { + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "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": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v7.4.18" + }, + "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-08-30T20:10:52+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "275d2d2d24530f2a0eaf17704a3a93860a036351" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/275d2d2d24530f2a0eaf17704a3a93860a036351", + "reference": "275d2d2d24530f2a0eaf17704a3a93860a036351", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/flex": "<2.10", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<6.4", + "symfony/validator": "<6.4", + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12|^4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "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 a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v7.4.18" + }, + "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-08-30T21:24:29+00:00" + }, + { + "name": "symfony/mailer", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "b17c9bf3a551d5f635638a3b6c05f06c4dc87584" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/b17c9bf3a551d5f635638a3b6c05f06c4dc87584", + "reference": "b17c9bf3a551d5f635638a3b6c05f06c4dc87584", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10|^3|^4", + "php": ">=8.2", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/mime": "^7.2|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/twig-bridge": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/twig-bridge": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "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 sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v7.4.17" + }, + "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-08-21T17:40:08+00:00" + }, + { + "name": "symfony/mime", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "bf328d82105831db3e409195db0540ff57f27c80" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/bf328d82105831db3e409195db0540ff57f27c80", + "reference": "bf328d82105831db3e409195db0540ff57f27c80", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/mailer": "<6.4", + "symfony/serializer": "<6.4.3|>7.0,<7.0.3" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.44|^7.4.17|^8.1.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "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 manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v7.4.18" + }, + "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-08-22T09:04:42+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "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.37.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": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "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.41.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": "2026-07-28T08:25:59+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.42.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/51b5ff5ba85452b31ec6f55490b08148612339d9", + "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "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\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.42.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": "2026-08-24T10:51:20+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.42.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "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.42.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": "2026-08-07T06:33:24+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "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.38.2" + }, + "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-05-27T06:59:30+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "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.37.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": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "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\\Php83\\": "" + }, + "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 backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.41.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": "2026-07-01T12:47:55+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", + "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\\Php85\\": "" + }, + "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 backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.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": "2026-07-01T12:47:55+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.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": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/process", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "058d17fc284cce14efb2385783b55014a461b176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/058d17fc284cce14efb2385783b55014a461b176", + "reference": "058d17fc284cce14efb2385783b55014a461b176", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "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": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.4.18" + }, + "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-08-21T17:40:08+00:00" + }, + { + "name": "symfony/routing", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "ddd558991e98f693ae6bf5063cc1b0362c6bbec3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/ddd558991e98f693ae6bf5063cc1b0362c6bbec3", + "reference": "ddd558991e98f693ae6bf5063cc1b0362c6bbec3", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "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": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v7.4.18" + }, + "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-08-17T13:12:36+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "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.7-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.7.3" + }, + "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-07-27T15:39:01+00:00" + }, + { + "name": "symfony/string", + "version": "v8.1.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", + "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "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.1.2" + }, + "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-07-28T07:35:25+00:00" + }, + { + "name": "symfony/translation", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "d9e1caba0d6b6f9a26710af8a2f88d37f001215a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/d9e1caba0d6b6f9a26710af8a2f88d37f001215a", + "reference": "d9e1caba0d6b6f9a26710af8a2f88d37f001215a", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation-contracts": "^3.6.1" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/http-client-contracts": "<2.5", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "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 tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v8.1.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-08-21T17:47:34+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "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 translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.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": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/uid", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "69d732355a139c6f8881337d28515aa01f12b8be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/69d732355a139c6f8881337d28515aa01f12b8be", + "reference": "69d732355a139c6f8881337d28515aa01f12b8be", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v7.4.17" + }, + "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-08-11T07:38:58+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "e088da50b813f32473a76871616cbb8fa54653a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/e088da50b813f32473a76871616cbb8fa54653a8", + "reference": "e088da50b813f32473a76871616cbb8fa54653a8", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12|^4.0" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "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 mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v7.4.18" + }, + "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-08-30T20:10:52+00:00" + }, + { + "name": "tijsverkoyen/css-to-inline-styles", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "php": "^7.4 || ^8.0", + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^8.5.21 || ^9.5.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "TijsVerkoyen\\CssToInlineStyles\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Tijs Verkoyen", + "email": "css_to_inline_styles@verkoyen.eu", + "role": "Developer" + } + ], + "description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.", + "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", + "support": { + "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" + }, + "time": "2025-12-02T11:56:42+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "301c07936b16d88628b126b01d082ba153cf4c40" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/301c07936b16d88628b126b01d082ba153cf4c40", + "reference": "301c07936b16d88628b126b01d082ba153cf4c40", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.2", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.10", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `$_ENV` and `$_SERVER` automagically, and optionally to `getenv()`.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.7.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2026-08-24T18:07:49+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2026-04-26T05:33:54+00:00" + } + ], + "packages-dev": [ + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, + { + "name": "fakerphp/faker", + "version": "v1.24.1", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + }, + "time": "2024-11-21T13:46:39+00:00" + }, + { + "name": "filp/whoops", + "version": "2.18.4", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", + "symfony/var-dumper": "^4.0 || ^5.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.4" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2025-08-08T12:00:00+00:00" + }, + { + "name": "hamcrest/hamcrest-php", + "version": "v3.0.0", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/b61cd040da1a4925bc90a51c074f5297e7c0fa52", + "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-dom": "*", + "php": "^7.4|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "hamcrest" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "support": { + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v3.0.0" + }, + "time": "2026-03-17T11:56:53+00:00" + }, + { + "name": "illuminate/json-schema", + "version": "v12.68.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/json-schema.git", + "reference": "543aab5a872b6b08f3b8c907064b009b84a56e07" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/json-schema/zipball/543aab5a872b6b08f3b8c907064b009b84a56e07", + "reference": "543aab5a872b6b08f3b8c907064b009b84a56e07", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^10.50.0|^11.47.0|^12.40.2", + "php": "^8.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "12.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\JsonSchema\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Json Schema package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-06-16T14:57:46+00:00" + }, + { + "name": "laravel/boost", + "version": "v2.6.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/boost.git", + "reference": "44f5944bf837856bc727aab87aba026dc70faa94" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/boost/zipball/44f5944bf837856bc727aab87aba026dc70faa94", + "reference": "44f5944bf837856bc727aab87aba026dc70faa94", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^7.9|^8.0", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "laravel/mcp": "^0.7.1|^0.8.0|^0.9.0", + "laravel/prompts": "^0.3.10", + "laravel/roster": "^1.0.0", + "php": "^8.2" + }, + "require-dev": { + "laravel/pint": "^1.27.0", + "mockery/mockery": "^1.6.12", + "orchestra/testbench": "^9.15.0|^10.6|^11.0", + "pestphp/pest": "^2.36.0|^3.8.4|^4.1.5", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Boost\\BoostServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Boost\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Laravel Boost accelerates AI-assisted development by providing the essential context and structure that AI needs to generate high-quality, Laravel-specific code.", + "homepage": "https://github.com/laravel/boost", + "keywords": [ + "ai", + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/boost/issues", + "source": "https://github.com/laravel/boost" + }, + "time": "2026-08-25T15:25:31+00:00" + }, + { + "name": "laravel/mcp", + "version": "v0.9.4", + "source": { + "type": "git", + "url": "https://github.com/laravel/mcp.git", + "reference": "7ca5b923630118696602d14348cd0466a5e853ec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/mcp/zipball/7ca5b923630118696602d14348cd0466a5e853ec", + "reference": "7ca5b923630118696602d14348cd0466a5e853ec", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/container": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/http": "^11.45.3|^12.41.1|^13.0", + "illuminate/json-schema": "^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "illuminate/validation": "^11.45.3|^12.41.1|^13.0", + "php": "^8.2", + "symfony/process": "^7.4.5|^8.0.5" + }, + "require-dev": { + "laravel/pint": "^1.20", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "pestphp/pest": "^3.8.5|^4.3.2", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.2.4" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Mcp": "Laravel\\Mcp\\Facades\\Mcp" + }, + "providers": [ + "Laravel\\Mcp\\Server\\McpServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Mcp\\": "src/", + "Laravel\\Mcp\\Server\\": "src/Server/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Rapidly build MCP servers for your Laravel applications.", + "homepage": "https://github.com/laravel/mcp", + "keywords": [ + "laravel", + "mcp" + ], + "support": { + "issues": "https://github.com/laravel/mcp/issues", + "source": "https://github.com/laravel/mcp" + }, + "time": "2026-08-13T15:01:07+00:00" + }, + { + "name": "laravel/pint", + "version": "v1.30.5", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "fe4148c503a0e266353d61396b79bbf7f35122df" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/fe4148c503a0e266353d61396b79bbf7f35122df", + "reference": "fe4148c503a0e266353d61396b79bbf7f35122df", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.3.0" + }, + "require-dev": { + "composer/semver": "^3.4.4", + "friendsofphp/php-cs-fixer": "^3.95.18", + "illuminate/view": "^13.24.0", + "larastan/larastan": "^3.10.0", + "laravel-zero/framework": "^13.0.0", + "laravel/agent-detector": "^2.0.2", + "laravel/prompts": "^0.3.22", + "mockery/mockery": "^1.6.12", + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest": "^4.7.8" + }, + "bin": [ + "builds/pint" + ], + "type": "project", + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Seeders\\": "database/seeders/", + "Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "An opinionated code formatter for PHP.", + "homepage": "https://laravel.com", + "keywords": [ + "dev", + "format", + "formatter", + "lint", + "linter", + "php" + ], + "support": { + "issues": "https://github.com/laravel/pint/issues", + "source": "https://github.com/laravel/pint" + }, + "time": "2026-08-10T15:35:50+00:00" + }, + { + "name": "laravel/roster", + "version": "v1.0.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/roster.git", + "reference": "89e518bd88ae98ff50f6082f6b517c8d8e8245fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/roster/zipball/89e518bd88ae98ff50f6082f6b517c8d8e8245fa", + "reference": "89e518bd88ae98ff50f6082f6b517c8d8e8245fa", + "shasum": "" + }, + "require": { + "composer/semver": "^3.0", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/yaml": "^7.2|^8.0" + }, + "require-dev": { + "laravel/pint": "^1.29", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.0|^10.0|^11.0", + "pestphp/pest": "^3.0|^4.1", + "phpstan/phpstan": "^2.0", + "rector/rector": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Roster\\RosterServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Roster\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Detect packages & approaches in use within a Laravel project", + "homepage": "https://github.com/laravel/roster", + "keywords": [ + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/roster/issues", + "source": "https://github.com/laravel/roster" + }, + "time": "2026-07-18T17:53:15+00:00" + }, + { + "name": "laravel/sail", + "version": "v1.67.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/sail.git", + "reference": "639e03ac12cf23def171770bcab05758045b2642" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sail/zipball/639e03ac12cf23def171770bcab05758045b2642", + "reference": "639e03ac12cf23def171770bcab05758045b2642", + "shasum": "" + }, + "require": { + "illuminate/console": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "php": "^8.0", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/yaml": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0|^11.0", + "phpstan/phpstan": "^2.0" + }, + "bin": [ + "bin/sail" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sail\\SailServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Docker files for running a basic Laravel application.", + "keywords": [ + "docker", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/sail/issues", + "source": "https://github.com/laravel/sail" + }, + "time": "2026-08-12T13:55:56+00:00" + }, + { + "name": "mockery/mockery", + "version": "1.6.15", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "967a801bd188989a5669bd280f252d51c0fdc9ee" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/967a801bd188989a5669bd280f252d51c0fdc9ee", + "reference": "967a801bd188989a5669bd280f252d51c0fdc9ee", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "^2.0 || ^3.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6.36", + "symplify/easy-coding-standard": "^13.2.17" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2026-08-19T19:37:52+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.14.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2026-08-11T10:17:44+00:00" + }, + { + "name": "nunomaduro/collision", + "version": "v8.5.0", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/collision.git", + "reference": "f5c101b929c958e849a633283adff296ed5f38f5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/f5c101b929c958e849a633283adff296ed5f38f5", + "reference": "f5c101b929c958e849a633283adff296ed5f38f5", + "shasum": "" + }, + "require": { + "filp/whoops": "^2.16.0", + "nunomaduro/termwind": "^2.1.0", + "php": "^8.2.0", + "symfony/console": "^7.1.5" + }, + "conflict": { + "laravel/framework": "<11.0.0 || >=12.0.0", + "phpunit/phpunit": "<10.5.1 || >=12.0.0" + }, + "require-dev": { + "larastan/larastan": "^2.9.8", + "laravel/framework": "^11.28.0", + "laravel/pint": "^1.18.1", + "laravel/sail": "^1.36.0", + "laravel/sanctum": "^4.0.3", + "laravel/tinker": "^2.10.0", + "orchestra/testbench-core": "^9.5.3", + "pestphp/pest": "^2.36.0 || ^3.4.0", + "sebastian/environment": "^6.1.0 || ^7.2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" + ] + }, + "branch-alias": { + "dev-8.x": "8.x-dev" + } + }, + "autoload": { + "files": [ + "./src/Adapters/Phpunit/Autoload.php" + ], + "psr-4": { + "NunoMaduro\\Collision\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Cli error handling for console/command-line PHP applications.", + "keywords": [ + "artisan", + "cli", + "command-line", + "console", + "error", + "handling", + "laravel", + "laravel-zero", + "php", + "symfony" + ], + "support": { + "issues": "https://github.com/nunomaduro/collision/issues", + "source": "https://github.com/nunomaduro/collision" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2024-10-15T16:06:32+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "10.1.16", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7e308268858ed6baedc8704a304727d20bc07c77", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^4.19.1 || ^5.1.0", + "php": ">=8.1", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-text-template": "^3.0.1", + "sebastian/code-unit-reverse-lookup": "^3.0.0", + "sebastian/complexity": "^3.2.0", + "sebastian/environment": "^6.1.0", + "sebastian/lines-of-code": "^2.0.2", + "sebastian/version": "^4.0.1", + "theseer/tokenizer": "^1.2.3" + }, + "require-dev": { + "phpunit/phpunit": "^10.1" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "10.1.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.16" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-08-22T04:31:57+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "4.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a95037b6d9e608ba092da1b23931e537cadc3c3c", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/4.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T06:24:48+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^10.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/4.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:56:09+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T14:07:24+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:57:52+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "10.5.64", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "0e8c1d19cea35ad97d4887f363d07c78e30fbf06" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0e8c1d19cea35ad97d4887f363d07c78e30fbf06", + "reference": "0e8c1d19cea35ad97d4887f363d07c78e30fbf06", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.1", + "phpunit/php-code-coverage": "^10.1.16", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-invoker": "^4.0.0", + "phpunit/php-text-template": "^3.0.1", + "phpunit/php-timer": "^6.0.0", + "sebastian/cli-parser": "^2.0.1", + "sebastian/code-unit": "^2.0.0", + "sebastian/comparator": "^5.0.5", + "sebastian/diff": "^5.1.1", + "sebastian/environment": "^6.1.0", + "sebastian/exporter": "^5.1.4", + "sebastian/global-state": "^6.0.2", + "sebastian/object-enumerator": "^5.0.0", + "sebastian/recursion-context": "^5.0.1", + "sebastian/type": "^4.0.0", + "sebastian/version": "^4.0.1" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "10.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.64" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-07-06T14:50:35+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:12:49+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/a81fee9eef0b7a76af11d121767abc44c104e503", + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/2.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:58:43+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/3.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:59:15+00:00" + }, + { + "name": "sebastian/comparator", + "version": "5.0.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55dfef806eb7dfeb6e7a6935601fef866f8ca48d", + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.1", + "sebastian/diff": "^5.0", + "sebastian/exporter": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:25:16+00:00" + }, + { + "name": "sebastian/complexity", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "68ff824baeae169ec9f2137158ee529584553799" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/68ff824baeae169ec9f2137158ee529584553799", + "reference": "68ff824baeae169ec9f2137158ee529584553799", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-21T08:37:17+00:00" + }, + { + "name": "sebastian/diff", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/c41e007b4b62af48218231d6c2275e4c9b975b2e", + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0", + "symfony/process": "^6.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:15:17+00:00" + }, + { + "name": "sebastian/environment", + "version": "6.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/8074dbcd93529b357029f5cc5058fd3e43666984", + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/6.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-23T08:47:14+00:00" + }, + { + "name": "sebastian/exporter", + "version": "5.1.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "0735b90f4da94969541dac1da743446e276defa6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/0735b90f4da94969541dac1da743446e276defa6", + "reference": "0735b90f4da94969541dac1da743446e276defa6", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.1", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:09:11+00:00" + }, + { + "name": "sebastian/global-state", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", + "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:19:19+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/856e7f6a75a84e339195d48c556f23be2ebf75d0", + "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/2.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-21T08:38:20+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/202d0e344a580d7f7d04b3fafce6933e59dae906", + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:08:32+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/24ed13d98130f0e7122df55d06c5c4942a577957", + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/3.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:06:18+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "5d32fe257a9b39cb63146924d6b4e32a22d4502a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/5d32fe257a9b39cb63146924d6b4e32a22d4502a", + "reference": "5d32fe257a9b39cb63146924d6b4e32a22d4502a", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2026-08-11T05:27:39+00:00" + }, + { + "name": "sebastian/type", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/462699a16464c3944eefc02ebdd77882bd3925bf", + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/4.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:10:45+00:00" + }, + { + "name": "sebastian/version", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-07T11:34:05+00:00" + }, + { + "name": "spatie/backtrace", + "version": "1.8.2", + "source": { + "type": "git", + "url": "https://github.com/spatie/backtrace.git", + "reference": "8ffe78be5ed355b5009e3dd989d183433e9a5adc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/backtrace/zipball/8ffe78be5ed355b5009e3dd989d183433e9a5adc", + "reference": "8ffe78be5ed355b5009e3dd989d183433e9a5adc", + "shasum": "" + }, + "require": { + "php": "^7.3 || ^8.0" + }, + "require-dev": { + "ext-json": "*", + "laravel/serializable-closure": "^1.3 || ^2.0", + "phpunit/phpunit": "^9.3 || ^11.4.3", + "spatie/phpunit-snapshot-assertions": "^4.2 || ^5.1.6", + "symfony/var-dumper": "^5.1|^6.0|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\Backtrace\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van de Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "A better backtrace", + "homepage": "https://github.com/spatie/backtrace", + "keywords": [ + "Backtrace", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/backtrace/issues", + "source": "https://github.com/spatie/backtrace/tree/1.8.2" + }, + "funding": [ + { + "url": "https://github.com/sponsors/spatie", + "type": "github" + }, + { + "url": "https://spatie.be/open-source/support-us", + "type": "other" + } + ], + "time": "2026-03-11T13:48:28+00:00" + }, + { + "name": "spatie/error-solutions", + "version": "1.1.3", + "source": { + "type": "git", + "url": "https://github.com/spatie/error-solutions.git", + "reference": "e495d7178ca524f2dd0fe6a1d99a1e608e1c9936" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/error-solutions/zipball/e495d7178ca524f2dd0fe6a1d99a1e608e1c9936", + "reference": "e495d7178ca524f2dd0fe6a1d99a1e608e1c9936", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "illuminate/broadcasting": "^10.0|^11.0|^12.0", + "illuminate/cache": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "livewire/livewire": "^2.11|^3.5.20", + "openai-php/client": "^0.10.1", + "orchestra/testbench": "8.22.3|^9.0|^10.0", + "pestphp/pest": "^2.20|^3.0", + "phpstan/phpstan": "^2.1", + "psr/simple-cache": "^3.0", + "psr/simple-cache-implementation": "^3.0", + "spatie/ray": "^1.28", + "symfony/cache": "^5.4|^6.0|^7.0", + "symfony/process": "^5.4|^6.0|^7.0", + "vlucas/phpdotenv": "^5.5" + }, + "suggest": { + "openai-php/client": "Require get solutions from OpenAI", + "simple-cache-implementation": "To cache solutions from OpenAI" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\Ignition\\": "legacy/ignition", + "Spatie\\ErrorSolutions\\": "src", + "Spatie\\LaravelIgnition\\": "legacy/laravel-ignition" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ruben Van Assche", + "email": "ruben@spatie.be", + "role": "Developer" + } + ], + "description": "This is my package error-solutions", + "homepage": "https://github.com/spatie/error-solutions", + "keywords": [ + "error-solutions", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/error-solutions/issues", + "source": "https://github.com/spatie/error-solutions/tree/1.1.3" + }, + "funding": [ + { + "url": "https://github.com/Spatie", + "type": "github" + } + ], + "time": "2025-02-14T12:29:50+00:00" + }, + { + "name": "spatie/flare-client-php", + "version": "1.11.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/flare-client-php.git", + "reference": "53f41b08a27cc039e1a8ed2be9a202e924f31bad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/flare-client-php/zipball/53f41b08a27cc039e1a8ed2be9a202e924f31bad", + "reference": "53f41b08a27cc039e1a8ed2be9a202e924f31bad", + "shasum": "" + }, + "require": { + "illuminate/pipeline": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": "^8.0", + "spatie/backtrace": "^1.6.1", + "symfony/http-foundation": "^5.2|^6.0|^7.0|^8.0", + "symfony/mime": "^5.2|^6.0|^7.0|^8.0", + "symfony/process": "^5.2|^6.0|^7.0|^8.0", + "symfony/var-dumper": "^5.2|^6.0|^7.0|^8.0" + }, + "require-dev": { + "dms/phpunit-arraysubset-asserts": "^0.5.0", + "pestphp/pest": "^1.20|^2.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1.0", + "spatie/pest-plugin-snapshots": "^1.0|^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.3.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Spatie\\FlareClient\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Send PHP errors to Flare", + "homepage": "https://github.com/spatie/flare-client-php", + "keywords": [ + "exception", + "flare", + "reporting", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/flare-client-php/issues", + "source": "https://github.com/spatie/flare-client-php/tree/1.11.1" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-05-15T09:31:32+00:00" + }, + { + "name": "spatie/ignition", + "version": "1.16.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/ignition.git", + "reference": "b59385bb7aa24dae81bcc15850ebecfda7b40838" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/ignition/zipball/b59385bb7aa24dae81bcc15850ebecfda7b40838", + "reference": "b59385bb7aa24dae81bcc15850ebecfda7b40838", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "php": "^8.0", + "spatie/backtrace": "^1.7.1", + "spatie/error-solutions": "^1.1.2", + "spatie/flare-client-php": "^1.9", + "symfony/console": "^5.4.42|^6.0|^7.0|^8.0", + "symfony/http-foundation": "^5.4.42|^6.0|^7.0|^8.0", + "symfony/mime": "^5.4.42|^6.0|^7.0|^8.0", + "symfony/var-dumper": "^5.4.42|^6.0|^7.0|^8.0" + }, + "require-dev": { + "illuminate/cache": "^9.52|^10.0|^11.0|^12.0|^13.0", + "mockery/mockery": "^1.4", + "pestphp/pest": "^1.20|^2.0|^3.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1.0", + "psr/simple-cache-implementation": "*", + "symfony/cache": "^5.4.38|^6.0|^7.0|^8.0", + "symfony/process": "^5.4.35|^6.0|^7.0|^8.0", + "vlucas/phpdotenv": "^5.5" + }, + "suggest": { + "openai-php/client": "Require get solutions from OpenAI", + "simple-cache-implementation": "To cache solutions from OpenAI" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.5.x-dev" + } + }, + "autoload": { + "psr-4": { + "Spatie\\Ignition\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Spatie", + "email": "info@spatie.be", + "role": "Developer" + } + ], + "description": "A beautiful error page for PHP applications.", + "homepage": "https://flareapp.io/ignition", + "keywords": [ + "error", + "flare", + "laravel", + "page" + ], + "support": { + "docs": "https://flareapp.io/docs/ignition-for-laravel/introduction", + "forum": "https://twitter.com/flareappio", + "issues": "https://github.com/spatie/ignition/issues", + "source": "https://github.com/spatie/ignition" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-03-17T10:51:08+00:00" + }, + { + "name": "spatie/laravel-ignition", + "version": "2.12.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-ignition.git", + "reference": "45b3b6e1e73fc161cba2149972698644b99594ee" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-ignition/zipball/45b3b6e1e73fc161cba2149972698644b99594ee", + "reference": "45b3b6e1e73fc161cba2149972698644b99594ee", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "illuminate/support": "^11.0|^12.0|^13.0", + "nesbot/carbon": "^2.72|^3.0", + "php": "^8.2", + "spatie/ignition": "^1.16", + "symfony/console": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "require-dev": { + "livewire/livewire": "^3.7.0|^4.0|dev-josh/v3-laravel-13-support", + "mockery/mockery": "^1.6.12", + "openai-php/client": "^0.10.3|^0.19", + "orchestra/testbench": "^v9.16.0|^10.6|^11.0", + "pestphp/pest": "^3.7|^4.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan-deprecation-rules": "^2.0.3", + "phpstan/phpstan-phpunit": "^2.0.8", + "vlucas/phpdotenv": "^5.6.2" + }, + "suggest": { + "openai-php/client": "Require get solutions from OpenAI", + "psr/simple-cache-implementation": "Needed to cache solutions from OpenAI" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Flare": "Spatie\\LaravelIgnition\\Facades\\Flare" + }, + "providers": [ + "Spatie\\LaravelIgnition\\IgnitionServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Spatie\\LaravelIgnition\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Spatie", + "email": "info@spatie.be", + "role": "Developer" + } + ], + "description": "A beautiful error page for Laravel applications.", + "homepage": "https://flareapp.io/ignition", + "keywords": [ + "error", + "flare", + "laravel", + "page" + ], + "support": { + "docs": "https://flareapp.io/docs/ignition-for-laravel/introduction", + "forum": "https://twitter.com/flareappio", + "issues": "https://github.com/spatie/laravel-ignition/issues", + "source": "https://github.com/spatie/laravel-ignition" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-03-17T12:20:04+00:00" + }, + { + "name": "symfony/yaml", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "b3fc9e8888eeb9daddc33bfbd15d282a61f543cd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/b3fc9e8888eeb9daddc33bfbd15d282a61f543cd", + "reference": "b3fc9e8888eeb9daddc33bfbd15d282a61f543cd", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<7.4" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "yaml/yaml-test-suite": "*" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "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": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v8.1.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-08-21T12:16:08+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": "^8.2" + }, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/config/app.php b/config/app.php new file mode 100644 index 0000000..9e0c3a5 --- /dev/null +++ b/config/app.php @@ -0,0 +1,126 @@ + env('APP_NAME', 'Laravel'), + + /* + |-------------------------------------------------------------------------- + | Application Environment + |-------------------------------------------------------------------------- + | + | This value determines the "environment" your application is currently + | running in. This may determine how you prefer to configure various + | services the application utilizes. Set this in your ".env" file. + | + */ + + 'env' => env('APP_ENV', 'production'), + + /* + |-------------------------------------------------------------------------- + | Application Debug Mode + |-------------------------------------------------------------------------- + | + | When your application is in debug mode, detailed error messages with + | stack traces will be shown on every error that occurs within your + | application. If disabled, a simple generic error page is shown. + | + */ + + 'debug' => (bool) env('APP_DEBUG', false), + + /* + |-------------------------------------------------------------------------- + | Application URL + |-------------------------------------------------------------------------- + | + | This URL is used by the console to properly generate URLs when using + | the Artisan command line tool. You should set this to the root of + | the application so that it's available within Artisan commands. + | + */ + + 'url' => env('APP_URL', 'http://localhost'), + + /* + |-------------------------------------------------------------------------- + | Application Timezone + |-------------------------------------------------------------------------- + | + | Here you may specify the default timezone for your application, which + | will be used by the PHP date and date-time functions. The timezone + | is set to "UTC" by default as it is suitable for most use cases. + | + */ + + 'timezone' => 'UTC', + + /* + |-------------------------------------------------------------------------- + | Application Locale Configuration + |-------------------------------------------------------------------------- + | + | The application locale determines the default locale that will be used + | by Laravel's translation / localization methods. This option can be + | set to any locale for which you plan to have translation strings. + | + */ + + 'locale' => env('APP_LOCALE', 'pl'), + + 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), + + 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'), + + /* + |-------------------------------------------------------------------------- + | Encryption Key + |-------------------------------------------------------------------------- + | + | This key is utilized by Laravel's encryption services and should be set + | to a random, 32 character string to ensure that all encrypted values + | are secure. You should do this prior to deploying the application. + | + */ + + 'cipher' => 'AES-256-CBC', + + 'key' => env('APP_KEY'), + + 'previous_keys' => [ + ...array_filter( + explode(',', (string) env('APP_PREVIOUS_KEYS', '')) + ), + ], + + /* + |-------------------------------------------------------------------------- + | Maintenance Mode Driver + |-------------------------------------------------------------------------- + | + | These configuration options determine the driver used to determine and + | manage Laravel's "maintenance mode" status. The "cache" driver will + | allow maintenance mode to be controlled across multiple machines. + | + | Supported drivers: "file", "cache", "array" + | + */ + + 'maintenance' => [ + 'driver' => env('APP_MAINTENANCE_DRIVER', 'file'), + 'store' => env('APP_MAINTENANCE_STORE', 'database'), + ], + +]; diff --git a/config/auth.php b/config/auth.php new file mode 100644 index 0000000..a589c0b --- /dev/null +++ b/config/auth.php @@ -0,0 +1,130 @@ + [ + 'guard' => env('AUTH_GUARD', 'ldap'), + 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), + ], + + /* + |-------------------------------------------------------------------------- + | Authentication Guards + |-------------------------------------------------------------------------- + | + | Next, you may define every authentication guard for your application. + | Of course, a great default configuration has been defined for you + | which utilizes session storage plus the Eloquent user provider. + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | Supported: "session" + | + */ + + 'guards' => [ + 'web' => [ + 'driver' => 'session', + 'provider' => 'users', + ], + + // Logowanie do panelu kontem Active Directory (LdapRecord). + 'ldap' => [ + 'driver' => 'session', + 'provider' => 'ldap-users', + ], + ], + + /* + |-------------------------------------------------------------------------- + | User Providers + |-------------------------------------------------------------------------- + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | If you have multiple user tables or models you may configure multiple + | providers to represent the model / table. These providers may then + | be assigned to any extra authentication guards you have defined. + | + | Supported: "database", "eloquent" + | + */ + + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => env('AUTH_MODEL', User::class), + ], + + // LdapRecord "plain bind" - brak lokalnej tabeli users. Uwierzytelnia + // bindowaniem do kontrolera domeny, a autoryzację (dostęp do panelu) + // wymusza reguła MemberOfLoginGroup na bazie config('ldap.auth.login_group'). + 'ldap-users' => [ + 'driver' => 'ldap', + 'model' => App\Ldap\User::class, + 'rules' => [ + MemberOfLoginGroup::class, + ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | Resetting Passwords + |-------------------------------------------------------------------------- + | + | These configuration options specify the behavior of Laravel's password + | reset functionality, including the table utilized for token storage + | and the user provider that is invoked to actually retrieve users. + | + | The expiry time is the number of minutes that each reset token will be + | considered valid. This security feature keeps tokens short-lived so + | they have less time to be guessed. You may change this as needed. + | + | The throttle setting is the number of seconds a user must wait before + | generating more password reset tokens. This prevents the user from + | quickly generating a very large amount of password reset tokens. + | + */ + + 'passwords' => [ + 'users' => [ + 'provider' => 'users', + 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), + 'expire' => 60, + 'throttle' => 60, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Password Confirmation Timeout + |-------------------------------------------------------------------------- + | + | Here you may define the number of seconds before a password confirmation + | window expires and users are asked to re-enter their password via the + | confirmation screen. By default, the timeout lasts for three hours. + | + */ + + 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), + +]; diff --git a/config/cache.php b/config/cache.php new file mode 100644 index 0000000..d7eec61 --- /dev/null +++ b/config/cache.php @@ -0,0 +1,136 @@ + env('CACHE_STORE', 'database'), + + /* + |-------------------------------------------------------------------------- + | Cache Stores + |-------------------------------------------------------------------------- + | + | Here you may define all of the cache "stores" for your application as + | well as their drivers. You may even define multiple stores for the + | same cache driver to group types of items stored in your caches. + | + | Supported drivers: "array", "database", "file", "memcached", + | "redis", "dynamodb", "storage", "octane", + | "session", "failover", "null" + | + */ + + 'stores' => [ + + 'array' => [ + 'driver' => 'array', + 'serialize' => false, + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_CACHE_CONNECTION'), + 'table' => env('DB_CACHE_TABLE', 'cache'), + 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'), + 'lock_table' => env('DB_CACHE_LOCK_TABLE'), + ], + + 'file' => [ + 'driver' => 'file', + 'path' => storage_path('framework/cache/data'), + 'lock_path' => storage_path('framework/cache/data'), + ], + + 'storage' => [ + 'driver' => 'storage', + 'disk' => env('CACHE_STORAGE_DISK'), + 'path' => env('CACHE_STORAGE_PATH', 'framework/cache/data'), + ], + + 'memcached' => [ + 'driver' => 'memcached', + 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), + 'sasl' => [ + env('MEMCACHED_USERNAME'), + env('MEMCACHED_PASSWORD'), + ], + 'options' => [ + // Memcached::OPT_CONNECT_TIMEOUT => 2000, + ], + 'servers' => [ + [ + 'host' => env('MEMCACHED_HOST', '127.0.0.1'), + 'port' => env('MEMCACHED_PORT', 11211), + 'weight' => 100, + ], + ], + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), + 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'), + ], + + 'dynamodb' => [ + 'driver' => 'dynamodb', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), + 'endpoint' => env('DYNAMODB_ENDPOINT'), + ], + + 'octane' => [ + 'driver' => 'octane', + ], + + 'failover' => [ + 'driver' => 'failover', + 'stores' => [ + 'database', + 'array', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Cache Key Prefix + |-------------------------------------------------------------------------- + | + | When utilizing the APC, database, memcached, Redis, and DynamoDB cache + | stores, there might be other applications using the same cache. For + | that reason, you may prefix every cache key to avoid collisions. + | + */ + + 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'), + + /* + |-------------------------------------------------------------------------- + | Serializable Classes + |-------------------------------------------------------------------------- + | + | This value determines the classes that can be unserialized from cache + | storage. By default, no PHP classes will be unserialized from your + | cache to prevent gadget chain attacks if your APP_KEY is leaked. + | + */ + + 'serializable_classes' => false, + +]; diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..abbb88e --- /dev/null +++ b/config/database.php @@ -0,0 +1,184 @@ + env('DB_CONNECTION', 'sqlite'), + + /* + |-------------------------------------------------------------------------- + | Database Connections + |-------------------------------------------------------------------------- + | + | Below are all of the database connections defined for your application. + | An example configuration is provided for each database system which + | is supported by Laravel. You're free to add / remove connections. + | + */ + + 'connections' => [ + + 'sqlite' => [ + 'driver' => 'sqlite', + 'url' => env('DB_URL'), + 'database' => env('DB_DATABASE', database_path('database.sqlite')), + 'prefix' => '', + 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + 'busy_timeout' => null, + 'journal_mode' => null, + 'synchronous' => null, + 'transaction_mode' => 'DEFERRED', + ], + + 'mysql' => [ + 'driver' => 'mysql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'mariadb' => [ + 'driver' => 'mariadb', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'pgsql' => [ + 'driver' => 'pgsql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => env('DB_SSLMODE', 'prefer'), + ], + + 'sqlsrv' => [ + 'driver' => 'sqlsrv', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', '1433'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + // 'encrypt' => env('DB_ENCRYPT', 'yes'), + // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Migration Repository Table + |-------------------------------------------------------------------------- + | + | This table keeps track of all the migrations that have already run for + | your application. Using this information, we can determine which of + | the migrations on disk haven't actually been run on the database. + | + */ + + 'migrations' => [ + 'table' => 'migrations', + 'update_date_on_publish' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Redis Databases + |-------------------------------------------------------------------------- + | + | Redis is an open source, fast, and advanced key-value store that also + | provides a richer body of commands than a typical key-value system + | such as Memcached. You may define your connection settings here. + | + */ + + 'redis' => [ + + 'client' => env('REDIS_CLIENT', 'phpredis'), + + 'options' => [ + 'cluster' => env('REDIS_CLUSTER', 'redis'), + 'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'), + 'persistent' => env('REDIS_PERSISTENT', false), + ], + + 'default' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_DB', '0'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + 'cache' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_CACHE_DB', '1'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + ], + +]; diff --git a/config/filesystems.php b/config/filesystems.php new file mode 100644 index 0000000..37d8fca --- /dev/null +++ b/config/filesystems.php @@ -0,0 +1,80 @@ + env('FILESYSTEM_DISK', 'local'), + + /* + |-------------------------------------------------------------------------- + | Filesystem Disks + |-------------------------------------------------------------------------- + | + | Below you may configure as many filesystem disks as necessary, and you + | may even configure multiple disks for the same driver. Examples for + | most supported storage drivers are configured here for reference. + | + | Supported drivers: "local", "ftp", "sftp", "s3" + | + */ + + 'disks' => [ + + 'local' => [ + 'driver' => 'local', + 'root' => storage_path('app/private'), + 'serve' => true, + 'throw' => false, + 'report' => false, + ], + + 'public' => [ + 'driver' => 'local', + 'root' => storage_path('app/public'), + 'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage', + 'visibility' => 'public', + 'throw' => false, + 'report' => false, + ], + + 's3' => [ + 'driver' => 's3', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + 'bucket' => env('AWS_BUCKET'), + 'url' => env('AWS_URL'), + 'endpoint' => env('AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'throw' => false, + 'report' => false, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Symbolic Links + |-------------------------------------------------------------------------- + | + | Here you may configure the symbolic links that will be created when the + | `storage:link` Artisan command is executed. The array keys should be + | the locations of the links and the values should be their targets. + | + */ + + 'links' => [ + public_path('storage') => storage_path('app/public'), + ], + +]; diff --git a/config/ldap.php b/config/ldap.php new file mode 100644 index 0000000..452a56b --- /dev/null +++ b/config/ldap.php @@ -0,0 +1,147 @@ + env('LDAP_CONNECTION', 'default'), + + /* + |-------------------------------------------------------------------------- + | Logowanie administratora do panelu + |-------------------------------------------------------------------------- + | + | Do panelu logujemy się kontem Active Directory (LdapRecord "plain bind", + | bez lokalnej tabeli users). Dostęp mają wyłącznie członkowie grupy + | wskazanej w AD_LOGIN_GROUP - pełny DN grupy (zalecane) albo sama nazwa + | (CN). Uwzględniane jest członkostwo zagnieżdżone (grupa w grupie) oraz + | grupa podstawowa. Pusta wartość => nikt nie może się zalogować. + | + */ + + 'auth' => [ + 'login_group' => env('AD_LOGIN_GROUP'), + ], + + /* + |-------------------------------------------------------------------------- + | LDAP Connections + |-------------------------------------------------------------------------- + | + | Konto z LDAP_USERNAME / LDAP_PASSWORD to konto serwisowe aplikacji - + | używane wyłącznie do odpytywania i modyfikowania obiektów w AD. Panel + | NIE prowadzi lokalnej tabeli kont administratorów - logują się oni + | własnym kontem domenowym (guard 'ldap', patrz sekcja 'auth' powyżej). + | + | Szyfrowanie połączenia: + | - LDAP_SSL=true -> LDAPS na porcie 636 (zalecane produkcyjnie), + | - LDAP_TLS=true -> STARTTLS na porcie 389, + | - oba false -> połączenie nieszyfrowane (tylko lab; reset hasła + | i inne zapisy AD wtedy NIE zadziałają). + | + */ + 'connections' => [ + + 'default' => [ + 'hosts' => explode(',', env('LDAP_HOSTS', env('LDAP_HOST', '127.0.0.1'))), + 'username' => env('LDAP_USERNAME'), + 'password' => env('LDAP_PASSWORD'), + 'port' => (int) env('LDAP_PORT', 636), + 'base_dn' => env('LDAP_BASE_DN'), + 'timeout' => (int) env('LDAP_TIMEOUT', 5), + // LdapRecord v4: `use_tls` = LDAPS (ldaps://, zwykle port 636), + // `use_starttls` = STARTTLS na zwykłym porcie 389. Włącz dokładnie jedno. + 'use_tls' => filter_var(env('LDAP_SSL', true), FILTER_VALIDATE_BOOL), + 'use_starttls' => filter_var(env('LDAP_TLS', false), FILTER_VALIDATE_BOOL), + 'version' => 3, + 'follow_referrals' => false, + + 'options' => [ + // Dla LDAPS z certyfikatem DC niezaufanym przez serwer (self-signed + // / wewnętrzne CA) można wyłączyć weryfikację: LDAP_TLS_VERIFY=false. + LDAP_OPT_X_TLS_REQUIRE_CERT => filter_var(env('LDAP_TLS_VERIFY', false), FILTER_VALIDATE_BOOL) + ? 2 // LDAP_OPT_X_TLS_DEMAND + : 0, // LDAP_OPT_X_TLS_NEVER + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Logging + |-------------------------------------------------------------------------- + */ + + 'logging' => env('LDAP_LOGGING', true), + + /* + |-------------------------------------------------------------------------- + | Cache + |-------------------------------------------------------------------------- + */ + + 'cache' => [ + 'enabled' => env('LDAP_CACHE', false), + 'driver' => env('LDAP_CACHE_DRIVER', 'file'), + ], + + /* + |-------------------------------------------------------------------------- + | Domyślne kontenery (OU) używane przy tworzeniu nowych obiektów + |-------------------------------------------------------------------------- + */ + + 'defaults' => [ + 'user_ou' => env('LDAP_DEFAULT_USER_OU'), + 'computer_ou' => env('LDAP_DEFAULT_COMPUTER_OU'), + 'groups_ou' => env('LDAP_DEFAULT_GROUPS_OU'), + 'domain_label' => env('AD_DOMAIN_LABEL', env('LDAP_BASE_DN')), + ], + + /* + |-------------------------------------------------------------------------- + | Mapowanie atrybutów AD -> profil w sesji + |-------------------------------------------------------------------------- + | + | Po zalogowaniu profil administratora budowany jest na podstawie tego + | mapowania (App\Ldap\User::toPanelProfile()) i trzymany w sesji pod + | kluczem 'ad_admin'. Zestaw atrybutów widocznych w module "Użytkownicy" + | konfiguruje się osobno w panelu (/settings -> zakładka "Atrybuty"). + | + */ + + 'attributes' => [ + 'user' => [ + 'login' => 'samaccountname', + 'first_name' => 'givenname', + 'last_name' => 'sn', + 'display_name' => 'displayname', + 'mail' => 'mail', + 'distinguished_name' => 'distinguishedname', + 'title' => 'title', + 'department' => 'department', + 'company' => 'company', + 'telephone' => 'telephonenumber', + 'mobile' => 'mobile', + 'country' => 'c', + 'state' => 'st', + 'city' => 'l', + 'postal_code' => 'postalcode', + 'street_address' => 'streetaddress', + 'room_number' => 'roomnumber', + 'phisical_delivery_office_name' => 'physicaldeliveryofficename', + 'employee_id' => 'employeeid', + 'employee_number' => 'employeenumber', + 'memberof' => 'memberof', + ], + 'group' => [ + 'name' => 'cn', + ], + ], + +]; diff --git a/config/locales.php b/config/locales.php new file mode 100644 index 0000000..3b5edf9 --- /dev/null +++ b/config/locales.php @@ -0,0 +1,18 @@ + 'Polski', + 'en' => 'English', +]; diff --git a/config/logging.php b/config/logging.php new file mode 100644 index 0000000..97bdc03 --- /dev/null +++ b/config/logging.php @@ -0,0 +1,140 @@ + env('LOG_CHANNEL', 'stack'), + + /* + |-------------------------------------------------------------------------- + | Deprecations Log Channel + |-------------------------------------------------------------------------- + | + | This option controls the log channel that should be used to log warnings + | regarding deprecated PHP and library features. This allows you to get + | your application ready for upcoming major versions of dependencies. + | + */ + + 'deprecations' => [ + 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), + 'trace' => env('LOG_DEPRECATIONS_TRACE', false), + ], + + /* + |-------------------------------------------------------------------------- + | Log Channels + |-------------------------------------------------------------------------- + | + | Here you may configure the log channels for your application. Laravel + | utilizes the Monolog PHP logging library, which includes a variety + | of powerful log handlers and formatters that you're free to use. + | + | Available drivers: "single", "daily", "monthly", "slack", "syslog", + | "errorlog", "monolog", "custom", "stack" + | + */ + + 'channels' => [ + + 'stack' => [ + 'driver' => 'stack', + 'channels' => explode(',', (string) env('LOG_STACK', 'single')), + 'ignore_exceptions' => false, + ], + + 'single' => [ + 'driver' => 'single', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'daily' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'max_files' => env('LOG_DAILY_DAYS', 14), + 'replace_placeholders' => true, + ], + + 'monthly' => [ + 'driver' => 'monthly', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'max_files' => 3, + 'replace_placeholders' => true, + ], + + 'slack' => [ + 'driver' => 'slack', + 'url' => env('LOG_SLACK_WEBHOOK_URL'), + 'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')), + 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'), + 'level' => env('LOG_LEVEL', 'critical'), + 'replace_placeholders' => true, + ], + + 'papertrail' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), + 'handler_with' => [ + 'host' => env('PAPERTRAIL_URL'), + 'port' => env('PAPERTRAIL_PORT'), + 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), + ], + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'stderr' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => StreamHandler::class, + 'handler_with' => [ + 'stream' => 'php://stderr', + ], + 'formatter' => env('LOG_STDERR_FORMATTER'), + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'syslog' => [ + 'driver' => 'syslog', + 'level' => env('LOG_LEVEL', 'debug'), + 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), + 'replace_placeholders' => true, + ], + + 'errorlog' => [ + 'driver' => 'errorlog', + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'null' => [ + 'driver' => 'monolog', + 'handler' => NullHandler::class, + ], + + 'emergency' => [ + 'path' => storage_path('logs/laravel.log'), + ], + + ], + +]; diff --git a/config/mail.php b/config/mail.php new file mode 100644 index 0000000..e32e88d --- /dev/null +++ b/config/mail.php @@ -0,0 +1,118 @@ + env('MAIL_MAILER', 'log'), + + /* + |-------------------------------------------------------------------------- + | Mailer Configurations + |-------------------------------------------------------------------------- + | + | Here you may configure all of the mailers used by your application plus + | their respective settings. Several examples have been configured for + | you and you are free to add your own as your application requires. + | + | Laravel supports a variety of mail "transport" drivers that can be used + | when delivering an email. You may specify which one you're using for + | your mailers below. You may also add additional mailers if needed. + | + | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2", + | "postmark", "resend", "log", "array", + | "failover", "roundrobin" + | + */ + + 'mailers' => [ + + 'smtp' => [ + 'transport' => 'smtp', + 'scheme' => env('MAIL_SCHEME'), + 'url' => env('MAIL_URL'), + 'host' => env('MAIL_HOST', '127.0.0.1'), + 'port' => env('MAIL_PORT', 2525), + 'username' => env('MAIL_USERNAME'), + 'password' => env('MAIL_PASSWORD'), + 'timeout' => null, + 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)), + ], + + 'ses' => [ + 'transport' => 'ses', + ], + + 'postmark' => [ + 'transport' => 'postmark', + // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'), + // 'client' => [ + // 'timeout' => 5, + // ], + ], + + 'resend' => [ + 'transport' => 'resend', + ], + + 'sendmail' => [ + 'transport' => 'sendmail', + 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), + ], + + 'log' => [ + 'transport' => 'log', + 'channel' => env('MAIL_LOG_CHANNEL'), + ], + + 'array' => [ + 'transport' => 'array', + ], + + 'failover' => [ + 'transport' => 'failover', + 'mailers' => [ + 'smtp', + 'log', + ], + 'retry_after' => 60, + ], + + 'roundrobin' => [ + 'transport' => 'roundrobin', + 'mailers' => [ + 'ses', + 'postmark', + ], + 'retry_after' => 60, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Global "From" Address + |-------------------------------------------------------------------------- + | + | You may wish for all emails sent by your application to be sent from + | the same address. Here you may specify a name and address that is + | used globally for all emails that are sent by your application. + | + */ + + 'from' => [ + 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), + 'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')), + ], + +]; diff --git a/config/queue.php b/config/queue.php new file mode 100644 index 0000000..79c2c0a --- /dev/null +++ b/config/queue.php @@ -0,0 +1,129 @@ + env('QUEUE_CONNECTION', 'database'), + + /* + |-------------------------------------------------------------------------- + | Queue Connections + |-------------------------------------------------------------------------- + | + | Here you may configure the connection options for every queue backend + | used by your application. An example configuration is provided for + | each backend supported by Laravel. You're also free to add more. + | + | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", + | "deferred", "background", "failover", "null" + | + */ + + 'connections' => [ + + 'sync' => [ + 'driver' => 'sync', + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_QUEUE_CONNECTION'), + 'table' => env('DB_QUEUE_TABLE', 'jobs'), + 'queue' => env('DB_QUEUE', 'default'), + 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), + 'after_commit' => false, + ], + + 'beanstalkd' => [ + 'driver' => 'beanstalkd', + 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'), + 'queue' => env('BEANSTALKD_QUEUE', 'default'), + 'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90), + 'block_for' => 0, + 'after_commit' => false, + ], + + 'sqs' => [ + 'driver' => 'sqs', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), + 'queue' => env('SQS_QUEUE', 'default'), + 'suffix' => env('SQS_SUFFIX'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'after_commit' => false, + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), + 'queue' => env('REDIS_QUEUE', 'default'), + 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), + 'block_for' => null, + 'after_commit' => false, + ], + + 'deferred' => [ + 'driver' => 'deferred', + ], + + 'background' => [ + 'driver' => 'background', + ], + + 'failover' => [ + 'driver' => 'failover', + 'connections' => [ + 'database', + 'deferred', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Job Batching + |-------------------------------------------------------------------------- + | + | The following options configure the database and table that store job + | batching information. These options can be updated to any database + | connection and table which has been defined by your application. + | + */ + + 'batching' => [ + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'job_batches', + ], + + /* + |-------------------------------------------------------------------------- + | Failed Queue Jobs + |-------------------------------------------------------------------------- + | + | These options configure the behavior of failed queue job logging so you + | can control how and where failed jobs are stored. Laravel ships with + | support for storing failed jobs in a simple file or in a database. + | + | Supported drivers: "database-uuids", "dynamodb", "file", "null" + | + */ + + 'failed' => [ + 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'failed_jobs', + ], + +]; diff --git a/config/services.php b/config/services.php new file mode 100644 index 0000000..ec226a4 --- /dev/null +++ b/config/services.php @@ -0,0 +1,38 @@ + [ + 'key' => env('POSTMARK_API_KEY'), + ], + + 'resend' => [ + 'key' => env('RESEND_API_KEY'), + ], + + 'ses' => [ + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + ], + + 'slack' => [ + 'notifications' => [ + 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), + 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), + ], + ], + +]; diff --git a/config/session.php b/config/session.php new file mode 100644 index 0000000..f574482 --- /dev/null +++ b/config/session.php @@ -0,0 +1,233 @@ + env('SESSION_DRIVER', 'database'), + + /* + |-------------------------------------------------------------------------- + | Session Lifetime + |-------------------------------------------------------------------------- + | + | Here you may specify the number of minutes that you wish the session + | to be allowed to remain idle before it expires. If you want them + | to expire immediately when the browser is closed then you may + | indicate that via the expire_on_close configuration option. + | + */ + + 'lifetime' => (int) env('SESSION_LIFETIME', 120), + + 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false), + + /* + |-------------------------------------------------------------------------- + | Session Encryption + |-------------------------------------------------------------------------- + | + | This option allows you to easily specify that all of your session data + | should be encrypted before it's stored. All encryption is performed + | automatically by Laravel and you may use the session like normal. + | + */ + + 'encrypt' => env('SESSION_ENCRYPT', false), + + /* + |-------------------------------------------------------------------------- + | Session File Location + |-------------------------------------------------------------------------- + | + | When utilizing the "file" session driver, the session files are placed + | on disk. The default storage location is defined here; however, you + | are free to provide another location where they should be stored. + | + */ + + 'files' => storage_path('framework/sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Database Connection + |-------------------------------------------------------------------------- + | + | When using the "database" or "redis" session drivers, you may specify a + | connection that should be used to manage these sessions. This should + | correspond to a connection in your database configuration options. + | + */ + + 'connection' => env('SESSION_CONNECTION'), + + /* + |-------------------------------------------------------------------------- + | Session Database Table + |-------------------------------------------------------------------------- + | + | When using the "database" session driver, you may specify the table to + | be used to store sessions. Of course, a sensible default is defined + | for you; however, you're welcome to change this to another table. + | + */ + + 'table' => env('SESSION_TABLE', 'sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Cache Store + |-------------------------------------------------------------------------- + | + | When using one of the framework's cache driven session backends, you may + | define the cache store which should be used to store the session data + | between requests. This must match one of your defined cache stores. + | + | Affects: "dynamodb", "memcached", "redis" + | + */ + + 'store' => env('SESSION_STORE'), + + /* + |-------------------------------------------------------------------------- + | Session Sweeping Lottery + |-------------------------------------------------------------------------- + | + | Some session drivers must manually sweep their storage location to get + | rid of old sessions from storage. Here are the chances that it will + | happen on a given request. By default, the odds are 2 out of 100. + | + */ + + 'lottery' => [2, 100], + + /* + |-------------------------------------------------------------------------- + | Session Cookie Name + |-------------------------------------------------------------------------- + | + | Here you may change the name of the session cookie that is created by + | the framework. Typically, you should not need to change this value + | since doing so does not grant a meaningful security improvement. + | + */ + + 'cookie' => env( + 'SESSION_COOKIE', + Str::slug((string) env('APP_NAME', 'laravel')).'-session' + ), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Path + |-------------------------------------------------------------------------- + | + | The session cookie path determines the path for which the cookie will + | be regarded as available. Typically, this will be the root path of + | your application, but you're free to change this when necessary. + | + */ + + 'path' => env('SESSION_PATH', '/'), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Domain + |-------------------------------------------------------------------------- + | + | This value determines the domain and subdomains the session cookie is + | available to. By default, the cookie will be available to the root + | domain without subdomains. Typically, this shouldn't be changed. + | + */ + + 'domain' => env('SESSION_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | HTTPS Only Cookies + |-------------------------------------------------------------------------- + | + | By setting this option to true, session cookies will only be sent back + | to the server if the browser has a HTTPS connection. This will keep + | the cookie from being sent to you when it can't be done securely. + | + */ + + 'secure' => env('SESSION_SECURE_COOKIE'), + + /* + |-------------------------------------------------------------------------- + | HTTP Access Only + |-------------------------------------------------------------------------- + | + | Setting this value to true will prevent JavaScript from accessing the + | value of the cookie and the cookie will only be accessible through + | the HTTP protocol. It's unlikely you should disable this option. + | + */ + + 'http_only' => env('SESSION_HTTP_ONLY', true), + + /* + |-------------------------------------------------------------------------- + | Same-Site Cookies + |-------------------------------------------------------------------------- + | + | This option determines how your cookies behave when cross-site requests + | take place, and can be used to mitigate CSRF attacks. By default, we + | will set this value to "lax" to permit secure cross-site requests. + | + | See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value + | + | Supported: "lax", "strict", "none", null + | + */ + + 'same_site' => env('SESSION_SAME_SITE', 'lax'), + + /* + |-------------------------------------------------------------------------- + | Partitioned Cookies + |-------------------------------------------------------------------------- + | + | Setting this value to true will tie the cookie to the top-level site for + | a cross-site context. Partitioned cookies are accepted by the browser + | when flagged "secure" and the Same-Site attribute is set to "none". + | + */ + + 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false), + + /* + |-------------------------------------------------------------------------- + | Session Serialization + |-------------------------------------------------------------------------- + | + | This value controls the serialization strategy for session data, which + | is JSON by default. Setting this to "php" allows the storage of PHP + | objects in the session but can make an application vulnerable to + | "gadget chain" serialization attacks if the APP_KEY is leaked. + | + | Supported: "json", "php" + | + */ + + 'serialization' => 'json', + +]; diff --git a/database/.gitignore b/database/.gitignore new file mode 100644 index 0000000..9b19b93 --- /dev/null +++ b/database/.gitignore @@ -0,0 +1 @@ +*.sqlite* diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php new file mode 100644 index 0000000..c4ceb07 --- /dev/null +++ b/database/factories/UserFactory.php @@ -0,0 +1,45 @@ + + */ +class UserFactory extends Factory +{ + /** + * The current password being used by the factory. + */ + protected static ?string $password; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => static::$password ??= Hash::make('password'), + 'remember_token' => Str::random(10), + ]; + } + + /** + * Indicate that the model's email address should be unverified. + */ + public function unverified(): static + { + return $this->state(fn (array $attributes) => [ + 'email_verified_at' => null, + ]); + } +} diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php new file mode 100644 index 0000000..05fb5d9 --- /dev/null +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -0,0 +1,49 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + + Schema::create('password_reset_tokens', function (Blueprint $table) { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('sessions', function (Blueprint $table) { + $table->string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('users'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('sessions'); + } +}; diff --git a/database/migrations/0001_01_01_000001_create_cache_table.php b/database/migrations/0001_01_01_000001_create_cache_table.php new file mode 100644 index 0000000..06dc7a5 --- /dev/null +++ b/database/migrations/0001_01_01_000001_create_cache_table.php @@ -0,0 +1,35 @@ +string('key')->primary(); + $table->mediumText('value'); + $table->bigInteger('expiration')->index(); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->bigInteger('expiration')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); + } +}; diff --git a/database/migrations/0001_01_01_000002_create_jobs_table.php b/database/migrations/0001_01_01_000002_create_jobs_table.php new file mode 100644 index 0000000..edac6fe --- /dev/null +++ b/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -0,0 +1,59 @@ +id(); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedSmallInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + + Schema::create('job_batches', function (Blueprint $table) { + $table->string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->longText('failed_job_ids'); + $table->mediumText('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + + Schema::create('failed_jobs', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->string('connection'); + $table->string('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + + $table->index(['connection', 'queue', 'failed_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('jobs'); + Schema::dropIfExists('job_batches'); + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/database/migrations/2026_09_07_000100_create_panel_settings_table.php b/database/migrations/2026_09_07_000100_create_panel_settings_table.php new file mode 100644 index 0000000..8251937 --- /dev/null +++ b/database/migrations/2026_09_07_000100_create_panel_settings_table.php @@ -0,0 +1,61 @@ + wartość JSON), obsługiwane przez App\Services\PanelSettingsService. + */ +return new class extends Migration +{ + public function up(): void + { + Schema::create('panel_settings', function (Blueprint $table) { + $table->string('key')->primary(); + $table->json('value')->nullable(); + $table->timestamps(); + }); + + // Jednorazowy import istniejącej konfiguracji z pliku JSON (jeśli była). + // Pomijamy w testach - tam baza ma startować na wbudowanych domyślnych. + if (app()->runningUnitTests()) { + return; + } + + $legacy = storage_path('app/panel_settings.json'); + + if (! is_file($legacy)) { + return; + } + + $decoded = json_decode((string) file_get_contents($legacy), true); + + if (! is_array($decoded) || $decoded === []) { + return; + } + + DB::table('panel_settings')->updateOrInsert( + ['key' => 'panel'], + [ + 'value' => json_encode($decoded, JSON_UNESCAPED_UNICODE), + 'created_at' => now(), + 'updated_at' => now(), + ] + ); + + // Zostaw ślad, żeby plik nie był importowany ponownie ani mylący. + @rename($legacy, $legacy.'.migrated'); + } + + public function down(): void + { + Schema::dropIfExists('panel_settings'); + } +}; diff --git a/database/migrations/2026_09_08_000100_create_panel_attributes_table.php b/database/migrations/2026_09_08_000100_create_panel_attributes_table.php new file mode 100644 index 0000000..b002a33 --- /dev/null +++ b/database/migrations/2026_09_08_000100_create_panel_attributes_table.php @@ -0,0 +1,86 @@ + wiersz 'panel' -> 'attributes'. + * + * `position` wyznacza kolejność kolumn i pól. Zarządza tym + * App\Services\PanelSettingsService (odczyt: normalizacja + odtworzenie + * pozycji "na stałe"; zapis: saveAttributes()). + */ +return new class extends Migration +{ + public function up(): void + { + Schema::create('panel_attributes', function (Blueprint $table) { + $table->id(); + $table->string('key', 64)->unique(); + $table->string('label', 191)->default(''); + $table->string('ldap', 128)->default(''); + $table->string('category', 32)->default('general'); + $table->string('type', 32)->default('text'); + $table->boolean('is_column')->default(false); + $table->unsignedInteger('position')->default(0)->index(); + $table->timestamps(); + }); + + // Przenieś istniejącą listę z wiersza 'panel' (jeśli istnieje) i usuń + // z niego 'attributes' oraz nieużywane już klucze konfiguracji. + if (! Schema::hasTable('panel_settings')) { + return; + } + + $row = DB::table('panel_settings')->where('key', 'panel')->first(); + + if (! $row) { + return; + } + + $value = json_decode((string) $row->value, true); + + if (! is_array($value)) { + return; + } + + $now = now(); + $position = 0; + + foreach ($value['attributes'] ?? [] as $attr) { + if (! is_array($attr) || (string) ($attr['key'] ?? '') === '') { + continue; + } + + DB::table('panel_attributes')->updateOrInsert( + ['key' => (string) $attr['key']], + [ + 'label' => (string) ($attr['label'] ?? $attr['key']), + 'ldap' => (string) ($attr['ldap'] ?? ''), + 'category' => (string) ($attr['category'] ?? 'general'), + 'type' => (string) ($attr['type'] ?? 'text'), + 'is_column' => filter_var($attr['column'] ?? false, FILTER_VALIDATE_BOOL), + 'position' => $position++, + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + } + + unset($value['attributes'], $value['domains'], $value['users_table']); + + DB::table('panel_settings')->where('key', 'panel')->update([ + 'value' => json_encode($value, JSON_UNESCAPED_UNICODE), + 'updated_at' => $now, + ]); + } + + public function down(): void + { + Schema::dropIfExists('panel_attributes'); + } +}; diff --git a/database/migrations/2026_09_09_000100_add_validation_to_panel_attributes.php b/database/migrations/2026_09_09_000100_add_validation_to_panel_attributes.php new file mode 100644 index 0000000..dc40d50 --- /dev/null +++ b/database/migrations/2026_09_09_000100_add_validation_to_panel_attributes.php @@ -0,0 +1,32 @@ +string('regex', 191)->nullable()->after('type'); + $table->boolean('required')->default(false)->after('regex'); + $table->string('help', 255)->default('')->after('required'); + }); + } + + public function down(): void + { + Schema::table('panel_attributes', function (Blueprint $table) { + $table->dropColumn(['regex', 'required', 'help']); + }); + } +}; diff --git a/database/migrations/2026_09_10_000100_create_panel_audit_table.php b/database/migrations/2026_09_10_000100_create_panel_audit_table.php new file mode 100644 index 0000000..1418079 --- /dev/null +++ b/database/migrations/2026_09_10_000100_create_panel_audit_table.php @@ -0,0 +1,39 @@ +id(); + $table->string('actor', 128)->index(); // sAMAccountName administratora + $table->string('actor_name', 191)->default(''); + $table->string('action', 64)->index(); // slug, np. user.disable + $table->string('target_type', 32)->nullable(); + $table->string('target', 191)->nullable()->index(); + $table->text('target_dn')->nullable(); + $table->text('reason')->nullable(); + $table->json('changes')->nullable(); + $table->json('context')->nullable(); + $table->string('ip', 45)->nullable(); + $table->timestamp('created_at')->nullable()->index(); + }); + } + + public function down(): void + { + Schema::dropIfExists('panel_audit'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php new file mode 100644 index 0000000..6b901f8 --- /dev/null +++ b/database/seeders/DatabaseSeeder.php @@ -0,0 +1,25 @@ +create(); + + User::factory()->create([ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + } +} diff --git a/docker/compose.override.yaml.template b/docker/compose.override.yaml.template new file mode 100644 index 0000000..7af241a --- /dev/null +++ b/docker/compose.override.yaml.template @@ -0,0 +1,32 @@ +# ============================================================================= +# Local / no-Traefik override. +# +# Copy to docker/compose.override.yaml (git-ignored) to run the stack without +# a reverse proxy - the app is published straight on a host port and the +# external `traefik_public` network is neutralised. +# +# cp docker/compose.override.yaml.template docker/compose.override.yaml +# docker compose -f docker/compose.yaml -f docker/compose.override.yaml up -d --build +# +# Then open http://localhost:8080 (set APP_URL=http://localhost:8080 in .env). +# ============================================================================= + +services: + app: + ports: + - "8080:80" + # Drop the Traefik wiring for a local run. + networks: + - internal + labels: [] + + mariadb: + # Expose the DB on the host if you want to connect a GUI client. + ports: + - "3306:3306" + +networks: + # Redefine the external network as a normal local one so Compose doesn't + # require a pre-existing `traefik_public`. + traefik_public: + external: false diff --git a/docker/compose.yaml b/docker/compose.yaml new file mode 100644 index 0000000..0bb246b --- /dev/null +++ b/docker/compose.yaml @@ -0,0 +1,71 @@ +name: adconsole + +# ============================================================================= +# Production-ish Compose stack: PHP/Apache app + MariaDB. +# +# Reverse proxy: this file assumes an external Traefik instance on the +# `traefik_public` network. If you don't use Traefik, drop in a local override +# that publishes a port instead - see docker/compose.override.yaml.template +# and docs/deployment.md. +# +# Run from the repo root: +# docker compose -f docker/compose.yaml up -d --build +# ============================================================================= + +services: + app: + build: + context: .. + dockerfile: docker/php/Dockerfile + container_name: adconsole_app + restart: unless-stopped + volumes: + - ..:/var/www/html + - app_storage:/var/www/html/storage + depends_on: + mariadb: + condition: service_healthy + networks: + - internal + - traefik_public + labels: + - traefik.enable=true + - traefik.docker.network=traefik_public + - traefik.http.routers.adconsole.rule=Host(`${APP_DOMAIN:-adconsole.localhost}`) + - traefik.http.routers.adconsole.entrypoints=websecure + - traefik.http.routers.adconsole.tls=true + # Uncomment and set your resolver to let Traefik obtain a certificate: + # - traefik.http.routers.adconsole.tls.certresolver=letsencrypt + - traefik.http.services.adconsole.loadbalancer.server.port=80 + + mariadb: + image: mariadb:11 + container_name: adconsole_db + restart: unless-stopped + environment: + MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-change-me-root} + MARIADB_DATABASE: ${DB_DATABASE:-adconsole} + MARIADB_USER: ${DB_USERNAME:-adconsole} + MARIADB_PASSWORD: ${DB_PASSWORD:-change-me} + MARIADB_AUTO_UPGRADE: "1" + volumes: + - db_data:/var/lib/mysql + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + networks: + - internal + +volumes: + db_data: + app_storage: + +networks: + internal: + # Provided by your Traefik deployment. Remove this (and the `traefik_public` + # entry + labels under `app`) if you use the local port-publishing override. + traefik_public: + external: true diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile new file mode 100644 index 0000000..d2f6a59 --- /dev/null +++ b/docker/php/Dockerfile @@ -0,0 +1,39 @@ +FROM php:8.4-apache + +# --- System packages + PHP extensions ------------------------------------- +RUN apt-get update && apt-get install -y --no-install-recommends \ + libcurl4-openssl-dev \ + libonig-dev \ + libzip-dev \ + libldap2-dev \ + unzip \ + git \ + && docker-php-ext-install -j"$(nproc)" curl mbstring zip pdo pdo_mysql ldap opcache \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Sensible opcache defaults for production. +RUN { \ + echo 'opcache.enable=1'; \ + echo 'opcache.enable_cli=0'; \ + echo 'opcache.memory_consumption=128'; \ + echo 'opcache.max_accelerated_files=20000'; \ + echo 'opcache.validate_timestamps=1'; \ + echo 'opcache.revalidate_freq=60'; \ + } > /usr/local/etc/php/conf.d/opcache.ini + +# Apache: enable rewrite + serve from public/. +ENV APACHE_DOCUMENT_ROOT=/var/www/html/public +RUN a2enmod rewrite \ + && sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf \ + && sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf + +# Composer (from the official image, no network installer). +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +COPY docker/php/entrypoint.sh /usr/local/bin/adconsole-entrypoint +RUN chmod +x /usr/local/bin/adconsole-entrypoint + +WORKDIR /var/www/html + +ENTRYPOINT ["adconsole-entrypoint"] +CMD ["apache2-foreground"] diff --git a/docker/php/entrypoint.sh b/docker/php/entrypoint.sh new file mode 100644 index 0000000..80ca386 --- /dev/null +++ b/docker/php/entrypoint.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# First-run bootstrap for the ADconsole container. The project is bind-mounted, +# so a fresh `git clone` has no vendor/ and no APP_KEY yet - handle both here so +# `docker compose up` just works. Safe to run on every start (all steps are +# idempotent). +set -e + +cd /var/www/html + +if [ ! -f .env ]; then + echo "[adconsole] .env is missing - copy .env.example to .env and fill it in." >&2 + exit 1 +fi + +# 1. PHP dependencies. +if [ ! -f vendor/autoload.php ]; then + echo "[adconsole] installing composer dependencies..." + composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader +fi + +# 2. Application key. +if ! grep -qE '^APP_KEY=.+' .env; then + echo "[adconsole] generating APP_KEY..." + php artisan key:generate --force +fi + +# 3. Writable paths (bind-mounted repo is owned by the host user). +chown -R www-data:www-data storage bootstrap/cache 2>/dev/null || true + +# 4. Database schema + storage symlink. +php artisan migrate --force +php artisan storage:link 2>/dev/null || true + +# 5. Framework caches (config/route/view/event). Refreshed on every boot. +php artisan optimize:clear +php artisan optimize + +exec "$@" diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..e9c98f1 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,17 @@ +# ADconsole documentation + +| Doc | Read it when you want to… | +|---|---| +| [installation.md](installation.md) | Install on a bare-metal / VM host (Apache or nginx). | +| [configuration.md](configuration.md) | Understand every `.env` key and the in-app settings. | +| [active-directory.md](active-directory.md) | Set up the service account, delegation, LDAPS and the login group. | +| [architecture.md](architecture.md) | Understand how the app is put together and why. | +| [deployment.md](deployment.md) | Run it in production — Docker/Traefik, HTTPS, caches, upgrades. | +| [development.md](development.md) | Hack on it: local setup, tests, code style, translations. | +| [troubleshooting.md](troubleshooting.md) | Fix "the list is empty", "password reset fails", etc. | +| [upgrading.md](upgrading.md) | Pull a new version safely. | +| [publishing.md](publishing.md) | Turn this tree into a clean shared/public repo. | + +Start with the [project README](../README.md) for the 5-minute overview. + +Security policy and vulnerability reporting: [../SECURITY.md](../SECURITY.md). diff --git a/docs/active-directory.md b/docs/active-directory.md new file mode 100644 index 0000000..2c17b13 --- /dev/null +++ b/docs/active-directory.md @@ -0,0 +1,118 @@ +# Active Directory setup + +ADconsole needs three things from your directory: a **service account** to bind +with, an **encrypted channel** (LDAPS/STARTTLS), and a **login group** whose +members may use the panel. What the panel can actually *change* is governed +entirely by AD delegation on that service account — grant only what you need. + +## 1. Service account + +Create a dedicated account, e.g. `svc-adconsole`: + +- Password never expires, **cannot** change password, not a member of any + privileged group by default. +- Deny interactive/RDP logon (it only needs "Access this computer from the + network" to the DCs, i.e. LDAP bind). +- Put its DN in `LDAP_USERNAME` and its password in `LDAP_PASSWORD`. + +Test the bind: + +```sh +php artisan tinker +>>> \LdapRecord\Container::getConnection('default')->connect(); // no exception = OK +``` + +## 2. Encrypted transport (LDAPS / STARTTLS) + +AD refuses password writes (`unicodePwd`) and other sensitive changes over an +unencrypted connection, so this is mandatory for anything beyond read-only use. + +- **LDAPS (recommended):** ensure the DCs have a certificate with Server + Authentication EKU (AD CS "Domain Controller" / "Kerberos Authentication" + template auto-enrols this). Set `LDAP_SSL=true`, `LDAP_PORT=636`. +- **STARTTLS:** `LDAP_TLS=true`, `LDAP_PORT=389`. +- If the app host does **not** trust the issuing CA, set `LDAP_TLS_VERIFY=false` + (skips chain validation — acceptable on a trusted network, not ideal). To + verify instead, install the internal root CA in the app host's trust store + (`/etc/ssl/certs`, `update-ca-certificates`) and set `LDAP_TLS_VERIFY=true`. + +Quick check from the app host: + +```sh +openssl s_client -connect dc01.corp.example.com:636 -showcerts ` and read `gPLink` on OUs (default for Authenticated Users). No write is ever attempted. | + +Example — password reset + unlock + enable/disable on one OU: + +```powershell +dsacls "OU=Staff,DC=corp,DC=example,DC=com" /I:S ` + /G "CORP\svc-adconsole:CA;Reset Password;user" ` + "CORP\svc-adconsole:WP;lockoutTime;user" ` + "CORP\svc-adconsole:WP;userAccountControl;user" ` + "CORP\svc-adconsole:WP;pwdLastSet;user" +``` + +## 6. Verify + +1. Sign in as a member of `AD_LOGIN_GROUP`. +2. Open **Users** — the list and OU tree populate. +3. Open a test account → **Reset password** → set a value → confirm on a real + logon. A failure here almost always means "no encrypted channel" or "Reset + Password right not delegated". +4. Repeat for the other features you delegated. + +See [troubleshooting.md](troubleshooting.md) for specific errors. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..4f751c0 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,135 @@ +# Architecture + +## Principles + +1. **The directory is the database.** No table mirrors AD. Controllers call + LdapRecord which talks to a DC per request. Nothing to sync, nothing to + reconcile, no stale copy. +2. **The SQL database is infrastructure only** — `sessions`, `cache`, + `jobs`/`job_batches`/`failed_jobs`, the panel's own configuration + (`panel_settings` for general settings, `panel_attributes` for the AD + attribute / column definitions) and the change log (`panel_audit`). None of + these mirror directory state. The default `users` / `password_reset_tokens` + tables from Laravel's starter migration are unused. +3. **Thin services, fat controllers-per-module.** One controller per screen; a + handful of small services for the cross-cutting bits. + +## Request lifecycle + +``` +Browser + │ cookie session (file) ──► ad_admin profile + ▼ +routes/web.php (English URL segments and route names) + │ middleware: auth:ldap + App\Http\Middleware\SetLocale + ▼ +App\Http\Controllers\Admin\*Controller + │ LdapRecord query / model save + ▼ +Active Directory (LDAPS) Cache::store('file') ── OU tree / dashboard / org chart +``` + +## Authentication + +- Guard `ldap` (config/auth.php) — LdapRecord "plain bind", **no** Eloquent user + provider, no `users` table. +- `LoginController@login` binds to AD with the submitted credentials, then runs + `App\Ldap\Rules\MemberOfLoginGroup`: the account must be a member of + `AD_LOGIN_GROUP` (accepts full DN or CN; walks nested groups; includes the + primary group). +- On success the sanitised profile from `App\Ldap\User::toPanelProfile()` is + written to the session key `ad_admin`; every panel route group is behind + `auth:ldap`. +- Logout clears the session. Sessions are `file`-driver by default. + +## Key classes + +| Class | Responsibility | +|---|---| +| `App\Ldap\User` | AD user model. `attributeMap()` (config/ldap.php → field names), `panelAttributes()` (the attribute list every user query selects), `toPanelProfile()`. | +| `App\Ldap\Rules\MemberOfLoginGroup` | Login authorisation. | +| `App\Services\PanelSettingsService` | The one entry point for panel config. General settings ← `panel_settings` (JSON row `panel`, merged with `defaults()`); attribute / column definitions ← `panel_attributes` (one row each, ordered by `position`, merged with `defaultAttributes()`), normalised with the locked rows enforced. | +| `App\Models\PanelSetting` / `App\Models\PanelAttribute` | Eloquent models for the two config tables. Don't read them from controllers — go through `PanelSettingsService`. | +| `App\Services\AttributeCipher` | Laravel-`Crypt`-compatible encrypt/decrypt for attributes typed *encrypted*; key from the panel (or a pasted `APP_KEY`). | +| `App\Services\LdapAttributeHelper` | Decodes `userAccountControl` bit flags, Windows FileTime ↔ Carbon, `accountExpires` / `pwdLastSet` "never" sentinels, derived status badge, `passwordExpiresAt()`. | +| `App\Services\OuTreeService` | Builds the sidebar OU tree (with object counts), cached on the `file` store; `flush()` on demand. | +| `App\Services\AuditLogger` | Writes one `panel_audit` row per panel action (actor from the `ldap` guard, action slug, target, optional reason + `changes`/`context` JSON). Best-effort: a write failure is `report()`ed, never propagated, so auditing can't break an AD operation. | +| `App\Http\Middleware\SetLocale` | Locale from session → cookie → `APP_LOCALE`. | + +## Modules + +| Route prefix | Controller | Notes | +|---|---|---| +| `/dashboard` | `DashboardController` | Streams every user with `chunk()`, aggregates in PHP, caches the result 5 min (`dashboard:summary` on the `file` store) with a manual refresh. | +| `/users` | `UserController` | List + OU tree + search; CRUD; password reset / OU move / expiry — single and **bulk**; `filter=` query for the dashboard tiles; CSV export of the matching list (`GET /users/export`, streamed, UTF-8 BOM, one column per configured attribute); **offboarding** (`offboardOne` / `bulkOffboard` → `runOffboard`: attribute changes → group removal → OU move, in that order because the move changes the DN). | +| `/computers` | `ComputerController` | List + OU tree; details include LAPS (legacy `ms-Mcs-AdmPwd` and Windows LAPS `msLAPS-Password`) and BitLocker (`msFVE-RecoveryInformation`); trust-account reset; enable/disable; delete. | +| `/groups` | `GroupController` | List with type/scope filter; create; edit description & mail; membership add/remove; delete. | +| `/organisation` | `OrgChartController` | `manager` → `directReports`; rendered client-side with d3-org-chart (CDN); cached 10 min. | +| `/gpo` | `GpoController` | Read-only: `groupPolicyContainer` objects, `gPLink` on OUs, simplified RSoP. | +| `/audit` | `AuditController` | Read-only view of `panel_audit` with filters (actor / action / object / date range). No write route. | +| `/settings` | `ConfigController` | General settings + attribute definitions → `PanelSettingsService`. | + +## Audit log + +Every write the panel performs against AD calls `AuditLogger::log($action, +$opts)`, which appends a row to `panel_audit`: + +- `actor` / `actor_name` — the signed-in admin (`sAMAccountName` + `cn` from the + `ldap` guard; falls back to the `ad_admin` session profile, then `system`). +- `action` — a stable slug: `user.disable`, `user.reset_password`, + `user.offboard`, `user.bulk_move`, `computer.laps_view`, + `group.member_add`, `config.settings`, … (viewing a LAPS / BitLocker secret is + audited too). +- `target_type` / `target` / `target_dn` — the object acted on. +- `reason` — free text. Optional by default; the **Audit log** settings block has + a *"require a reason for destructive actions"* switch + (`panel_settings` → `audit.require_reason`) that makes it mandatory for + disable / delete / move / offboard. A row is written either way. +- `changes` / `context` — JSON: a before/after diff, and extras such as the + number of accounts in a bulk action or the list of offboarding steps run. +- `ip`, `created_at`. + +The table is read only by `AuditController` (filter + paginate) and never by the +rest of the app. A failed insert is `report()`ed and swallowed — auditing can +never abort an AD operation. Nothing prunes the table; see +[deployment.md](deployment.md#data--backups) for retention. + +## Caching + +App-level caches use `Cache::store('file')` explicitly (independent of +`CACHE_STORE`): + +| Key | TTL | Refresh | +|---|---|---| +| `ou_tree:{scope}` | 10 min | `?refresh_ou=1` in a module, or `OuTreeService::flush()` | +| `dashboard:summary` (+ `…_ts`) | 5 min | "Recalculate now" button (`POST /dashboard/refresh`) | +| `org_chart:v3` (+ `…_ts`) | 10 min | "Refresh" on the org chart | + +Some user mutations also bust `org_chart:v3` and `dashboard:summary`. + +## Front-end + +- Bootstrap 5 + Bootstrap Icons from a CDN. +- `public/css/style.css` and `public/js/main.js` are **hand-written and served + directly** — there is no Vite build to run for the app to work. (`resources/js`, + `resources/css` and `vite.config.js` exist for anyone who wants to add a + bundled asset pipeline.) +- Light/dark theme via `data-bs-theme` on ``, remembered in + `localStorage`. + +## Internationalisation + +- `lang/pl/*.php` and `lang/en/*.php`, structured by namespace + (`nav`, `users`, `computers`, `groups`, `gpo`, `dashboard`, `config`, + `common`, `auth`, `pages`, …). Access with `__('namespace.key')`. +- `config/locales.php` is the single list of available languages. +- Switchers on the login page, the navbar and the footer; + `GET /language/{locale}` (`LocaleController@switch`) persists the choice. + +## What is intentionally *not* here + +- No local mirror / cache of AD objects. +- No GPO **content** editing (SYSVOL over SMB). +- No background jobs or scheduler. +- No REST API (server-rendered Blade only; some endpoints return partial HTML or + JSON for the live-search / typeahead helpers). diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..83bd917 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,124 @@ +# Configuration + +Two layers: + +1. **`.env`** — deployment settings, read at boot, never written back. After any + change run `php artisan config:cache` (or `php artisan optimize`), or the old + cached values keep being used. +2. **Database (`/settings`)** — in-app settings via `PanelSettingsService`: + general settings in `panel_settings` (one JSON row), the AD attribute / + column definitions in `panel_attributes` (one row each). + +## `.env` reference + +### Application + +| Key | Default | Notes | +|---|---|---| +| `APP_NAME` | `ADconsole` | Shown in the header / page titles. | +| `APP_ENV` | `production` | `local` on a dev box only. | +| `APP_KEY` | — | `php artisan key:generate`. Also the fallback key for encrypted attributes (see below). **Rotating it invalidates existing sessions and any attributes encrypted with it.** | +| `APP_DEBUG` | `false` | Never `true` in production — stack traces leak DNs and query filters. | +| `APP_URL` | — | Exact public URL, scheme + host, no trailing slash. | +| `APP_DOMAIN` | — | Hostname the Docker/Traefik router matches. Ignored on bare metal. | +| `APP_LOCALE` | `pl` | `pl` or `en`. | +| `APP_FALLBACK_LOCALE` | `en` | Used for any missing translation key. | +| `LOG_CHANNEL` / `LOG_LEVEL` | `stack` / `warning` | Standard Laravel logging. | + +### Database + +ADconsole stores **no directory data** in SQL — only sessions, cache, queue, the +panel's own config (`panel_settings`, `panel_attributes`) and the change log +(`panel_audit`). + +| Key | Notes | +|---|---| +| `DB_CONNECTION` | `mysql` (MariaDB/MySQL), `pgsql`, or `sqlite`. | +| `DB_HOST` / `DB_PORT` / `DB_DATABASE` / `DB_USERNAME` / `DB_PASSWORD` | Connection. | +| `DB_ROOT_PASSWORD` | Only consumed by `docker/compose.yaml` on first volume init. | + +### Sessions / cache / queue + +| Key | Default | Notes | +|---|---|---| +| `SESSION_DRIVER` | `file` | `database` or `redis` if you run more than one app node. | +| `SESSION_LIFETIME` | `120` | Minutes. | +| `CACHE_STORE` | `database` | The app's own heavy caches (OU tree, dashboard, org chart) always use the **`file`** store regardless of this. | +| `QUEUE_CONNECTION` | `database` | No queued jobs ship today. | + +### Active Directory — connection + +| Key | Default | Notes | +|---|---|---| +| `LDAP_HOSTS` | `127.0.0.1` | Comma-separated DC list, tried in order. `LDAP_HOST` (singular) also accepted. | +| `LDAP_USERNAME` | — | Service-account bind DN or `user@domain`. | +| `LDAP_PASSWORD` | — | Service-account password. | +| `LDAP_PORT` | `636` | `636` LDAPS, `389` plain/STARTTLS. | +| `LDAP_SSL` | `true` | LDAPS. | +| `LDAP_TLS` | `false` | STARTTLS on 389. Use **one** of SSL/TLS. | +| `LDAP_TLS_VERIFY` | `false` | `true` = validate the DC certificate chain. Leave `false` for an internal-CA / self-signed cert the app host doesn't trust. | +| `LDAP_TIMEOUT` | `5` | Seconds. | +| `LDAP_BASE_DN` | — | **Must cover users, computers and groups.** If those OUs are siblings, use the domain root (`DC=…,DC=…`). | +| `LDAP_LOGGING` | `true` | Logs LDAP operations to the Laravel log. | + +> Writes to AD (password reset, enable/disable, create, move, delete) **require** +> `LDAP_SSL=true` or `LDAP_TLS=true`. A clear connection can read but every write +> of `unicodePwd` etc. is refused by the DC. + +### Active Directory — defaults & access + +| Key | Notes | +|---|---| +| `LDAP_DEFAULT_USER_OU` | Where new users are created; also caps the Users OU tree. | +| `LDAP_DEFAULT_COMPUTER_OU` | Same for Computers. | +| `LDAP_DEFAULT_GROUPS_OU` | Same for Groups. | +| `AD_DOMAIN_LABEL` | Friendly domain name in the header (defaults to `LDAP_BASE_DN`). | +| `AD_LOGIN_GROUP` | AD group whose members may sign in. Full DN (recommended) or bare CN. Nested membership + primary group honoured. **Empty = nobody can log in.** | + +`AD_LOGIN_GROUP` controls *who can open the panel*. It does **not** grant +directory permissions — what the service account may change is decided by AD +delegation. See [active-directory.md](active-directory.md). + +### Mail + +Not used by the app as shipped. Configure `MAIL_*` only if you add notifications. + +## In-app settings (`/settings`) + +All read and written through `App\Services\PanelSettingsService`. Nothing here +touches `.env`. + +### General tab + +Stored as one JSON row in **`panel_settings`** (key `panel`). Absent → the +built-in `PanelSettingsService::defaults()` apply. + +| Setting | Meaning | +|---|---| +| Reset password → *must change at next logon* | Default state of that checkbox in the reset dialog. | +| Reset password → *unlock account* | Default state of the "also unlock" checkbox. | +| Password policy → *max age (days)* | Drives the "password expires / expired" columns and dashboard tiles. `0` = no expiry. This is ADconsole's own figure — it does not read the domain's `maxPwdAge`. | +| Password generator → *length* / *symbols* | For the in-panel generator only; does not touch AD policy. | +| Attribute encryption key | Symmetric key used to encrypt/decrypt attributes whose type is *encrypted* (e.g. cloud extension attributes). Empty = feature off. `base64:…` (16/32 bytes) is used verbatim — paste `APP_KEY` here if values were encrypted with it; any other string is hashed to a 32-byte key. See `App\Services\AttributeCipher`. | +| Audit log → *require a reason for destructive actions* | When on, disable / delete / move-OU / offboard refuse to run without a reason. An audit row (`panel_audit`) is written either way — this only forces the operator to type why. Default: off. | + +### Attributes tab + +Defines the attribute set the Users module works with. One row per attribute in +**`panel_attributes`**; row order (`position`) sets column / field order. Empty +table → `PanelSettingsService::defaultAttributes()` apply. + +| Column | Meaning | +|---|---| +| `key` | internal id (`[A-Za-z0-9_-]`) | +| `label` | column / field caption | +| `ldap` | the AD attribute name | +| `category` | profile-dialog tab: `general` / `org` / `address` / `hr` / `account` | +| `type` | `text` · `number` · `date` · `encrypted` (editable) or `system` · `account_status` · `password_expiry` (computed / read-only) | +| `is_column` | show it as a column on the user list | +| `position` | display order | + +Locked rows that can't be removed or retyped: `login` (sAMAccountName), +`distinguishedName`, the computed `status` and `password_expiry`, and +`account_expiry` (fixed `date`, `accountExpires`). If deleted directly in SQL +they are re-created from the defaults on the next read. diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..5f89312 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,97 @@ +# Deployment (Docker) + +The repo ships a small production stack in `docker/`: + +- `docker/php/Dockerfile` — PHP 8.4 + Apache, `ldap`/`pdo_mysql`/`opcache`, + Composer, and an entrypoint that self-bootstraps on first boot. +- `docker/php/entrypoint.sh` — installs `vendor/` if missing, generates + `APP_KEY`, fixes `storage/` ownership, runs `migrate --force`, warms caches, + then starts Apache. Idempotent — runs on every start. +- `docker/compose.yaml` — `app` + `mariadb`, wired for an **external Traefik** + proxy on the `traefik_public` network. +- `docker/compose.override.yaml.template` — copy to + `docker/compose.override.yaml` (git-ignored) to run **without** Traefik by + publishing a host port. + +## With Traefik + +```sh +cp .env.example .env +# APP_ENV=production, APP_DEBUG=false +# APP_URL=https://adconsole.example.com +# APP_DOMAIN=adconsole.example.com <- Traefik Host() rule +# DB_PASSWORD / DB_ROOT_PASSWORD <- set real values +# LDAP_* / AD_* <- your domain + +docker compose -f docker/compose.yaml up -d --build +docker compose -f docker/compose.yaml logs -f app # watch the bootstrap +``` + +Enable a certificate resolver by uncommenting the `certresolver` label in +`docker/compose.yaml` and setting its name to match your Traefik config. + +The `.env` file is **bind-mounted**, not baked in: edit it and +`docker compose restart app` — no rebuild needed. (This is deliberate; see the +comment in `compose.yaml`.) + +## Without Traefik + +```sh +cp docker/compose.override.yaml.template docker/compose.override.yaml +# APP_URL=http://localhost:8080 in .env +docker compose -f docker/compose.yaml -f docker/compose.override.yaml up -d --build +``` + +App on `http://localhost:8080`, MariaDB on `localhost:3306`. Put a TLS-terminating +proxy (nginx, Caddy, your load balancer) in front for anything but a lab. + +## Data & backups + +| What | Where | Back up? | +|---|---|---| +| Database (`panel_settings` + `panel_attributes` config, `panel_audit` change log, sessions, cache) | `db_data` volume | **Yes** — `panel_settings` / `panel_attributes` are your config and `panel_audit` is your only record of who changed what. `mysqldump adconsole`. | +| `storage/` (logs, framework cache, sessions if file-driver) | `app_storage` volume | Optional (logs only really). | +| `.env` | bind-mounted from the repo | **Yes**, securely — it holds the service-account password. | + +Restore config elsewhere: `mysqldump`/import the `panel_settings` and +`panel_attributes` tables, or re-enter it in `/settings`. + +**Audit-log retention.** `panel_audit` grows with panel activity and nothing +prunes it automatically. If you need a retention window, trim it with a scheduled +job of your own, e.g. +`DELETE FROM panel_audit WHERE created_at < NOW() - INTERVAL 365 DAY;`, and +back it up (or ship it to a SIEM) before trimming if the entries have +compliance value. + +## Upgrades + +```sh +git pull +docker compose -f docker/compose.yaml up -d --build +``` + +The entrypoint runs `migrate --force` and `optimize` on the new container. See +[upgrading.md](upgrading.md) for version-specific notes. + +## Hardening checklist + +- [ ] `APP_DEBUG=false`, `APP_ENV=production` +- [ ] HTTPS only; HSTS at the proxy +- [ ] `LDAP_SSL=true` (or `LDAP_TLS=true`); `LDAP_TLS_VERIFY=true` once the CA is + trusted by the app host +- [ ] Service account delegated **only** the rights you use ([active-directory.md](active-directory.md)) +- [ ] `AD_LOGIN_GROUP` is a small, audited group +- [ ] Panel reachable only from admin networks / VPN (proxy ACL or firewall) +- [ ] DB not exposed to the internet; strong `DB_PASSWORD` / `DB_ROOT_PASSWORD` +- [ ] `.env` file permissions `600`, owned by the deploy user +- [ ] Log shipping / retention configured; review `LDAP_LOGGING` volume +- [ ] Backups of the database tested + +## Scaling notes + +- Multiple app containers: move `SESSION_DRIVER` and the app caches off local + disk (`database`/`redis`) — note the OU-tree/dashboard/org-chart caches are + hard-coded to the `file` store, so with >1 node each node keeps its own (they + expire in minutes; acceptable, or add a shared store and change + `Cache::store('file')` call sites). +- One `mariadb` is plenty; the workload is tiny. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..520f0ea --- /dev/null +++ b/docs/development.md @@ -0,0 +1,99 @@ +# Development + +## Local setup + +```sh +git clone https://gitea.kzbikowski.pl/kzbkowski/ADconsle.git adconsole && cd adconsole +composer install +cp .env.example .env +php artisan key:generate +``` + +Edit `.env` for a dev box: + +``` +APP_ENV=local +APP_DEBUG=true +LOG_LEVEL=debug +DB_CONNECTION=sqlite # simplest; then: touch database/database.sqlite +``` + +```sh +php artisan migrate +php artisan serve +``` + +You still need a reachable domain controller for the AD-backed screens. Without +one, the auth and list pages will error — the test suite does **not** need a DC +(it fakes the guard and mocks LdapRecord). + +## Tests + +```sh +php artisan test # everything (~40, SQLite :memory:) +php artisan test --filter=Dashboard # one file / group +composer test # config:clear + test +``` + +- `tests/Unit` — pure logic (`LdapAttributeHelper`, `AttributeCipher`, + `MemberOfLoginGroup`, `OuTreeService`). +- `tests/Feature` — HTTP: auth, localisation, the About page, attribute config, + the audit log and the user CSV export (`RefreshDatabase`), and the dashboard + (seeds the `file` cache, no LDAP). +- `tests/TestCase::actingAsPanelAdmin()` logs in a fake `App\Ldap\User` on the + `ldap` guard so you can hit protected routes. + +PHPUnit tags every test `DEPR` because PHP 8.5 deprecates a PDO constant PHPUnit +touches. It is **not** a failure — look at the `Tests:` summary line. + +## Code style + +[Laravel Pint](https://laravel.com/docs/pint): + +```sh +vendor/bin/pint # fix +vendor/bin/pint --test # check only (CI) +composer lint / composer format +``` + +Match the surrounding code: constructor property promotion, typed properties, +short closures, `App\Services` for cross-cutting logic. Comments in the codebase +are Polish — keep new ones consistent with the file you're editing. + +## Adding a translation string + +1. Add the key to **both** `lang/pl/.php` and `lang/en/.php`. +2. Use `__('.')` in Blade/PHP — never inline copy. +3. Some existing `lang/pl/*` values are asserted verbatim by + `tests/Feature/AttributeConfigTest` and `AboutPageTest`; run the suite after + touching PL strings. + +## Adding an AD attribute to the UI + +No code change needed for a plain attribute: `/settings` → **Attributes** → +add key / label / `ldap` name / category / type / column. Computed or +specially-encoded attributes (bit flags, FileTime) need a helper in +`App\Services\LdapAttributeHelper` and wiring in the relevant controller. + +## Front-end + +`public/css/style.css` and `public/js/main.js` are edited directly and served +as-is — no build step. Keep the light/dark (`data-bs-theme`) treatment working +for anything new: define colours as tokens on `:root` and override under +`[data-bs-theme="dark"]`. + +## Database changes + +New migrations go in `database/migrations`. Remember the project rule: **no AD +data in SQL** — migrations are for app infrastructure and `panel_settings`-style +config only. `PanelSettingsService` is the access point for panel config; don't +read the table directly from controllers. + +## Handy commands + +```sh +php artisan route:list --path=users +php artisan tinker +php artisan optimize:clear # after .env / config edits +php artisan migrate:fresh # dev DB reset (infra tables only) +``` diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..df99fac --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,141 @@ +# Installation (bare metal / VM) + +For the containerised path see [deployment.md](deployment.md); this page covers a +classic PHP host. + +## 1. Prerequisites + +- PHP **8.2+** with extensions: `ldap`, `pdo_mysql` (or `pdo_pgsql` / `pdo_sqlite`), + `mbstring`, `curl`, `zip`, `openssl`, `fileinfo`, `ctype`, `json` +- Composer 2 +- A web server (Apache with `mod_rewrite`, or nginx + PHP-FPM) +- A database server — MariaDB 10.6+ / MySQL 8 recommended +- Outbound access to a domain controller on **636/tcp** (LDAPS) or **389/tcp** + (STARTTLS) + +Check the LDAP extension: + +```sh +php -m | grep -i ldap +``` + +## 2. Get the code + +```sh +git clone https://gitea.kzbikowski.pl/kzbkowski/ADconsle.git /var/www/adconsole +cd /var/www/adconsole +composer install --no-dev --optimize-autoloader +``` + +## 3. Environment + +```sh +cp .env.example .env +php artisan key:generate +``` + +Edit `.env` — at minimum: + +- `APP_URL` — the exact public URL (scheme + host, no trailing slash) +- `APP_ENV=production`, `APP_DEBUG=false` +- `DB_*` — database connection +- `LDAP_HOSTS`, `LDAP_USERNAME`, `LDAP_PASSWORD`, `LDAP_BASE_DN` +- `LDAP_SSL=true` (or `LDAP_TLS=true`), `LDAP_TLS_VERIFY` +- `LDAP_DEFAULT_USER_OU` / `_COMPUTER_OU` / `_GROUPS_OU`, `AD_DOMAIN_LABEL` +- `AD_LOGIN_GROUP` — the AD group whose members may sign in + +Every key is explained in [configuration.md](configuration.md). + +## 4. Database + +Create the schema (sessions, cache, queue, `panel_settings`, `panel_attributes`, +`panel_audit`): + +```sh +php artisan migrate --force +``` + +## 5. Caches & storage + +```sh +php artisan storage:link +php artisan config:cache +php artisan route:cache +php artisan view:cache +``` + +Make `storage/` and `bootstrap/cache/` writable by the web-server user: + +```sh +chown -R www-data:www-data storage bootstrap/cache +find storage bootstrap/cache -type d -exec chmod 775 {} \; +``` + +Re-run the three `*:cache` commands (or `php artisan optimize`) after **any** +`.env` change. + +## 6. Web server + +**Document root must be `public/`.** Never expose the project root. + +### Apache + +```apache + + ServerName adconsole.example.com + DocumentRoot /var/www/adconsole/public + + + AllowOverride All + Require all granted + + + SSLEngine on + SSLCertificateFile /etc/ssl/certs/adconsole.pem + SSLCertificateKeyFile /etc/ssl/private/adconsole.key + + ErrorLog ${APACHE_LOG_DIR}/adconsole-error.log + CustomLog ${APACHE_LOG_DIR}/adconsole-access.log combined + +``` + +`a2enmod rewrite ssl && systemctl reload apache2`. Laravel ships the matching +`public/.htaccess`. + +### nginx + PHP-FPM + +```nginx +server { + listen 443 ssl; + server_name adconsole.example.com; + root /var/www/adconsole/public; + index index.php; + + ssl_certificate /etc/ssl/certs/adconsole.pem; + ssl_certificate_key /etc/ssl/private/adconsole.key; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location ~ \.php$ { + fastcgi_pass unix:/run/php/php8.2-fpm.sock; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + include fastcgi_params; + } + + location ~ /\.(?!well-known).* { deny all; } +} +``` + +## 7. First login + +Open `APP_URL`, pick a language, sign in with an AD account that belongs to +`AD_LOGIN_GROUP`. If it rejects you, see +[troubleshooting.md](troubleshooting.md#i-cannot-log-in). + +## 8. Scheduler / queue (optional) + +ADconsole has no cron jobs or queued work today, so no `schedule:run` or +`queue:work` is required. `QUEUE_CONNECTION=database` is a safe default if you +add async work later. diff --git a/docs/publishing.md b/docs/publishing.md new file mode 100644 index 0000000..f287ae4 --- /dev/null +++ b/docs/publishing.md @@ -0,0 +1,97 @@ +# Publishing ADconsole to a Git host + +How this working tree was turned into the public repository, and the checklist to +follow if you re-publish it somewhere else (a fork, a mirror, a different host). + +**Canonical repository:** +(self-hosted Gitea). The first public tag is **`v1.0.0`**. + +## 1. Placeholders — all filled for the canonical repo + +If you re-publish under a different owner/host, revisit these: + +| File | Was | Now / put | +|---|---|---| +| `README.md`, `docs/installation.md`, `docs/development.md` | `` in the `git clone` lines | `https://gitea.kzbikowski.pl/kzbkowski/ADconsle.git` | +| `SECURITY.md` | `` | `mail@kzbikowski.pl` | +| `.github/ISSUE_TEMPLATE/config.yml` | `https://github.com/OWNER/REPO/...` | The repo's Gitea issue/wiki URLs | +| `LICENSE` | `2026 Kacper Żbikowski` | Copyright year + holder, if different | +| `lang/{pl,en}/pages.php` → `author.*` | `Kacper Żbikowski` | Author/maintainer shown on the in-app *About* page | +| `composer.json` | `adconsole/adconsole` | Your package name (optional; nothing depends on it) | + +`tests/Feature/Pages/AboutPageTest.php` asserts the author name and +`Copyright (c) ` — if you change `pages.php` `author`, update that +test too. + +## 2. Confirm nothing sensitive is tracked + +Everything below is already covered by `.gitignore` / `.dockerignore` — verify +none of it slipped in: + +- `.env`, `.env.backup`, `.env.production`, `.env.*.local` +- `storage/app/panel_settings.json` and `*.migrated` +- `vendor/`, `node_modules/`, `public/build/`, `public/hot/` +- `storage/` runtime (logs, sessions, framework cache) — only the `.gitignore` + keep-files should be tracked +- `docker/compose.override.yaml` +- IDE / agent dirs (`.idea/`, `.vscode/`, `.claude/`, …) + +```sh +# Dry-run: what would a fresh repo add? +git init -q _probe && git --git-dir=_probe/.git --work-tree=. add -An | grep -Ei '(^add ...env$|vendor/|node_modules/|storage/(logs|framework)/.+/|panel_settings\.json)' +rm -rf _probe +# No output = clean. +``` + +Also grep for lab-specific values before the first push: + +```sh +grep -rniE 'YOUR-DOMAIN|internal-hostname|real-ip' --exclude-dir=vendor --exclude-dir=node_modules . +``` + +## 3. Green build + +```sh +composer install +cp .env.example .env && php artisan key:generate +php artisan migrate # against a throwaway SQLite/MySQL +php artisan test # 40+ pass (the DEPR tag is noise) +vendor/bin/pint --test # style clean +``` + +## 4. Create the repository + +This tree started life inside an unrelated parent checkout, so it is published as +a fresh repo scoped to just this folder: + +```sh +cd /path/to/adconsole +git init -b main +git add . +git status # sanity-check the file list +git commit -m "ADconsole v1.0.0" +git remote add origin https://gitea.kzbikowski.pl/kzbkowski/ADconsle.git +git push -u origin main +git tag -a v1.0.0 -m "ADconsole v1.0.0" +git push origin v1.0.0 +``` + +## 5. On the Git host + +- Set the repository **description** and topics: `active-directory`, `ldap`, + `laravel`, `ldaprecord`, `admin-panel`, `self-hosted`. +- Enable **Issues**. Point the "Question / help" contact link at the repo's + issues (Gitea has no Discussions). +- The GitHub-format templates in `.github/` (issue/PR templates, the + Pint + PHPUnit CI workflow on PHP 8.2/8.3/8.4) are read by Gitea Actions too if + Actions is enabled on the instance; otherwise they are harmless. +- Cut a **release** from the `v1.0.0` tag and paste the matching `CHANGELOG.md` + section as the notes. + +## 6. First-deploy sanity (for whoever installs it) + +Point them at [`installation.md`](installation.md) (bare metal) or +[`deployment.md`](deployment.md) (Docker), and +[`active-directory.md`](active-directory.md) for the service account and +delegation. [`troubleshooting.md`](troubleshooting.md) covers the usual +first-run stumbles. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..b45723e --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,93 @@ +# Troubleshooting + +Turn on detail while diagnosing (dev/staging only): + +``` +APP_DEBUG=true +LOG_LEVEL=debug +LDAP_LOGGING=true +``` + +then `php artisan optimize:clear` and watch `storage/logs/laravel.log`. + +--- + +## I cannot log in + +- **"These credentials do not match our records" for a valid account** — the + account is not in `AD_LOGIN_GROUP`. Check the value is a correct **DN or CN**; + nested membership and the primary group count. Empty `AD_LOGIN_GROUP` = nobody + can log in. +- **Every login fails, including known-good** — the service account bind itself + is failing (wrong `LDAP_USERNAME`/`LDAP_PASSWORD`, unreachable + `LDAP_HOSTS`, or TLS handshake). See the LDAP lines in the log. +- **"Can't contact LDAP server"** — DNS/routing to `LDAP_HOSTS`, firewall on + 636/389, or a TLS problem (below). + +## TLS / certificate errors + +- `LDAP_TLS_VERIFY=true` but the DC cert chains to an internal CA the app host + doesn't trust → install the root CA (`/usr/local/share/ca-certificates/`, + `update-ca-certificates`) or set `LDAP_TLS_VERIFY=false`. +- Mixed settings: use **either** `LDAP_SSL=true` + port 636 **or** + `LDAP_TLS=true` + port 389, not both. +- Verify from the host: `openssl s_client -connect dc01.example.com:636`. + +## The users / computers / groups list is empty + +1. **Base DN too narrow.** `LDAP_BASE_DN` must be an ancestor of the users, + computers **and** groups OUs. If they're siblings, use the domain root. +2. **Default OU wrong.** `LDAP_DEFAULT_*_OU` points somewhere with no objects — + the tree opens there. Try the "whole domain" (globe) button in the module. +3. **Stale cache** after fixing `.env` — click the refresh/globe control, or + `php artisan optimize:clear`, or wait out the 10-minute OU-tree TTL. +4. **Config cache** — you edited `.env` but didn't re-run `php artisan config:cache`. + +## Password reset fails + +- Almost always **no encrypted channel** — set `LDAP_SSL=true` (or + `LDAP_TLS=true`). AD refuses `unicodePwd` over clear LDAP. +- **"insufficient access rights"** — the service account lacks the *Reset + Password* extended right on that OU. See + [active-directory.md](active-directory.md#5-delegation--grant-per-feature). +- **"constraint violation"** — the new password violates the domain password + policy (length/complexity/history). ADconsole only checks `min:8` locally. + +## Enable/disable or unlock does nothing + +Write permission on `userAccountControl` (enable/disable, "never expires") or +`lockoutTime` (unlock) not delegated. The button returns without error but the +DC rejects the write — check the log. + +## LAPS / BitLocker fields are blank + +Those attributes are **not** world-readable. Grant the service account read on +`ms-Mcs-AdmPwd` / `msLAPS-Password` (LAPS) and on the +`msFVE-RecoveryInformation` child objects (BitLocker). Blank ≠ error — it means +"not readable" or "not present on this computer". + +## GPO list is empty + +The service account needs read on `CN=Policies,CN=System,`. Authenticated +Users usually have it; a locked-down forest may not. + +## Dashboard numbers look wrong / stale + +- The dashboard is cached 5 minutes. Hit **Recalculate now**. +- A **yellow age badge** = the cache is older than one refresh cycle; background + recalculation isn't running (an LDAP error during rebuild — check the log). +- Counts stream every matching object with `chunk()`, so they are not capped; + if a number seems low, verify the base DN and that the objects aren't filtered + out (disabled users are still counted in totals). + +## 500 error after deploy / upgrade + +- `php artisan migrate --force` not run → missing `panel_settings` table. +- `APP_KEY` not set → `php artisan key:generate`. +- Stale cache → `php artisan optimize:clear && php artisan optimize`. +- `storage/` / `bootstrap/cache/` not writable by the web user. + +## `php artisan test` shows "DEPR" on every test + +Expected. PHP 8.5 deprecates `PDO::MYSQL_ATTR_SSL_CA`, which PHPUnit references. +Read the `Tests: … ` summary line — `deprecated` there is not `failed`. diff --git a/docs/upgrading.md b/docs/upgrading.md new file mode 100644 index 0000000..6c96087 --- /dev/null +++ b/docs/upgrading.md @@ -0,0 +1,72 @@ +# Upgrading + +## Versioning + +From `v1.0.0` on, releases are [semantic](https://semver.org/) git tags +`vX.Y.Z`: + +- **MAJOR** — incompatible change: a required new `.env` key, a schema change + that is not auto-migrated, or a new AD permission the service account needs. + Read this page before upgrading. +- **MINOR** — new, backwards-compatible feature. Safe to pull; still run + `php artisan migrate`. +- **PATCH** — backwards-compatible fix only. + +The latest tag on `main` is the supported version; there are no long-term-support +branches. Per-version notes are under [Version notes](#version-notes) below and +in [`CHANGELOG.md`](../CHANGELOG.md). + +## General procedure + +```sh +# back up the DB first (panel_settings + panel_attributes hold your config) +mysqldump adconsole > adconsole-$(date +%F).sql + +git pull # or fetch a new release +composer install --no-dev --optimize-autoloader +php artisan migrate --force +php artisan optimize:clear +php artisan optimize # config + route + view + event cache +``` + +Docker: `docker compose -f docker/compose.yaml up -d --build` — the entrypoint +runs `migrate --force` and `optimize` for you. + +Roll back = restore the DB dump and check out the previous revision. + +## Version notes + +### Panel configuration moved into the database + +Two migrations, both automatic — just run `php artisan migrate` (the Docker +entrypoint does this for you). + +1. **`create_panel_settings_table`** — general settings (password policy & + generator, reset-dialog defaults, encryption key) move out of the old + `storage/app/panel_settings.json`. The migration **imports that file if + present** and renames it `panel_settings.json.migrated`. +2. **`create_panel_attributes_table`** — the AD attribute / column definitions + move from the JSON blob into their own table, one row per attribute with + real columns (`key`, `label`, `ldap`, `category`, `type`, `is_column`, + `position`). The migration copies the existing list across and strips the + now-unused `attributes` (and dead `domains` / `users_table`) keys from the + `panel_settings` row. + +- After migrating, open `/settings` and confirm the **General** tab and the + **Attributes** tab still show your settings, then delete any + `storage/app/panel_settings.json.migrated`. +- Fresh installs have empty tables and run on `PanelSettingsService::defaults()` + / `defaultAttributes()` until you save something. +- The legacy file and its `.migrated` rename are git-ignored. + +### New `.env` keys + +`LDAP_SSL`, `LDAP_TLS_VERIFY`, `APP_DOMAIN`. Existing installs keep working with +the defaults (`LDAP_SSL=true`, `LDAP_TLS_VERIFY=false`); add explicit values when +you next edit `.env`. See [configuration.md](configuration.md). + +## After any upgrade + +- `php artisan test` if you carry local patches. +- Re-check the [hardening checklist](deployment.md#hardening-checklist). +- Watch `storage/logs/laravel.log` on the first real use. diff --git a/lang/en/audit.php b/lang/en/audit.php new file mode 100644 index 0000000..c0f654b --- /dev/null +++ b/lang/en/audit.php @@ -0,0 +1,82 @@ + 'Audit log', + 'heading' => 'Audit log', + 'subtitle' => 'Who changed what in Active Directory through the panel, and when.', + + 'filter' => [ + 'actor' => 'Administrator', + 'action' => 'Action', + 'action_all' => 'All actions', + 'target' => 'Object (login / CN / DN)', + 'from' => 'From', + 'to' => 'To', + 'apply' => 'Filter', + 'clear' => 'Clear', + ], + + 'col' => [ + 'when' => 'Date and time', + 'actor' => 'Administrator', + 'action' => 'Action', + 'target' => 'Object', + 'reason' => 'Reason', + ], + + 'details' => 'Details', + 'changes' => 'Changes', + 'context' => 'Context', + + 'target_type' => [ + 'user' => 'user', + 'computer' => 'computer', + 'group' => 'group', + ], + + 'actions' => [ + 'config.settings' => 'Panel settings', + 'config.attributes' => 'Attribute configuration', + + 'user.create' => 'User created', + 'user.update' => 'User edited', + 'user.reset_password' => 'Password reset', + 'user.password_expiry' => 'Password / account expiry', + 'user.move' => 'Moved to OU', + 'user.enable' => 'Account enabled', + 'user.disable' => 'Account disabled', + 'user.unlock' => 'Account unlocked', + 'user.force_password_change' => 'Forced password change', + 'user.delete' => 'User deleted', + 'user.photo' => 'Photo changed', + 'user.group_add' => 'Added to group', + 'user.group_remove' => 'Removed from group', + 'user.bulk_reset_password' => 'Bulk password reset', + 'user.bulk_move' => 'Bulk move to OU', + 'user.bulk_password_expiry' => 'Bulk password expiry change', + 'user.bulk_enable' => 'Bulk account enable', + 'user.bulk_disable' => 'Bulk account disable', + 'user.bulk_group' => 'Bulk group membership change', + 'user.bulk_force_password_change' => 'Bulk forced password change', + 'user.bulk_unlock' => 'Bulk account unlock', + 'user.bulk_attributes' => 'Bulk attribute edit', + 'user.offboard' => 'Account offboarding', + + 'computer.enable' => 'Computer enabled', + 'computer.disable' => 'Computer disabled', + 'computer.move' => 'Computer moved to OU', + 'computer.delete' => 'Computer deleted', + 'computer.reset_trust' => 'Trust account reset', + 'computer.laps_view' => 'LAPS password viewed', + 'computer.bitlocker_view' => 'BitLocker keys viewed', + 'computer.bulk_enable' => 'Bulk computer enable', + 'computer.bulk_disable' => 'Bulk computer disable', + 'computer.bulk_move' => 'Bulk computer move', + + 'group.create' => 'Group created', + 'group.update' => 'Group edited', + 'group.delete' => 'Group deleted', + 'group.member_add' => 'Group member added', + 'group.member_remove' => 'Group member removed', + ], +]; diff --git a/lang/en/auth.php b/lang/en/auth.php new file mode 100644 index 0000000..d591b59 --- /dev/null +++ b/lang/en/auth.php @@ -0,0 +1,26 @@ + 'Sign in', + 'heading' => 'Sign in with your domain account', + 'login_label' => 'Login', + 'password_label' => 'Password', + 'submit' => 'Sign in', + 'access_note' => 'Access is restricted to members of the AD group authorised for this panel.', + + // Field names used in validation messages + 'attributes' => [ + 'login' => 'login', + 'password' => 'password', + ], + + // LoginController messages + 'failed_dc' => 'Could not reach a domain controller. Please try again in a moment.', + 'failed_credentials' => 'Invalid login or password, or the account is not authorised for this panel.', + 'throttled' => 'Too many failed sign-in attempts. Please try again in :seconds s.', + 'logged_out' => 'You have been signed out.', + + // User menu after sign-in + 'logout' => 'Sign out', +]; diff --git a/lang/en/common.php b/lang/en/common.php new file mode 100644 index 0000000..154e36d --- /dev/null +++ b/lang/en/common.php @@ -0,0 +1,60 @@ + 'Save', + 'save_changes' => 'Save changes', + 'cancel' => 'Cancel', + 'close' => 'Close', + 'reason' => 'Reason', + 'reason_placeholder' => 'Short justification (recorded in the audit log)', + 'add' => 'Add', + 'delete' => 'Delete', + 'edit' => 'Edit', + 'refresh' => 'Refresh', + 'search' => 'Search', + 'check' => 'Check', + 'columns' => 'Columns', + 'recalculate' => 'Recalculate', + 'recalculate_now' => 'Recalculate now', + 'actions' => 'Actions', + + // Reusable headings / labels + 'name' => 'Name', + 'status' => 'Status', + 'description' => 'Description', + 'type' => 'Type', + 'yes' => 'yes', + 'no' => 'no', + 'none_dash' => '—', + + // Account status (user / computer tables) + 'account_status' => [ + 'active' => 'Active', + 'disabled' => 'Disabled', + 'locked' => 'Locked out', + ], + + // States + 'loading' => 'Loading...', + 'saving' => 'Saving...', + 'checking' => 'Checking...', + 'load_error' => 'Failed to load.', + 'error' => 'Error.', + 'no_items' => 'No items.', + 'search_all_fields' => 'Search all fields...', + + // OU tree + 'ou_show_whole_domain' => 'Show the whole domain', + 'ou_refresh_tree' => 'Refresh tree', + 'ou_select' => '— choose an OU —', + 'ou_collapse' => 'Collapse the OU tree to the side', + 'ou_expand' => 'Expand the OU tree', + + // Pagination bar + 'pagination_summary' => 'Showing :first - :last of :total', + 'pagination_per_page' => 'per page', + 'pagination_nav' => 'Table pagination', + 'pagination_prev' => 'Previous page', + 'pagination_next' => 'Next page', +]; diff --git a/lang/en/computers.php b/lang/en/computers.php new file mode 100644 index 0000000..98353fd --- /dev/null +++ b/lang/en/computers.php @@ -0,0 +1,91 @@ + 'Computers', + 'heading' => 'Computer management', + 'subtitle' => 'Overview of workstations and servers registered in Active Directory', + + 'ou_tree_title' => 'Computer OU structure', + 'os_all' => 'All operating systems', + + 'toolbar' => [ + 'set_enabled' => 'Account state', + 'move_ou' => 'Move OU', + ], + + 'js' => [ + 'select_at_least_one' => 'Select at least one computer in the table.', + 'selected' => ':count selected computers', + ], + + 'enabled_modal' => [ + 'title' => 'Computer account state', + 'applies_to' => 'Applies to:', + 'enable' => 'Enable computer', + 'disable' => 'Disable computer', + ], + + 'move_modal' => [ + 'title' => 'Move to OU', + 'applies_to' => 'Applies to:', + 'target_ou' => 'Target organisational unit', + 'submit' => 'Move', + ], + + 'col' => [ + 'name' => 'Name', + 'os' => 'OS', + 'status' => 'Status', + 'last_logon' => 'Last logon', + ], + + 'table' => [ + 'name' => 'Computer name', + 'os' => 'Operating system', + 'status' => 'Status', + 'last_logon' => 'Last logon', + ], + 'empty' => 'No computers match the criteria.', + 'filter_inactive' => 'Dashboard filter: inactive ≥ 90 days', + + 'actions' => [ + 'details' => 'Details / LAPS / BitLocker', + 'reset_trust' => 'Reset trust account', + 'reset_trust_confirm' => 'Reset this computer\'s trust account?', + 'enable' => 'Enable computer', + 'disable' => 'Disable computer', + ], + + 'modal' => [ + 'title' => 'Computer', + 'title_named' => 'Computer: :name', + 'dns' => 'DNS', + 'os' => 'OS', + 'dn' => 'DN', + 'last_logon' => 'Last logon', + 'created' => 'Created', + 'laps' => 'LAPS', + 'bitlocker' => 'BitLocker - recovery keys', + 'laps_available' => 'available', + 'laps_missing' => 'none / no permission', + 'laps_legacy_password' => 'Password (ms-Mcs-AdmPwd):', + 'laps_expires' => '(expires :date)', + 'laps_windows' => 'Windows LAPS', + 'laps_encrypted' => 'Windows LAPS: password is encrypted (msLAPS-EncryptedPassword) - decryption requires DPAPI permissions, out of scope for this panel.', + 'laps_none' => 'No LAPS data (not configured or no read permission).', + 'bitlocker_none' => 'No recovery keys in AD.', + 'bitlocker_guid' => 'GUID', + 'bitlocker_key' => 'Recovery key', + 'bitlocker_created' => 'Created', + ], + + 'flash' => [ + 'enabled' => 'Computer :cn has been enabled.', + 'disabled' => 'Computer :cn has been disabled.', + 'trust_reset' => 'Trust account for computer :cn has been reset.', + 'deleted' => 'Computer object :cn has been removed from AD.', + 'bulk_enabled' => 'Enabled :count computers.', + 'bulk_disabled' => 'Disabled :count computers.', + 'bulk_moved' => ':count computers have been moved to the selected OU.', + ], +]; diff --git a/lang/en/config.php b/lang/en/config.php new file mode 100644 index 0000000..fb19cb4 --- /dev/null +++ b/lang/en/config.php @@ -0,0 +1,87 @@ + 'Configuration', + 'heading' => 'Configuration', + 'subtitle' => 'Settings for the application itself (not Active Directory data) - stored locally, without a database.', + + 'tab_general' => 'General', + 'tab_attributes' => 'Attributes', + + 'general' => [ + 'reset_defaults_heading' => 'Default checkboxes for password reset', + 'default_must_change' => 'Tick "User must change password at next logon" by default', + 'default_unlock' => 'Tick "Unlock account" by default', + + 'password_policy_heading' => 'Password policy', + 'max_age_label' => 'Maximum password age (days)', + 'max_age_help' => 'The panel calculates the password expiry date from this value (default 42). Accounts set to "password never expires" are skipped. 0 = do not calculate.', + + 'generator_heading' => 'Password generator (panel only)', + 'generator_length' => 'Generated password length', + 'generator_symbols' => 'Special characters', + 'generator_help' => 'Used by the "dice" button next to password fields (create / reset). It does not change the Active Directory password policy.', + + 'encryption_heading' => 'Attribute encryption', + 'encryption_key' => 'Encryption key', + 'encryption_help' => 'Used to decrypt and encrypt attributes whose type is set to "Encrypted text" on the Attributes tab (by default msDS-cloudExtensionAttribute1/2). Compatible with Laravel Crypt (AES-CBC + MAC). You can enter any passphrase (key = SHA-256 of the text) or paste a key in base64:… form — if the attributes were already encrypted with APP_KEY, paste that value here. Empty = encryption disabled.', + + 'columns_note' => 'User list columns (including account status and password expiry) - set their visibility and order on the Attributes tab.', + + 'audit_heading' => 'Audit log', + 'require_reason' => 'Require a reason for destructive actions', + 'require_reason_help' => 'Applies to disabling an account, deletion, moving to an OU and offboarding. An audit entry is always written regardless of this setting.', + 'save' => 'Save configuration', + + 'system_heading' => 'System settings', + 'ldap_from_env' => 'LDAP connection in .env', + 'ou_defs_env' => 'OU definitions in .env', + 'env_notice' => 'LDAP connection parameters and the sign-in group (AD_LOGIN_GROUP) are changed via the .env file (not through this form).', + ], + + 'attributes' => [ + 'heading' => 'Active Directory attributes in the panel', + 'intro' => 'Defines which AD attributes the panel fetches and shows. Column = show the value as an extra column in the user list. Category = the tab in the "View / Edit" dialog where the attribute appears. Type decides the editor field: text, number, date or encrypted text (value in AD encrypted with the key from the "General" tab). The system, account status and password expiry types are built-in - read-only.', + 'add' => 'Add attribute', + + 'th_order' => 'Order', + 'th_key' => 'Key', + 'th_label' => 'Label', + 'th_ldap' => 'AD attribute', + 'th_category' => 'Category', + 'th_column' => 'Column', + 'th_type' => 'Type', + + 'move_up' => 'Up', + 'move_down' => 'Down', + 'badge_computed' => 'computed', + 'badge_system' => 'system', + 'badge_fixed' => 'built-in', + 'optgroup_editable' => 'Editable', + 'optgroup_non_editable' => 'Non-editable', + 'remove' => 'Remove attribute', + 'detail_toggle' => 'Validation rules', + 'detail_regex' => 'Pattern (regex with delimiters)', + 'detail_regex_placeholder' => '/^[A-Z]{2}\d{4}$/', + 'detail_required' => 'Required field', + 'detail_help' => 'Help text below the field', + + 'footer_help' => 'You pick the attribute type and the panel chooses the field and validation. sAMAccountName, Path (DN) (system type) and the computed columns (account status, password expiry) are "built-in" - their type, key and AD attribute cannot be changed, and they cannot be removed. Account expiry (accountExpires) has a fixed date type but can be assigned a category (a date field appears in the "View / Edit" dialog; empty = account never expires). All other entries are fully configurable.', + 'save' => 'Save attributes', + + 'tpl_key_placeholder' => 'e.g. myAttribute', + 'tpl_label_placeholder' => 'Label in the panel', + 'tpl_ldap_placeholder' => 'e.g. extensionAttribute5', + ], + + 'flash' => [ + 'saved' => 'Panel configuration has been saved.', + 'attributes_saved' => 'Attribute configuration has been saved.', + ], + + 'validation' => [ + 'key_regex' => 'The attribute key may contain only letters, digits, hyphen and underscore.', + 'ldap_regex' => 'The AD attribute name may contain only letters, digits and hyphen.', + 'regex_invalid' => 'The validation pattern is not a valid regular expression (remember the delimiters, e.g. /.../).', + ], +]; diff --git a/lang/en/dashboard.php b/lang/en/dashboard.php new file mode 100644 index 0000000..342be31 --- /dev/null +++ b/lang/en/dashboard.php @@ -0,0 +1,67 @@ + 'Dashboard', + 'heading' => 'Dashboard', + 'subtitle' => 'Domain health summary (cached data, refreshed :time).', + + 'tiles' => [ + 'users_total' => 'Users', + 'users_disabled' => 'Disabled accounts', + 'users_locked' => 'Locked-out accounts', + 'pwd_never_expires' => 'Password never expires', + 'pwd_expiring_7d' => 'Passwords expiring in ≤ 7 days', + 'pwd_expired' => 'Passwords already expired', + 'inactive_90d' => 'Inactive ≥ 90 days', + 'account_expired' => 'Accounts past expiry date', + 'users_no_manager' => 'Accounts without a manager', + 'computers_total' => 'Computers', + 'computers_inactive_90d' => 'Computers inactive ≥ 90 days', + 'groups_total' => 'Groups', + ], + + 'reports' => [ + 'expiring' => 'Passwords expiring within 7 days', + 'expiring_date' => 'Expires', + 'inactive' => 'Inactive accounts (last logon ≥ 90 days ago)', + 'inactive_date' => 'Last logon', + 'recent' => 'New accounts (last 30 days)', + 'recent_date' => 'Created', + 'pwd_never' => 'Password never expires', + 'locked' => 'Locked-out accounts', + 'locked_since' => 'Since', + 'no_manager' => 'Accounts without a manager', + 'computers_inactive' => 'Computers inactive (≥ 90 days)', + 'computers_inactive_date' => 'Last logon', + 'unlock' => 'Unlock', + 'reset' => 'Reset', + ], + + 'tile_hint' => 'Click to open the filtered list', + 'stale_hint' => 'Data older than one recalculation cycle — check that background recalculation is working.', + 'empty' => 'Could not read data from Active Directory (no results). Check the LDAP connection and OU settings, then recalculate.', + + 'pwd_age' => [ + 'heading' => 'Password age (days since last change)', + 'lt30' => 'under 30 days', + 'd30_60' => '30–60 days', + 'd60_90' => '60–90 days', + 'gt90' => 'over 90 days', + 'unknown' => 'no data / never', + ], + + 'prefs' => [ + 'button' => 'Choose what to show on the dashboard', + 'title' => 'Dashboard preferences', + 'sections' => 'Sections', + 'section_tiles' => 'Summary tiles', + 'section_pwd_age' => 'Password age (histogram)', + 'reports_heading' => 'Report cards', + 'reports_hint' => 'Tick the cards to show; use the arrows to reorder.', + 'save' => 'Save preferences', + ], + + 'user' => 'User', + 'computer' => 'Computer', + 'footnote' => 'Password expiry is calculated from the panel policy (:days days). "Inactive" excludes disabled accounts.', +]; diff --git a/lang/en/gpo.php b/lang/en/gpo.php new file mode 100644 index 0000000..cf06103 --- /dev/null +++ b/lang/en/gpo.php @@ -0,0 +1,65 @@ + 'GPO policies', + 'heading' => 'Group Policy Objects (GPO)', + 'subtitle' => 'Overview of the Group Policy objects defined in the domain', + + 'read_only_notice' => 'For now this module only reads the list of GPOs and the OUs they are linked to (the :attr attribute). Editing policy contents (registry settings, administrative templates in SYSVOL) will be added in a later stage.', + 'search_placeholder' => 'Search policies (name, GUID, OU)...', + + 'col' => [ + 'name' => 'Name', + 'version' => 'Version', + 'config' => 'Configuration', + 'linked' => 'Linked OUs', + ], + + 'table' => [ + 'name' => 'Policy name', + 'version' => 'Version', + 'config' => 'Configuration', + 'linked' => 'Linked OUs', + 'changed' => 'Last change', + ], + 'user' => 'User', + 'computer' => 'Computer', + 'config_disabled' => 'Disabled', + 'no_links' => 'No links', + 'details' => 'GPO details', + 'empty' => 'No GPO policies defined, or no access to the CN=Policies,CN=System container.', + + 'rsop' => [ + 'heading' => 'Which GPOs apply (RSoP-lite)', + 'hint' => 'Enter an OU distinguished name - the panel walks from the domain down, honouring blocked inheritance and enforced links.', + 'ou_dn' => 'OU DN', + 'ou_placeholder' => 'OU=Swinoujscie,OU=oddzial,DC=...', + 'need_ou' => 'Enter an OU DN.', + 'col_index' => '#', + 'col_gpo' => 'GPO', + 'col_from' => 'From level', + 'blocked_at' => 'Inheritance blocked at: :ou', + 'none_effective' => 'No effective GPOs.', + 'order_note' => 'Order = order of application; lower entries have higher priority, "enforced" links win over the rest.', + ], + + 'modal' => [ + 'title' => 'GPO', + 'title_named' => 'GPO: :name', + 'guid' => 'GUID', + 'version_user_computer' => 'Version (User / Computer)', + 'config' => 'Configuration', + 'cse' => 'CSE extensions', + 'cse_value' => 'Computer: :machine, User: :user', + 'wmi_filter' => 'WMI filter', + 'sysvol' => 'SYSVOL', + 'created_changed' => 'Created / changed', + 'links' => 'Links', + 'links_none' => 'none', + 'enforced' => 'enforced', + 'link_disabled' => 'link disabled', + ], + + 'domain_root' => 'Domain (Root)', + 'not_found' => 'GPO not found.', +]; diff --git a/lang/en/groups.php b/lang/en/groups.php new file mode 100644 index 0000000..19811b7 --- /dev/null +++ b/lang/en/groups.php @@ -0,0 +1,66 @@ + 'Groups', + 'heading' => 'AD group management', + 'subtitle' => 'Security and distribution groups in Active Directory', + 'add' => 'Add group', + + 'ou_tree_title' => 'Group OU structure', + + 'filter' => [ + 'all_types' => 'All types', + 'all_scopes' => 'All scopes', + ], + + 'col' => [ + 'name' => 'Name', + 'type' => 'Type', + 'members' => 'Members', + 'description' => 'Description', + ], + + 'table' => [ + 'name' => 'Group name', + 'type_scope' => 'Type and scope', + 'members' => 'Members', + 'description' => 'Description', + 'mail' => 'Group e-mail', + ], + 'members_count' => 'ppl.', + 'empty' => 'No groups match the criteria.', + + 'actions' => [ + 'members_edit' => 'Members / edit', + 'delete' => 'Delete group', + 'delete_confirm' => 'Delete group :cn?', + ], + + 'add_modal' => [ + 'title' => 'Add AD group', + 'cn' => 'Group name (CN)', + 'description' => 'Description', + 'ou' => 'Organisational unit (OU)', + 'type' => 'Type', + 'scope' => 'Scope', + 'submit' => 'Create group', + ], + + 'view_modal' => [ + 'title' => 'Group', + 'title_named' => 'Group: :name', + 'description' => 'Description', + 'mail' => 'Group e-mail', + 'save_desc_mail' => 'Save description / e-mail', + 'members_label' => 'Group members:', + 'add_member_placeholder' => 'sAMAccountName to add', + ], + + 'flash' => [ + 'created' => 'Group :cn has been created.', + 'updated' => 'Group has been updated.', + 'member_added' => 'Member added to the group.', + 'member_removed' => 'Member removed from the group.', + 'deleted' => 'Group :cn has been deleted.', + ], +]; diff --git a/lang/en/nav.php b/lang/en/nav.php new file mode 100644 index 0000000..d2dc149 --- /dev/null +++ b/lang/en/nav.php @@ -0,0 +1,37 @@ + 'ADconsole', + 'domain' => 'Domain:', + 'search_placeholder' => 'Search AD (login, e-mail)...', + 'theme_toggle' => 'Toggle light / dark theme', + 'language' => 'Language', + 'admin_panel' => 'Administration panel', + 'settings' => 'Settings', + 'about' => 'About', + 'logout' => 'Sign out', + + 'dashboard' => 'Dashboard', + 'users' => 'Users', + 'computers' => 'Computers', + 'groups' => 'Groups', + 'structure' => 'Org chart', + 'gpo' => 'GPO policies', + 'audit' => 'Audit log', + 'config' => 'Configuration', + + 'footer' => [ + 'tagline' => 'Active Directory administration panel', + 'author' => 'Author: :name', + 'rights' => '© :year :name', + 'about' => 'About', + 'legal' => 'Legal', + 'source' => 'Source code', + ], + + 'shortcuts' => [ + 'title' => 'Keyboard shortcuts', + 'search' => 'Jump to the search box', + 'help' => 'This dialog', + ], +]; diff --git a/lang/en/org.php b/lang/en/org.php new file mode 100644 index 0000000..ca58dab --- /dev/null +++ b/lang/en/org.php @@ -0,0 +1,21 @@ + 'Org chart', + 'heading' => 'Organisational structure', + 'subtitle' => 'Only active accounts within the hierarchy (cached data, :time).', + + 'expand_all' => 'Expand all', + 'collapse_all' => 'Collapse', + 'fit' => 'Fit to screen', + 'compact' => 'Compact mode', + 'export_png' => 'Export PNG', + 'export_pdf' => 'Export PDF', + 'search_placeholder' => 'Jump to a person...', + + 'empty' => 'No data - no active account has the :field field set, nor any direct reports.', + 'hint' => 'Drag to pan; scroll to zoom; click a node to collapse/expand its branch.', + 'chart_load_error' => 'Failed to load the chart library (d3-org-chart).', + + 'top_level' => 'Top level', +]; diff --git a/lang/en/pages.php b/lang/en/pages.php new file mode 100644 index 0000000..dce4598 --- /dev/null +++ b/lang/en/pages.php @@ -0,0 +1,70 @@ + [ + 'title' => 'About', + 'heading' => 'About ADconsole', + 'lead' => 'An Active Directory administration panel built on Laravel and LdapRecord.', + + 'nav_project' => 'About the project', + 'nav_author' => 'Author', + 'nav_legal' => 'Legal', + ], + + 'project' => [ + 'heading' => 'About the project', + 'p1' => 'ADconsole is a web application for managing an Active Directory domain: users, computers, groups and read-only Group Policy. It is built on the Laravel framework, and the LdapRecord library is the sole directory access layer.', + 'p2' => 'The application keeps no database of AD objects - every operation runs directly against a domain controller. The database backs the application layer only (sessions, cache, queues and the panel configuration).', + 'p3' => 'Sign-in uses a domain account, and access to the panel is restricted to members of a designated Active Directory group (the AD_LOGIN_GROUP setting). All administrative actions run under the service account configured in the .env file.', + 'modules_heading' => 'Modules', + 'modules' => [ + 'users' => 'Users - list with an OU tree, search, creation, view and edit, password reset (single and bulk), moving between OUs, password expiry policy, disabling and unlocking accounts.', + 'computers' => 'Computers - list with an OU tree, operating-system filter, LAPS and BitLocker key view, trust-account reset, enabling and disabling the object, deletion.', + 'groups' => 'Groups - list with type and scope filters, group creation, description and e-mail editing, member management, deletion.', + 'gpo' => 'GPO policies - reading the list of Group Policy objects and linked OUs, a simplified RSoP analysis. Read-only.', + 'config' => 'Panel configuration - application settings and the definitions of AD attributes shown in lists and in the edit dialog.', + ], + ], + + 'author' => [ + 'heading' => 'Author', + 'name' => 'Kacper Żbikowski', + 'p1' => 'ADconsole is authored and maintained by Kacper Żbikowski.', + 'copyright' => '© :year ADconsole. All rights reserved except as granted by the licence below.', + ], + + 'legal' => [ + 'heading' => 'Legal', + + 'license_heading' => 'Licence and warranty disclaimer', + 'license_summary' => 'ADconsole is released under the MIT licence. The software is provided "as is", without warranty of any kind. You use it at your own risk, and the author is not liable for any damages arising from its use.', + 'license_full_heading' => 'Full text of the MIT licence', + 'mit' => 'MIT License + +Copyright (c) :year :holder + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.', + + 'trademarks_heading' => 'Trademarks', + 'trademarks' => 'Active Directory, Windows, Windows Server, Group Policy and the other Microsoft product names mentioned are trademarks or registered trademarks of Microsoft Corporation. ADconsole is an independent tool and is not affiliated with, endorsed, sponsored or authorised by Microsoft Corporation in any way.', + + 'privacy_heading' => 'Data protection (GDPR)', + 'privacy' => 'ADconsole processes employees\' personal data retrieved from Active Directory (including name, login, e-mail address, phone number, job title, department and photo). The data controller within the meaning of the GDPR is the organisation that deploys and operates the panel - it is responsible for the legal basis of processing, the set of exposed attributes, retention periods and handling data-subject requests. Access to the panel should be limited to authorised administrators only, and the connection to the domain controller should be secured with LDAPS or STARTTLS. The software\'s author is neither a controller nor a processor of this data.', + ], +]; diff --git a/lang/en/users.php b/lang/en/users.php new file mode 100644 index 0000000..a9c7746 --- /dev/null +++ b/lang/en/users.php @@ -0,0 +1,252 @@ + 'Users', + 'heading' => 'User management', + 'subtitle' => 'Overview of user accounts registered in Active Directory', + + 'ou_tree_title' => 'OU structure', + 'ou_refresh_counts' => 'Refresh tree (recalculate counters)', + + 'toolbar' => [ + 'add' => 'Add user', + 'move_ou' => 'Move OU', + 'reset_password' => 'Reset password', + 'password_expiry' => 'Password expiry', + 'set_enabled' => 'Account state', + 'group' => 'Group', + 'force_password_change' => 'Force password change', + 'unlock' => 'Unlock', + 'bulk_attributes' => 'Edit attributes', + 'offboard' => 'Offboarding', + 'export' => 'CSV export', + ], + + 'status_filter' => [ + 'all' => 'Account status: all', + ], + + 'saved_filters' => [ + 'button' => 'Filters', + 'save_current' => 'Save current view', + 'prompt_name' => 'Name for the saved filter:', + 'empty' => 'No saved filters.', + 'delete' => 'Delete', + ], + + // Labels for filters coming from dashboard tiles (?filter=...). + 'dashboard_filter' => [ + 'active' => 'Dashboard filter:', + 'clear' => 'Clear filter', + 'locked' => 'Locked-out accounts', + 'pwd_never' => 'Password never expires', + 'pwd_expiring' => 'Passwords expiring in ≤ 7 days', + 'pwd_expired' => 'Passwords already expired', + 'inactive' => 'Inactive ≥ 90 days', + 'account_expired' => 'Accounts past expiry date', + 'no_manager' => 'Accounts without a manager', + ], + + 'table' => [ + 'empty' => 'No users match the criteria.', + 'password_never_expires' => 'Never expires', + 'account_never_expires' => 'Never expires', + ], + + 'row_actions' => [ + 'view_edit' => 'View / Edit', + 'reset_password' => 'Reset password', + 'move_ou' => 'Move to OU', + 'password_expiry' => 'Password expiry', + 'enable' => 'Unlock account', + 'disable' => 'Disable account', + 'force_password_change' => 'Force password change', + 'unlock' => 'Unlock (clear lockout)', + 'offboard' => 'Offboarding', + ], + + 'js' => [ + 'select_at_least_one' => 'Select at least one user in the table.', + 'selected_users' => ':count selected users', + 'profile_title' => 'User profile: :name', + 'no_groups' => 'No groups.', + 'managers_chain' => 'Managers: ', + 'no_manager' => 'No manager assigned.', + 'no_reports' => 'No direct reports.', + 'structure_load_error' => 'Failed to load the structure.', + 'remove_from_group_confirm' => 'Remove from this group?', + 'template_loading' => 'Loading template...', + 'template_loaded' => 'Template: :name — OU and organisational data pre-filled, groups: :count.', + 'template_load_error' => 'Failed to load the template.', + 'photo_saving' => 'Saving...', + 'photo_saved' => 'Saved. Refreshing...', + 'photo_save_error' => 'Failed to save the photo.', + 'photo_choose_file' => 'Choose a file.', + 'connection_error' => 'Connection error.', + ], + + 'pwd' => [ + 'toggle' => 'Show / hide', + 'generate' => 'Generate password', + 'copy' => 'Copy', + ], + + 'add_modal' => [ + 'title' => 'Add a new AD user', + 'template_label' => 'Create like... (template - optional)', + 'template_placeholder' => 'Type the name / login of the person to copy from...', + 'copy_attributes' => 'Copy organisational data (department, company, title, address, manager)', + 'copy_groups' => 'Copy group membership', + 'given_name' => 'First name', + 'given_name_placeholder' => 'e.g. Piotr', + 'surname' => 'Last name', + 'surname_placeholder' => 'e.g. Zieliński', + 'samaccountname' => 'Logon name (sAMAccountName)', + 'samaccountname_placeholder' => 'pzielinski', + 'email' => 'E-mail', + 'email_placeholder' => 'p.zielinski@example.com', + 'ou' => 'Organisational unit (OU)', + 'initial_password' => 'Initial password', + 'password_hint' => 'Min. 8 characters. Generator in the panel configuration.', + 'must_change_password' => 'User must change password at next logon', + 'account_enabled' => 'Account enabled immediately', + 'submit' => 'Create user', + + 'groups_section' => 'Groups', + 'groups_placeholder' => 'Type a group name and pick from the list...', + + 'summary_title' => 'Account :name created:', + 'summary_enabled' => 'account enabled', + 'summary_disabled' => 'account disabled', + 'summary_must_change' => 'password change at first logon', + 'summary_groups' => 'added to groups: :list', + 'summary_groups_failed' => 'could not add to groups: :list', + ], + + 'reset_modal' => [ + 'title' => 'Reset password', + 'applies_to' => 'Applies to:', + 'new_password' => 'New password', + 'must_change_password' => 'User must change password at next logon', + 'unlock_account' => 'Unlock account (clear the logon lockout)', + 'submit' => 'Reset password', + ], + + 'move_modal' => [ + 'title' => 'Move to OU', + 'applies_to' => 'Applies to:', + 'target_ou' => 'Target organisational unit', + 'submit' => 'Move', + ], + + 'expiry_modal' => [ + 'title' => 'Password and account expiry', + 'applies_to' => 'Applies to:', + 'password_section' => 'Password', + 'password_never' => 'Password never expires', + 'account_section' => 'Account (accountExpires)', + 'account_never' => 'Account never expires (clear the date)', + 'account_on_date' => 'or the account expires on (single-user action only):', + ], + + 'enabled_modal' => [ + 'title' => 'Account state', + 'applies_to' => 'Applies to:', + 'enable' => 'Enable account (and clear the logon lockout)', + 'disable' => 'Disable account', + ], + + 'group_modal' => [ + 'title' => 'Group membership', + 'applies_to' => 'Applies to:', + 'group' => 'Group', + 'mode_add' => 'Add to group', + 'mode_remove' => 'Remove from group', + ], + + 'force_pwd_modal' => [ + 'title' => 'Force password change', + 'applies_to' => 'Applies to:', + 'hint' => 'At next logon the user will have to set a new password. The current password is not changed.', + 'submit' => 'Force change', + ], + + 'unlock_modal' => [ + 'title' => 'Unlock account', + 'applies_to' => 'Applies to:', + 'hint' => 'Clears the logon lockout (lockoutTime). Does not change the account enabled state or the password.', + 'submit' => 'Unlock', + ], + + 'bulk_attr_modal' => [ + 'title' => 'Bulk attribute edit', + 'applies_to' => 'Applies to:', + 'hint' => 'Tick the fields you want to change. Unticked fields are left as they are. Ticked and empty clears the attribute.', + 'value_placeholder' => 'New value', + 'submit' => 'Apply', + ], + + 'offboard_modal' => [ + 'title' => 'Account offboarding', + 'applies_to' => 'Applies to:', + 'hint' => 'Tick the steps to run. Order: attribute changes → group removal → OU move (last, changes the DN).', + 'step_disable' => 'Disable the account and set a random password', + 'step_groups' => 'Remove from all groups', + 'step_groups_keep_primary' => 'Keep the primary group', + 'step_move' => 'Move to an organisational unit', + 'step_expire' => 'Set the account expiry date and append to the description', + 'expire_hint' => 'Appends to the "description" field: "Offboarded YYYY-MM-DD by ".', + 'submit' => 'Run offboarding', + ], + + 'view_modal' => [ + 'title' => 'User profile', + 'tab_structure' => 'Structure', + 'tab_groups' => 'Groups (membership)', + 'tab_photo' => 'Photo', + 'manager_placeholder' => 'Type the manager\'s name or login...', + 'manager_dn_placeholder' => 'Manager DN (filled in after selection)', + 'manager_clear' => 'Clear manager', + 'account_expiry_hint' => 'Empty = account never expires.', + 'structure_intro' => 'Structure built from the :from → :to relationship.', + 'structure_full_link' => 'Full organisational structure', + 'group_add_label' => 'Add to group:', + 'group_add_placeholder' => 'Type a group name...', + 'group_member_of' => 'Member of the following Active Directory groups:', + 'photo_alt' => 'No photo', + 'photo_upload_label' => 'Upload a photo (JPG/PNG, square, up to 2 MB - it will be scaled to 96×96 and stored in thumbnailPhoto)', + 'photo_upload' => 'Upload', + 'photo_remove' => 'Remove photo', + ], + + 'flash' => [ + 'created' => 'User :name has been created:suffix.', + 'created_suffix' => ' (template: :template:groups)', + 'created_suffix_groups' => ', groups copied: :count', + 'updated' => 'User data has been saved.', + 'password_reset' => 'Password for user :name has been reset.', + 'expiry_updated' => 'Password / account expiry policy has been updated.', + 'moved' => 'User has been moved to the selected OU.', + 'unlocked' => 'The account has been unlocked.', + 'locked' => 'The account has been disabled.', + 'deleted' => 'User has been deleted.', + 'photo_removed' => 'The photo has been removed.', + 'photo_saved' => 'The photo has been saved.', + 'bulk_password_reset' => 'Password has been reset for :count users.', + 'bulk_moved' => ':count users have been moved to the selected OU.', + 'bulk_expiry_updated' => 'Password policy has been updated for :count users.', + 'group_added' => 'Added to group :group.', + 'group_removed' => 'Removed from the group.', + 'bulk_enabled' => 'Enabled :count accounts.', + 'bulk_disabled' => 'Disabled :count accounts.', + 'bulk_group_added' => 'Added :count users to group :group.', + 'bulk_group_removed' => 'Removed :count users from group :group.', + 'force_pwd_change' => 'Forced a password change for :name at next logon.', + 'bulk_force_pwd_change' => 'Forced a password change for :count users at next logon.', + 'unlocked_one' => 'Account :name has been unlocked.', + 'bulk_unlocked' => 'Unlocked :count accounts.', + 'bulk_attributes' => 'Updated :fields fields for :count accounts.', + 'offboarded' => 'Offboarding of account :name completed.', + 'bulk_offboarded' => 'Offboarding completed for :count accounts.', + ], +]; diff --git a/lang/en/validation.php b/lang/en/validation.php new file mode 100644 index 0000000..e668804 --- /dev/null +++ b/lang/en/validation.php @@ -0,0 +1,221 @@ + 'The :attribute field must be accepted.', + 'accepted_if' => 'The :attribute field must be accepted when :other is :value.', + 'active_url' => 'The :attribute field must be a valid URL.', + 'after' => 'The :attribute field must be a date after :date.', + 'after_or_equal' => 'The :attribute field must be a date after or equal to :date.', + 'alpha' => 'The :attribute field must only contain letters.', + 'alpha_dash' => 'The :attribute field must only contain letters, numbers, dashes, and underscores.', + 'alpha_num' => 'The :attribute field must only contain letters and numbers.', + 'array' => 'The :attribute field must be an array.', + 'ascii' => 'The :attribute field must only contain single-byte alphanumeric characters and symbols.', + 'before' => 'The :attribute field must be a date before :date.', + 'before_or_equal' => 'The :attribute field must be a date before or equal to :date.', + 'between' => [ + 'array' => 'The :attribute field must have between :min and :max items.', + 'file' => 'The :attribute field must be between :min and :max kilobytes.', + 'numeric' => 'The :attribute field must be between :min and :max.', + 'string' => 'The :attribute field must be between :min and :max characters.', + ], + 'boolean' => 'The :attribute field must be true or false.', + 'can' => 'The :attribute field contains an unauthorized value.', + 'confirmed' => 'The :attribute field confirmation does not match.', + 'contains' => 'The :attribute field is missing a required value.', + 'current_password' => 'The password is incorrect.', + 'date' => 'The :attribute field must be a valid date.', + 'date_equals' => 'The :attribute field must be a date equal to :date.', + 'date_format' => 'The :attribute field must match the format :format.', + 'decimal' => 'The :attribute field must have :decimal decimal places.', + 'declined' => 'The :attribute field must be declined.', + 'declined_if' => 'The :attribute field must be declined when :other is :value.', + 'different' => 'The :attribute field and :other must be different.', + 'digits' => 'The :attribute field must be :digits digits.', + 'digits_between' => 'The :attribute field must be between :min and :max digits.', + 'dimensions' => 'The :attribute field has invalid image dimensions.', + 'distinct' => 'The :attribute field has a duplicate value.', + 'doesnt_end_with' => 'The :attribute field must not end with one of the following: :values.', + 'doesnt_start_with' => 'The :attribute field must not start with one of the following: :values.', + 'email' => 'The :attribute field must be a valid email address.', + 'ends_with' => 'The :attribute field must end with one of the following: :values.', + 'enum' => 'The selected :attribute is invalid.', + 'exists' => 'The selected :attribute is invalid.', + 'extensions' => 'The :attribute field must have one of the following extensions: :values.', + 'file' => 'The :attribute field must be a file.', + 'filled' => 'The :attribute field must have a value.', + 'gt' => [ + 'array' => 'The :attribute field must have more than :value items.', + 'file' => 'The :attribute field must be greater than :value kilobytes.', + 'numeric' => 'The :attribute field must be greater than :value.', + 'string' => 'The :attribute field must be greater than :value characters.', + ], + 'gte' => [ + 'array' => 'The :attribute field must have :value items or more.', + 'file' => 'The :attribute field must be greater than or equal to :value kilobytes.', + 'numeric' => 'The :attribute field must be greater than or equal to :value.', + 'string' => 'The :attribute field must be greater than or equal to :value characters.', + ], + 'hex_color' => 'The :attribute field must be a valid hexadecimal color.', + 'image' => 'The :attribute field must be an image.', + 'in' => 'The selected :attribute is invalid.', + 'in_array' => 'The :attribute field must exist in :other.', + 'integer' => 'The :attribute field must be an integer.', + 'ip' => 'The :attribute field must be a valid IP address.', + 'ipv4' => 'The :attribute field must be a valid IPv4 address.', + 'ipv6' => 'The :attribute field must be a valid IPv6 address.', + 'json' => 'The :attribute field must be a valid JSON string.', + 'list' => 'The :attribute field must be a list.', + 'lowercase' => 'The :attribute field must be lowercase.', + 'lt' => [ + 'array' => 'The :attribute field must have less than :value items.', + 'file' => 'The :attribute field must be less than :value kilobytes.', + 'numeric' => 'The :attribute field must be less than :value.', + 'string' => 'The :attribute field must be less than :value characters.', + ], + 'lte' => [ + 'array' => 'The :attribute field must not have more than :value items.', + 'file' => 'The :attribute field must be less than or equal to :value kilobytes.', + 'numeric' => 'The :attribute field must be less than or equal to :value.', + 'string' => 'The :attribute field must be less than or equal to :value characters.', + ], + 'mac_address' => 'The :attribute field must be a valid MAC address.', + 'max' => [ + 'array' => 'The :attribute field must not have more than :max items.', + 'file' => 'The :attribute field must not be greater than :max kilobytes.', + 'numeric' => 'The :attribute field must not be greater than :max.', + 'string' => 'The :attribute field must not be greater than :max characters.', + ], + 'max_digits' => 'The :attribute field must not have more than :max digits.', + 'mimes' => 'The :attribute field must be a file of type: :values.', + 'mimetypes' => 'The :attribute field must be a file of type: :values.', + 'min' => [ + 'array' => 'The :attribute field must have at least :min items.', + 'file' => 'The :attribute field must be at least :min kilobytes.', + 'numeric' => 'The :attribute field must be at least :min.', + 'string' => 'The :attribute field must be at least :min characters.', + ], + 'min_digits' => 'The :attribute field must have at least :min digits.', + 'missing' => 'The :attribute field must be missing.', + 'missing_if' => 'The :attribute field must be missing when :other is :value.', + 'missing_unless' => 'The :attribute field must be missing unless :other is :value.', + 'missing_with' => 'The :attribute field must be missing when :values is present.', + 'missing_with_all' => 'The :attribute field must be missing when :values are present.', + 'multiple_of' => 'The :attribute field must be a multiple of :value.', + 'not_in' => 'The selected :attribute is invalid.', + 'not_regex' => 'The :attribute field format is invalid.', + 'numeric' => 'The :attribute field must be a number.', + 'password' => [ + 'letters' => 'The :attribute field must contain at least one letter.', + 'mixed' => 'The :attribute field must contain at least one uppercase and one lowercase letter.', + 'numbers' => 'The :attribute field must contain at least one number.', + 'symbols' => 'The :attribute field must contain at least one symbol.', + 'uncompromised' => 'The given :attribute has appeared in a data leak. Please choose a different :attribute.', + ], + 'present' => 'The :attribute field must be present.', + 'present_if' => 'The :attribute field must be present when :other is :value.', + 'present_unless' => 'The :attribute field must be present unless :other is :value.', + 'present_with' => 'The :attribute field must be present when :values is present.', + 'present_with_all' => 'The :attribute field must be present when :values are present.', + 'prohibited' => 'The :attribute field is prohibited.', + 'prohibited_if' => 'The :attribute field is prohibited when :other is :value.', + 'prohibited_if_accepted' => 'The :attribute field is prohibited when :other is accepted.', + 'prohibited_if_declined' => 'The :attribute field is prohibited when :other is declined.', + 'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.', + 'prohibits' => 'The :attribute field prohibits :other from being present.', + 'regex' => 'The :attribute field format is invalid.', + 'required' => 'The :attribute field is required.', + 'required_array_keys' => 'The :attribute field must contain entries for: :values.', + 'required_if' => 'The :attribute field is required when :other is :value.', + 'required_if_accepted' => 'The :attribute field is required when :other is accepted.', + 'required_if_declined' => 'The :attribute field is required when :other is declined.', + 'required_unless' => 'The :attribute field is required unless :other is in :values.', + 'required_with' => 'The :attribute field is required when :values is present.', + 'required_with_all' => 'The :attribute field is required when :values are present.', + 'required_without' => 'The :attribute field is required when :values is not present.', + 'required_without_all' => 'The :attribute field is required when none of :values are present.', + 'same' => 'The :attribute field must match :other.', + 'size' => [ + 'array' => 'The :attribute field must contain :size items.', + 'file' => 'The :attribute field must be :size kilobytes.', + 'numeric' => 'The :attribute field must be :size.', + 'string' => 'The :attribute field must be :size characters.', + ], + 'starts_with' => 'The :attribute field must start with one of the following: :values.', + 'string' => 'The :attribute field must be a string.', + 'timezone' => 'The :attribute field must be a valid timezone.', + 'unique' => 'The :attribute has already been taken.', + 'uploaded' => 'The :attribute failed to upload.', + 'uppercase' => 'The :attribute field must be uppercase.', + 'url' => 'The :attribute field must be a valid URL.', + 'ulid' => 'The :attribute field must be a valid ULID.', + 'uuid' => 'The :attribute field must be a valid UUID.', + + /* + |-------------------------------------------------------------------------- + | Custom Validation Language Lines + |-------------------------------------------------------------------------- + | + | Here you may specify custom validation messages for attributes using the + | convention "attribute.rule" to name the lines. This makes it quick to + | specify a specific custom language line for a given attribute rule. + | + */ + + 'custom' => [ + 'attribute-name' => [ + 'rule-name' => 'custom-message', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Custom Validation Attributes + |-------------------------------------------------------------------------- + | + | The following language lines are used to swap our attribute placeholder + | with something more reader friendly such as "E-Mail Address" instead + | of "email". This simply helps us make our message more expressive. + | + */ + + 'attributes' => [ + 'login' => 'login', + 'password' => 'password', + 'given_name' => 'first name', + 'surname' => 'last name', + 'samaccountname' => 'logon name', + 'samaccountnames' => 'selected users', + 'upn_prefix' => 'UPN prefix', + 'mail' => 'e-mail address', + 'ou' => 'organisational unit', + 'target_ou' => 'target organisational unit', + 'template_sam' => 'template account', + 'account_expire_on' => 'account expiry date', + 'cn' => 'group name', + 'description' => 'description', + 'group_type' => 'group type', + 'group_scope' => 'group scope', + 'group_cn' => 'group', + 'group_dn' => 'group', + 'member_dn' => 'member', + 'photo' => 'photo', + 'password_policy.max_age_days' => 'maximum password age', + 'password_policy.generator_length' => 'generated password length', + 'encryption.key' => 'encryption key', + 'attributes' => 'attributes', + ], + +]; diff --git a/lang/pl/audit.php b/lang/pl/audit.php new file mode 100644 index 0000000..bf049d2 --- /dev/null +++ b/lang/pl/audit.php @@ -0,0 +1,82 @@ + 'Dziennik zmian', + 'heading' => 'Dziennik zmian', + 'subtitle' => 'Kto, kiedy i co zmienił w Active Directory przez panel.', + + 'filter' => [ + 'actor' => 'Administrator', + 'action' => 'Akcja', + 'action_all' => 'Wszystkie akcje', + 'target' => 'Obiekt (login / CN / DN)', + 'from' => 'Od', + 'to' => 'Do', + 'apply' => 'Filtruj', + 'clear' => 'Wyczyść', + ], + + 'col' => [ + 'when' => 'Data i godzina', + 'actor' => 'Administrator', + 'action' => 'Akcja', + 'target' => 'Obiekt', + 'reason' => 'Powód', + ], + + 'details' => 'Szczegóły', + 'changes' => 'Zmiany', + 'context' => 'Kontekst', + + 'target_type' => [ + 'user' => 'użytkownik', + 'computer' => 'komputer', + 'group' => 'grupa', + ], + + 'actions' => [ + 'config.settings' => 'Ustawienia panelu', + 'config.attributes' => 'Konfiguracja atrybutów', + + 'user.create' => 'Utworzenie użytkownika', + 'user.update' => 'Edycja użytkownika', + 'user.reset_password' => 'Reset hasła', + 'user.password_expiry' => 'Wygasanie hasła / konta', + 'user.move' => 'Przeniesienie do OU', + 'user.enable' => 'Włączenie konta', + 'user.disable' => 'Wyłączenie konta', + 'user.unlock' => 'Odblokowanie konta', + 'user.force_password_change' => 'Wymuszenie zmiany hasła', + 'user.delete' => 'Usunięcie użytkownika', + 'user.photo' => 'Zmiana zdjęcia', + 'user.group_add' => 'Dodanie do grupy', + 'user.group_remove' => 'Usunięcie z grupy', + 'user.bulk_reset_password' => 'Masowy reset hasła', + 'user.bulk_move' => 'Masowe przeniesienie do OU', + 'user.bulk_password_expiry' => 'Masowa zmiana wygasania hasła', + 'user.bulk_enable' => 'Masowe włączenie kont', + 'user.bulk_disable' => 'Masowe wyłączenie kont', + 'user.bulk_group' => 'Masowa zmiana członkostwa w grupie', + 'user.bulk_force_password_change' => 'Masowe wymuszenie zmiany hasła', + 'user.bulk_unlock' => 'Masowe odblokowanie kont', + 'user.bulk_attributes' => 'Masowa edycja atrybutów', + 'user.offboard' => 'Offboarding konta', + + 'computer.enable' => 'Włączenie komputera', + 'computer.disable' => 'Wyłączenie komputera', + 'computer.move' => 'Przeniesienie komputera do OU', + 'computer.delete' => 'Usunięcie komputera', + 'computer.reset_trust' => 'Reset konta zaufania', + 'computer.laps_view' => 'Podgląd hasła LAPS', + 'computer.bitlocker_view' => 'Podgląd kluczy BitLocker', + 'computer.bulk_enable' => 'Masowe włączenie komputerów', + 'computer.bulk_disable' => 'Masowe wyłączenie komputerów', + 'computer.bulk_move' => 'Masowe przeniesienie komputerów', + + 'group.create' => 'Utworzenie grupy', + 'group.update' => 'Edycja grupy', + 'group.delete' => 'Usunięcie grupy', + 'group.member_add' => 'Dodanie członka grupy', + 'group.member_remove' => 'Usunięcie członka grupy', + ], +]; diff --git a/lang/pl/auth.php b/lang/pl/auth.php new file mode 100644 index 0000000..922567d --- /dev/null +++ b/lang/pl/auth.php @@ -0,0 +1,26 @@ + 'Logowanie', + 'heading' => 'Zaloguj się kontem domenowym', + 'login_label' => 'Login', + 'password_label' => 'Hasło', + 'submit' => 'Zaloguj', + 'access_note' => 'Dostęp mają wyłącznie członkowie grupy AD uprawnionej do panelu.', + + // Nazwy pól w komunikatach walidacji + 'attributes' => [ + 'login' => 'login', + 'password' => 'hasło', + ], + + // Komunikaty LoginController + 'failed_dc' => 'Nie udało się połączyć z kontrolerem domeny. Spróbuj ponownie za chwilę.', + 'failed_credentials' => 'Nieprawidłowy login lub hasło, albo konto nie ma uprawnień do panelu.', + 'throttled' => 'Zbyt wiele nieudanych prób logowania. Spróbuj ponownie za :seconds s.', + 'logged_out' => 'Wylogowano z panelu.', + + // Menu użytkownika po zalogowaniu + 'logout' => 'Wyloguj', +]; diff --git a/lang/pl/common.php b/lang/pl/common.php new file mode 100644 index 0000000..fc6a855 --- /dev/null +++ b/lang/pl/common.php @@ -0,0 +1,60 @@ + 'Zapisz', + 'save_changes' => 'Zapisz Zmiany', + 'cancel' => 'Anuluj', + 'close' => 'Zamknij', + 'reason' => 'Powód', + 'reason_placeholder' => 'Krótkie uzasadnienie (trafia do dziennika zmian)', + 'add' => 'Dodaj', + 'delete' => 'Usuń', + 'edit' => 'Edytuj', + 'refresh' => 'Odśwież', + 'search' => 'Szukaj', + 'check' => 'Sprawdź', + 'columns' => 'Kolumny', + 'recalculate' => 'Przelicz', + 'recalculate_now' => 'Przelicz teraz', + 'actions' => 'Akcje', + + // Nagłówki / etykiety wielokrotnego użytku + 'name' => 'Nazwa', + 'status' => 'Status', + 'description' => 'Opis', + 'type' => 'Typ', + 'yes' => 'tak', + 'no' => 'nie', + 'none_dash' => '—', + + // Status konta (tabele użytkowników / komputerów) + 'account_status' => [ + 'active' => 'Aktywny', + 'disabled' => 'Wyłączony', + 'locked' => 'Zablokowany', + ], + + // Stany + 'loading' => 'Ładowanie...', + 'saving' => 'Zapisywanie...', + 'checking' => 'Sprawdzanie...', + 'load_error' => 'Błąd wczytywania.', + 'error' => 'Błąd.', + 'no_items' => 'Brak pozycji.', + 'search_all_fields' => 'Szukaj we wszystkich polach...', + + // Drzewo OU + 'ou_show_whole_domain' => 'Pokaż całą domenę', + 'ou_refresh_tree' => 'Odśwież drzewo', + 'ou_select' => '— wybierz OU —', + 'ou_collapse' => 'Zwiń drzewo OU do boku', + 'ou_expand' => 'Rozwiń drzewo OU', + + // Pasek paginacji + 'pagination_summary' => 'Wyświetlanie :first - :last z :total', + 'pagination_per_page' => 'na stronę', + 'pagination_nav' => 'Nawigacja po stronach tabeli', + 'pagination_prev' => 'Poprzednia strona', + 'pagination_next' => 'Następna strona', +]; diff --git a/lang/pl/computers.php b/lang/pl/computers.php new file mode 100644 index 0000000..f342069 --- /dev/null +++ b/lang/pl/computers.php @@ -0,0 +1,91 @@ + 'Komputery', + 'heading' => 'Zarządzanie Komputerami', + 'subtitle' => 'Podgląd stacji roboczych i serwerów zarejestrowanych w Active Directory', + + 'ou_tree_title' => 'Struktura OU Komputerów', + 'os_all' => 'Wszystkie systemy operacyjne', + + 'toolbar' => [ + 'set_enabled' => 'Stan konta', + 'move_ou' => 'Przenieś OU', + ], + + 'js' => [ + 'select_at_least_one' => 'Zaznacz co najmniej jeden komputer w tabeli.', + 'selected' => ':count zaznaczonych komputerów', + ], + + 'enabled_modal' => [ + 'title' => 'Stan konta komputera', + 'applies_to' => 'Dotyczy:', + 'enable' => 'Włącz komputer', + 'disable' => 'Wyłącz komputer', + ], + + 'move_modal' => [ + 'title' => 'Przenieś do OU', + 'applies_to' => 'Dotyczy:', + 'target_ou' => 'Docelowa jednostka organizacyjna', + 'submit' => 'Przenieś', + ], + + 'col' => [ + 'name' => 'Nazwa', + 'os' => 'System', + 'status' => 'Status', + 'last_logon' => 'Ostatnie logowanie', + ], + + 'table' => [ + 'name' => 'Nazwa Komputera', + 'os' => 'System Operacyjny', + 'status' => 'Status', + 'last_logon' => 'Ostatnie Logowanie', + ], + 'empty' => 'Brak komputerów spełniających kryteria.', + 'filter_inactive' => 'Filtr z pulpitu: nieaktywne ≥ 90 dni', + + 'actions' => [ + 'details' => 'Szczegóły / LAPS / BitLocker', + 'reset_trust' => 'Resetuj konto zaufania', + 'reset_trust_confirm' => 'Zresetować konto zaufania tego komputera?', + 'enable' => 'Włącz komputer', + 'disable' => 'Wyłącz komputer', + ], + + 'modal' => [ + 'title' => 'Komputer', + 'title_named' => 'Komputer: :name', + 'dns' => 'DNS', + 'os' => 'System', + 'dn' => 'DN', + 'last_logon' => 'Ostatnie logowanie', + 'created' => 'Utworzono', + 'laps' => 'LAPS', + 'bitlocker' => 'BitLocker - klucze odzyskiwania', + 'laps_available' => 'dostępne', + 'laps_missing' => 'brak / brak uprawnień', + 'laps_legacy_password' => 'Hasło (ms-Mcs-AdmPwd):', + 'laps_expires' => '(wygasa :date)', + 'laps_windows' => 'Windows LAPS', + 'laps_encrypted' => 'Windows LAPS: hasło zaszyfrowane (msLAPS-EncryptedPassword) - odszyfrowanie wymaga uprawnień DPAPI, poza zakresem panelu.', + 'laps_none' => 'Brak danych LAPS (nie skonfigurowano lub brak uprawnień do odczytu).', + 'bitlocker_none' => 'Brak kluczy odzyskiwania w AD.', + 'bitlocker_guid' => 'GUID', + 'bitlocker_key' => 'Klucz odzyskiwania', + 'bitlocker_created' => 'Utworzono', + ], + + 'flash' => [ + 'enabled' => 'Komputer :cn został włączony.', + 'disabled' => 'Komputer :cn został wyłączony.', + 'trust_reset' => 'Konto zaufania komputera :cn zostało zresetowane.', + 'deleted' => 'Obiekt komputera :cn został usunięty z AD.', + 'bulk_enabled' => 'Włączono :count komputerów.', + 'bulk_disabled' => 'Wyłączono :count komputerów.', + 'bulk_moved' => 'Przeniesiono :count komputerów do wybranej OU.', + ], +]; diff --git a/lang/pl/config.php b/lang/pl/config.php new file mode 100644 index 0000000..7f546f7 --- /dev/null +++ b/lang/pl/config.php @@ -0,0 +1,87 @@ + 'Konfiguracja', + 'heading' => 'Konfiguracja', + 'subtitle' => 'Ustawienia samej aplikacji (nie danych w Active Directory) - zapisywane lokalnie, bez bazy danych.', + + 'tab_general' => 'Ogólne', + 'tab_attributes' => 'Atrybuty', + + 'general' => [ + 'reset_defaults_heading' => 'Domyślne zaznaczenia przy resecie hasła', + 'default_must_change' => 'Domyślnie zaznacz "Użytkownik musi zmienić hasło przy logowaniu"', + 'default_unlock' => 'Domyślnie zaznacz "Odblokuj konto"', + + 'password_policy_heading' => 'Polityka haseł', + 'max_age_label' => 'Maksymalny wiek hasła (dni)', + 'max_age_help' => 'Na tej podstawie panel wylicza datę wygaśnięcia hasła (domyślnie 42). Konta z ustawieniem „hasło nigdy nie wygasa" są pomijane. 0 = nie wyliczaj.', + + 'generator_heading' => 'Generator haseł (tylko panel)', + 'generator_length' => 'Długość generowanego hasła', + 'generator_symbols' => 'Znaki specjalne', + 'generator_help' => 'Używane przez przycisk „kostki" przy polach hasła (dodawanie / reset). Nie zmienia polityki haseł w Active Directory.', + + 'encryption_heading' => 'Szyfrowanie atrybutów', + 'encryption_key' => 'Klucz szyfrujący', + 'encryption_help' => 'Używany do odszyfrowania i zaszyfrowania atrybutów, którym w zakładce Atrybuty ustawiono typ „Tekst szyfrowany" (domyślnie msDS-cloudExtensionAttribute1/2). Format zgodny z Laravel Crypt (AES-CBC + MAC). Możesz wpisać dowolne hasło (klucz = SHA-256 z tekstu) albo wkleić klucz w formacie base64:… — jeśli atrybuty były już szyfrowane wartością APP_KEY, wklej tutaj tę wartość. Pusty = szyfrowanie wyłączone.', + + 'columns_note' => 'Kolumny listy użytkowników (w tym status konta i wygasanie hasła) - ich widoczność i kolejność ustawisz w zakładce Atrybuty.', + + 'audit_heading' => 'Dziennik zmian', + 'require_reason' => 'Wymagaj podania powodu przy akcjach niszczących', + 'require_reason_help' => 'Dotyczy wyłączenia konta, usunięcia, przeniesienia do OU oraz offboardingu. Wpis w dzienniku powstaje zawsze, niezależnie od tego ustawienia.', + 'save' => 'Zapisz Konfigurację', + + 'system_heading' => 'Ustawienia Systemu', + 'ldap_from_env' => 'Połączenie LDAP w .env', + 'ou_defs_env' => 'Definicje OU w .env', + 'env_notice' => 'Zmiana parametrów połączenia LDAP oraz grupy logowania (AD_LOGIN_GROUP) odbywa się przez plik .env (nie przez ten formularz).', + ], + + 'attributes' => [ + 'heading' => 'Atrybuty Active Directory w panelu', + 'intro' => 'Definiuje, które atrybuty AD panel pobiera i pokazuje. Kolumna = pokaż wartość jako dodatkową kolumnę listy użytkowników. Kategoria = zakładka w oknie „Podgląd / Edycja", na której atrybut się pojawia. Typ decyduje o polu w oknie edycji: tekst, liczba, data lub tekst szyfrowany (wartość w AD szyfrowana kluczem z zakładki „Ogólne"). Typy systemowy, status konta i wygaśnięcie hasła mają pozycje wbudowane - są tylko do odczytu.', + 'add' => 'Dodaj atrybut', + + 'th_order' => 'Kolej.', + 'th_key' => 'Klucz', + 'th_label' => 'Etykieta', + 'th_ldap' => 'Atrybut AD', + 'th_category' => 'Kategoria', + 'th_column' => 'Kolumna', + 'th_type' => 'Typ', + + 'move_up' => 'W górę', + 'move_down' => 'W dół', + 'badge_computed' => 'wyliczana', + 'badge_system' => 'systemowy', + 'badge_fixed' => 'na stałe', + 'optgroup_editable' => 'Edytowalne', + 'optgroup_non_editable' => 'Nieedytowalne', + 'remove' => 'Usuń atrybut', + 'detail_toggle' => 'Reguły walidacji', + 'detail_regex' => 'Wzorzec (regex z ogranicznikami)', + 'detail_regex_placeholder' => '/^[A-Z]{2}\d{4}$/', + 'detail_required' => 'Pole wymagane', + 'detail_help' => 'Tekst pomocy pod polem', + + 'footer_help' => 'Wybierasz typ atrybutu, a panel dobiera pole i walidację. sAMAccountName, Ścieżka (DN) (typ systemowy) oraz kolumny wyliczane (status konta, wygaśnięcie hasła) są „na stałe" - typu, klucza i atrybutu AD nie da się zmienić, nie można ich też usunąć. Wygasanie konta (accountExpires) ma stały typ data, ale można mu przypisać kategorię (w oknie „Podgląd / Edycja" pojawi się pole daty; puste = konto nie wygasa). Pozostałe pozycje są w pełni konfigurowalne.', + 'save' => 'Zapisz Atrybuty', + + 'tpl_key_placeholder' => 'np. mojAtrybut', + 'tpl_label_placeholder' => 'Etykieta w panelu', + 'tpl_ldap_placeholder' => 'np. extensionAttribute5', + ], + + 'flash' => [ + 'saved' => 'Konfiguracja panelu została zapisana.', + 'attributes_saved' => 'Konfiguracja atrybutów została zapisana.', + ], + + 'validation' => [ + 'key_regex' => 'Klucz atrybutu może zawierać tylko litery, cyfry, myślnik i podkreślenie.', + 'ldap_regex' => 'Nazwa atrybutu AD może zawierać tylko litery, cyfry i myślnik.', + 'regex_invalid' => 'Wzorzec walidacji jest niepoprawnym wyrażeniem regularnym (pamiętaj o ogranicznikach, np. /.../).', + ], +]; diff --git a/lang/pl/dashboard.php b/lang/pl/dashboard.php new file mode 100644 index 0000000..4e4d76c --- /dev/null +++ b/lang/pl/dashboard.php @@ -0,0 +1,67 @@ + 'Pulpit', + 'heading' => 'Pulpit', + 'subtitle' => 'Podsumowanie stanu domeny (dane z cache, odświeżone :time).', + + 'tiles' => [ + 'users_total' => 'Użytkownicy', + 'users_disabled' => 'Konta wyłączone', + 'users_locked' => 'Konta zablokowane (lockout)', + 'pwd_never_expires' => 'Hasło nigdy nie wygasa', + 'pwd_expiring_7d' => 'Hasła wygasające ≤ 7 dni', + 'pwd_expired' => 'Hasła już wygasłe', + 'inactive_90d' => 'Nieaktywne ≥ 90 dni', + 'account_expired' => 'Konta po dacie ważności', + 'users_no_manager' => 'Konta bez przełożonego', + 'computers_total' => 'Komputery', + 'computers_inactive_90d' => 'Komputery nieaktywne ≥ 90 dni', + 'groups_total' => 'Grupy', + ], + + 'reports' => [ + 'expiring' => 'Hasła wygasające w ciągu 7 dni', + 'expiring_date' => 'Wygasa', + 'inactive' => 'Konta nieaktywne (ostatnie logowanie ≥ 90 dni temu)', + 'inactive_date' => 'Ost. logowanie', + 'recent' => 'Nowe konta (ostatnie 30 dni)', + 'recent_date' => 'Utworzono', + 'pwd_never' => 'Hasło nigdy nie wygasa', + 'locked' => 'Konta zablokowane (lockout)', + 'locked_since' => 'Od', + 'no_manager' => 'Konta bez przełożonego', + 'computers_inactive' => 'Komputery nieaktywne (≥ 90 dni)', + 'computers_inactive_date' => 'Ost. logowanie', + 'unlock' => 'Odblokuj', + 'reset' => 'Resetuj', + ], + + 'tile_hint' => 'Kliknij, aby otworzyć przefiltrowaną listę', + 'stale_hint' => 'Dane starsze niż jeden cykl przeliczenia — sprawdź, czy przeliczanie w tle działa.', + 'empty' => 'Nie udało się pobrać danych z Active Directory (brak wyników). Sprawdź połączenie LDAP oraz ustawienia OU, a następnie przelicz ponownie.', + + 'pwd_age' => [ + 'heading' => 'Wiek hasła (dni od ostatniej zmiany)', + 'lt30' => 'poniżej 30 dni', + 'd30_60' => '30–60 dni', + 'd60_90' => '60–90 dni', + 'gt90' => 'ponad 90 dni', + 'unknown' => 'brak danych / nigdy', + ], + + 'prefs' => [ + 'button' => 'Ustaw, co pokazywać na pulpicie', + 'title' => 'Preferencje pulpitu', + 'sections' => 'Sekcje', + 'section_tiles' => 'Kafelki podsumowania', + 'section_pwd_age' => 'Wiek hasła (histogram)', + 'reports_heading' => 'Karty raportów', + 'reports_hint' => 'Zaznacz karty do pokazania; strzałkami ustaw kolejność.', + 'save' => 'Zapisz preferencje', + ], + + 'user' => 'Użytkownik', + 'computer' => 'Komputer', + 'footnote' => 'Wygasanie hasła liczone wg polityki panelu (:days dni). „Nieaktywne" nie uwzględnia kont wyłączonych.', +]; diff --git a/lang/pl/gpo.php b/lang/pl/gpo.php new file mode 100644 index 0000000..29eaec6 --- /dev/null +++ b/lang/pl/gpo.php @@ -0,0 +1,65 @@ + 'Polityki GPO', + 'heading' => 'Polityki Grupowe (GPO)', + 'subtitle' => 'Przegląd obiektów zasad grupy zdefiniowanych w domenie', + + 'read_only_notice' => 'Ten moduł na razie tylko odczytuje listę GPO oraz OU, do których są podpięte (atrybut :attr). Edycja zawartości polityk (ustawienia rejestru, szablony administracyjne w SYSVOL) zostanie dodana w kolejnym etapie.', + 'search_placeholder' => 'Szukaj polityki (nazwa, GUID, OU)...', + + 'col' => [ + 'name' => 'Nazwa', + 'version' => 'Wersja', + 'config' => 'Konfiguracja', + 'linked' => 'Podpięte OU', + ], + + 'table' => [ + 'name' => 'Nazwa Polityki', + 'version' => 'Wersja', + 'config' => 'Konfiguracja', + 'linked' => 'Podpięte OU', + 'changed' => 'Ostatnia zmiana', + ], + 'user' => 'Użytkownik', + 'computer' => 'Komputer', + 'config_disabled' => 'Wyłączona', + 'no_links' => 'Brak powiązań', + 'details' => 'Szczegóły GPO', + 'empty' => 'Brak zdefiniowanych polityk GPO lub brak dostępu do kontenera CN=Policies,CN=System.', + + 'rsop' => [ + 'heading' => 'Które GPO obowiązują (RSoP-lite)', + 'hint' => 'Podaj DN jednostki organizacyjnej - panel przejdzie od domeny w dół, uwzględniając blokowanie dziedziczenia i linki wymuszone.', + 'ou_dn' => 'OU DN', + 'ou_placeholder' => 'OU=Swinoujscie,OU=oddzial,DC=...', + 'need_ou' => 'Podaj DN OU.', + 'col_index' => '#', + 'col_gpo' => 'GPO', + 'col_from' => 'Z poziomu', + 'blocked_at' => 'Blokada dziedziczenia na: :ou', + 'none_effective' => 'Brak obowiązujących GPO.', + 'order_note' => 'Kolejność = kolejność stosowania; pozycje niżej mają wyższy priorytet, linki „wymuszone" wygrywają z resztą.', + ], + + 'modal' => [ + 'title' => 'GPO', + 'title_named' => 'GPO: :name', + 'guid' => 'GUID', + 'version_user_computer' => 'Wersja (User / Computer)', + 'config' => 'Konfiguracja', + 'cse' => 'Rozszerzenia CSE', + 'cse_value' => 'Komputer: :machine, Użytkownik: :user', + 'wmi_filter' => 'Filtr WMI', + 'sysvol' => 'SYSVOL', + 'created_changed' => 'Utworzono / zmieniono', + 'links' => 'Podpięcia', + 'links_none' => 'brak', + 'enforced' => 'wymuszone', + 'link_disabled' => 'link wyłączony', + ], + + 'domain_root' => 'Domena (Główna)', + 'not_found' => 'Nie znaleziono GPO.', +]; diff --git a/lang/pl/groups.php b/lang/pl/groups.php new file mode 100644 index 0000000..ed3e50a --- /dev/null +++ b/lang/pl/groups.php @@ -0,0 +1,66 @@ + 'Grupy', + 'heading' => 'Zarządzanie Grupami AD', + 'subtitle' => 'Grupy zabezpieczeń i dystrybucyjne w Active Directory', + 'add' => 'Dodaj Grupę', + + 'ou_tree_title' => 'Struktura OU Grup', + + 'filter' => [ + 'all_types' => 'Wszystkie typy', + 'all_scopes' => 'Wszystkie zasięgi (Scope)', + ], + + 'col' => [ + 'name' => 'Nazwa', + 'type' => 'Typ', + 'members' => 'Członkowie', + 'description' => 'Opis', + ], + + 'table' => [ + 'name' => 'Nazwa Grupy', + 'type_scope' => 'Typ i Zasięg', + 'members' => 'Członkowie', + 'description' => 'Opis', + 'mail' => 'E-mail Grupowy', + ], + 'members_count' => 'os.', + 'empty' => 'Brak grup spełniających kryteria.', + + 'actions' => [ + 'members_edit' => 'Członkowie / edycja', + 'delete' => 'Usuń grupę', + 'delete_confirm' => 'Usunąć grupę :cn?', + ], + + 'add_modal' => [ + 'title' => 'Dodaj Grupę AD', + 'cn' => 'Nazwa grupy (CN)', + 'description' => 'Opis', + 'ou' => 'Jednostka organizacyjna (OU)', + 'type' => 'Typ', + 'scope' => 'Zasięg (Scope)', + 'submit' => 'Utwórz Grupę', + ], + + 'view_modal' => [ + 'title' => 'Grupa', + 'title_named' => 'Grupa: :name', + 'description' => 'Opis', + 'mail' => 'E-mail grupowy', + 'save_desc_mail' => 'Zapisz opis / e-mail', + 'members_label' => 'Członkowie grupy:', + 'add_member_placeholder' => 'sAMAccountName do dodania', + ], + + 'flash' => [ + 'created' => 'Grupa :cn została utworzona.', + 'updated' => 'Grupa została zaktualizowana.', + 'member_added' => 'Dodano członka do grupy.', + 'member_removed' => 'Usunięto członka z grupy.', + 'deleted' => 'Grupa :cn została usunięta.', + ], +]; diff --git a/lang/pl/nav.php b/lang/pl/nav.php new file mode 100644 index 0000000..e03b9ee --- /dev/null +++ b/lang/pl/nav.php @@ -0,0 +1,37 @@ + 'ADconsole', + 'domain' => 'Domena:', + 'search_placeholder' => 'Szukaj w AD (login, e-mail)...', + 'theme_toggle' => 'Przełącz motyw jasny / ciemny', + 'language' => 'Język', + 'admin_panel' => 'Panel administracyjny', + 'settings' => 'Ustawienia', + 'about' => 'O aplikacji', + 'logout' => 'Wyloguj', + + 'dashboard' => 'Pulpit', + 'users' => 'Użytkownicy', + 'computers' => 'Komputery', + 'groups' => 'Grupy', + 'structure' => 'Struktura', + 'gpo' => 'Polityki GPO', + 'audit' => 'Dziennik', + 'config' => 'Konfiguracja', + + 'footer' => [ + 'tagline' => 'Panel administracyjny Active Directory', + 'author' => 'Autor: :name', + 'rights' => '© :year :name', + 'about' => 'O aplikacji', + 'legal' => 'Kwestie prawne', + 'source' => 'Kod źródłowy', + ], + + 'shortcuts' => [ + 'title' => 'Skróty klawiaturowe', + 'search' => 'Przejdź do wyszukiwarki', + 'help' => 'To okno', + ], +]; diff --git a/lang/pl/org.php b/lang/pl/org.php new file mode 100644 index 0000000..d6bcbcd --- /dev/null +++ b/lang/pl/org.php @@ -0,0 +1,21 @@ + 'Struktura organizacyjna', + 'heading' => 'Struktura organizacyjna', + 'subtitle' => 'Tylko aktywne konta będące w hierarchii (dane z cache, :time).', + + 'expand_all' => 'Rozwiń wszystko', + 'collapse_all' => 'Zwiń', + 'fit' => 'Dopasuj do ekranu', + 'compact' => 'Tryb kompaktowy', + 'export_png' => 'Eksportuj PNG', + 'export_pdf' => 'Eksportuj PDF', + 'search_placeholder' => 'Skocz do osoby...', + + 'empty' => 'Brak danych - żadne aktywne konto nie ma ustawionego pola :field ani podwładnych.', + 'hint' => 'Przeciągnij, aby przesunąć; kółkiem zoom; kliknij węzeł, aby zwinąć/rozwinąć jego gałąź.', + 'chart_load_error' => 'Nie udało się załadować biblioteki wykresu (d3-org-chart).', + + 'top_level' => 'Najwyższy poziom', +]; diff --git a/lang/pl/pages.php b/lang/pl/pages.php new file mode 100644 index 0000000..b24dcf4 --- /dev/null +++ b/lang/pl/pages.php @@ -0,0 +1,70 @@ + [ + 'title' => 'O aplikacji', + 'heading' => 'O aplikacji ADconsole', + 'lead' => 'Panel administracyjny Active Directory oparty o Laravel i LdapRecord.', + + 'nav_project' => 'O projekcie', + 'nav_author' => 'Autor', + 'nav_legal' => 'Kwestie prawne', + ], + + 'project' => [ + 'heading' => 'O projekcie', + 'p1' => 'ADconsole to aplikacja webowa do zarządzania domeną Active Directory: użytkownikami, komputerami, grupami oraz odczytem polityk GPO. Zbudowana jest na frameworku Laravel, a jedyną warstwą dostępu do katalogu jest biblioteka LdapRecord.', + 'p2' => 'Aplikacja nie prowadzi własnej bazy danych obiektów AD - wszystkie operacje wykonywane są bezpośrednio na kontrolerze domeny. Baza danych służy wyłącznie warstwie aplikacyjnej (sesje, cache, kolejki oraz konfiguracja panelu).', + 'p3' => 'Logowanie odbywa się kontem domenowym, a dostęp do panelu mają wyłącznie członkowie wskazanej grupy Active Directory (parametr AD_LOGIN_GROUP). Wszystkie akcje administracyjne wykonywane są w kontekście konta serwisowego skonfigurowanego w pliku .env.', + 'modules_heading' => 'Moduły', + 'modules' => [ + 'users' => 'Użytkownicy - lista z drzewem OU, wyszukiwarka, dodawanie, podgląd i edycja, reset hasła (pojedynczo i wsadowo), przenoszenie między OU, polityka wygasania hasła, blokowanie i odblokowywanie konta.', + 'computers' => 'Komputery - lista z drzewem OU, filtr po systemie operacyjnym, podgląd LAPS i kluczy BitLocker, reset konta zaufania, włączanie i wyłączanie obiektu, usuwanie.', + 'groups' => 'Grupy - lista z filtrem typu i zasięgu, tworzenie grupy, edycja opisu i adresu e-mail, zarządzanie członkami, usuwanie.', + 'gpo' => 'Polityki GPO - odczyt listy obiektów zasad grupy oraz podpiętych OU, uproszczona analiza RSoP. Tylko podgląd.', + 'config' => 'Konfiguracja panelu - ustawienia aplikacji oraz definicje atrybutów AD pokazywanych na listach i w oknie edycji.', + ], + ], + + 'author' => [ + 'heading' => 'Autor', + 'name' => 'Kacper Żbikowski', + 'p1' => 'Autorem i opiekunem projektu ADconsole jest Kacper Żbikowski.', + 'copyright' => '© :year ADconsole. Wszelkie prawa zastrzeżone w zakresie nieobjętym licencją poniżej.', + ], + + 'legal' => [ + 'heading' => 'Kwestie prawne', + + 'license_heading' => 'Licencja i wyłączenie gwarancji', + 'license_summary' => 'ADconsole udostępniany jest na licencji MIT. Oprogramowanie dostarczane jest „tak jak jest", bez jakiejkolwiek gwarancji. Korzystasz z niego na własną odpowiedzialność, a autor nie ponosi odpowiedzialności za żadne szkody wynikające z jego użycia.', + 'license_full_heading' => 'Pełny tekst licencji MIT', + 'mit' => 'MIT License + +Copyright (c) :year :holder + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.', + + 'trademarks_heading' => 'Znaki towarowe', + 'trademarks' => 'Active Directory, Windows, Windows Server, Group Policy oraz pozostałe wymienione nazwy produktów Microsoft są znakami towarowymi lub zastrzeżonymi znakami towarowymi Microsoft Corporation. ADconsole jest niezależnym narzędziem i nie jest w żaden sposób powiązany z Microsoft Corporation ani przez nią wspierany, sponsorowany czy autoryzowany.', + + 'privacy_heading' => 'Ochrona danych osobowych (RODO)', + 'privacy' => 'ADconsole przetwarza dane osobowe pracowników pobierane z Active Directory (m.in. imię i nazwisko, login, adres e-mail, numer telefonu, stanowisko, dział, zdjęcie). Administratorem tych danych w rozumieniu RODO jest organizacja, która wdraża i eksploatuje panel - to ona odpowiada za podstawę prawną przetwarzania, zakres udostępnianych atrybutów, okresy retencji oraz realizację praw osób, których dane dotyczą. Dostęp do panelu należy ograniczyć wyłącznie do upoważnionych administratorów, a połączenie z kontrolerem domeny zabezpieczyć protokołem LDAPS lub STARTTLS. Autor oprogramowania nie jest administratorem ani podmiotem przetwarzającym te dane.', + ], +]; diff --git a/lang/pl/users.php b/lang/pl/users.php new file mode 100644 index 0000000..a347eb7 --- /dev/null +++ b/lang/pl/users.php @@ -0,0 +1,252 @@ + 'Użytkownicy', + 'heading' => 'Zarządzanie Użytkownikami', + 'subtitle' => 'Przegląd kont użytkowników zarejestrowanych w Active Directory', + + 'ou_tree_title' => 'Struktura OU', + 'ou_refresh_counts' => 'Odśwież drzewo (przelicz liczniki)', + + 'toolbar' => [ + 'add' => 'Dodaj użytkownika', + 'move_ou' => 'Przenieś OU', + 'reset_password' => 'Resetuj hasło', + 'password_expiry' => 'Wygasanie hasła', + 'set_enabled' => 'Stan konta', + 'group' => 'Grupa', + 'force_password_change' => 'Wymuś zmianę hasła', + 'unlock' => 'Odblokuj', + 'bulk_attributes' => 'Edytuj atrybuty', + 'offboard' => 'Offboarding', + 'export' => 'Eksport CSV', + ], + + 'status_filter' => [ + 'all' => 'Status konta: wszystkie', + ], + + 'saved_filters' => [ + 'button' => 'Filtry', + 'save_current' => 'Zapisz bieżący widok', + 'prompt_name' => 'Nazwa zapisanego filtra:', + 'empty' => 'Brak zapisanych filtrów.', + 'delete' => 'Usuń', + ], + + // Etykiety filtrów przychodzących z kafelków pulpitu (?filter=...). + 'dashboard_filter' => [ + 'active' => 'Filtr z pulpitu:', + 'clear' => 'Wyczyść filtr', + 'locked' => 'Konta zablokowane (lockout)', + 'pwd_never' => 'Hasło nigdy nie wygasa', + 'pwd_expiring' => 'Hasła wygasające ≤ 7 dni', + 'pwd_expired' => 'Hasła już wygasłe', + 'inactive' => 'Nieaktywne ≥ 90 dni', + 'account_expired' => 'Konta po dacie ważności', + 'no_manager' => 'Konta bez przełożonego', + ], + + 'table' => [ + 'empty' => 'Brak użytkowników spełniających kryteria.', + 'password_never_expires' => 'Nigdy nie wygasa', + 'account_never_expires' => 'Nie wygasa', + ], + + 'row_actions' => [ + 'view_edit' => 'Podgląd / Edycja', + 'reset_password' => 'Resetuj hasło', + 'move_ou' => 'Przenieś do OU', + 'password_expiry' => 'Wygasanie hasła', + 'enable' => 'Odblokuj konto', + 'disable' => 'Zablokuj konto', + 'force_password_change' => 'Wymuś zmianę hasła', + 'unlock' => 'Odblokuj (wyczyść lockout)', + 'offboard' => 'Offboarding', + ], + + 'js' => [ + 'select_at_least_one' => 'Zaznacz co najmniej jednego użytkownika w tabeli.', + 'selected_users' => ':count zaznaczonych użytkowników', + 'profile_title' => 'Profil Użytkownika: :name', + 'no_groups' => 'Brak grup.', + 'managers_chain' => 'Przełożeni: ', + 'no_manager' => 'Brak przypisanego przełożonego.', + 'no_reports' => 'Brak podwładnych.', + 'structure_load_error' => 'Nie udało się wczytać struktury.', + 'remove_from_group_confirm' => 'Usunąć z tej grupy?', + 'template_loading' => 'Wczytywanie wzorca...', + 'template_loaded' => 'Wzorzec: :name — OU i dane organizacyjne wstępnie wypełnione, grup: :count.', + 'template_load_error' => 'Nie udało się wczytać wzorca.', + 'photo_saving' => 'Zapisywanie...', + 'photo_saved' => 'Zapisano. Odświeżam...', + 'photo_save_error' => 'Błąd zapisu zdjęcia.', + 'photo_choose_file' => 'Wybierz plik.', + 'connection_error' => 'Błąd połączenia.', + ], + + 'pwd' => [ + 'toggle' => 'Pokaż / ukryj', + 'generate' => 'Wygeneruj hasło', + 'copy' => 'Kopiuj', + ], + + 'add_modal' => [ + 'title' => 'Dodaj Nowego Użytkownika AD', + 'template_label' => 'Utwórz jak... (wzorzec - opcjonalnie)', + 'template_placeholder' => 'Wpisz nazwisko / login osoby do skopiowania...', + 'copy_attributes' => 'Kopiuj dane organizacyjne (dział, firma, stanowisko, adres, przełożony)', + 'copy_groups' => 'Kopiuj członkostwo w grupach', + 'given_name' => 'Imię', + 'given_name_placeholder' => 'np. Piotr', + 'surname' => 'Nazwisko', + 'surname_placeholder' => 'np. Zieliński', + 'samaccountname' => 'Nazwa logowania (sAMAccountName)', + 'samaccountname_placeholder' => 'pzielinski', + 'email' => 'E-mail', + 'email_placeholder' => 'p.zielinski@example.com', + 'ou' => 'Jednostka organizacyjna (OU)', + 'initial_password' => 'Hasło początkowe', + 'password_hint' => 'Min. 8 znaków. Generator w konfiguracji panelu.', + 'must_change_password' => 'Użytkownik musi zmienić hasło przy logowaniu', + 'account_enabled' => 'Konto aktywne od razu', + 'submit' => 'Utwórz Użytkownika', + + 'groups_section' => 'Grupy', + 'groups_placeholder' => 'Wpisz nazwę grupy i wybierz z listy...', + + 'summary_title' => 'Utworzono konto :name:', + 'summary_enabled' => 'konto aktywne', + 'summary_disabled' => 'konto wyłączone', + 'summary_must_change' => 'zmiana hasła przy pierwszym logowaniu', + 'summary_groups' => 'dodano do grup: :list', + 'summary_groups_failed' => 'nie udało się dodać do grup: :list', + ], + + 'reset_modal' => [ + 'title' => 'Resetuj Hasło', + 'applies_to' => 'Dotyczy:', + 'new_password' => 'Nowe hasło', + 'must_change_password' => 'Użytkownik musi zmienić hasło przy następnym logowaniu', + 'unlock_account' => 'Odblokuj konto (wyczyść blokadę logowania)', + 'submit' => 'Resetuj Hasło', + ], + + 'move_modal' => [ + 'title' => 'Przenieś do OU', + 'applies_to' => 'Dotyczy:', + 'target_ou' => 'Docelowa jednostka organizacyjna', + 'submit' => 'Przenieś', + ], + + 'expiry_modal' => [ + 'title' => 'Wygasanie hasła i konta', + 'applies_to' => 'Dotyczy:', + 'password_section' => 'Hasło', + 'password_never' => 'Hasło nigdy nie wygasa', + 'account_section' => 'Konto (accountExpires)', + 'account_never' => 'Konto nigdy nie wygasa (wyczyść datę)', + 'account_on_date' => 'lub konto wygasa z dniem (tylko akcja jednostkowa):', + ], + + 'enabled_modal' => [ + 'title' => 'Stan konta', + 'applies_to' => 'Dotyczy:', + 'enable' => 'Włącz konto (i wyczyść blokadę logowania)', + 'disable' => 'Wyłącz konto', + ], + + 'group_modal' => [ + 'title' => 'Członkostwo w grupie', + 'applies_to' => 'Dotyczy:', + 'group' => 'Grupa', + 'mode_add' => 'Dodaj do grupy', + 'mode_remove' => 'Usuń z grupy', + ], + + 'force_pwd_modal' => [ + 'title' => 'Wymuś zmianę hasła', + 'applies_to' => 'Dotyczy:', + 'hint' => 'Przy następnym logowaniu użytkownik będzie musiał ustawić nowe hasło. Bieżące hasło nie zostanie zmienione.', + 'submit' => 'Wymuś zmianę', + ], + + 'unlock_modal' => [ + 'title' => 'Odblokuj konto', + 'applies_to' => 'Dotyczy:', + 'hint' => 'Czyści blokadę logowania (lockoutTime). Nie zmienia stanu włączenia konta ani hasła.', + 'submit' => 'Odblokuj', + ], + + 'bulk_attr_modal' => [ + 'title' => 'Masowa edycja atrybutów', + 'applies_to' => 'Dotyczy:', + 'hint' => 'Zaznacz pola, które chcesz zmienić. Pola niezaznaczone pozostają bez zmian. Zaznaczone i puste — wyczyszczą atrybut.', + 'value_placeholder' => 'Nowa wartość', + 'submit' => 'Zastosuj', + ], + + 'offboard_modal' => [ + 'title' => 'Offboarding konta', + 'applies_to' => 'Dotyczy:', + 'hint' => 'Zaznacz kroki, które mają zostać wykonane. Kolejność: zmiana atrybutów → zdjęcie z grup → przeniesienie OU (na końcu, zmienia DN).', + 'step_disable' => 'Wyłącz konto i ustaw losowe hasło', + 'step_groups' => 'Usuń ze wszystkich grup', + 'step_groups_keep_primary' => 'Zostaw grupę podstawową', + 'step_move' => 'Przenieś do jednostki organizacyjnej', + 'step_expire' => 'Ustaw datę wygaśnięcia konta i dopisz do opisu', + 'expire_hint' => 'Do pola „opis" dopisze: „Offboarded RRRR-MM-DD by ".', + 'submit' => 'Wykonaj offboarding', + ], + + 'view_modal' => [ + 'title' => 'Profil Użytkownika', + 'tab_structure' => 'Struktura', + 'tab_groups' => 'Grupy (Członkostwo)', + 'tab_photo' => 'Zdjęcie', + 'manager_placeholder' => 'Wpisz nazwisko lub login kierownika...', + 'manager_dn_placeholder' => 'DN kierownika (uzupełni się po wyborze)', + 'manager_clear' => 'Wyczyść kierownika', + 'account_expiry_hint' => 'Puste = konto nigdy nie wygasa.', + 'structure_intro' => 'Struktura wygenerowana z relacji :from → :to.', + 'structure_full_link' => 'Pełna struktura organizacyjna', + 'group_add_label' => 'Dodaj do grupy:', + 'group_add_placeholder' => 'Wpisz nazwę grupy...', + 'group_member_of' => 'Należy do grup Active Directory:', + 'photo_alt' => 'Brak zdjęcia', + 'photo_upload_label' => 'Wgraj zdjęcie (JPG/PNG, kwadrat, do 2 MB - zostanie przeskalowane do 96×96 i zapisane w thumbnailPhoto)', + 'photo_upload' => 'Wgraj', + 'photo_remove' => 'Usuń zdjęcie', + ], + + 'flash' => [ + 'created' => 'Użytkownik :name został utworzony:suffix.', + 'created_suffix' => ' (wzorzec: :template:groups)', + 'created_suffix_groups' => ', skopiowano grup: :count', + 'updated' => 'Dane użytkownika zostały zapisane.', + 'password_reset' => 'Hasło użytkownika :name zostało zresetowane.', + 'expiry_updated' => 'Zaktualizowano politykę wygasania hasła / konta.', + 'moved' => 'Użytkownik został przeniesiony do wybranej OU.', + 'unlocked' => 'Konto zostało odblokowane.', + 'locked' => 'Konto zostało zablokowane.', + 'deleted' => 'Użytkownik został usunięty.', + 'photo_removed' => 'Zdjęcie zostało usunięte.', + 'photo_saved' => 'Zdjęcie zostało zapisane.', + 'bulk_password_reset' => 'Hasło zostało zresetowane dla :count użytkowników.', + 'bulk_moved' => 'Przeniesiono :count użytkowników do wybranej OU.', + 'bulk_expiry_updated' => 'Zaktualizowano politykę hasła dla :count użytkowników.', + 'group_added' => 'Dodano do grupy :group.', + 'group_removed' => 'Usunięto z grupy.', + 'bulk_enabled' => 'Włączono :count kont.', + 'bulk_disabled' => 'Wyłączono :count kont.', + 'bulk_group_added' => 'Dodano :count użytkowników do grupy :group.', + 'bulk_group_removed' => 'Usunięto :count użytkowników z grupy :group.', + 'force_pwd_change' => 'Wymuszono zmianę hasła dla :name przy następnym logowaniu.', + 'bulk_force_pwd_change' => 'Wymuszono zmianę hasła dla :count użytkowników przy następnym logowaniu.', + 'unlocked_one' => 'Konto :name zostało odblokowane.', + 'bulk_unlocked' => 'Odblokowano :count kont.', + 'bulk_attributes' => 'Zaktualizowano :fields pól dla :count kont.', + 'offboarded' => 'Offboarding konta :name został wykonany.', + 'bulk_offboarded' => 'Offboarding wykonany dla :count kont.', + ], +]; diff --git a/lang/pl/validation.php b/lang/pl/validation.php new file mode 100644 index 0000000..582b17c --- /dev/null +++ b/lang/pl/validation.php @@ -0,0 +1,204 @@ + 'Pole :attribute musi zostać zaakceptowane.', + 'accepted_if' => 'Pole :attribute musi zostać zaakceptowane, gdy :other ma wartość :value.', + 'active_url' => 'Pole :attribute nie jest prawidłowym adresem URL.', + 'after' => 'Pole :attribute musi być datą późniejszą niż :date.', + 'after_or_equal' => 'Pole :attribute musi być datą późniejszą lub równą :date.', + 'alpha' => 'Pole :attribute może zawierać wyłącznie litery.', + 'alpha_dash' => 'Pole :attribute może zawierać wyłącznie litery, cyfry, myślniki i podkreślenia.', + 'alpha_num' => 'Pole :attribute może zawierać wyłącznie litery i cyfry.', + 'array' => 'Pole :attribute musi być tablicą.', + 'ascii' => 'Pole :attribute może zawierać wyłącznie jednobajtowe znaki alfanumeryczne i symbole.', + 'before' => 'Pole :attribute musi być datą wcześniejszą niż :date.', + 'before_or_equal' => 'Pole :attribute musi być datą wcześniejszą lub równą :date.', + 'between' => [ + 'array' => 'Pole :attribute musi zawierać od :min do :max elementów.', + 'file' => 'Pole :attribute musi mieć od :min do :max kilobajtów.', + 'numeric' => 'Pole :attribute musi mieć wartość od :min do :max.', + 'string' => 'Pole :attribute musi mieć od :min do :max znaków.', + ], + 'boolean' => 'Pole :attribute musi mieć wartość prawda albo fałsz.', + 'can' => 'Pole :attribute zawiera niedozwoloną wartość.', + 'confirmed' => 'Potwierdzenie pola :attribute nie zgadza się.', + 'contains' => 'W polu :attribute brakuje wymaganej wartości.', + 'current_password' => 'Hasło jest nieprawidłowe.', + 'date' => 'Pole :attribute nie jest prawidłową datą.', + 'date_equals' => 'Pole :attribute musi być datą równą :date.', + 'date_format' => 'Pole :attribute musi odpowiadać formatowi :format.', + 'decimal' => 'Pole :attribute musi mieć :decimal miejsc po przecinku.', + 'declined' => 'Pole :attribute musi zostać odrzucone.', + 'declined_if' => 'Pole :attribute musi zostać odrzucone, gdy :other ma wartość :value.', + 'different' => 'Pola :attribute oraz :other muszą się różnić.', + 'digits' => 'Pole :attribute musi mieć :digits cyfr.', + 'digits_between' => 'Pole :attribute musi mieć od :min do :max cyfr.', + 'dimensions' => 'Pole :attribute ma nieprawidłowe wymiary obrazu.', + 'distinct' => 'Pole :attribute ma zduplikowaną wartość.', + 'doesnt_end_with' => 'Pole :attribute nie może kończyć się jednym z: :values.', + 'doesnt_start_with' => 'Pole :attribute nie może zaczynać się jednym z: :values.', + 'email' => 'Pole :attribute musi być prawidłowym adresem e-mail.', + 'ends_with' => 'Pole :attribute musi kończyć się jednym z: :values.', + 'enum' => 'Wybrana wartość pola :attribute jest nieprawidłowa.', + 'exists' => 'Wybrana wartość pola :attribute jest nieprawidłowa.', + 'extensions' => 'Pole :attribute musi mieć jedno z rozszerzeń: :values.', + 'file' => 'Pole :attribute musi być plikiem.', + 'filled' => 'Pole :attribute musi mieć wartość.', + 'gt' => [ + 'array' => 'Pole :attribute musi mieć więcej niż :value elementów.', + 'file' => 'Pole :attribute musi być większe niż :value kilobajtów.', + 'numeric' => 'Pole :attribute musi być większe niż :value.', + 'string' => 'Pole :attribute musi mieć więcej niż :value znaków.', + ], + 'gte' => [ + 'array' => 'Pole :attribute musi mieć :value lub więcej elementów.', + 'file' => 'Pole :attribute musi być większe lub równe :value kilobajtów.', + 'numeric' => 'Pole :attribute musi być większe lub równe :value.', + 'string' => 'Pole :attribute musi mieć co najmniej :value znaków.', + ], + 'hex_color' => 'Pole :attribute musi być prawidłowym kolorem szesnastkowym.', + 'image' => 'Pole :attribute musi być obrazem.', + 'in' => 'Wybrana wartość pola :attribute jest nieprawidłowa.', + 'in_array' => 'Pole :attribute musi istnieć w :other.', + 'integer' => 'Pole :attribute musi być liczbą całkowitą.', + 'ip' => 'Pole :attribute musi być prawidłowym adresem IP.', + 'ipv4' => 'Pole :attribute musi być prawidłowym adresem IPv4.', + 'ipv6' => 'Pole :attribute musi być prawidłowym adresem IPv6.', + 'json' => 'Pole :attribute musi być prawidłowym ciągiem JSON.', + 'list' => 'Pole :attribute musi być listą.', + 'lowercase' => 'Pole :attribute musi być zapisane małymi literami.', + 'lt' => [ + 'array' => 'Pole :attribute musi mieć mniej niż :value elementów.', + 'file' => 'Pole :attribute musi być mniejsze niż :value kilobajtów.', + 'numeric' => 'Pole :attribute musi być mniejsze niż :value.', + 'string' => 'Pole :attribute musi mieć mniej niż :value znaków.', + ], + 'lte' => [ + 'array' => 'Pole :attribute nie może mieć więcej niż :value elementów.', + 'file' => 'Pole :attribute musi być mniejsze lub równe :value kilobajtów.', + 'numeric' => 'Pole :attribute musi być mniejsze lub równe :value.', + 'string' => 'Pole :attribute musi mieć co najwyżej :value znaków.', + ], + 'mac_address' => 'Pole :attribute musi być prawidłowym adresem MAC.', + 'max' => [ + 'array' => 'Pole :attribute nie może mieć więcej niż :max elementów.', + 'file' => 'Pole :attribute nie może być większe niż :max kilobajtów.', + 'numeric' => 'Pole :attribute nie może być większe niż :max.', + 'string' => 'Pole :attribute nie może mieć więcej niż :max znaków.', + ], + 'max_digits' => 'Pole :attribute nie może mieć więcej niż :max cyfr.', + 'mimes' => 'Pole :attribute musi być plikiem typu: :values.', + 'mimetypes' => 'Pole :attribute musi być plikiem typu: :values.', + 'min' => [ + 'array' => 'Pole :attribute musi mieć co najmniej :min elementów.', + 'file' => 'Pole :attribute musi mieć co najmniej :min kilobajtów.', + 'numeric' => 'Pole :attribute musi mieć wartość co najmniej :min.', + 'string' => 'Pole :attribute musi mieć co najmniej :min znaków.', + ], + 'min_digits' => 'Pole :attribute musi mieć co najmniej :min cyfr.', + 'missing' => 'Pole :attribute musi być nieobecne.', + 'missing_if' => 'Pole :attribute musi być nieobecne, gdy :other ma wartość :value.', + 'missing_unless' => 'Pole :attribute musi być nieobecne, chyba że :other ma wartość :value.', + 'missing_with' => 'Pole :attribute musi być nieobecne, gdy podano :values.', + 'missing_with_all' => 'Pole :attribute musi być nieobecne, gdy podano :values.', + 'multiple_of' => 'Pole :attribute musi być wielokrotnością :value.', + 'not_in' => 'Wybrana wartość pola :attribute jest nieprawidłowa.', + 'not_regex' => 'Format pola :attribute jest nieprawidłowy.', + 'numeric' => 'Pole :attribute musi być liczbą.', + 'password' => [ + 'letters' => 'Pole :attribute musi zawierać co najmniej jedną literę.', + 'mixed' => 'Pole :attribute musi zawierać co najmniej jedną wielką i jedną małą literę.', + 'numbers' => 'Pole :attribute musi zawierać co najmniej jedną cyfrę.', + 'symbols' => 'Pole :attribute musi zawierać co najmniej jeden znak specjalny.', + 'uncompromised' => 'Podane :attribute pojawiło się w wycieku danych. Wybierz inną wartość.', + ], + 'present' => 'Pole :attribute musi być obecne.', + 'present_if' => 'Pole :attribute musi być obecne, gdy :other ma wartość :value.', + 'present_unless' => 'Pole :attribute musi być obecne, chyba że :other ma wartość :value.', + 'present_with' => 'Pole :attribute musi być obecne, gdy podano :values.', + 'present_with_all' => 'Pole :attribute musi być obecne, gdy podano :values.', + 'prohibited' => 'Pole :attribute jest zabronione.', + 'prohibited_if' => 'Pole :attribute jest zabronione, gdy :other ma wartość :value.', + 'prohibited_unless' => 'Pole :attribute jest zabronione, chyba że :other ma wartość w :values.', + 'prohibits' => 'Pole :attribute uniemożliwia obecność :other.', + 'regex' => 'Format pola :attribute jest nieprawidłowy.', + 'required' => 'Pole :attribute jest wymagane.', + 'required_array_keys' => 'Pole :attribute musi zawierać wpisy dla: :values.', + 'required_if' => 'Pole :attribute jest wymagane, gdy :other ma wartość :value.', + 'required_if_accepted' => 'Pole :attribute jest wymagane, gdy :other zostało zaakceptowane.', + 'required_if_declined' => 'Pole :attribute jest wymagane, gdy :other zostało odrzucone.', + 'required_unless' => 'Pole :attribute jest wymagane, chyba że :other ma wartość w :values.', + 'required_with' => 'Pole :attribute jest wymagane, gdy podano :values.', + 'required_with_all' => 'Pole :attribute jest wymagane, gdy podano :values.', + 'required_without' => 'Pole :attribute jest wymagane, gdy nie podano :values.', + 'required_without_all' => 'Pole :attribute jest wymagane, gdy nie podano żadnego z :values.', + 'same' => 'Pola :attribute i :other muszą się zgadzać.', + 'size' => [ + 'array' => 'Pole :attribute musi zawierać :size elementów.', + 'file' => 'Pole :attribute musi mieć :size kilobajtów.', + 'numeric' => 'Pole :attribute musi mieć wartość :size.', + 'string' => 'Pole :attribute musi mieć :size znaków.', + ], + 'starts_with' => 'Pole :attribute musi zaczynać się jednym z: :values.', + 'string' => 'Pole :attribute musi być ciągiem znaków.', + 'timezone' => 'Pole :attribute musi być prawidłową strefą czasową.', + 'unique' => 'Wartość pola :attribute jest już zajęta.', + 'uploaded' => 'Nie udało się wgrać pliku :attribute.', + 'uppercase' => 'Pole :attribute musi być zapisane wielkimi literami.', + 'url' => 'Pole :attribute musi być prawidłowym adresem URL.', + 'ulid' => 'Pole :attribute musi być prawidłowym ULID.', + 'uuid' => 'Pole :attribute musi być prawidłowym UUID.', + + /* + |-------------------------------------------------------------------------- + | Własne komunikaty walidacji + |-------------------------------------------------------------------------- + */ + + 'custom' => [ + 'attribute-name' => [ + 'rule-name' => 'custom-message', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Nazwy pól w komunikatach + |-------------------------------------------------------------------------- + */ + + 'attributes' => [ + 'login' => 'login', + 'password' => 'hasło', + 'given_name' => 'imię', + 'surname' => 'nazwisko', + 'samaccountname' => 'nazwa logowania', + 'samaccountnames' => 'zaznaczeni użytkownicy', + 'upn_prefix' => 'prefiks UPN', + 'mail' => 'adres e-mail', + 'ou' => 'jednostka organizacyjna', + 'target_ou' => 'docelowa jednostka organizacyjna', + 'template_sam' => 'konto wzorcowe', + 'account_expire_on' => 'data wygaśnięcia konta', + 'cn' => 'nazwa grupy', + 'description' => 'opis', + 'group_type' => 'typ grupy', + 'group_scope' => 'zasięg grupy', + 'group_cn' => 'grupa', + 'group_dn' => 'grupa', + 'member_dn' => 'członek', + 'photo' => 'zdjęcie', + 'password_policy.max_age_days' => 'maksymalny wiek hasła', + 'password_policy.generator_length' => 'długość generowanego hasła', + 'encryption.key' => 'klucz szyfrujący', + 'attributes' => 'atrybuty', + ], + +]; diff --git a/package.json b/package.json new file mode 100644 index 0000000..13ec885 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://www.schemastore.org/package.json", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "concurrently": "^10.0.3", + "laravel-vite-plugin": "^3.1", + "tailwindcss": "^4.0.0", + "vite": "^8.0.0" + }, + "optionalDependencies": { + "@laravel/multiplex": "^0.4.1" + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..e7f0a48 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,36 @@ + + + + + tests/Unit + + + tests/Feature + + + + + app + + + + + + + + + + + + + + + + + + + diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..b574a59 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,25 @@ + + + Options -MultiViews -Indexes + + + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Handle X-XSRF-Token Header + RewriteCond %{HTTP:x-xsrf-token} . + RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}] + + # Redirect Trailing Slashes If Not A Folder... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} (.+)/$ + RewriteRule ^ %1 [L,R=301] + + # Send Requests To Front Controller... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] + diff --git a/public/css/style.css b/public/css/style.css new file mode 100644 index 0000000..f67c55b --- /dev/null +++ b/public/css/style.css @@ -0,0 +1,718 @@ +/* ADconsole - Shared Styles */ +:root { + --sidebar-width: 280px; + --top-navbar-height: 60px; + --primary-soft: #e0f2fe; + --primary-strong: #0369a1; + --muted: #64748b; + --heading: #0f172a; +} + +body { + font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; + background-color: #f1f5f9; +} + +/* Stopka zlewa się z tłem strony (to samo co body na /struktura). */ +.app-footer { + background-color: #f1f5f9; +} +[data-bs-theme="dark"] .app-footer { + background-color: #0b1220; +} + +.ou-module-tabs { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-bottom: 1rem; +} + +.tab-link { + padding: 0.6rem 1rem; + border-radius: 999px; + background: #fff; + border: 1px solid #e2e8f0; + color: #475569; + font-weight: 600; + text-decoration: none; + transition: all 0.15s ease; +} + +.tab-link:hover, +.tab-link.active { + background: var(--primary-soft); + border-color: #bae6fd; + color: var(--primary-strong); +} + +/* Top Navigation Header */ +.navbar-custom { + background-color: #0f172a; + color: #ffffff; + height: var(--top-navbar-height); +} + +/* Pasek wyszukiwania w navbarze - navbar jest zawsze ciemny, więc placeholder + pola musi być jasny niezależnie od motywu strony (w trybie jasnym Bootstrap + dawał ciemny placeholder, nieczytelny na ciemnym tle). */ +.navbar-custom .form-control::placeholder { + color: #94a3b8; + opacity: 1; +} + +.navbar-custom .form-control:focus { + color: #f8fafc; + border-color: #64748b; + box-shadow: none; +} + +.navbar-custom .navbar-brand { + color: #ffffff; + font-weight: 600; +} + +/* Sub Navigation Bar */ +.sub-nav { + background-color: #1e293b; + border-bottom: 1px solid #334155; +} + +.sub-nav .nav-link { + color: #94a3b8; + font-weight: 500; + padding: 0.75rem 1rem; + border-bottom: 2px solid transparent; +} + +.sub-nav .nav-link.active, +.sub-nav .nav-link:hover { + color: #38bdf8; + border-bottom-color: #38bdf8; +} + +/* Main Container Layout */ +.main-container { + min-height: calc(100vh - var(--top-navbar-height) - 49px); +} + +.content-area { + flex-grow: 1; + padding: 1.5rem; + max-width: 1600px; + margin: 0 auto; +} + +/* Custom Cards */ +.card-custom { + border: 1px solid #e2e8f0; + border-radius: 10px; + background: #ffffff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); + margin-bottom: 1.5rem; +} + +.card-custom .card-header { + background-color: #ffffff; + border-bottom: 1px solid #f1f5f9; + padding: 1.25rem 1.5rem; +} + +.card-custom .card-body { + padding: 1.5rem; +} + +.section-title { + font-size: 1.1rem; + font-weight: 600; + color: #0f172a; +} + +.section-subtitle { + font-size: 0.85rem; + color: #64748b; +} + +/* Sidebars & Trees */ +.settings-sidebar, +.ou-sidebar { + width: var(--sidebar-width); + background-color: #ffffff; + border-right: 1px solid #e2e8f0; +} + +.ou-tree-card { + height: calc(100vh - 180px); + overflow-y: auto; +} + +.settings-menu .nav-link { + color: #475569; + font-weight: 500; + padding: 0.75rem 1rem; + border-radius: 6px; + margin-bottom: 4px; + display: flex; + align-items: center; +} + +.settings-menu .nav-link:hover { + background-color: #f8fafc; + color: #0f172a; +} + +.settings-menu .nav-link.active { + background-color: #e0f2fe; + color: #0284c7; + font-weight: 600; +} + +.settings-menu .nav-link i { + font-size: 1.1rem; + margin-right: 10px; +} + +/* Treeview OU Styling */ +.tree-view ul, +.ou-tree, +.ou-tree ul { + list-style: none; + padding-left: 1.2rem; +} + +.tree-view > ul, +.ou-tree { + padding-left: 0; +} + +.ou-tree li { + margin: 4px 0; +} + +.tree-item, +.ou-tree-item { + padding: 4px 8px; + border-radius: 6px; + cursor: pointer; + color: #334155; + font-size: 0.9rem; + display: flex; + align-items: center; + text-decoration: none; +} + +.tree-item:hover, +.ou-tree-item:hover { + background-color: #f1f5f9; + color: #0284c7; +} + +.ou-tree-item { + justify-content: space-between; + gap: 0.5rem; +} + +.ou-tree-label { + flex: 1; + min-width: 0; +} + +.ou-tree-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.6rem; + height: 1.5rem; + padding: 0 0.45rem; + border-radius: 999px; + background: #e2e8f0; + color: #334155; + font-size: 0.72rem; + font-weight: 700; +} + +.tree-item.active, +.ou-tree-item.active { + background-color: #e0f2fe; + color: #0369a1; + font-weight: 600; +} + +.tree-item i { + margin-right: 6px; + color: #64748b; +} + +.tree-item.active i { + color: #0284c7; +} + +/* ========================================================================= + Zwijana kolumna z drzewem OU (użytkownicy / komputery / grupy). + Stan trzyma klasa .ou-collapsed na (localStorage 'panel-ou-collapsed', + ustawiana też inline w ). Zwinięta = wąski pasek przy krawędzi; + po najechaniu / fokusie rozwija się jako nakładka nad tabelą, dzięki czemu + tabela zyskuje całą szerokość na stałe. + ========================================================================= */ +.ou-rail-expand { + display: none; +} + +@media (min-width: 768px) { + .ou-collapsed .ou-layout .ou-col { + width: 3.25rem; + position: relative; + transition: width 0.15s ease; + } + + .ou-collapsed .ou-layout .ou-content-col { + width: calc(100% - 3.25rem); + } + + /* Pasek w stanie zwiniętym. */ + .ou-collapsed .ou-layout .ou-tree-card { + height: auto; + overflow: visible; + padding: 0.5rem !important; + } + + .ou-collapsed .ou-layout .ou-tree-card > .ou-tree-head { + display: none !important; /* bije .d-flex !important z Bootstrapa */ + } + + .ou-collapsed .ou-layout .ou-tree-card > .tree-view { + display: none; + } + + .ou-collapsed .ou-layout .ou-tree-card > .ou-rail-expand { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; + width: 100%; + padding: 0.35rem 0; + border: 0; + background: transparent; + color: var(--muted); + font-weight: 700; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.06em; + } + + .ou-collapsed .ou-layout .ou-tree-card > .ou-rail-expand i { + font-size: 1.15rem; + color: #f59e0b; + } + + .ou-collapsed .ou-layout .ou-tree-card > .ou-rail-expand span { + writing-mode: vertical-rl; + transform: rotate(180deg); + white-space: nowrap; + } + + /* Rozwinięcie po najechaniu / fokusie - nakładka nad tabelą. */ + .ou-collapsed .ou-layout .ou-col:hover, + .ou-collapsed .ou-layout .ou-col:focus-within { + z-index: 1035; + } + + .ou-collapsed .ou-layout .ou-col:hover .ou-tree-card, + .ou-collapsed .ou-layout .ou-col:focus-within .ou-tree-card { + position: absolute; + top: 0; + left: calc(var(--bs-gutter-x) * 0.5); + width: 300px; + max-width: calc(100vw - 2rem); + height: calc(100vh - 180px); + overflow-y: auto; + padding: 1rem !important; + z-index: 1035; + box-shadow: 0 16px 40px rgba(15, 23, 42, 0.25); + } + + .ou-collapsed .ou-layout .ou-col:hover .ou-tree-card > .ou-tree-head, + .ou-collapsed .ou-layout .ou-col:focus-within .ou-tree-card > .ou-tree-head { + display: flex !important; + } + + .ou-collapsed .ou-layout .ou-col:hover .ou-tree-card > .tree-view, + .ou-collapsed .ou-layout .ou-col:focus-within .ou-tree-card > .tree-view { + display: block; + } + + /* Po najechaniu myszą pasek chowamy (widać pełne drzewo). Przy nawigacji + klawiaturą (:focus-within bez :hover) zostaje widoczny, bo to jedyny + fokusowalny element w zwiniętym pasku - Enter na nim przypina drzewo; + zwijamy go wtedy do jednej linii, żeby nie rozpychał treści. */ + .ou-collapsed .ou-layout .ou-col:hover .ou-tree-card > .ou-rail-expand { + display: none; + } + + .ou-collapsed .ou-layout .ou-col:focus-within:not(:hover) .ou-tree-card > .ou-rail-expand { + flex-direction: row; + justify-content: center; + gap: 0.4rem; + margin-bottom: 0.25rem; + } + + .ou-collapsed .ou-layout .ou-col:focus-within:not(:hover) .ou-tree-card > .ou-rail-expand span { + writing-mode: horizontal-tb; + transform: none; + } +} + +/* ========================================================================= + Pulpit - klikalne kafelki podsumowania + pasek histogramu wieku hasła. + ========================================================================= */ +.dashboard-tile { + transition: box-shadow 0.15s ease, transform 0.15s ease, border-color 0.15s ease; +} + +.dashboard-tile:hover { + box-shadow: 0 6px 18px rgba(15, 23, 42, 0.12); + transform: translateY(-1px); +} + +.dashboard-tile .dashboard-tile-arrow { + opacity: 0; + transition: opacity 0.15s ease; +} + +.dashboard-tile:hover .dashboard-tile-arrow { + opacity: 1; +} + +.dashboard-tile .min-w-0 { + min-width: 0; +} + +.dashboard-tile-alert { + border-color: #dc3545; + background: #fef2f2; + box-shadow: inset 3px 0 0 #dc3545; +} + +.dashboard-tile-alert:hover { + box-shadow: inset 3px 0 0 #dc3545, 0 6px 18px rgba(220, 53, 69, 0.25); +} + +.pwd-age-bar { + height: 1.5rem; + background: #e2e8f0; +} + +[data-bs-theme="dark"] .dashboard-tile:hover { + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.5); +} + +[data-bs-theme="dark"] .dashboard-tile-alert { + background: #2a1416; + border-color: #b91c1c; +} + +[data-bs-theme="dark"] .pwd-age-bar { + background: #1f2937; +} + +/* Tables & Badges */ +.table-responsive { + background: #ffffff; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); +} + +.table th, +.table-custom th { + font-size: 0.825rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #475569; + background-color: #f8fafc; + border-bottom: 2px solid #e2e8f0; + white-space: nowrap; +} + +.table td { + font-size: 0.9rem; + vertical-align: middle; + white-space: normal; +} + +.table th[data-sortable="true"] { + cursor: pointer; + user-select: none; +} + +.data-table-search-active { + border-color: #93c5fd; + box-shadow: 0 0 0 0.1rem rgba(59, 130, 246, 0.15); +} + +.badge-status-active { + background-color: #dcfce7; + color: #166534; +} + +.badge-status-locked, +.badge-status-disabled { + background-color: #fee2e2; + color: #991b1b; +} + +.badge-status-warning { + background-color: #fef3c7; + color: #92400e; +} + +.form-label { + font-weight: 500; + color: #334155; + font-size: 0.9rem; +} + +.form-text { + font-size: 0.8rem; + color: #64748b; +} + +.column-select-box { + border: 1px solid #cbd5e1; + border-radius: 8px; + padding: 1rem; + background-color: #f8fafc; + max-height: 280px; + overflow-y: auto; +} + +/* ========================================================================= + Motyw ciemny (przełącznik w navbarze - ustawia data-bs-theme na ). + Bootstrap 5.3 sam ogarnia swoje komponenty; poniżej dopinamy klasy własne + oraz utility, które w BS zostają jasne (.bg-white, .bg-light, .text-dark). + ========================================================================= */ +[data-bs-theme="dark"] { + color-scheme: dark; +} + +[data-bs-theme="dark"] body { + background-color: #0b1220; + color: #cbd5e1; +} + +[data-bs-theme="dark"] .card, +[data-bs-theme="dark"] .card-custom, +[data-bs-theme="dark"] .table-responsive, +[data-bs-theme="dark"] .modal-content, +[data-bs-theme="dark"] .bg-white { + background-color: #111827 !important; + color: #cbd5e1; +} + +[data-bs-theme="dark"] .card-custom, +[data-bs-theme="dark"] .card-custom .card-header, +[data-bs-theme="dark"] .border, +[data-bs-theme="dark"] .border-top, +[data-bs-theme="dark"] .border-bottom { + border-color: #1f2937 !important; +} + +[data-bs-theme="dark"] .table { + --bs-table-bg: #111827; + --bs-table-color: #cbd5e1; + --bs-table-border-color: #1f2937; +} + +[data-bs-theme="dark"] .table th, +[data-bs-theme="dark"] .table-light, +[data-bs-theme="dark"] .table thead.table-light tr, +[data-bs-theme="dark"] .table-custom th { + background-color: #0f172a; + color: #94a3b8; + border-bottom-color: #1f2937; +} + +[data-bs-theme="dark"] .bg-light { + background-color: #0f172a !important; +} + +[data-bs-theme="dark"] .text-dark { + color: #e2e8f0 !important; +} + +/* Utility klasy Bootstrapa wpisane na stałe w widokach (.btn-light, .text-bg-light) + nie reagują na data-bs-theme - w trybie ciemnym zostawały białe. */ +[data-bs-theme="dark"] .btn-light { + --bs-btn-bg: #1f2937; + --bs-btn-border-color: #374151; + --bs-btn-color: #e2e8f0; + --bs-btn-hover-bg: #374151; + --bs-btn-hover-border-color: #4b5563; + --bs-btn-hover-color: #f8fafc; + --bs-btn-active-bg: #4b5563; + --bs-btn-active-border-color: #4b5563; + --bs-btn-active-color: #f8fafc; + --bs-btn-disabled-bg: #1f2937; + --bs-btn-disabled-border-color: #374151; + --bs-btn-disabled-color: #9ca3af; +} + +[data-bs-theme="dark"] .btn-light.active { + background-color: #4b5563; + border-color: #4b5563; + color: #f8fafc; +} + +[data-bs-theme="dark"] .text-bg-light { + background-color: #1f2937 !important; + color: #e2e8f0 !important; +} + +[data-bs-theme="dark"] .form-label, +[data-bs-theme="dark"] .section-title { + color: #cbd5e1; +} + +[data-bs-theme="dark"] .column-select-box { + background-color: #0f172a; + border-color: #1f2937; +} + +[data-bs-theme="dark"] .tree-item, +[data-bs-theme="dark"] .ou-tree-item { + color: #cbd5e1; +} + +[data-bs-theme="dark"] .tree-item:hover, +[data-bs-theme="dark"] .ou-tree-item:hover { + background-color: #1f2937; + color: #7dd3fc; +} + +[data-bs-theme="dark"] .tree-item.active, +[data-bs-theme="dark"] .ou-tree-item.active { + background-color: #1e3a5f; + color: #7dd3fc; +} + +[data-bs-theme="dark"] .ou-tree-count { + background: #1f2937; + color: #cbd5e1; +} + +[data-bs-theme="dark"] .ou-collapsed .ou-layout .ou-col:hover .ou-tree-card, +[data-bs-theme="dark"] .ou-collapsed .ou-layout .ou-col:focus-within .ou-tree-card { + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.6); +} + +[data-bs-theme="dark"] .badge-status-active { + background-color: #14532d; + color: #bbf7d0; +} + +[data-bs-theme="dark"] .badge-status-locked, +[data-bs-theme="dark"] .badge-status-disabled { + background-color: #7f1d1d; + color: #fecaca; +} + +/* ========================================================================= + Wykres organizacyjny (/struktura) - biblioteka d3-org-chart (CDN). + Karta osoby jest renderowana w foreignObject; łącza to . + ========================================================================= */ +.org-d3 { + width: 100%; + height: 72vh; + min-height: 460px; + --org-line: #cbd5e1; +} + +#orgChart .node-rect { fill: transparent; } +#orgChart .link { stroke: var(--org-line); stroke-width: 1.5px; } +#orgChart .node-button-rect { fill: #ffffff; stroke: var(--org-line); } +#orgChart .node-button-text { fill: #334155; } + +.org-card { + box-sizing: border-box; + border: 1px solid #e2e8f0; + border-radius: 12px; + background: #ffffff; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); + padding: 10px 12px; + display: flex; + flex-direction: column; + justify-content: center; + overflow: hidden; +} +.org-card-root { + background: #0f172a; + border-color: #0f172a; +} +.org-card-head { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} +.org-card-id { min-width: 0; } +.org-avatar { + flex: 0 0 auto; + width: 34px; + height: 34px; + border-radius: 50%; + background: #e0f2fe; + color: #0369a1; + font-weight: 700; + font-size: 0.78rem; + display: flex; + align-items: center; + justify-content: center; +} +.org-card-root .org-avatar { background: #1e293b; color: #7dd3fc; } +.org-name { + font-weight: 600; + font-size: 0.85rem; + color: #0f172a; + text-decoration: none; + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.org-card-root .org-name { color: #ffffff; } +.org-name:hover { text-decoration: underline; } +.org-title { + font-size: 0.7rem; + color: #64748b; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.org-card-root .org-title { color: #94a3b8; } +.org-dept { + margin-top: 1px; + font-size: 0.68rem; + color: #94a3b8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* wariant ciemny */ +[data-bs-theme="dark"] .org-d3 { --org-line: #334155; } +[data-bs-theme="dark"] .org-card { background: #111827; border-color: #1f2937; } +[data-bs-theme="dark"] .org-dept { color: #64748b; } +[data-bs-theme="dark"] .org-card-root { background: #000000; border-color: #1f2937; } +[data-bs-theme="dark"] .org-name { color: #e2e8f0; } +[data-bs-theme="dark"] .org-avatar { background: #1e3a5f; color: #7dd3fc; } +[data-bs-theme="dark"] #orgChart .node-button-rect { fill: #111827; stroke: #334155; } +[data-bs-theme="dark"] #orgChart .node-button-text { fill: #cbd5e1; } + +/* ========================================================================= + Konfiguracja atrybutów - rozwijany panel reguł walidacji (regex / wymagane + / tekst pomocy) w ostatniej komórce wiersza atrybutu. + ========================================================================= */ +#attributesTable .attr-detail { + width: 260px; + max-width: 40vw; + margin-left: auto; +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..a4f71e1514fb9d14e0cae23ea148bd24393b6c34 GIT binary patch literal 32038 zcmds=3y>vMd4PKua6p9t;uaeeW`IBtA8dRFTxNs1fjmU5L@M!77)(Vg62zbol47QK zi3tfBl|q!nkcVg>O7MXg$Y=!3f{~yGvJn^-6nru$$fB?}-`8`_-JU!BytnVnKv#YH zcb{|q*Xh%zPxmyJBpFF|Om^QrK~5%b+A&EsCP^|mxo~_b<^Mw2x^?0Bz$AI~nk3n< zVd41TBa>v?VM%f@IyA+?LOCMAZ0R`!@1Elfw8VTjvKN}u}tZv-@7UeI7MK=5gsi@8?fb*w!A#wi?CUR$K?#2XS>3q*hm^k# zwq@$?W&arv^>SNmnDF1Oz-T9P7{k_=#@0>Q#MxHz~qrVILS2WOf zT)bX=Hzllti@}^9f?l7VKgY*)a0=v&LC60Dcy2xbH-q^*b{vQ6VGl@srq20i>l86e zB*`4ij>A}z%*f~h33B928X~h;Z+qvNN!S3+SLx=&Gr$&N+!5(@@fO$!_VWN%t5vlh za7>&7-v;Ls=byiYgJ4nrRa>psBHBeB0-uHKH5qHHK&p`F!{aM1OhmF6v(i zBTz57l2+OXJO}BR&GXq?=6QKV&?~WCUKRC``@{9n>!5oRA^Kz5s=Ech`hPAsp33Z3)j7w#8q$2~#1PsV zv-4t={W|rI9p{Wn_qGXaME(e>?Ei!|Ti{N}yO*i&9CZRz_CaXlJ^vq}%D46B!am@A z+OgvOKTpp!z#1s+1MA^=@LkBW(_7~JAN$OC$NOh*8;>2szXOGR5bKbqL7t7gvI`pM z*{9yOa{D0I@p_+yJYRWb*V6Wh;Cx$k&gN)y0F?QE7WH-e-gv$Vo_phVzR8O*mU(Vo z1!ev{M@3^O+KJNp?w^ABJ7(Vm_d=}ClV?-EWDLYIq~keHFR$!fv~^vT+6(eTq}N+s zd&{nbC%~dU@ECT0k3f-bw%mDbJ#_834+n$KRN1!K{jX5B2Xyt^@fhsKS;)38+IQV} zBy{b#56^_}K#`vF)FgEEGXD3Z{H7A!UYp%}i}t|&o37CR0riJMo`2`3uUF`2V{q;@ z2K&Kt;dpRNX4~}cTkm}>&;MG=Z>`YpwdXp}I6NPDYr@|4_tWnAkmuif)Gg4f)8?`H z7y0+O<;EfVcYb&}h``(>Vg$8W!Dy}IYf{w=!xy@rm5LDqX!ar`Cr zbDs?;*T46XzW}e(DqBtJoi9HMOZq;0BB(b8=V#X-r^40XeD)|b@#nR8A6yUTfOEu? zLHk*kx%;slau;w;eIr}~&QGoji>~#DsrwYX62Z`3P-^Vd>S0j z_O0{JZSY=r3N-TBnV;@-=B7HG*@;ePW~|dm#yZ=Q(as~u2qc}YFrOsTGM6M%b0ae{ zF`FdY8JQiQ84ZlgjL#%VF6fM_H-{bVcV@;rzNc6R$3gc!#V>F$q%HOj(lOzDbReY9 zNys62ZO5$ffpewnZO7W(a0j^lTo37*s2y+ZSs|`CmiK4>VsOm%Uen*B>*}%70)5W;GFN;e-5&-mhJmq`9-i|IRxK3AYTXG2g>5j zt8+bd1T1?F(I?W_2f3eaMt^;@w{UlM&GkAbr1nY=#FEAzhjoM^ZGh__peK9Dbj z+aNEVe#*Xpt$ksrV=36*2dTe);w;noFgDY5(Doi{Z!g5|ef0f9h~2zgU;PoZeh#8d zwGHGECNK&L=`a6S1^sO!6}b~C|$UpYiwyac5jQiO_K?+I$-Q?z3aC`&SKYK1AE!40^Rw2<^R>Ujs$4)hTxj z?;Y&Lxxo3oj&5w@{XQLkbrT5fU8DK!M6}b7^quAlf<0p}m;Z$(w!RVcYK2eRG*{%? z8~VwM=}~OFHe=I1F{iw?MP*xQ|LfYQv)K;(TiC~-$YzuBvx80Tnu|F#(YLMhZ`!X_ zIfQn5A$_iB>L=UY`OkNRFN1hapUwWif@_3q+p2w^`JVt);YUBN0q<|6 z88WNibpKlPpQNqcE!fvjlXLJh+WU^C*W9AxjO`Dw-!$fI`^(YKeOH+AaaKbgZ&9~w z3%0#Zu54&K8@u~og4|~i%I$k|`+MlsD?2_n=oY3B>b>teZ?UmGP ze^2-?D6-R}{0{7#3En?9!pU$G^fR~pGkSI6cfEQuG>NmS?Zen}9neqQ(RSgS5YGel z?famHV^Ss$=%KhGJ1^lzE& zJnyJWdbyaZISEXeQ+yy z4_%IU_3eIed0|1-uuu>-w_IT)aN-gX`cNcqu#uO4pm&SgZE2 zWgloyyBpwi_#E5=k3iFWe1@NcPrymwdm^8W`|9he__OuV2l9#FHzuzI$C=M9{w=%e z{eu1NJ!U`femV4*v+?)t2mhX%J>Yq8JX`>u2m9FP?+0KDTnj#5PeN;PcRKSOK2A#b zP>GL~lrwy=q)f(B`3N5_dAya6mlh-+7;(&pOR1dW1167W`S?gVvt3SjOw!~0vn?aX zIOZED3@^x0y-)UK}LR%bL zNZ0&cBaY7%ie~xTnq$DZ;Y9Gc^HXph906mnd;u+!CvysnBToRIL7gYF_XY1!7sE54 zwYg`RQY>^6b3q;oS3uSM@BzNgNf@3MW@SY8vpFM3Rlg%G#r z(DD9VD9bmm&UMqJFbR*TJ_zx;ewqQ#WnO-D%Uq`%0*{G42yyO;{B!WVUfn#)+PU_8 zHmvGC2=RH|&jk$A5kH)N|I@Hi?{mE`?qe*=x#=%p|9c+VJg-e{{Wbp6_e{&x z2O;;pk=L(q?)KG>;kRK~u7l_Y>3g!?XV|9auC4Z8CYEKnCx-l8i_Sq?@egXp`R9}n z+t4n9V1Fm%2Vu~d+t$CGn9_GA!{~sJyVuxDz;`um$KQrM|Atcfcldt$2D|3&Jo^vO zMy%1sPq4QI?#wXXr;oeQOP>?_&9g`kyXOBwX!CyOdif170s98LpF76-{m$l{=o)KL zDvis7J=Z$#fq359kp6vZ(Y-tPGXB&0^tBni{k7B9rY$%G9HVXI9c}nN)cLd{8+&T6-z~)QWaKqamct-*zT;2VEWw|Ba8e8Y zd@lMGZT7pV3(ouZz#uu6>E4L#^gJQ;Hv(;~uLpW22=RD*w*4+U+aLF0-#(6G-|_YZ z$hPTs|6|zMQQJ*7bpq!g`}JR?cR#lO2G#~&`rH}3_YXS%>H8ay+U_%dV;R=FJ@fOu z#TazMb056_`^}ScSXCdE)!V;^LDzoW9d%#=Zig}-{nYu)^F~O=SZ_?R9BJ%>t~uWv z?e+R8I}SRBBV9}MlVh3A)!6hpFATxDfX_2!wyNsRi?#}#;$e4itN-W_g>Rz9ZY+D`^@|AP{&8pHomWZ7c6@B6VDIj z@%noW`-9AV#`RM;TAOt@+k*F}|A3~s)ouSEw$Fm}+C;{jm3}|-dvF)jwO7>6{C^GV zrXS<*9mJPf@Lv>{<&Fcd1?Pif!FloR;F@#L=ilhp{C@+Q=I=9^u|+$>mVWQ%y`gPC z+H?+Wn*SPfZXQBBZRb~I&-`E2NB(=E^YzC;{*J3f@7Ssaf_(+M!SztpzpK68^WV?) zKZf4bkAwUlAcrHM>a=(3Z3{j#Tn?-K{8q*H9dc;&cVXJcd*J&X2l;OyhyBc{?v}~l z^+{F#uJ-y{$f0zt-PiQ zzt_!euw1dW?PnXYzd7X6n_}s4&>2BagLc}EZ8_|1BJQU`X|6?;>VfOvOTlNA<%qd0 zA9KXJ5z>2EQI2KhQ5R_+_%7^jSnXrsL1MfLrl9ZKv;7k6IET27I2p9>x?;7A33L7` zya#;OkZV?aLv z-(ej9$HIkh6Zp61svMfu`yS&?@SCZVz`u|AjMmB+uak3Gn-IqiNYBF&a4K90cfeL? zns03H`s2gkGpzlbzB5FYg|nh{#uo4y(lvz7pT6VzBy0x9N1eIDe!B*=v7$K*;@sE+ zzEd|B=cspp=hb`kH{m|8UmPztz(w$SSPQBCATiX@ztND&nXViS$D@XYUMk1Ul;d%K zngTiDPb>7wxloRz{fKFd1&N*Xm^hnD{G@v2GL4INY5d5sw3`Vf?S4WF{Yd}Cevjve NOcu^x2klPh{{gs*-D3a% literal 0 HcmV?d00001 diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..164624f --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..ee8f07e --- /dev/null +++ b/public/index.php @@ -0,0 +1,20 @@ +handleRequest(Request::capture()); diff --git a/public/js/main.js b/public/js/main.js new file mode 100644 index 0000000..06b0695 --- /dev/null +++ b/public/js/main.js @@ -0,0 +1,432 @@ +// ADconsole - Shared JavaScript Functionality + +document.addEventListener("DOMContentLoaded", function () { + initTableEnhancements(document); + initLiveSearch(); +}); + +/** + * Ulepszenia dla tabel, które trzeba móc uruchomić ponownie po dynamicznej + * podmianie regionu #tableResults (wyszukiwanie / paginacja bez przeładowania). + */ +function initTableEnhancements(root) { + root = root || document; + + // Sortowanie po kliknięciu nagłówka. + root.querySelectorAll('th[data-sortable="true"]').forEach((header) => { + if (header.dataset.sortBound) return; + header.dataset.sortBound = "1"; + + header.addEventListener("click", function () { + const table = header.closest("table"); + const tbody = table?.querySelector("tbody"); + if (!tbody) return; + + const rows = Array.from(tbody.querySelectorAll("tr")); + const index = Array.from(header.parentElement.children).indexOf(header); + const asc = !header.dataset.asc; + header.dataset.asc = String(asc); + + rows.sort((a, b) => { + const aText = a.children[index]?.textContent.trim().toLowerCase() || ""; + const bText = b.children[index]?.textContent.trim().toLowerCase() || ""; + return asc ? aText.localeCompare(bText) : bText.localeCompare(aText); + }); + + rows.forEach((row) => tbody.appendChild(row)); + }); + }); + + // Menu "..." w wierszach tabeli chowało się pod dolnym paskiem / krawędzią + // kontenera z przewijaniem (.table-responsive ma overflow, karta ma + // overflow-hidden). Strategia "fixed" wyprowadza je poza obszar + // przycinania, a Popper sam odwraca je do góry, gdy pod przyciskiem + // brakuje miejsca (np. tabela z jednym wierszem). + if (window.bootstrap && bootstrap.Dropdown) { + root + .querySelectorAll('.table-responsive [data-bs-toggle="dropdown"]') + .forEach((toggle) => { + bootstrap.Dropdown.getOrCreateInstance(toggle, { + popperConfig(defaultConfig) { + return { ...defaultConfig, strategy: "fixed" }; + }, + }); + }); + } +} + +// "Zaznacz wszystko" - delegacja, bo #selectAll bywa w wymienianym regionie. +document.addEventListener("change", function (e) { + if (!e.target || e.target.id !== "selectAll") return; + const scope = e.target.closest("table") || document; + scope + .querySelectorAll('tbody input[type="checkbox"].form-check-input') + .forEach((cb) => (cb.checked = e.target.checked)); +}); + +// Zmiana liczby wierszy na stronę (select w pasku pod tabelą) - przeładowanie +// z parametrem ?per_page=, z resetem numeru strony. +document.addEventListener("change", function (e) { + const sel = e.target; + if (!sel || !sel.classList || !sel.classList.contains("js-per-page")) return; + const params = new URLSearchParams(window.location.search); + params.set("per_page", sel.value); + params.delete("page"); + params.delete("partial"); + window.location.search = params.toString(); +}); + +// Przełączanie widoczności kolumn (przycisk "Kolumny") - delegacja. +document.addEventListener("change", function (e) { + const toggle = e.target; + if (!toggle.classList || !toggle.classList.contains("js-col-toggle")) return; + const targetClass = toggle.dataset.col; + if (!targetClass) return; + document.querySelectorAll("." + targetClass).forEach((el) => { + el.style.display = toggle.checked ? "" : "none"; + }); +}); + +/** + * Dynamiczne wyszukiwanie i paginacja bez przeładowania strony. + * + * Wymaga na stronie: + * - pola z atrybutem [data-live-search] wewnątrz formularza GET (action -> index), + * - kontenera #tableResults obejmującego tabelę i pasek paginacji. + * + * Kontroler zwraca sam #tableResults, gdy w zapytaniu jest partial=1. + */ +function initLiveSearch() { + const input = document.querySelector("[data-live-search]"); + const region = document.getElementById("tableResults"); + if (!input || !region) return; + + const form = input.closest("form"); + if (!form) return; + + let timer = null; + let seq = 0; + + function buildUrl(page) { + const params = new URLSearchParams(new FormData(form)); + params.delete("page"); + if (page) params.set("page", page); + // "Na stronę" wybiera się w pasku pod tabelą - nie jest polem formularza, + // więc dokładamy je z bieżącego adresu, żeby nie gubić wyboru. + const pp = new URLSearchParams(window.location.search).get("per_page"); + if (pp && !params.has("per_page")) params.set("per_page", pp); + params.set("partial", "1"); + const action = form.getAttribute("action") || window.location.pathname; + return action + "?" + params.toString(); + } + + function displayUrl(url) { + return url + .replace(/([?&])partial=1(&|$)/, (_, p1, p2) => (p2 ? p1 : "")) + .replace(/[?&]$/, ""); + } + + async function load(url) { + const mySeq = ++seq; + const before = document.getElementById("tableResults"); + if (before) before.style.opacity = "0.5"; + + try { + const res = await fetch(url, { + headers: { "X-Requested-With": "XMLHttpRequest" }, + }); + const html = await res.text(); + if (mySeq !== seq) return; + + const tmp = document.createElement("div"); + tmp.innerHTML = html.trim(); + const fresh = tmp.querySelector("#tableResults"); + const current = document.getElementById("tableResults"); + if (!fresh || !current) return; + + current.replaceWith(fresh); + window.history.replaceState(null, "", displayUrl(url)); + initTableEnhancements(fresh); + document.dispatchEvent( + new CustomEvent("tableResults:updated", { detail: { root: fresh } }), + ); + } catch (err) { + const current = document.getElementById("tableResults"); + if (current) current.style.opacity = ""; + } + } + + input.addEventListener("input", function () { + clearTimeout(timer); + timer = setTimeout(() => load(buildUrl()), 300); + }); + + form.addEventListener("submit", function (e) { + e.preventDefault(); + clearTimeout(timer); + load(buildUrl()); + }); + + // Kliknięcie w link paginacji w wymienianym regionie. + document.addEventListener("click", function (e) { + const link = e.target.closest("#tableResults .pagination a[href]"); + if (!link) return; + e.preventDefault(); + + let page = "1"; + try { + page = + new URL(link.href, window.location.origin).searchParams.get("page") || + "1"; + } catch (_) {} + + load(buildUrl(page)); + }); +} + +/* --------------------------------------------------------------------------- + * Motyw jasny / ciemny (przełącznik w navbarze). Wartość w localStorage; + * atrybut data-bs-theme jest ustawiany inline w (bez migotania). + * ------------------------------------------------------------------------- */ +(function () { + function currentTheme() { + return document.documentElement.getAttribute("data-bs-theme") || "light"; + } + function syncIcon() { + var dark = currentTheme() === "dark"; + document.querySelectorAll("#themeToggle i").forEach(function (icon) { + icon.className = dark ? "bi bi-sun" : "bi bi-moon-stars"; + }); + } + function applyTheme(theme) { + document.documentElement.setAttribute("data-bs-theme", theme); + try { + localStorage.setItem("panel-theme", theme); + } catch (e) {} + syncIcon(); + } + document.addEventListener("DOMContentLoaded", function () { + syncIcon(); + var btn = document.getElementById("themeToggle"); + if (btn) { + btn.addEventListener("click", function () { + applyTheme(currentTheme() === "dark" ? "light" : "dark"); + }); + } + }); +})(); + +/* --------------------------------------------------------------------------- + * Pola hasła: pokaż/ukryj, generator (wg konfiguracji panelu), kopiuj. + * Obsługa delegowana - działa też w modalach ładowanych po fakcie. + * ------------------------------------------------------------------------- */ +(function () { + function generatePassword() { + var cfg = window.PANEL_PWD_POLICY || { length: 16, symbols: true }; + var len = Math.max(8, Math.min(128, parseInt(cfg.length, 10) || 16)); + var lower = "abcdefghijkmnopqrstuvwxyz"; + var upper = "ABCDEFGHJKLMNPQRSTUVWXYZ"; + var digits = "23456789"; + var symbols = "!@#$%^&*()-_=+[]{}"; + var sets = [lower, upper, digits]; + if (cfg.symbols) sets.push(symbols); + var all = sets.join(""); + var rnd = function (n) { + var a = new Uint32Array(1); + (window.crypto || window.msCrypto).getRandomValues(a); + return a[0] % n; + }; + var out = sets.map(function (s) { + return s[rnd(s.length)]; + }); + while (out.length < len) out.push(all[rnd(all.length)]); + for (var i = out.length - 1; i > 0; i--) { + var j = rnd(i + 1); + var t = out[i]; + out[i] = out[j]; + out[j] = t; + } + return out.join(""); + } + + document.addEventListener("click", function (e) { + var btn = e.target.closest( + ".js-pwd-toggle, .js-pwd-gen, .js-pwd-copy", + ); + if (!btn) return; + var field = btn.closest(".js-pwd-field"); + var input = field && field.querySelector(".js-pwd-input"); + if (!input) return; + + if (btn.classList.contains("js-pwd-toggle")) { + input.type = input.type === "password" ? "text" : "password"; + var ic = btn.querySelector("i"); + if (ic) ic.className = input.type === "password" ? "bi bi-eye" : "bi bi-eye-slash"; + } else if (btn.classList.contains("js-pwd-gen")) { + input.value = generatePassword(); + input.type = "text"; + input.dispatchEvent(new Event("input", { bubbles: true })); + var ic2 = field.querySelector(".js-pwd-toggle i"); + if (ic2) ic2.className = "bi bi-eye-slash"; + } else if (btn.classList.contains("js-pwd-copy")) { + if (navigator.clipboard) { + navigator.clipboard.writeText(input.value || "").then(function () { + var ic3 = btn.querySelector("i"); + if (ic3) { + ic3.className = "bi bi-check2"; + setTimeout(function () { + ic3.className = "bi bi-clipboard"; + }, 1200); + } + }); + } + } + }); +})(); + +/* --------------------------------------------------------------------------- + * Generyczna podpowiadajka (typeahead). Kontener .js-typeahead z atrybutami: + * data-source = "users" | "groups" + * data-target = selektor elementu, do którego wpiszemy wybraną wartość + * data-value = "dn" | "sam" | "cn" (co wpisać do targetu; domyślnie dn) + * Emituje event "typeahead:selected" (detail: {source, item}) na kontenerze. + * ------------------------------------------------------------------------- */ +(function () { + var URLS = { users: "/users/search", groups: "/groups/search" }; + var timers = new WeakMap(); + + function results(box) { + return box.querySelector(".js-typeahead-results"); + } + + function render(box, items) { + var r = results(box); + if (!items.length) { + r.style.display = "none"; + r.innerHTML = ""; + return; + } + r.innerHTML = items + .map(function (it) { + var sub = it.title || it.department || it.description || it.sam || ""; + return ( + '" + ); + }) + .join(""); + r.style.display = ""; + } + + document.addEventListener("input", function (e) { + var input = e.target.closest(".js-typeahead-input"); + if (!input) return; + var box = input.closest(".js-typeahead"); + var source = box.getAttribute("data-source"); + var url = URLS[source]; + if (!url) return; + + clearTimeout(timers.get(box)); + var q = input.value.trim(); + if (q.length < 2) { + render(box, []); + return; + } + timers.set( + box, + setTimeout(function () { + fetch(url + "?q=" + encodeURIComponent(q), { + headers: { Accept: "application/json" }, + }) + .then(function (r) { return r.json(); }) + .then(function (items) { render(box, items || []); }) + .catch(function () { render(box, []); }); + }, 250), + ); + }); + + document.addEventListener("click", function (e) { + var opt = e.target.closest(".js-typeahead-results .list-group-item"); + if (opt) { + var box = opt.closest(".js-typeahead"); + var item = { dn: opt.dataset.dn, sam: opt.dataset.sam, cn: opt.dataset.cn }; + var valKind = box.getAttribute("data-value") || "dn"; + var targetSel = box.getAttribute("data-target"); + var target = + targetSel && + (box.parentElement.querySelector(targetSel) || + document.querySelector(targetSel)); + if (target) { + target.value = item[valKind] || item.dn || ""; + target.dispatchEvent(new Event("change", { bubbles: true })); + } + box.querySelector(".js-typeahead-input").value = item.cn || item.sam || ""; + results(box).style.display = "none"; + box.dispatchEvent( + new CustomEvent("typeahead:selected", { + bubbles: true, + detail: { source: box.getAttribute("data-source"), item: item }, + }), + ); + return; + } + // klik poza podpowiedziami - schowaj + if (!e.target.closest(".js-typeahead")) { + document + .querySelectorAll(".js-typeahead-results") + .forEach(function (r) { r.style.display = "none"; }); + } + }); +})(); + +/* --------------------------------------------------------------------------- + * Kolumna z drzewem OU: zwinięta do wąskiego paska (z rozwijaniem po + * najechaniu) albo przypięta na stałe jak dotychczas. Stan w localStorage; + * klasa .ou-collapsed na ustawiana też inline w , więc przy + * wczytaniu strony nie ma mrugnięcia. + * ------------------------------------------------------------------------- */ +(function () { + var KEY = "panel-ou-collapsed"; + + function collapsed() { + return document.documentElement.classList.contains("ou-collapsed"); + } + + function syncIcons() { + var isCollapsed = collapsed(); + document + .querySelectorAll(".ou-collapse-toggle:not(.ou-rail-expand) i") + .forEach(function (icon) { + icon.className = isCollapsed + ? "bi bi-chevron-double-right" + : "bi bi-chevron-double-left"; + }); + } + + function setCollapsed(value) { + document.documentElement.classList.toggle("ou-collapsed", value); + try { + localStorage.setItem(KEY, value ? "1" : "0"); + } catch (e) {} + syncIcons(); + } + + document.addEventListener("DOMContentLoaded", function () { + syncIcons(); + + document.addEventListener("click", function (e) { + var btn = e.target.closest(".ou-collapse-toggle"); + if (!btn) return; + e.preventDefault(); + setCollapsed(!collapsed()); + // Zdejmij fokus z klikniętego przycisku - inaczej :focus-within + // trzymałoby zwinięty pasek rozwinięty jako nakładkę. + btn.blur(); + }); + }); +})(); diff --git a/public/js/shortcuts.js b/public/js/shortcuts.js new file mode 100644 index 0000000..eb2fa66 --- /dev/null +++ b/public/js/shortcuts.js @@ -0,0 +1,76 @@ +/** + * Minimalne skróty klawiaturowe panelu: + * / - fokus na pole wyszukiwania + * g d/u/c/g/o/s - nawigacja do modułu (dashboard/users/computers/groups/org/settings) + * ? - okno z listą skrótów + * + * Skróty (poza "/") są ignorowane, gdy fokus jest w polu formularza. + */ +(function () { + "use strict"; + + var routes = window.ADCONSOLE_ROUTES || {}; + var awaitingG = false; + var gTimer = null; + + function inField(el) { + if (!el) return false; + var tag = (el.tagName || "").toLowerCase(); + return tag === "input" || tag === "textarea" || tag === "select" || el.isContentEditable; + } + + function focusSearch() { + var el = + document.querySelector("[data-live-search]") || + document.querySelector('input[type="search"]') || + document.querySelector('input[name="q"]'); + if (el) { + el.focus(); + if (typeof el.select === "function") el.select(); + } + } + + function openHelp() { + var m = document.getElementById("shortcutsModal"); + if (m && window.bootstrap) { + window.bootstrap.Modal.getOrCreateInstance(m).show(); + } + } + + document.addEventListener("keydown", function (e) { + if (e.ctrlKey || e.metaKey || e.altKey) return; + + // "/" - fokus na wyszukiwarkę (działa też spoza pól, ale nie wewnątrz nich). + if (e.key === "/" && !inField(e.target)) { + e.preventDefault(); + focusSearch(); + return; + } + + if (inField(e.target)) return; + + if (e.key === "?") { + e.preventDefault(); + openHelp(); + return; + } + + if (awaitingG) { + awaitingG = false; + clearTimeout(gTimer); + var url = routes[e.key.toLowerCase()]; + if (url) { + e.preventDefault(); + window.location.href = url; + } + return; + } + + if (e.key === "g") { + awaitingG = true; + gTimer = setTimeout(function () { + awaitingG = false; + }, 1200); + } + }); +})(); diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..eb05362 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/resources/css/app.css b/resources/css/app.css new file mode 100644 index 0000000..54b247e --- /dev/null +++ b/resources/css/app.css @@ -0,0 +1,9 @@ +@import 'tailwindcss'; + +@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; +@source '../../storage/framework/views/*.php'; + +@theme { + --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', + 'Segoe UI Symbol', 'Noto Color Emoji'; +} diff --git a/resources/js/app.js b/resources/js/app.js new file mode 100644 index 0000000..8337712 --- /dev/null +++ b/resources/js/app.js @@ -0,0 +1 @@ +// diff --git a/resources/views/admin/audit/index.blade.php b/resources/views/admin/audit/index.blade.php new file mode 100644 index 0000000..99e53d1 --- /dev/null +++ b/resources/views/admin/audit/index.blade.php @@ -0,0 +1,142 @@ +@extends('layouts.app') + +@section('title', __('audit.title') . ' - ' . __('nav.brand')) + +@section('content') +
+
+

{{ __('audit.heading') }}

+

{{ __('audit.subtitle') }}

+
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + {{ __('audit.filter.clear') }} +
+
+
+
+ +
+
+
+ + + + + + + + + + + + + @forelse ($entries as $e) + + + + + + + + + @if ($e->changes || $e->context || $e->target_dn || $e->ip) + + + + @endif + @empty + + @endforelse + +
{{ __('audit.col.when') }}{{ __('audit.col.actor') }}{{ __('audit.col.action') }}{{ __('audit.col.target') }}{{ __('audit.col.reason') }}
{{ optional($e->created_at)->format('Y-m-d H:i:s') ?? '—' }} + {{ $e->actor }} + @if ($e->actor_name && $e->actor_name !== $e->actor) +
{{ $e->actor_name }}
+ @endif +
{{ __('audit.actions.' . $e->action) }} + @if ($e->target) + {{ $e->target }} + @if ($e->target_type) ({{ __('audit.target_type.' . $e->target_type) }})@endif + @else + — + @endif + {{ $e->reason ?: '—' }} + @if ($e->changes || $e->context || $e->target_dn || $e->ip) + + @endif +
{{ __('common.no_items') }}
+
+ + @include('admin.partials.pagination-bar', ['paginator' => $entries]) +
+
+
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/computers/index.blade.php b/resources/views/admin/computers/index.blade.php new file mode 100644 index 0000000..e503bf8 --- /dev/null +++ b/resources/views/admin/computers/index.blade.php @@ -0,0 +1,277 @@ +@extends('layouts.app') + +@section('title', __('computers.title') . ' - ' . __('nav.brand')) + +@section('content') +
+
+
+

{{ __('computers.heading') }}

+

{{ __('computers.subtitle') }}

+
+ +
+ +
+
+
+
+ {{ __('computers.ou_tree_title') }} +
+
+ + +
+ +
+
+ + + +
+ +
+
+
+ +
+
+
+
+
+ + + @if ($inactiveOnly ?? false) + + {{ __('computers.filter_inactive') }} + + + @endif +
+ +
+ @if ($currentOu)@endif + @if ($allOu ?? false)@endif + + + + + +
+ + +
+
+
+
+
+ + @include('admin.computers.partials.results') +
+
+
+ +{{-- MODAL: masowa zmiana stanu kont komputerów --}} + + +{{-- MODAL: masowe przeniesienie komputerów do OU --}} + + +{{-- MODAL: szczegóły komputera (LAPS + BitLocker) --}} + +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/computers/partials/results.blade.php b/resources/views/admin/computers/partials/results.blade.php new file mode 100644 index 0000000..bfb7112 --- /dev/null +++ b/resources/views/admin/computers/partials/results.blade.php @@ -0,0 +1,57 @@ +{{-- Region wymieniany przez dynamiczne wyszukiwanie / paginację (bez przeładowania strony). --}} +
+
+
+
+ + + + + + + + + + + + + @forelse ($rows as $row) + + + + + + + + + @empty + + @endforelse + +
{{ __('computers.table.name') }}{{ __('computers.table.os') }}{{ __('computers.table.status') }}{{ __('computers.table.last_logon') }}{{ __('common.actions') }}
+
+ +
+
{{ $row['cn'] }}
+
{{ $row['dn'] }}
+
+
+
{{ $row['os'] ?? '—' }} {{ $row['os_version'] }}{{ $row['status_label'] }}{{ $row['last_logon']?->format('Y-m-d H:i') ?? '—' }} + +
+ @csrf + +
+
+ @csrf + +
+
{{ __('computers.empty') }}
+
+ + @include('admin.partials.pagination-bar', ['paginator' => $computers]) +
+
+
diff --git a/resources/views/admin/config/index.blade.php b/resources/views/admin/config/index.blade.php new file mode 100644 index 0000000..826c9fc --- /dev/null +++ b/resources/views/admin/config/index.blade.php @@ -0,0 +1,416 @@ +@extends('layouts.app') + +@section('title', __('config.title') . ' - ' . __('nav.brand')) + +@section('content') +
+
+
+

{{ __('config.heading') }}

+

{{ __('config.subtitle') }}

+
+ + {{ __('nav.about') }} + +
+ + + +
+ {{-- ================= ZAKŁADKA: OGÓLNE ================= --}} +
+
+
+
+
+
{{ __('config.general.reset_defaults_heading') }}
+
+ @csrf + @method('PUT') +
+ + +
+
+ + +
+ +
{{ __('config.general.password_policy_heading') }}
+
+ + +
{!! __('config.general.max_age_help') !!}
+
+ +
{{ __('config.general.generator_heading') }}
+
+
+ + +
+
+
+ + +
+
+
+
{!! __('config.general.generator_help') !!}
+ +
{{ __('config.general.encryption_heading') }}
+
+ + +
{!! __('config.general.encryption_help') !!}
+
+ +
{{ __('config.general.audit_heading') }}
+
+ + + +
{{ __('config.general.require_reason_help') }}
+
+ +

{!! __('config.general.columns_note') !!}

+ + +
+
+
+
+ +
+
+
+
{{ __('config.general.system_heading') }}
+ +
{{ __('config.general.ldap_from_env') }}
+
+
LDAP_HOSTS
+
{{ implode(', ', $ldapConnection['hosts'] ?? []) ?: '—' }}
+
LDAP_BASE_DN
+
{{ $ldapConnection['base_dn'] ?? '—' }}
+
LDAP_SSL / LDAP_TLS
+
{{ ($ldapConnection['encrypted'] ?? false) ? __('common.yes') : __('common.no') }}
+
LDAP_USERNAME
+
{{ $ldapConnection['username'] ?? '—' }}
+
+ +
{{ __('config.general.ou_defs_env') }}
+
+
LDAP_DEFAULT_USER_OU
+
{{ $ldapDefaults['user_ou'] ?? '—' }}
+
LDAP_DEFAULT_COMPUTER_OU
+
{{ $ldapDefaults['computer_ou'] ?? '—' }}
+
LDAP_DEFAULT_GROUPS_OU
+
{{ $ldapDefaults['groups_ou'] ?? '—' }}
+
+ +
+ + {!! __('config.general.env_notice') !!} +
+
+
+
+
+
+ + {{-- ================= ZAKŁADKA: ATRYBUTY ================= --}} +
+
+
+
+
+
{{ __('config.attributes.heading') }}
+

{!! __('config.attributes.intro') !!}

+
+ +
+ +
+ @csrf + @method('PUT') + +
+ + + + + + + + + + + + + + + @foreach ($attributes as $i => $attr) + @php($fixed = $attr['fixed'] ?? false) + {{-- $structural: pozycja wbudowana - klucz, atrybut AD, typ i usuwanie zablokowane (kategorię wciąż można zmienić) --}} + @php($structural = $attr['system'] || $attr['computed'] || $fixed) + + + + + + + + + + + @endforeach + +
{{ __('config.attributes.th_order') }}{{ __('config.attributes.th_key') }}{{ __('config.attributes.th_label') }}{{ __('config.attributes.th_ldap') }}{{ __('config.attributes.th_category') }}{{ __('config.attributes.th_column') }}{{ __('config.attributes.th_type') }}
+
+ + +
+
+ + @if ($attr['computed']) + {{ __('config.attributes.badge_computed') }} + @elseif ($attr['system']) + {{ __('config.attributes.badge_system') }} + @elseif ($fixed) + {{ __('config.attributes.badge_fixed') }} + @endif + + + + @if ($attr['computed']) + — + @else + + @endif + + @if ($attr['computed']) + — + @else + + @endif + + + + + + + @unless ($structural) + + + + @endunless +
+
+ +
+ +
+
+
+
+
+
+ + {{-- Szablon nowego wiersza atrybutu (klonowany przez JS). __I__ = indeks. --}} + +
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/dashboard/index.blade.php b/resources/views/admin/dashboard/index.blade.php new file mode 100644 index 0000000..6ee2d98 --- /dev/null +++ b/resources/views/admin/dashboard/index.blade.php @@ -0,0 +1,260 @@ +@extends('layouts.app') + +@section('title', __('dashboard.title') . ' - ' . __('nav.brand')) + +@section('content') +
+
+
+

{{ __('dashboard.heading') }}

+

+ {{ __('dashboard.subtitle', ['time' => $refreshed_at ?? '—']) }} + @if (!empty($refreshed_at_human)) + + {{ $refreshed_at_human }} + + @endif +

+
+
+ +
+ @csrf + +
+
+
+ + @if ($empty ?? false) +
+ +
{{ __('dashboard.empty') }}
+
+ @else + @php + $tiles = [ + [__('dashboard.tiles.users_total'), $stats['users_total'], 'people', 'primary', route('users.index', ['all_ou' => 1]), false], + [__('dashboard.tiles.users_disabled'), $stats['users_disabled'], 'slash-circle', 'secondary', route('users.index', ['all_ou' => 1, 'status' => 'disabled']), false], + [__('dashboard.tiles.users_locked'), $stats['users_locked'], 'lock', 'danger', route('users.index', ['all_ou' => 1, 'filter' => 'locked']), true], + [__('dashboard.tiles.pwd_never_expires'), $stats['pwd_never_expires'], 'shield-exclamation', 'warning', route('users.index', ['all_ou' => 1, 'filter' => 'pwd_never']), false], + [__('dashboard.tiles.pwd_expiring_7d'), $stats['pwd_expiring_7d'], 'clock-history', 'warning', route('users.index', ['all_ou' => 1, 'filter' => 'pwd_expiring']), false], + [__('dashboard.tiles.pwd_expired'), $stats['pwd_expired'], 'exclamation-octagon', 'danger', route('users.index', ['all_ou' => 1, 'filter' => 'pwd_expired']), true], + [__('dashboard.tiles.inactive_90d'), $stats['inactive_90d'], 'moon', 'secondary', route('users.index', ['all_ou' => 1, 'filter' => 'inactive']), false], + [__('dashboard.tiles.account_expired'), $stats['account_expired'], 'calendar-x', 'danger', route('users.index', ['all_ou' => 1, 'filter' => 'account_expired']), true], + [__('dashboard.tiles.users_no_manager'), $stats['users_no_manager'] ?? 0, 'diagram-2', 'secondary', route('users.index', ['all_ou' => 1, 'filter' => 'no_manager']), false], + [__('dashboard.tiles.computers_total'), $computers_total, 'pc-display', 'primary', route('computers.index', ['all_ou' => 1]), false], + [__('dashboard.tiles.computers_inactive_90d'), $stats['computers_inactive_90d'] ?? 0, 'pc-display-horizontal', 'secondary', route('computers.index', ['all_ou' => 1, 'inactive' => 1]), false], + [__('dashboard.tiles.groups_total'), $groups_total, 'diagram-3', 'primary', route('groups.index', ['all_ou' => 1]), false], + ]; + @endphp + + @if ($layout['tiles'] ?? true) +
+ @foreach ($tiles as [$label, $value, $icon, $color, $url, $alert]) + @php $isAlert = $alert && $value > 0; @endphp + + @endforeach +
+ @endif + + @if ($layout['pwd_age'] ?? true) + @php + $ageBuckets = [ + ['lt30', __('dashboard.pwd_age.lt30'), '#22c55e'], + ['d30_60', __('dashboard.pwd_age.d30_60'), '#84cc16'], + ['d60_90', __('dashboard.pwd_age.d60_90'), '#f59e0b'], + ['gt90', __('dashboard.pwd_age.gt90'), '#ef4444'], + ['unknown', __('dashboard.pwd_age.unknown'), '#94a3b8'], + ]; + $ageTotal = array_sum($pwd_age ?? []); + @endphp +
+
+
{{ __('dashboard.pwd_age.heading') }}
+ @if ($ageTotal === 0) +

{{ __('common.no_items') }}

+ @else +
+ @foreach ($ageBuckets as [$key, $lbl, $hex]) + @php $v = $pwd_age[$key] ?? 0; @endphp + @if ($v > 0) +
+ @endif + @endforeach +
+
+ @foreach ($ageBuckets as [$key, $lbl, $hex]) +
+
+ {{ number_format($pwd_age[$key] ?? 0, 0, ',', ' ') }} + + {{ $lbl }} +
+
+ @endforeach +
+ @endif +
+
+ @endif + + @php + $reportDefs = [ + 'r_expiring' => ['title' => __('dashboard.reports.expiring'), 'rows' => $expiring_soon, 'date' => 'date', 'date_label' => __('dashboard.reports.expiring_date'), 'link' => 'users', 'action' => 'reset'], + 'r_pwd_never' => ['title' => __('dashboard.reports.pwd_never'), 'rows' => $pwd_never_list ?? [], 'link' => 'users'], + 'r_locked' => ['title' => __('dashboard.reports.locked'), 'rows' => $locked_list ?? [], 'date' => 'since', 'date_label' => __('dashboard.reports.locked_since'), 'link' => 'users', 'action' => 'unlock'], + 'r_no_manager' => ['title' => __('dashboard.reports.no_manager'), 'rows' => $no_manager_list ?? [], 'link' => 'users'], + 'r_inactive' => ['title' => __('dashboard.reports.inactive'), 'rows' => $inactive, 'date' => 'last', 'date_label' => __('dashboard.reports.inactive_date'), 'link' => 'users'], + 'r_computers_inactive' => ['title' => __('dashboard.reports.computers_inactive'), 'rows' => $computers_inactive ?? [], 'date' => 'last', 'date_label' => __('dashboard.reports.computers_inactive_date'), 'link' => 'computers'], + 'r_recent' => ['title' => __('dashboard.reports.recent'), 'rows' => $recent, 'date' => 'created', 'date_label' => __('dashboard.reports.recent_date'), 'link' => 'users'], + ]; + $visibleReports = collect($layout['reports'] ?? []) + ->filter(fn ($b) => ($b['visible'] ?? false) && isset($reportDefs[$b['key']])) + ->pluck('key'); + @endphp +
+ @foreach ($visibleReports as $rk) + @php $rep = $reportDefs[$rk]; $isComputers = ($rep['link'] ?? 'users') === 'computers'; @endphp +
+
+
+
{{ $rep['title'] }} {{ count($rep['rows']) }}
+ @if (empty($rep['rows'])) +

{{ __('common.no_items') }}

+ @else +
+ + + + @isset($rep['date'])@endisset + @isset($rep['action'])@endisset + + + @foreach ($rep['rows'] as $r) + @php $rid = $isComputers ? ($r['cn'] ?? '') : ($r['sam'] ?? ''); @endphp + + + @isset($rep['date'])@endisset + @isset($rep['action']) + + @endisset + + @endforeach + +
{{ $isComputers ? __('dashboard.computer') : __('dashboard.user') }}{{ $rep['date_label'] }}
+ {{ $r['cn'] }} + @unless ($isComputers)
{{ $rid }}
@endunless +
{{ $r[$rep['date']] ?? '—' }} + @if ($rep['action'] === 'unlock') +
+ @csrf + +
+ @elseif ($rep['action'] === 'reset') + {{ __('dashboard.reports.reset') }} + @endif +
+
+ @endif +
+
+
+ @endforeach +
+ @endif + +

{{ __('dashboard.footnote', ['days' => $max_password_age_days]) }}

+ + {{-- Preferencje układu pulpitu (widoczność + kolejność, zapis w cookie) --}} + +
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/gpo/index.blade.php b/resources/views/admin/gpo/index.blade.php new file mode 100644 index 0000000..277e6ac --- /dev/null +++ b/resources/views/admin/gpo/index.blade.php @@ -0,0 +1,151 @@ +@extends('layouts.app') + +@section('title', __('gpo.title') . ' - ' . __('nav.brand')) + +@section('content') +
+
+
+

{{ __('gpo.heading') }}

+

{{ __('gpo.subtitle') }}

+
+
+ +
+
+
+
+
+ + +
+
+
+ +
+
+
+
+ + @include('admin.gpo.partials.results') + +
+
+
{{ __('gpo.rsop.heading') }}
+

{{ __('gpo.rsop.hint') }}

+
+ {{ __('gpo.rsop.ou_dn') }} + + +
+
+
+
+
+ +{{-- MODAL: szczegóły GPO --}} + +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/gpo/partials/results.blade.php b/resources/views/admin/gpo/partials/results.blade.php new file mode 100644 index 0000000..c3c7273 --- /dev/null +++ b/resources/views/admin/gpo/partials/results.blade.php @@ -0,0 +1,52 @@ +{{-- Region wymieniany przez dynamiczne wyszukiwanie / paginację (bez przeładowania strony). --}} +
+
+
+
+ + + + + + + + + + + + + @forelse ($rows as $row) + + + + + + + + + @empty + + @endforelse + +
{{ __('gpo.table.name') }}{{ __('gpo.table.version') }}{{ __('gpo.table.config') }}{{ __('gpo.table.linked') }}{{ __('gpo.table.changed') }}
+
{{ $row['name'] }}
+
{{ '{'.$row['guid'].'}' }}
+
{{ $row['version'] ?? '—' }} + @if ($row['user_settings_enabled']){{ __('gpo.user') }}@endif + @if ($row['computer_settings_enabled']){{ __('gpo.computer') }}@endif + @if (! $row['user_settings_enabled'] && ! $row['computer_settings_enabled']){{ __('gpo.config_disabled') }}@endif + + @forelse ($row['linked_ous'] as $ou) + {{ $ou }} + @empty + {{ __('gpo.no_links') }} + @endforelse + {{ $row['changed'] ?? '—' }} + +
{{ __('gpo.empty') }}
+
+ + @include('admin.partials.pagination-bar', ['paginator' => $gpos]) +
+
+
diff --git a/resources/views/admin/groups/index.blade.php b/resources/views/admin/groups/index.blade.php new file mode 100644 index 0000000..6c7a5fd --- /dev/null +++ b/resources/views/admin/groups/index.blade.php @@ -0,0 +1,232 @@ +@extends('layouts.app') + +@section('title', __('groups.title') . ' - ' . __('nav.brand')) + +@section('content') +
+
+
+

{{ __('groups.heading') }}

+

{{ __('groups.subtitle') }}

+
+ +
+ +
+
+
+
+ {{ __('groups.ou_tree_title') }} +
+
+ + +
+ +
+
+ + + +
+ +
+
+
+ +
+
+
+
+ @if (request()->query('ou'))@endif +
+
+ + @if ($allOu ?? false)@endif + +
+
+
+ +
+
+ +
+
+ +
+
+
+
+ + @include('admin.groups.partials.results') +
+
+
+ +{{-- MODAL: Dodaj grupę --}} + + +{{-- MODAL: Podgląd grupy / członkowie --}} + +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/groups/partials/results.blade.php b/resources/views/admin/groups/partials/results.blade.php new file mode 100644 index 0000000..0179eb9 --- /dev/null +++ b/resources/views/admin/groups/partials/results.blade.php @@ -0,0 +1,47 @@ +{{-- Region wymieniany przez dynamiczne wyszukiwanie / paginację (bez przeładowania strony). --}} +
+
+
+
+ + + + + + + + + + + + + @forelse ($rows as $row) + + + + + + + + + @empty + + @endforelse + +
{{ __('groups.table.name') }}{{ __('groups.table.type_scope') }}{{ __('groups.table.members') }}{{ __('groups.table.description') }}{{ __('groups.table.mail') }}{{ __('common.actions') }}
{{ $row['cn'] }} + + {{ $row['type_label'] }} / {{ $row['scope_label'] }} + + {{ $row['member_count'] }} {{ __('groups.members_count') }}{{ $row['description'] ?? '—' }}{{ $row['mail'] ?? '—' }} + +
+ @csrf @method('DELETE') + +
+
{{ __('groups.empty') }}
+
+ + @include('admin.partials.pagination-bar', ['paginator' => $groups]) +
+
+
diff --git a/resources/views/admin/org/index.blade.php b/resources/views/admin/org/index.blade.php new file mode 100644 index 0000000..2bbbfb8 --- /dev/null +++ b/resources/views/admin/org/index.blade.php @@ -0,0 +1,168 @@ +@extends('layouts.app') + +@section('title', __('org.title') . ' - ' . __('nav.brand')) + +@section('content') +
+
+
+

{{ __('org.heading') }}

+

{{ __('org.subtitle', ['time' => $refreshed_at]) }}

+
+
+ @if (count($flat) > 1) + + @endif +
+ + + + + + +
+
+ @csrf + +
+
+
+ + @if (count($flat) <= 1) +
+

{!! __('org.empty', ['field' => 'manager']) !!}

+
+ @else + +
+
+
+
+
+

{{ __('org.hint') }}

+ @endif +
+@endsection + +@push('scripts') +@if (count($flat) > 1) + + + + + +@endif +@endpush diff --git a/resources/views/admin/partials/pagination-bar.blade.php b/resources/views/admin/partials/pagination-bar.blade.php new file mode 100644 index 0000000..92d6fdb --- /dev/null +++ b/resources/views/admin/partials/pagination-bar.blade.php @@ -0,0 +1,23 @@ +{{-- + Wspólny pasek pod tabelą: po lewej podsumowanie zakresu + wybór liczby + wierszy na stronę, po prawej same przyciski paginacji + (widok vendor.pagination.buttons). Wymaga zmiennej $paginator + (LengthAwarePaginator). Zmiana "na stronę" przeładowuje stronę z parametrem + ?per_page= (obsługa w main.js -> .js-per-page). +--}} +@php($perPageOptions = [25, 50, 100, 200]) +
+
+ {{ __('common.pagination_summary', [ + 'first' => $paginator->firstItem() ?? 0, + 'last' => $paginator->lastItem() ?? 0, + 'total' => $paginator->total(), + ]) }} + +
+ {{ $paginator->links('vendor.pagination.buttons') }} +
diff --git a/resources/views/admin/partials/reason-field.blade.php b/resources/views/admin/partials/reason-field.blade.php new file mode 100644 index 0000000..5dd11ee --- /dev/null +++ b/resources/views/admin/partials/reason-field.blade.php @@ -0,0 +1,14 @@ +{{-- + Pole "powód" w modalach akcji zmieniających AD. Trafia do dziennika zmian. + Wymagane tylko przy akcjach niszczących, gdy w /settings włączono + "Wymagaj powodu". Przekaż $required (bool) i - dla akcji nieniszczących - + ustaw go na false niezależnie od $requireReason. +--}} +@php($reasonRequired = ($required ?? ($requireReason ?? false))) +
+ + +
diff --git a/resources/views/admin/users/_modals.blade.php b/resources/views/admin/users/_modals.blade.php new file mode 100644 index 0000000..65b9b4f --- /dev/null +++ b/resources/views/admin/users/_modals.blade.php @@ -0,0 +1,569 @@ + + +{{-- MODAL: Dodawanie nowego użytkownika --}} + + +{{-- MODAL: Reset hasła (pojedynczy użytkownik lub wsadowo) --}} + + +{{-- MODAL: Przeniesienie do innej OU (pojedynczo lub wsadowo) --}} + + +{{-- MODAL: Wygasanie hasła (pojedynczo lub wsadowo) --}} + + +{{-- MODAL: Włącz / wyłącz konto (pojedynczo lub wsadowo) --}} + + +{{-- MODAL: Dodaj / usuń zaznaczonych z grupy (tylko wsadowo) --}} + + +{{-- MODAL: Wymuś zmianę hasła przy następnym logowaniu (pojedynczo lub wsadowo) --}} + + +{{-- MODAL: Odblokuj konto (pojedynczo lub wsadowo) --}} + + +{{-- MODAL: Masowa edycja atrybutów (tylko wsadowo) --}} + + +{{-- MODAL: Offboarding (pojedynczo lub wsadowo) --}} + + +{{-- MODAL: Podgląd i edycja szczegółów użytkownika --}} + diff --git a/resources/views/admin/users/index.blade.php b/resources/views/admin/users/index.blade.php new file mode 100644 index 0000000..1b9172f --- /dev/null +++ b/resources/views/admin/users/index.blade.php @@ -0,0 +1,534 @@ +@extends('layouts.app') + +@section('title', __('users.title') . ' - ' . __('nav.brand')) + +@section('content') +
+
+
+

{{ __('users.heading') }}

+

{{ __('users.subtitle') }}

+
+
+ +
+
+
+
+ {{ __('users.ou_tree_title') }} +
+
+ + +
+ +
+
+ + + +
+ +
+
+
+ +
+
+
+
+ + +
+ + + + + + + + + + +
+ +
+
+ @if (!empty($attributeColumns)) + + @endif + + + {{ __('users.toolbar.export') }} + + + +
+ +
+ @if ($currentOu ?? null)@endif + @if ($allOu ?? false)@endif + @if ($filter ?? '')@endif + + + +
+
+
+
+ + @if ($filter ?? '') +
+ {{ __('users.dashboard_filter.active') }} + + {{ __('users.dashboard_filter.' . $filter) }} + + +
+ @endif + + @include('admin.users.partials.results') +
+
+
+ +@include('admin.users._modals') +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/users/partials/results.blade.php b/resources/views/admin/users/partials/results.blade.php new file mode 100644 index 0000000..290d21a --- /dev/null +++ b/resources/views/admin/users/partials/results.blade.php @@ -0,0 +1,87 @@ +{{-- Region wymieniany przez dynamiczne wyszukiwanie / paginację (bez przeładowania strony). --}} +
+
+
+ + + + + @foreach ($attributeColumns as $attrCol) + + @endforeach + + + + + @forelse ($rows as $row) + + + @foreach ($attributeColumns as $attrCol) + + @endforeach + + + @empty + + @endforelse + +
{{ $attrCol['label'] }}{{ __('common.actions') }}
+ @switch($attrCol['key']) + @case('status') + {{ $row['status_label'] }} + @break + @case('password_expiry') + @if ($row['password_never_expires']) + {{ __('users.table.password_never_expires') }} + @elseif ($row['password_expires_at']) + + @if($row['password_expires_soon'])@endif + {{ $row['password_expires_at']->diffForHumans(['parts' => 1]) }} ({{ $row['password_expires_at']->format('Y-m-d') }}) + + @else + — + @endif + @break + @case('account_expiry') + @if ($row['account_expires_at'] ?? null) + + {{ $row['account_expires_at']->format('Y-m-d') }} + + @else + {{ __('users.table.account_never_expires') }} + @endif + @break + @default + {{ ($row['attrs'][$attrCol['key']] ?? '') !== '' ? $row['attrs'][$attrCol['key']] : '—' }} + @endswitch + + +
{{ __('users.table.empty') }}
+
+ + @include('admin.partials.pagination-bar', ['paginator' => $users]) +
+
diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php new file mode 100644 index 0000000..7d2897e --- /dev/null +++ b/resources/views/auth/login.blade.php @@ -0,0 +1,109 @@ + + + + + + + + {{ __('auth.title') }} · {{ __('nav.brand') }} + + + + + + + + + + + + + + diff --git a/resources/views/components/ou-options.blade.php b/resources/views/components/ou-options.blade.php new file mode 100644 index 0000000..b7bee38 --- /dev/null +++ b/resources/views/components/ou-options.blade.php @@ -0,0 +1,8 @@ +@props(['nodes', 'depth' => 0]) + +@foreach ($nodes as $node) + + @if (! empty($node['children'])) + + @endif +@endforeach diff --git a/resources/views/components/ou-tree.blade.php b/resources/views/components/ou-tree.blade.php new file mode 100644 index 0000000..192b27c --- /dev/null +++ b/resources/views/components/ou-tree.blade.php @@ -0,0 +1,20 @@ +@props(['nodes', 'currentOu' => null, 'route']) + + diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php new file mode 100644 index 0000000..66632be --- /dev/null +++ b/resources/views/layouts/app.blade.php @@ -0,0 +1,197 @@ + + + + + + + + @yield('title', __('nav.brand')) + + + + + @stack('styles') + + + + @php($adAdmin = session('ad_admin')) + @php($locales = config('locales')) + + + + + + @if (session('status')) +
+ +
+ @endif + + @if (session('created_user')) + @php($cu = session('created_user')) +
+ +
+ @endif + + @if ($errors->any()) +
+ +
+ @endif + +
+ @yield('content') +
+ + @include('partials.footer') + + {{-- Skróty klawiaturowe --}} + + + + + + + @stack('scripts') + + diff --git a/resources/views/pages/about.blade.php b/resources/views/pages/about.blade.php new file mode 100644 index 0000000..ebad588 --- /dev/null +++ b/resources/views/pages/about.blade.php @@ -0,0 +1,61 @@ +@extends('layouts.app') + +@section('title', __('pages.about.title') . ' - ' . __('nav.brand')) + +@section('content') +
+
+

{{ __('pages.about.heading') }}

+

{{ __('pages.about.lead') }}

+
+ +
+
+ {{-- ================= O PROJEKCIE ================= --}} +
+
+

{{ __('pages.project.heading') }}

+

{{ __('pages.project.p1') }}

+

{{ __('pages.project.p2') }}

+

{{ __('pages.project.p3') }}

+ +
{{ __('pages.project.modules_heading') }}
+
    + @foreach (__('pages.project.modules') as $module) +
  • {{ $module }}
  • + @endforeach +
+
+
+ + {{-- ================= AUTOR ================= --}} +
+
+

{{ __('pages.author.heading') }}

+

{{ __('pages.author.p1') }}

+

{{ __('pages.author.copyright', ['year' => now()->year]) }}

+
+
+ + {{-- ================= KWESTIE PRAWNE ================= --}} +
+
+

{{ __('pages.legal.heading') }}

+ +
{{ __('pages.legal.license_heading') }}
+

{{ __('pages.legal.license_summary') }}

+ +
{{ __('pages.legal.license_full_heading') }}
+
{{ __('pages.legal.mit', ['year' => now()->year, 'holder' => __('pages.author.name')]) }}
+ +
{{ __('pages.legal.trademarks_heading') }}
+

{{ __('pages.legal.trademarks') }}

+ +
{{ __('pages.legal.privacy_heading') }}
+

{{ __('pages.legal.privacy') }}

+
+
+
+
+
+@endsection diff --git a/resources/views/partials/footer.blade.php b/resources/views/partials/footer.blade.php new file mode 100644 index 0000000..15b7e51 --- /dev/null +++ b/resources/views/partials/footer.blade.php @@ -0,0 +1,13 @@ + diff --git a/resources/views/vendor/pagination/buttons.blade.php b/resources/views/vendor/pagination/buttons.blade.php new file mode 100644 index 0000000..ca8823f --- /dev/null +++ b/resources/views/vendor/pagination/buttons.blade.php @@ -0,0 +1,49 @@ +{{-- + Paginacja "tylko przyciski" - bez wiersza "Showing X to Y of Z results" + z domyślnego widoku Laravela (pagination::bootstrap-5). Tekst podsumowania + po lewej stronie dostarcza sam widok listy (po polsku). +--}} +@if ($paginator->hasPages()) + +@endif diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php new file mode 100644 index 0000000..26e294a --- /dev/null +++ b/resources/views/welcome.blade.php @@ -0,0 +1,223 @@ + + + + + + + {{ config('app.name', 'Laravel') }} + + @fonts + + + @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) + @vite(['resources/css/app.css', 'resources/js/app.js']) + @else + + @endif + + +
+ @if (Route::has('login')) + + @endif +
+
+
+
+

Let's get started

+

With so many options available to you,
we suggest you start with the following:

+ + + +

+ v{{ app()->version() }} + + View changelog + + + + +

+
+
+ {{-- Laravel Logo --}} + + + + + + + + + + + {{-- 13 --}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + @if (Route::has('login')) + + @endif + + diff --git a/routes/console.php b/routes/console.php new file mode 100644 index 0000000..3c9adf1 --- /dev/null +++ b/routes/console.php @@ -0,0 +1,8 @@ +comment(Inspiring::quote()); +})->purpose('Display an inspiring quote'); diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..2de01d6 --- /dev/null +++ b/routes/web.php @@ -0,0 +1,143 @@ +group(function () { + Route::get('/login', [LoginController::class, 'show'])->name('login'); + Route::post('/login', [LoginController::class, 'login'])->name('login.attempt'); +}); + +Route::post('/logout', [LoginController::class, 'logout']) + ->middleware('auth:ldap') + ->name('logout'); + +// --- Interface language + public info page (no auth) ----------------------- +Route::get('/language/{locale}', [LocaleController::class, 'switch'])->name('locale.switch'); +Route::get('/about', [PageController::class, 'about'])->name('pages.about'); + +Route::redirect('/', '/dashboard'); + +// --- Dashboard / reports -------------------------------------------------- +Route::prefix('dashboard')->name('dashboard.')->middleware('auth:ldap')->group(function () { + Route::get('/', [DashboardController::class, 'index'])->name('index'); + Route::post('/refresh', [DashboardController::class, 'refresh'])->name('refresh'); + Route::post('/preferences', [DashboardController::class, 'savePreferences'])->name('preferences'); +}); + +// --- Org chart (manager -> directReports) -------------------------------- +Route::prefix('organisation')->name('org.')->middleware('auth:ldap')->group(function () { + Route::get('/', [OrgChartController::class, 'index'])->name('index'); + Route::post('/refresh', [OrgChartController::class, 'refresh'])->name('refresh'); +}); + +// --- Users -------------------------------------------------------------- +Route::prefix('users')->name('users.')->middleware('auth:ldap')->group(function () { + Route::get('/', [UserController::class, 'index'])->name('index'); + Route::post('/', [UserController::class, 'store'])->name('store'); + + // Stałe segmenty PRZED wzorcem /{samaccountname}, żeby nie zostały przez + // niego przechwycone (np. "bulk" jako samaccountname). + Route::get('/search', [UserController::class, 'search'])->name('search'); + Route::get('/export', [UserController::class, 'export'])->name('export'); + + Route::post('/bulk/reset-password', [UserController::class, 'bulkResetPassword'])->name('bulk-reset-password'); + Route::post('/bulk/move', [UserController::class, 'bulkMove'])->name('bulk-move'); + Route::post('/bulk/password-expiry', [UserController::class, 'bulkPasswordExpiry'])->name('bulk-password-expiry'); + Route::post('/bulk/set-enabled', [UserController::class, 'bulkSetEnabled'])->name('bulk-set-enabled'); + Route::post('/bulk/group', [UserController::class, 'bulkGroup'])->name('bulk-group'); + Route::post('/bulk/force-password-change', [UserController::class, 'bulkForcePasswordChange'])->name('bulk-force-password-change'); + Route::post('/bulk/unlock', [UserController::class, 'bulkUnlock'])->name('bulk-unlock'); + Route::post('/bulk/attributes', [UserController::class, 'bulkAttributes'])->name('bulk-attributes'); + Route::post('/bulk/offboard', [UserController::class, 'bulkOffboard'])->name('bulk-offboard'); + + Route::get('/{samaccountname}/structure', [UserController::class, 'structure'])->name('structure'); + Route::get('/{samaccountname}/template', [UserController::class, 'template'])->name('template'); + + Route::get('/{samaccountname}', [UserController::class, 'show'])->name('show'); + Route::put('/{samaccountname}', [UserController::class, 'update'])->name('update'); + Route::delete('/{samaccountname}', [UserController::class, 'destroy'])->name('destroy'); + Route::post('/{samaccountname}/reset-password', [UserController::class, 'resetPassword'])->name('reset-password'); + Route::post('/{samaccountname}/move', [UserController::class, 'move'])->name('move'); + Route::post('/{samaccountname}/password-expiry', [UserController::class, 'passwordExpiry'])->name('password-expiry'); + Route::post('/{samaccountname}/toggle-enabled', [UserController::class, 'toggleEnabled'])->name('toggle-enabled'); + Route::post('/{samaccountname}/set-enabled', [UserController::class, 'setEnabled'])->name('set-enabled'); + Route::post('/{samaccountname}/force-password-change', [UserController::class, 'forcePasswordChange'])->name('force-password-change'); + Route::post('/{samaccountname}/unlock', [UserController::class, 'unlock'])->name('unlock'); + Route::post('/{samaccountname}/offboard', [UserController::class, 'offboardOne'])->name('offboard-one'); + Route::post('/{samaccountname}/photo', [UserController::class, 'photo'])->name('photo'); + Route::post('/{samaccountname}/groups', [UserController::class, 'addGroup'])->name('add-group'); + Route::delete('/{samaccountname}/groups', [UserController::class, 'removeGroup'])->name('remove-group'); +}); + +// --- Computers -------------------------------------------------------------- +Route::prefix('computers')->name('computers.')->middleware('auth:ldap')->group(function () { + Route::get('/', [ComputerController::class, 'index'])->name('index'); + + // Stałe segmenty PRZED wzorcem /{cn}. + Route::post('/bulk/set-enabled', [ComputerController::class, 'bulkSetEnabled'])->name('bulk-set-enabled'); + Route::post('/bulk/move', [ComputerController::class, 'bulkMove'])->name('bulk-move'); + + Route::get('/{cn}', [ComputerController::class, 'show'])->name('show'); + Route::delete('/{cn}', [ComputerController::class, 'destroy'])->name('destroy'); + Route::post('/{cn}/toggle-enabled', [ComputerController::class, 'toggleEnabled'])->name('toggle-enabled'); + Route::post('/{cn}/reset-trust', [ComputerController::class, 'resetTrustAccount'])->name('reset-trust'); +}); + +// --- Groups ----------------------------------------------------------------- +Route::prefix('groups')->name('groups.')->middleware('auth:ldap')->group(function () { + Route::get('/', [GroupController::class, 'index'])->name('index'); + Route::post('/', [GroupController::class, 'store'])->name('store'); + Route::get('/search', [GroupController::class, 'search'])->name('search'); + Route::get('/{cn}', [GroupController::class, 'show'])->name('show'); + Route::put('/{cn}', [GroupController::class, 'update'])->name('update'); + Route::delete('/{cn}', [GroupController::class, 'destroy'])->name('destroy'); + Route::post('/{cn}/members', [GroupController::class, 'addMember'])->name('add-member'); + Route::delete('/{cn}/members', [GroupController::class, 'removeMember'])->name('remove-member'); +}); + +// --- Group Policy (read-only) -------------------------------------------- +Route::prefix('gpo')->name('gpo.')->middleware('auth:ldap')->group(function () { + Route::get('/', [GpoController::class, 'index'])->name('index'); + Route::get('/rsop', [GpoController::class, 'rsop'])->name('rsop'); + Route::get('/{guid}', [GpoController::class, 'show'])->name('show'); +}); + +// --- Audit log (read-only) --------------------------------------------- +Route::prefix('audit')->name('audit.')->middleware('auth:ldap')->group(function () { + Route::get('/', [AuditController::class, 'index'])->name('index'); +}); + +// --- Panel settings ------------------------------------------------------- +Route::prefix('settings')->name('config.')->middleware('auth:ldap')->group(function () { + Route::get('/', [ConfigController::class, 'index'])->name('index'); + Route::put('/', [ConfigController::class, 'update'])->name('update'); + Route::put('/attributes', [ConfigController::class, 'updateAttributes'])->name('attributes.update'); +}); diff --git a/storage/app/.gitignore b/storage/app/.gitignore new file mode 100644 index 0000000..fedb287 --- /dev/null +++ b/storage/app/.gitignore @@ -0,0 +1,4 @@ +* +!private/ +!public/ +!.gitignore diff --git a/storage/app/private/.gitignore b/storage/app/private/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/app/private/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/app/public/.gitignore b/storage/app/public/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/app/public/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/.gitignore b/storage/framework/.gitignore new file mode 100644 index 0000000..7a0b408 --- /dev/null +++ b/storage/framework/.gitignore @@ -0,0 +1,10 @@ +compiled.php +config.php +down +events.scanned.php +lsp-*.php +maintenance.php +routes.php +routes.scanned.php +schedule-* +services.json diff --git a/storage/framework/cache/.gitignore b/storage/framework/cache/.gitignore new file mode 100644 index 0000000..01e4a6c --- /dev/null +++ b/storage/framework/cache/.gitignore @@ -0,0 +1,3 @@ +* +!data/ +!.gitignore diff --git a/storage/framework/cache/data/.gitignore b/storage/framework/cache/data/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/cache/data/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/sessions/.gitignore b/storage/framework/sessions/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/sessions/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/testing/.gitignore b/storage/framework/testing/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/testing/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/views/.gitignore b/storage/framework/views/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/views/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/logs/.gitignore b/storage/logs/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/logs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/tests/Feature/AttributeConfigTest.php b/tests/Feature/AttributeConfigTest.php new file mode 100644 index 0000000..bff7595 --- /dev/null +++ b/tests/Feature/AttributeConfigTest.php @@ -0,0 +1,391 @@ +actingAsPanelAdmin(); + } + + public function test_config_page_renders_attributes_tab(): void + { + $this->get('/settings') + ->assertOk() + ->assertSee('pane-attributes') + ->assertSee('Dodaj atrybut'); + } + + public function test_password_max_age_defaults_to_42_and_is_configurable(): void + { + $this->assertSame(42, app(PanelSettingsService::class)->passwordMaxAgeDays()); + + $this->get('/settings')->assertOk()->assertSee('Maksymalny wiek hasła'); + + $this->put('/settings', [ + 'reset_password' => ['must_change_password' => '1', 'unlock_account' => '1'], + 'password_policy' => ['max_age_days' => '30'], + ])->assertRedirect(); + + $this->assertSame(30, app(PanelSettingsService::class)->passwordMaxAgeDays()); + } + + public function test_encryption_key_is_configurable_and_marks_cloud_attributes_encrypted(): void + { + $settings = app(PanelSettingsService::class); + + $this->assertSame('', $settings->encryptionKey()); + + // Domyślnie zaszyfrowane: msDS-cloudExtensionAttribute1/2. + $byKey = collect($settings->attributes())->keyBy('key'); + $this->assertTrue($byKey['microsoft']['encrypted']); + $this->assertTrue($byKey['google']['encrypted']); + $this->assertFalse($byKey['firstName']['encrypted']); + + $this->get('/settings')->assertOk()->assertSee('Klucz szyfrujący')->assertSee('Tekst szyfrowany'); + + $this->put('/settings', [ + 'reset_password' => ['must_change_password' => '1', 'unlock_account' => '1'], + 'password_policy' => ['max_age_days' => '42'], + 'encryption' => ['key' => 'Sekret!42'], + ])->assertRedirect(); + + $this->assertSame('Sekret!42', app(PanelSettingsService::class)->encryptionKey()); + } + + public function test_attribute_type_drives_editability_and_encryption(): void + { + $settings = app(PanelSettingsService::class); + + $payload = ['attributes' => []]; + foreach ($settings->attributes() as $i => $attr) { + if ($attr['computed']) { + $payload['attributes'][$i] = ['key' => $attr['key'], 'label' => $attr['label'], 'column' => $attr['column']]; + + continue; + } + $row = [ + 'key' => $attr['key'], + 'label' => $attr['label'], + 'ldap' => $attr['ldap'], + 'category' => $attr['category'], + 'type' => $attr['type'], + 'column' => $attr['column'], + ]; + if ($attr['key'] === 'microsoft') { + $row['type'] = 'text'; // z szyfrowanego na zwykły tekst + } + if ($attr['key'] === 'firstName') { + $row['type'] = 'encrypted'; // zwykły atrybut - typ respektowany + } + if ($attr['key'] === 'employeeNumber') { + $row['type'] = 'number'; // typ liczbowy + } + if ($attr['key'] === 'login') { + $row['type'] = 'text'; // sAMAccountName "na stałe" - ma być zignorowany + } + $payload['attributes'][$i] = $row; + } + + $this->put('/settings/attributes', $payload)->assertRedirect(); + + $byKey = collect(app(PanelSettingsService::class)->attributes())->keyBy('key'); + + $this->assertSame('text', $byKey['microsoft']['type']); + $this->assertFalse($byKey['microsoft']['encrypted']); + + $this->assertSame('encrypted', $byKey['firstName']['type']); + $this->assertTrue($byKey['firstName']['encrypted']); + $this->assertTrue($byKey['firstName']['editable']); + + $this->assertSame('number', $byKey['employeeNumber']['type']); + $this->assertSame('number', $byKey['employeeNumber']['input']); + $this->assertTrue($byKey['employeeNumber']['editable']); + + // sAMAccountName pozostaje systemowy niezależnie od przesłanego typu. + $this->assertSame('system', $byKey['login']['type']); + $this->assertFalse($byKey['login']['editable']); + $this->assertFalse($byKey['login']['encrypted']); + } + + public function test_preview_modal_only_exposes_editable_configured_attributes(): void + { + $settings = app(PanelSettingsService::class); + + $groups = $settings->editableAttributesByCategory(); + $keys = collect($groups)->flatten(1)->pluck('key')->all(); + + // Edytowalne pozycje (w tym wygasanie konta - typ "data"). + $this->assertContains('firstName', $keys); + $this->assertContains('email', $keys); + $this->assertContains('account_expiry', $keys); + + // Pozycje systemowe (sAMAccountName, DN) nigdy nie są edytowalne; + // kolumny wyliczane też nie są polami. + $this->assertNotContains('login', $keys); + $this->assertNotContains('distinguishedName', $keys); + $this->assertNotContains('status', $keys); + + // Kategoria "Konto AD" pojawia się dzięki edytowalnemu "wygasaniu konta". + $this->assertArrayHasKey('account', $groups); + } + + public function test_system_attributes_are_never_editable_even_if_flagged(): void + { + $this->put('/settings/attributes', [ + 'attributes' => [ + ['key' => 'distinguishedName', 'label' => 'DN', 'ldap' => 'distinguishedName', 'category' => 'account', 'type' => 'text', 'column' => '0'], + ['key' => 'login', 'label' => 'Login', 'ldap' => 'sAMAccountName', 'category' => 'account', 'type' => 'encrypted', 'column' => '1'], + ], + ])->assertRedirect(); + + $byKey = collect(app(PanelSettingsService::class)->attributes())->keyBy('key'); + + // Pozycje systemowe: mimo przesłania edytowalnego typu zostają systemowe. + $this->assertSame('system', $byKey['distinguishedName']['type']); + $this->assertSame('system', $byKey['login']['type']); + $this->assertFalse($byKey['distinguishedName']['editable']); + $this->assertFalse($byKey['login']['editable']); + $this->assertSame('sAMAccountName', $byKey['login']['ldap']); + } + + public function test_it_persists_added_edited_and_removed_attributes(): void + { + $settings = app(PanelSettingsService::class); + + // Wyjściowo: pełna lista domyślna (24 atrybuty AD + 2 kolumny wyliczane). + $this->assertCount(26, $settings->attributes()); + // Domyślnie widoczne kolumny listy - wszystkie zarządzane z panelu. + $this->assertEqualsCanonicalizing( + ['login', 'displayName', 'email', 'status', 'password_expiry'], + collect($settings->attributeColumns())->pluck('key')->all() + ); + + $payload = ['attributes' => []]; + foreach ($settings->attributes() as $i => $attr) { + if ($attr['key'] === 'google') { + continue; // usunięcie atrybutu niesystemowego + } + + if ($attr['computed']) { + // Formularz dla kolumn wyliczanych wysyła tylko klucz, etykietę i widoczność. + $payload['attributes'][$i] = [ + 'key' => $attr['key'], + 'label' => $attr['label'], + 'column' => $attr['key'] === 'password_expiry' ? false : true, + ]; + + continue; + } + + if ($attr['key'] === 'title') { + $attr['column'] = true; // pokaż jako kolumnę listy + $attr['category'] = 'address'; // przypnij do innej kategorii + } + + if ($attr['key'] === 'login') { + $attr['ldap'] = 'tampered'; // próba zmiany atrybutu systemowego + } + + $payload['attributes'][$i] = $attr; + } + + // Nowy, własny atrybut. + $payload['attributes'][] = [ + 'key' => 'badgeId', + 'label' => 'Numer identyfikatora', + 'ldap' => 'extensionAttribute9', + 'category' => 'hr', + 'editable' => true, + 'column' => true, + ]; + + $this->put('/settings/attributes', $payload) + ->assertRedirect(); + + $fresh = app(PanelSettingsService::class); + $byKey = collect($fresh->attributes())->keyBy('key'); + + // Dodany + $this->assertTrue($byKey->has('badgeId')); + $this->assertSame('extensionAttribute9', $byKey['badgeId']['ldap']); + + // Usunięty + $this->assertFalse($byKey->has('google')); + + // Zmiana kolumny + kategorii + $this->assertTrue($byKey['title']['column']); + $this->assertSame('address', $byKey['title']['category']); + + // Atrybut systemowy: nazwa atrybutu AD wymuszona z powrotem, nadal obecny + $this->assertSame('sAMAccountName', $byKey['login']['ldap']); + $this->assertTrue($byKey['login']['system']); + + // Kolumny wyliczane pozostają (nie da się ich usunąć); password_expiry + // wyłączony jako kolumna, doszły title + badgeId. + $this->assertEqualsCanonicalizing( + ['login', 'displayName', 'email', 'status', 'title', 'badgeId'], + collect($fresh->attributeColumns())->pluck('key')->all() + ); + + // Kolumny wyliczane nie są edytowalne, mają computed=true i nadal istnieją. + $this->assertTrue($byKey['password_expiry']['computed']); + $this->assertFalse($byKey['password_expiry']['editable']); + $this->assertFalse($byKey['password_expiry']['column']); + $this->assertTrue($byKey['status']['computed']); + $this->assertFalse($byKey->has('last_logon')); + } + + public function test_system_and_computed_attributes_cannot_be_deleted(): void + { + // Wysyłamy tylko jeden zwykły atrybut - reszta powinna zostać odtworzona. + $this->put('/settings/attributes', [ + 'attributes' => [ + ['key' => 'title', 'label' => 'Stanowisko', 'ldap' => 'title', 'category' => 'org', 'editable' => true, 'column' => false], + ], + ])->assertRedirect(); + + $keys = collect(app(PanelSettingsService::class)->attributes())->pluck('key'); + + foreach (PanelSettingsService::SYSTEM_ATTRIBUTE_KEYS as $systemKey) { + $this->assertTrue($keys->contains($systemKey), "Brakuje atrybutu systemowego: {$systemKey}"); + } + + foreach (PanelSettingsService::COMPUTED_ATTRIBUTE_KEYS as $computedKey) { + $this->assertTrue($keys->contains($computedKey), "Brakuje kolumny wyliczanej: {$computedKey}"); + } + + foreach (array_keys(PanelSettingsService::FIXED_ATTRIBUTE_KEYS) as $fixedKey) { + $this->assertTrue($keys->contains($fixedKey), "Brakuje pozycji „na stałe”: {$fixedKey}"); + } + } + + public function test_account_expiry_has_fixed_date_type_but_configurable_category(): void + { + // Domyślnie: pozycja "na stałe", typ "data" (edytowalna), nie wyliczana. + $byKey = collect(app(PanelSettingsService::class)->attributes())->keyBy('key'); + $this->assertTrue($byKey['account_expiry']['fixed']); + $this->assertFalse($byKey['account_expiry']['computed']); + $this->assertSame('date', $byKey['account_expiry']['type']); + $this->assertSame('date', $byKey['account_expiry']['input']); + $this->assertTrue($byKey['account_expiry']['editable']); + $this->assertSame('accountExpires', $byKey['account_expiry']['ldap']); + + // Zmieniamy kategorię; próba podmiany typu i atrybutu AD ma być zignorowana. + $this->put('/settings/attributes', [ + 'attributes' => [ + ['key' => 'account_expiry', 'label' => 'Wygasanie konta', 'ldap' => 'whenChanged', 'category' => 'hr', 'type' => 'encrypted', 'column' => '1'], + ], + ])->assertRedirect(); + + $byKey = collect(app(PanelSettingsService::class)->attributes())->keyBy('key'); + $this->assertSame('date', $byKey['account_expiry']['type']); + $this->assertTrue($byKey['account_expiry']['editable']); + $this->assertSame('hr', $byKey['account_expiry']['category']); + $this->assertTrue($byKey['account_expiry']['column']); + $this->assertSame('accountExpires', $byKey['account_expiry']['ldap']); + $this->assertFalse($byKey['account_expiry']['encrypted']); + + // Trafia do pól okna "Podgląd / Edycja". + $groups = app(PanelSettingsService::class)->editableAttributesByCategory(); + $keys = collect($groups)->flatten(1)->pluck('key')->all(); + $this->assertContains('account_expiry', $keys); + } + + public function test_attribute_validation_rules_persist_and_are_forced_empty_for_locked_entries(): void + { + $settings = app(PanelSettingsService::class); + + // Domyślnie puste. + $byKey = collect($settings->attributes())->keyBy('key'); + $this->assertSame('', $byKey['title']['regex']); + $this->assertFalse($byKey['title']['required']); + $this->assertSame('', $byKey['title']['help']); + + $payload = ['attributes' => []]; + foreach ($settings->attributes() as $i => $attr) { + if ($attr['computed']) { + $payload['attributes'][$i] = ['key' => $attr['key'], 'label' => $attr['label'], 'column' => $attr['column']]; + + continue; + } + $row = [ + 'key' => $attr['key'], + 'label' => $attr['label'], + 'ldap' => $attr['ldap'], + 'category' => $attr['category'], + 'type' => $attr['type'], + 'column' => $attr['column'], + ]; + if ($attr['key'] === 'employeeNumber') { + $row['regex'] = '/^\d{4}$/'; + $row['required'] = '1'; + $row['help'] = 'Cztery cyfry'; + } + if ($attr['key'] === 'login') { + // Pozycja systemowa - reguły mają zostać wyzerowane. + $row['regex'] = '/x/'; + $row['required'] = '1'; + $row['help'] = 'ignorowane'; + } + $payload['attributes'][$i] = $row; + } + + $this->put('/settings/attributes', $payload)->assertRedirect()->assertSessionHasNoErrors(); + + $byKey = collect(app(PanelSettingsService::class)->attributes())->keyBy('key'); + + $this->assertSame('/^\d{4}$/', $byKey['employeeNumber']['regex']); + $this->assertTrue($byKey['employeeNumber']['required']); + $this->assertSame('Cztery cyfry', $byKey['employeeNumber']['help']); + + // Pozycja systemowa: reguły wymuszone na puste. + $this->assertSame('', $byKey['login']['regex']); + $this->assertFalse($byKey['login']['required']); + $this->assertSame('', $byKey['login']['help']); + } + + public function test_invalid_validation_regex_is_rejected(): void + { + $this->put('/settings/attributes', [ + 'attributes' => [ + ['key' => 'title', 'label' => 'Stanowisko', 'ldap' => 'title', 'category' => 'org', 'type' => 'text', 'column' => '0', 'regex' => '/[unclosed/'], + ], + ])->assertSessionHasErrors('attributes.0.regex'); + } + + public function test_require_reason_setting_persists(): void + { + $this->assertFalse(app(PanelSettingsService::class)->requireReason()); + + $this->get('/settings')->assertOk()->assertSee(__('config.general.require_reason')); + + $this->put('/settings', [ + 'reset_password' => ['must_change_password' => '1', 'unlock_account' => '1'], + 'password_policy' => ['max_age_days' => '42'], + 'audit' => ['require_reason' => '1'], + ])->assertRedirect(); + + $this->assertTrue(app(PanelSettingsService::class)->requireReason()); + } + + public function test_general_config_no_longer_exposes_column_picker(): void + { + $this->get('/settings') + ->assertOk() + ->assertDontSee('users_table[columns]', escape: false) + ->assertDontSee('Domyślnie widoczne kolumny listy użytkowników'); + } +} diff --git a/tests/Feature/AuditLogTest.php b/tests/Feature/AuditLogTest.php new file mode 100644 index 0000000..0d94e3d --- /dev/null +++ b/tests/Feature/AuditLogTest.php @@ -0,0 +1,57 @@ +actingAsPanelAdmin()->put('/settings', [ + 'reset_password' => ['must_change_password' => '1', 'unlock_account' => '1'], + 'password_policy' => ['max_age_days' => '30'], + 'audit' => ['require_reason' => '1'], + ])->assertRedirect(); + + $entry = AuditLog::query()->where('action', 'config.settings')->latest('id')->first(); + + $this->assertNotNull($entry); + $this->assertSame('tester', $entry->actor); + $this->assertSame(30, $entry->context['max_age_days']); + $this->assertTrue($entry->context['require_reason']); + } + + public function test_attribute_config_change_is_recorded(): void + { + $this->actingAsPanelAdmin()->put('/settings/attributes', [ + 'attributes' => [ + ['key' => 'title', 'label' => 'Stanowisko', 'ldap' => 'title', 'category' => 'org', 'type' => 'text', 'column' => '0'], + ], + ])->assertRedirect(); + + $this->assertDatabaseHas('panel_audit', ['action' => 'config.attributes', 'actor' => 'tester']); + } + + public function test_audit_page_renders_and_filters_by_action(): void + { + AuditLog::create(['actor' => 'alice', 'actor_name' => 'Alice', 'action' => 'user.disable', 'target' => 'jkowalski', 'created_at' => now()]); + AuditLog::create(['actor' => 'bob', 'actor_name' => 'Bob', 'action' => 'user.move', 'target' => 'anowak', 'created_at' => now()]); + + $this->actingAsPanelAdmin()->get('/audit')->assertOk()->assertSee('jkowalski')->assertSee('anowak'); + + $this->actingAsPanelAdmin()->get('/audit?action=user.disable') + ->assertOk() + ->assertSee('jkowalski') + ->assertDontSee('anowak'); + } + + public function test_audit_page_requires_authentication(): void + { + $this->get('/audit')->assertRedirect('/login'); + } +} diff --git a/tests/Feature/Auth/LoginTest.php b/tests/Feature/Auth/LoginTest.php new file mode 100644 index 0000000..23befdd --- /dev/null +++ b/tests/Feature/Auth/LoginTest.php @@ -0,0 +1,50 @@ +get('/dashboard')->assertRedirect(route('login')); + $this->get('/users')->assertRedirect(route('login')); + $this->get('/settings')->assertRedirect(route('login')); + } + + public function test_login_screen_renders(): void + { + $this->get('/login') + ->assertOk() + ->assertSee('Zaloguj') + ->assertSee(__('auth.login_label')); + } + + public function test_authenticated_admin_is_redirected_away_from_login(): void + { + $this->actingAsPanelAdmin() + ->get('/login') + ->assertRedirect('/dashboard'); + } + + public function test_login_requires_credentials(): void + { + $this->from('/login') + ->post('/login', []) + ->assertRedirect('/login') + ->assertSessionHasErrors(['login', 'password']); + + $this->assertGuest('ldap'); + } + + public function test_logout_clears_the_session(): void + { + $this->actingAsPanelAdmin() + ->withSession(['ad_admin' => ['login' => 'tester']]) + ->post('/logout') + ->assertRedirect(route('login')); + + $this->assertGuest('ldap'); + } +} diff --git a/tests/Feature/DashboardTest.php b/tests/Feature/DashboardTest.php new file mode 100644 index 0000000..6cf2188 --- /dev/null +++ b/tests/Feature/DashboardTest.php @@ -0,0 +1,179 @@ +forget('dashboard:summary'); + Cache::store('file')->forget('dashboard:summary_ts'); + + parent::tearDown(); + } + + private function seedSummary(array $overrides = []): void + { + $payload = array_merge([ + 'stats' => [ + 'users_total' => 128, + 'users_disabled' => 4, + 'users_locked' => 2, + 'pwd_never_expires' => 6, + 'pwd_expiring_7d' => 3, + 'pwd_expired' => 5, + 'inactive_90d' => 9, + 'account_expired' => 1, + 'users_no_manager' => 7, + 'computers_inactive_90d' => 3, + ], + 'pwd_age' => ['lt30' => 40, 'd30_60' => 30, 'd60_90' => 20, 'gt90' => 30, 'unknown' => 8], + 'computers_total' => 44, + 'groups_total' => 17, + 'expiring_soon' => [['cn' => 'Jan Kowalski', 'sam' => 'jkowalski', 'date' => '2026-09-10']], + 'inactive' => [], + 'recent' => [], + 'pwd_never_list' => [['cn' => 'Anna Nowak', 'sam' => 'anowak']], + 'locked_list' => [['cn' => 'Piotr Lis', 'sam' => 'plis', 'since' => '2026-09-08 07:30']], + 'no_manager_list' => [['cn' => 'Ewa Mak', 'sam' => 'emak']], + 'computers_inactive' => [['cn' => 'WS-OLD-01', 'last' => '2026-01-02']], + 'max_password_age_days' => 42, + ], $overrides); + + Cache::store('file')->put('dashboard:summary', $payload, 300); + Cache::store('file')->put('dashboard:summary_ts', now()->subMinutes(2)->format('Y-m-d H:i:s'), 300); + } + + public function test_it_renders_clickable_and_alerting_tiles(): void + { + $this->seedSummary(); + + $res = $this->actingAsPanelAdmin()->get('/dashboard')->assertOk(); + + // Klikalne kafelki prowadzą do przefiltrowanej listy (href w HTML ma &, + // więc porównujemy z domyślnym escapowaniem oczekiwanego łańcucha). + $res->assertSee(route('users.index', ['all_ou' => 1, 'filter' => 'locked'])); + $res->assertSee(route('users.index', ['all_ou' => 1, 'filter' => 'pwd_expired'])); + $res->assertSee(route('users.index', ['all_ou' => 1, 'status' => 'disabled'])); + $res->assertSee(route('computers.index', ['all_ou' => 1])); + + // Kafelek "z problemem" (locked > 0) dostaje wyróżniającą klasę. + $res->assertSee('dashboard-tile-alert', escape: false); + + // Histogram wieku hasła i wiek danych. + $res->assertSee(__('dashboard.pwd_age.heading')); + $res->assertSee(__('dashboard.reports.expiring_date')); + } + + public function test_it_shows_a_global_empty_state_when_ldap_returned_nothing(): void + { + $this->seedSummary([ + 'stats' => [ + 'users_total' => 0, 'users_disabled' => 0, 'users_locked' => 0, + 'pwd_never_expires' => 0, 'pwd_expiring_7d' => 0, 'pwd_expired' => 0, + 'inactive_90d' => 0, 'account_expired' => 0, + ], + 'pwd_age' => ['lt30' => 0, 'd30_60' => 0, 'd60_90' => 0, 'gt90' => 0, 'unknown' => 0], + 'computers_total' => 0, + 'groups_total' => 0, + 'expiring_soon' => [], + ]); + + $this->actingAsPanelAdmin()->get('/dashboard') + ->assertOk() + ->assertSee(__('dashboard.empty')) + ->assertDontSee('dashboard-tile', escape: false); + } + + public function test_it_renders_the_new_signal_cards_and_actions(): void + { + $this->seedSummary(); + + $res = $this->actingAsPanelAdmin()->get('/dashboard')->assertOk(); + + // Nowe kafelki + ich linki do przefiltrowanych list. + $res->assertSee(route('users.index', ['all_ou' => 1, 'filter' => 'no_manager'])); + $res->assertSee(route('computers.index', ['all_ou' => 1, 'inactive' => 1])); + + // Nowe karty raportów. + $res->assertSee(__('dashboard.reports.pwd_never')); + $res->assertSee(__('dashboard.reports.locked')); + $res->assertSee(__('dashboard.reports.no_manager')); + $res->assertSee(__('dashboard.reports.computers_inactive')); + + // Wiersze z list. + $res->assertSee('anowak')->assertSee('plis')->assertSee('WS-OLD-01'); + + // Akcja "odblokuj" w karcie zablokowanych + "resetuj" w karcie wygasających. + $res->assertSee(route('users.unlock', 'plis')); + $res->assertSee(route('users.index', ['all_ou' => 1, 'q' => 'jkowalski', 'open' => 'reset'])); + } + + public function test_preferences_endpoint_stores_layout_cookie(): void + { + $this->actingAsPanelAdmin()->post('/dashboard/preferences', [ + 'section_tiles' => '0', + 'section_pwd_age' => '1', + 'report_order' => ['r_recent', 'r_expiring', 'r_locked'], + 'visible' => ['r_recent', 'r_expiring'], + ]) + ->assertRedirect(route('dashboard.index')) + ->assertCookie('dashboard_layout'); + } + + public function test_layout_cookie_hides_and_reorders_report_cards(): void + { + $this->seedSummary([ + 'recent' => [['cn' => 'Nowe Konto', 'sam' => 'nowe', 'created' => '2026-09-07']], + ]); + + $layout = json_encode([ + 'tiles' => false, + 'pwd_age' => true, + 'reports' => [ + ['key' => 'r_recent', 'visible' => true], + ['key' => 'r_expiring', 'visible' => true], + ['key' => 'r_locked', 'visible' => false], + ], + ]); + + $res = $this->actingAsPanelAdmin() + ->withoutMiddleware(EncryptCookies::class) + ->withUnencryptedCookie('dashboard_layout', $layout) + ->get('/dashboard') + ->assertOk(); + + // Ukryta sekcja kafelków. + $res->assertDontSee('dashboard-tile', escape: false); + // Ukryta karta "zablokowane" - jej wiersz i nagłówek kolumny nie występują + // (sama nazwa raportu jest jeszcze na liście w oknie preferencji). + $res->assertDontSee('Piotr Lis'); + $res->assertDontSee(__('dashboard.reports.locked_since')); + + // Karta "Nowe konta" przed "Hasła wygasające" - nagłówki kolumn dat + // występują tylko w kartach (nie w oknie preferencji). + $res->assertSeeInOrder([ + __('dashboard.reports.recent_date'), + __('dashboard.reports.expiring_date'), + ]); + } + + public function test_stale_cache_is_flagged(): void + { + $this->seedSummary(); + Cache::store('file')->put('dashboard:summary_ts', now()->subHour()->format('Y-m-d H:i:s'), 300); + + $this->actingAsPanelAdmin()->get('/dashboard') + ->assertOk() + ->assertSee('text-bg-warning', escape: false); + } +} diff --git a/tests/Feature/LocalizationTest.php b/tests/Feature/LocalizationTest.php new file mode 100644 index 0000000..1415a58 --- /dev/null +++ b/tests/Feature/LocalizationTest.php @@ -0,0 +1,53 @@ +get('/login') + ->assertOk() + ->assertSee('Zaloguj się kontem domenowym') + ->assertSee('Zaloguj'); + } + + public function test_login_screen_has_language_and_theme_switchers(): void + { + $this->get('/login') + ->assertOk() + ->assertSee('id="themeToggle"', escape: false) + ->assertSee(route('locale.switch', 'pl'), escape: false) + ->assertSee(route('locale.switch', 'en'), escape: false); + } + + public function test_switching_language_persists_and_translates_the_ui(): void + { + $this->get('/language/en')->assertRedirect(); + $this->assertSame('en', session('locale')); + + $this->get('/login') + ->assertOk() + ->assertSee('Sign in with your domain account') + ->assertDontSee('Zaloguj się kontem domenowym'); + + $this->get('/language/pl')->assertRedirect(); + $this->assertSame('pl', session('locale')); + + $this->get('/login')->assertSee('Zaloguj się kontem domenowym'); + } + + public function test_unknown_locale_is_rejected(): void + { + $this->get('/language/de')->assertNotFound(); + $this->get('/language/xx')->assertNotFound(); + } + + public function test_locale_switch_route_works_without_authentication(): void + { + $this->get('/language/en')->assertRedirect(); + $this->assertGuest('ldap'); + } +} diff --git a/tests/Feature/Pages/AboutPageTest.php b/tests/Feature/Pages/AboutPageTest.php new file mode 100644 index 0000000..4988898 --- /dev/null +++ b/tests/Feature/Pages/AboutPageTest.php @@ -0,0 +1,40 @@ +get('/about') + ->assertOk() + ->assertSee('id="o-projekcie"', escape: false) + ->assertSee('id="autor"', escape: false) + ->assertSee('id="kwestie-prawne"', escape: false); + } + + public function test_about_page_shows_author_and_mit_licence(): void + { + $this->get('/about') + ->assertOk() + ->assertSee('Kacper') + ->assertSee('MIT License') + ->assertSee('Copyright (c) '.now()->year.' Kacper') + ->assertSee('Active Directory'); // trademark notice + } + + public function test_about_page_follows_the_selected_language(): void + { + $this->get('/about')->assertSee('Kwestie prawne'); + + $this->get('/language/en')->assertRedirect(); + + $this->get('/about') + ->assertOk() + ->assertSee('Legal') + ->assertSee('Trademarks') + ->assertDontSee('Kwestie prawne'); + } +} diff --git a/tests/Feature/UserExportTest.php b/tests/Feature/UserExportTest.php new file mode 100644 index 0000000..32b697d --- /dev/null +++ b/tests/Feature/UserExportTest.php @@ -0,0 +1,15 @@ + ekran logowania). + $this->get('/users/export')->assertRedirect('/login'); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..869cf0d --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,25 @@ + 'tester', + 'cn' => 'Test Admin', + 'displayname' => 'Test Admin', + 'mail' => 'tester@example.com', + ], $attributes)); + + return $this->actingAs($user, 'ldap'); + } +} diff --git a/tests/Unit/AttributeCipherTest.php b/tests/Unit/AttributeCipherTest.php new file mode 100644 index 0000000..72b45ed --- /dev/null +++ b/tests/Unit/AttributeCipherTest.php @@ -0,0 +1,78 @@ +k; + } + }; + + return new AttributeCipher($settings); + } + + #[Test] + public function it_round_trips_a_value_when_a_key_is_configured(): void + { + $cipher = $this->cipher('TajneHaslo123'); + + $encrypted = $cipher->encrypt('p.zielinski.secret'); + + $this->assertNotSame('p.zielinski.secret', $encrypted); + $this->assertSame('p.zielinski.secret', $cipher->decrypt($encrypted)); + } + + #[Test] + public function it_does_not_re_encrypt_an_already_encrypted_value(): void + { + $cipher = $this->cipher('TajneHaslo123'); + $encrypted = $cipher->encrypt('abc'); + + $this->assertSame($encrypted, $cipher->encrypt($encrypted)); + } + + #[Test] + public function it_passes_values_through_unchanged_when_no_key_is_configured(): void + { + $cipher = $this->cipher(''); + + $this->assertSame('abc', $cipher->encrypt('abc')); + $this->assertSame('abc', $cipher->decrypt('abc')); + $this->assertFalse($cipher->isConfigured()); + } + + #[Test] + public function it_returns_the_raw_value_when_it_cannot_be_decrypted(): void + { + $cipher = $this->cipher('TajneHaslo123'); + + // Wartość nie w naszym formacie (np. wpisana ręcznie / inny klucz). + $this->assertSame('not-our-ciphertext', $cipher->decrypt('not-our-ciphertext')); + } + + #[Test] + public function null_and_empty_values_are_left_alone(): void + { + $cipher = $this->cipher('TajneHaslo123'); + + $this->assertNull($cipher->encrypt(null)); + $this->assertSame('', $cipher->encrypt('')); + $this->assertNull($cipher->decrypt(null)); + } +} diff --git a/tests/Unit/AuditLoggerTest.php b/tests/Unit/AuditLoggerTest.php new file mode 100644 index 0000000..e6b5ef3 --- /dev/null +++ b/tests/Unit/AuditLoggerTest.php @@ -0,0 +1,50 @@ +actingAsPanelAdmin(); + + app(AuditLogger::class)->log('user.disable', [ + 'target_type' => 'user', + 'target' => 'jkowalski', + 'reason' => 'porzucone konto', + ]); + + $entry = AuditLog::query()->latest('id')->first(); + + $this->assertSame('user.disable', $entry->action); + $this->assertSame('tester', $entry->actor); + $this->assertSame('jkowalski', $entry->target); + $this->assertSame('porzucone konto', $entry->reason); + $this->assertNotNull($entry->created_at); + } + + public function test_it_falls_back_to_system_actor_without_a_session(): void + { + app(AuditLogger::class)->log('config.settings'); + + $this->assertDatabaseHas('panel_audit', ['action' => 'config.settings', 'actor' => 'system']); + } + + public function test_a_write_failure_does_not_bubble_up(): void + { + // Tabela zniknęła - log nie może powstać, ale metoda nie może rzucić. + Schema::drop('panel_audit'); + + app(AuditLogger::class)->log('user.delete', ['target' => 'x']); + + $this->assertTrue(true); + } +} diff --git a/tests/Unit/LdapAttributeHelperTest.php b/tests/Unit/LdapAttributeHelperTest.php new file mode 100644 index 0000000..4ba08c6 --- /dev/null +++ b/tests/Unit/LdapAttributeHelperTest.php @@ -0,0 +1,43 @@ +assertInstanceOf(Carbon::class, $result); + $this->assertSame('2026-09-03 11:26:45', $result->format('Y-m-d H:i:s')); + } + + #[Test] + public function it_converts_raw_windows_filetime_strings(): void + { + // 133697178050000000 = 2024-09-02 02:30:05 UTC + $result = LdapAttributeHelper::windowsTimestampToCarbon('133697178050000000'); + + $this->assertInstanceOf(Carbon::class, $result); + $this->assertSame('2024-09-02', $result->format('Y-m-d')); + } + + #[Test] + public function it_returns_null_for_zero_and_never_values(): void + { + $this->assertNull(LdapAttributeHelper::windowsTimestampToCarbon('0')); + $this->assertNull(LdapAttributeHelper::windowsTimestampToCarbon('9223372036854775807')); + $this->assertNull(LdapAttributeHelper::windowsTimestampToCarbon('')); + $this->assertNull(LdapAttributeHelper::windowsTimestampToCarbon(null)); + } +} diff --git a/tests/Unit/MemberOfLoginGroupTest.php b/tests/Unit/MemberOfLoginGroupTest.php new file mode 100644 index 0000000..656a4fd --- /dev/null +++ b/tests/Unit/MemberOfLoginGroupTest.php @@ -0,0 +1,92 @@ +nested; + } + }; + } + + private function user(array $memberOf): User + { + return new User(['memberof' => $memberOf]); + } + + #[Test] + public function it_rejects_everyone_when_no_group_is_configured(): void + { + config(['ldap.auth.login_group' => '']); + + $this->assertFalse( + $this->rule()->passes($this->user(['CN=Anything,DC=corp,DC=example,DC=com'])) + ); + } + + #[Test] + public function it_accepts_direct_membership_by_dn_ignoring_case_and_spacing(): void + { + config(['ldap.auth.login_group' => 'CN=ADconsole,OU=Aplikacje,OU=Grupy,DC=example,DC=com']); + + $user = $this->user([ + 'CN=Domain Users,CN=Users,DC=example,DC=com', + 'cn=adconsole, ou=aplikacje, ou=grupy, dc=example, dc=com', + ]); + + $this->assertTrue($this->rule()->passes($user)); + } + + #[Test] + public function it_accepts_membership_configured_by_cn_only(): void + { + config(['ldap.auth.login_group' => 'ADconsole']); + + $user = $this->user(['CN=ADconsole,OU=Aplikacje,OU=Grupy,DC=example,DC=com']); + + $this->assertTrue($this->rule()->passes($user)); + } + + #[Test] + public function it_accepts_nested_membership(): void + { + config(['ldap.auth.login_group' => 'CN=ADconsole,OU=Aplikacje,OU=Grupy,DC=example,DC=com']); + + $user = $this->user(['CN=Helpdesk,OU=Grupy,DC=example,DC=com']); + + $rule = $this->rule(['CN=ADconsole,OU=Aplikacje,OU=Grupy,DC=example,DC=com']); + + $this->assertTrue($rule->passes($user)); + } + + #[Test] + public function it_rejects_a_user_who_is_not_a_member(): void + { + config(['ldap.auth.login_group' => 'CN=ADconsole,OU=Aplikacje,OU=Grupy,DC=example,DC=com']); + + $user = $this->user([ + 'CN=Domain Users,CN=Users,DC=example,DC=com', + 'CN=Helpdesk,OU=Grupy,DC=example,DC=com', + ]); + + $this->assertFalse($this->rule()->passes($user)); + } +} diff --git a/tests/Unit/OuTreeServiceTest.php b/tests/Unit/OuTreeServiceTest.php new file mode 100644 index 0000000..a8933ea --- /dev/null +++ b/tests/Unit/OuTreeServiceTest.php @@ -0,0 +1,197 @@ + 'OU=oddzial,DC=example,DC=com', + 'ldap.defaults.computer_ou' => 'OU=Komputery,DC=example,DC=com', + ]); + + $userOu = new class('OU=oddzial,DC=example,DC=com', 'oddzial') + { + public function __construct(public string $dn, public string $name) {} + + public function getDn(): string + { + return $this->dn; + } + + public function getFirstAttribute(string $attribute): ?string + { + return $attribute === 'ou' ? $this->name : null; + } + }; + + $nestedUserOu = new class('OU=Users,OU=oddzial,DC=example,DC=com', 'Users') + { + public function __construct(public string $dn, public string $name) {} + + public function getDn(): string + { + return $this->dn; + } + + public function getFirstAttribute(string $attribute): ?string + { + return $attribute === 'ou' ? $this->name : null; + } + }; + + $computerOu = new class('OU=Komputery,DC=example,DC=com', 'Komputery') + { + public function __construct(public string $dn, public string $name) {} + + public function getDn(): string + { + return $this->dn; + } + + public function getFirstAttribute(string $attribute): ?string + { + return $attribute === 'ou' ? $this->name : null; + } + }; + + $computerChild = new class('OU=Workstations,OU=Komputery,DC=example,DC=com', 'Workstations') + { + public function __construct(public string $dn, public string $name) {} + + public function getDn(): string + { + return $this->dn; + } + + public function getFirstAttribute(string $attribute): ?string + { + return $attribute === 'ou' ? $this->name : null; + } + }; + + $externalOu = new class('OU=External,DC=example,DC=com', 'External') + { + public function __construct(public string $dn, public string $name) {} + + public function getDn(): string + { + return $this->dn; + } + + public function getFirstAttribute(string $attribute): ?string + { + return $attribute === 'ou' ? $this->name : null; + } + }; + + $query = Mockery::mock(); + $query->shouldReceive('select')->with(['ou', 'distinguishedname'])->andReturnSelf(); + $query->shouldReceive('get')->twice()->andReturn([ + $userOu, + $nestedUserOu, + $computerOu, + $computerChild, + $externalOu, + ]); + + Mockery::mock('alias:\LdapRecord\Models\ActiveDirectory\OrganizationalUnit') + ->shouldReceive('query')->twice()->andReturn($query); + + $service = new OuTreeService; + + $userTree = $service->tree('users'); + $computerTree = $service->tree('computers'); + + $this->assertCount(1, $userTree); + $this->assertSame('oddzial', $userTree[0]['name']); + $this->assertCount(1, $userTree[0]['children']); + $this->assertSame('Users', $userTree[0]['children'][0]['name']); + + $this->assertCount(1, $computerTree); + $this->assertSame('Komputery', $computerTree[0]['name']); + $this->assertCount(1, $computerTree[0]['children']); + $this->assertSame('Workstations', $computerTree[0]['children'][0]['name']); + + Mockery::close(); + } + + #[Test] + public function it_counts_actual_users_inside_each_ou_subtree(): void + { + config([ + 'ldap.defaults.user_ou' => 'OU=Users,DC=example,DC=com', + 'ldap.defaults.computer_ou' => 'OU=Komputery,DC=example,DC=com', + 'ldap.defaults.groups_ou' => 'OU=Grupy,DC=example,DC=com', + ]); + + $rootOu = new class('OU=Users,DC=example,DC=com', 'Users') + { + public function __construct(public string $dn, public string $name) {} + + public function getDn(): string + { + return $this->dn; + } + + public function getFirstAttribute(string $attribute): ?string + { + return $attribute === 'ou' ? $this->name : null; + } + }; + + $childOu = new class('OU=Developers,OU=Users,DC=example,DC=com', 'Developers') + { + public function __construct(public string $dn, public string $name) {} + + public function getDn(): string + { + return $this->dn; + } + + public function getFirstAttribute(string $attribute): ?string + { + return $attribute === 'ou' ? $this->name : null; + } + }; + + $ouQuery = Mockery::mock(); + $ouQuery->shouldReceive('select')->with(['ou', 'distinguishedname'])->andReturnSelf(); + $ouQuery->shouldReceive('get')->once()->andReturn([$rootOu, $childOu]); + + Mockery::mock('alias:\LdapRecord\Models\ActiveDirectory\OrganizationalUnit') + ->shouldReceive('query')->once()->andReturn($ouQuery); + + // countObjectsInOu() zwraca liczbę obiektów w CAŁYM poddrzewie danego OU + // (zapytanie rekursywne). Liczba przy OU nadrzędnym NIE może być sumą + // z dziećmi - inaczej te same obiekty są liczone wielokrotnie + // (błąd: root pokazywał 313 zamiast 121). + $service = Mockery::mock(OuTreeService::class)->makePartial()->shouldAllowMockingProtectedMethods(); + $service->shouldReceive('countObjectsInOu')->with('OU=Users,DC=example,DC=com', 'users')->once()->andReturn(5); + $service->shouldReceive('countObjectsInOu')->with('OU=Developers,OU=Users,DC=example,DC=com', 'users')->once()->andReturn(2); + + $tree = $service->tree('users'); + + $this->assertCount(1, $tree); + $this->assertSame(5, $tree[0]['count'], 'Licznik OU nadrzędnego = liczba w całym poddrzewie, bez podwójnego sumowania.'); + $this->assertSame(2, $tree[0]['children'][0]['count']); + + Mockery::close(); + } +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..1fd66d5 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,24 @@ +import { defineConfig } from 'vite'; +import laravel from 'laravel-vite-plugin'; +import { bunny } from 'laravel-vite-plugin/fonts'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [ + laravel({ + input: ['resources/css/app.css', 'resources/js/app.js'], + refresh: true, + fonts: [ + bunny('Instrument Sans', { + weights: [400, 500, 600], + }), + ], + }), + tailwindcss(), + ], + server: { + watch: { + ignored: ['**/storage/framework/views/**'], + }, + }, +});