185 Commits

Author SHA1 Message Date
Sebastian a54fe7b239 chore(deps): update dependency phpunit/phpunit to v12.5.33
Build Test / build (pull_request) Successful in 32s
JS Unit Tests / test (pull_request) Successful in 31s
PHP Unit Tests / test (pull_request) Failing after 1m2s
PHP Integration Tests / Integration Tests (pull_request) Failing after 1m18s
2026-07-29 03:01:58 +00:00
Sebastian b8afd75b77 chore: remove dead code
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-28 13:22:08 -04:00
Sebastian eb99eb5a2e refactor: unified kernal logic clean up
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-27 08:54:37 -04:00
Sebastian 65c1b5fb75 refactor(kernel): unify HTTP and CLI application lifecycle
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-27 00:48:11 -04:00
Sebastian 98826143a8 fix: sub menu spacing
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-26 20:32:46 -04:00
Sebastian e47d0ed619 feat: improve menu section selector
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-26 18:20:40 -04:00
Sebastian 071c225b2d fix: remove broken layout test
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-26 16:35:26 -04:00
Sebastian b58f6e9479 fix: empty object
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-25 21:28:00 -04:00
Sebastian f958cdd6a3 fix: test server deploy script
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-25 20:41:01 -04:00
Sebastian 4ffbb906bf feat: system configuration example
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-25 20:28:14 -04:00
Sebastian 076f14831b feat: test server update script
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-25 20:17:18 -04:00
Sebastian 0b6c89bf27 Merge pull request 'chore(deps): update dependency eslint to v10.8.0' (#141) from renovate/eslint-monorepo into main
Reviewed-on: #141
2026-07-25 23:54:35 +00:00
Sebastian c982a9b6a7 Merge pull request 'chore(deps): update dependency sass to v1.102.0' (#142) from renovate/sass-1.x-lockfile into main
Reviewed-on: #142
2026-07-25 23:54:27 +00:00
Sebastian add3380393 chore(deps): update dependency sass to v1.102.0
JS Unit Tests / test (pull_request) Failing after 27s
Build Test / build (pull_request) Successful in 28s
PHP Unit Tests / test (pull_request) Failing after 1m16s
PHP Integration Tests / Integration Tests (pull_request) Successful in 2m48s
2026-07-25 03:03:03 +00:00
Sebastian 300bb98a49 chore(deps): update dependency eslint to v10.8.0
JS Unit Tests / test (pull_request) Failing after 40s
Build Test / build (pull_request) Successful in 41s
PHP Unit Tests / test (pull_request) Failing after 1m30s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m42s
2026-07-25 03:03:01 +00:00
Sebastian a723051d11 feat: console role management
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-24 21:58:16 -04:00
Sebastian bf14b72a03 Merge pull request 'fix(deps): update dependency pinia to v4' (#140) from renovate/pinia-4.x into main
Reviewed-on: #140
2026-07-25 00:18:01 +00:00
Sebastian a346195c93 fix(deps): update dependency pinia to v4
JS Unit Tests / test (pull_request) Failing after 21s
Build Test / build (pull_request) Successful in 23s
PHP Unit Tests / test (pull_request) Successful in 52s
PHP Integration Tests / Integration Tests (pull_request) Successful in 58s
2026-07-24 23:41:24 +00:00
Sebastian 34c68c9c2c Merge pull request 'chore(deps): update dependency vue-i18n to v11.4.7' (#133) from renovate/vue-i18n-11.x-lockfile into main
Reviewed-on: #133
2026-07-24 23:40:01 +00:00
Sebastian 48700bf35e Merge pull request 'chore(deps): update dependency vite to v8.1.5' (#132) from renovate/vite-8.x-lockfile into main
Reviewed-on: #132
2026-07-24 23:39:55 +00:00
Sebastian 5aab0a5722 Merge pull request 'chore(deps): update dependency sass to v1.101.7' (#131) from renovate/sass-1.x-lockfile into main
Reviewed-on: #131
2026-07-24 23:39:49 +00:00
Sebastian d723793b5c Merge pull request 'chore(deps): update dependency vuetify to v4.1.6' (#135) from renovate/vuetify-4.x-lockfile into main
Reviewed-on: #135
2026-07-24 23:39:42 +00:00
Sebastian 78677c5da9 Merge pull request 'chore(deps): update dependency vue-tsc to v3.3.8' (#134) from renovate/vue-language-tools-monorepo into main
Reviewed-on: #134
2026-07-24 23:39:34 +00:00
Sebastian 84eb2d3396 Merge pull request 'chore(deps): update dependency eslint-plugin-vue to v10.10.0' (#136) from renovate/eslint-plugin-vue-10.x-lockfile into main
Reviewed-on: #136
2026-07-24 23:39:26 +00:00
Sebastian caeb8b7e67 Merge pull request 'chore(deps): update dependency vue-router to v5.2.0' (#137) from renovate/vue-router-5.x-lockfile into main
Reviewed-on: #137
2026-07-24 23:39:18 +00:00
Sebastian d93a8cc441 Merge pull request 'chore(deps): update fontsource monorepo to v5.3.0' (#138) from renovate/fontsource-monorepo into main
Reviewed-on: #138
2026-07-24 23:39:11 +00:00
Sebastian 8fc000a9d1 Merge pull request 'chore(deps): update mongo docker tag to v8' (#139) from renovate/mongo-8.x into main
Reviewed-on: #139
2026-07-24 23:38:59 +00:00
Sebastian 64c490f98d chore(deps): update mongo docker tag to v8
Build Test / build (pull_request) Successful in 23s
JS Unit Tests / test (pull_request) Failing after 21s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m45s
PHP Unit Tests / test (pull_request) Successful in 1m31s
2026-07-24 22:37:26 +00:00
Sebastian ca1ec469d5 chore(deps): update fontsource monorepo to v5.3.0
Build Test / build (pull_request) Successful in 20s
JS Unit Tests / test (pull_request) Failing after 12s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m0s
PHP Unit Tests / test (pull_request) Successful in 1m30s
2026-07-24 22:37:24 +00:00
Sebastian 770edf1bb9 chore(deps): update dependency vue-router to v5.2.0
Build Test / build (pull_request) Successful in 25s
JS Unit Tests / test (pull_request) Failing after 24s
PHP Unit Tests / test (pull_request) Successful in 56s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m53s
2026-07-24 22:37:21 +00:00
Sebastian a4f2edd6c8 chore(deps): update dependency eslint-plugin-vue to v10.10.0
Build Test / build (pull_request) Successful in 26s
JS Unit Tests / test (pull_request) Failing after 27s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m6s
PHP Unit Tests / test (pull_request) Successful in 1m44s
2026-07-24 22:37:14 +00:00
Sebastian 8452694f76 chore(deps): update dependency vuetify to v4.1.6
JS Unit Tests / test (pull_request) Failing after 22s
Build Test / build (pull_request) Successful in 24s
PHP Unit Tests / test (pull_request) Successful in 1m19s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m37s
2026-07-24 22:36:57 +00:00
Sebastian c27597e4f9 chore(deps): update dependency vue-tsc to v3.3.8
Build Test / build (pull_request) Successful in 27s
JS Unit Tests / test (pull_request) Failing after 26s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m25s
PHP Unit Tests / test (pull_request) Successful in 2m5s
2026-07-24 22:36:54 +00:00
Sebastian 598f0653e3 chore(deps): update dependency vue-i18n to v11.4.7
JS Unit Tests / test (pull_request) Failing after 26s
Build Test / build (pull_request) Successful in 29s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m15s
PHP Unit Tests / test (pull_request) Successful in 1m42s
2026-07-24 22:36:52 +00:00
Sebastian 9f353797f0 chore(deps): update dependency vite to v8.1.5
Build Test / build (pull_request) Successful in 33s
JS Unit Tests / test (pull_request) Failing after 34s
PHP Unit Tests / test (pull_request) Successful in 1m25s
PHP Integration Tests / Integration Tests (pull_request) Successful in 2m20s
2026-07-24 22:36:49 +00:00
Sebastian bd26ccb2df chore(deps): update dependency sass to v1.101.7
JS Unit Tests / test (pull_request) Failing after 34s
Build Test / build (pull_request) Successful in 42s
PHP Unit Tests / test (pull_request) Successful in 2m4s
PHP Integration Tests / Integration Tests (pull_request) Successful in 2m5s
2026-07-24 22:36:45 +00:00
Sebastian 35b438389b Merge pull request 'chore(deps): update dependency @vitejs/plugin-vue to v6.0.8' (#129) from renovate/vitejs-plugin-vue-6.x-lockfile into main
Reviewed-on: #129
2026-07-24 03:27:19 +00:00
Sebastian 512a403a19 Merge pull request 'chore(deps): update dependency prettier to v3.9.6' (#130) from renovate/prettier-3.x-lockfile into main
Reviewed-on: #130
2026-07-24 03:27:13 +00:00
Sebastian dbb0d65d62 chore(deps): update dependency prettier to v3.9.6
Build Test / build (pull_request) Successful in 19s
JS Unit Tests / test (pull_request) Failing after 11s
PHP Integration Tests / Integration Tests (pull_request) Successful in 56s
PHP Unit Tests / test (pull_request) Successful in 1m25s
2026-07-24 03:16:46 +00:00
Sebastian e88f459a35 chore(deps): update dependency @vitejs/plugin-vue to v6.0.8
JS Unit Tests / test (pull_request) Failing after 36s
Build Test / build (pull_request) Successful in 38s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m29s
PHP Unit Tests / test (pull_request) Successful in 1m52s
2026-07-24 03:16:43 +00:00
Sebastian a6b9c7a30b Merge pull request 'chore(deps): update dependency eslint to v10.7.0' (#124) from renovate/eslint-monorepo into main
Reviewed-on: #124
2026-07-24 03:08:20 +00:00
Sebastian 4e79ff727f Merge pull request 'chore(deps): update dependency dompurify to v3.4.12' (#125) from renovate/dompurify-3.x-lockfile into main
Reviewed-on: #125
2026-07-24 03:08:12 +00:00
Sebastian 314e200be9 Merge pull request 'chore(deps): update dependency vuetify to v4.1.5' (#126) from renovate/vuetify-4.x-lockfile into main
Reviewed-on: #126
2026-07-24 03:08:05 +00:00
Sebastian 71aaf46cf9 Merge pull request 'chore(deps): update typescript-eslint monorepo to v8.65.0' (#127) from renovate/typescript-eslint-monorepo into main
Reviewed-on: #127
2026-07-24 03:07:59 +00:00
Sebastian e4f3ce9e79 Merge pull request 'chore: implement php test suites' (#128) from chore/php83-test-suites into main
Reviewed-on: #128
2026-07-24 03:07:51 +00:00
Sebastian b570150258 chore: implement php test suites
JS Unit Tests / test (pull_request) Failing after 43s
Build Test / build (pull_request) Successful in 44s
PHP Unit Tests / test (pull_request) Successful in 1m9s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m28s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-23 22:20:35 -04:00
Sebastian e81b848a02 feat: console authentication control
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-22 23:44:35 -04:00
Sebastian bb847922b2 chore(deps): update typescript-eslint monorepo to v8.65.0
PHP Unit Tests / test (pull_request) Failing after 11m16s
JS Unit Tests / test (pull_request) Failing after 11m32s
Build Test / build (pull_request) Failing after 11m42s
2026-07-21 03:02:24 +00:00
Sebastian 009cff4747 chore(deps): update dependency eslint to v10.7.0
PHP Unit Tests / test (pull_request) Failing after 11m46s
JS Unit Tests / test (pull_request) Failing after 11m48s
Build Test / build (pull_request) Failing after 11m57s
2026-07-21 03:02:18 +00:00
Sebastian 0072308c67 chore(deps): update dependency vuetify to v4.1.5
PHP Unit Tests / test (pull_request) Failing after 12m5s
JS Unit Tests / test (pull_request) Failing after 12m14s
Build Test / build (pull_request) Failing after 12m35s
2026-07-21 03:02:16 +00:00
Sebastian 9f92e20383 chore(deps): update dependency dompurify to v3.4.12
PHP Unit Tests / test (pull_request) Failing after 12m41s
JS Unit Tests / test (pull_request) Failing after 12m51s
Build Test / build (pull_request) Failing after 13m10s
2026-07-21 03:02:10 +00:00
Sebastian 44e4e91e34 feat: user management console commands
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-20 06:58:44 -04:00
Sebastian 05dd82a0c5 refactor: move module commands to subfolder
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-19 22:39:28 -04:00
Sebastian 934fb24217 feat: tenant management console commands
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-19 22:38:43 -04:00
Sebastian 2c640125e8 feat: system user
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-19 06:56:38 -04:00
Sebastian 49e9291214 fix: authenticate via code
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-18 21:28:14 -04:00
Sebastian 584edce5d6 fix: convert request parameters to variables
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-18 21:27:12 -04:00
Sebastian eda7e0e8cc fix(shared): hydrate enum members and coerce keys in collections
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-17 15:52:07 -04:00
Sebastian f9f824a2f0 fix(shared): deserialize into null-initialized typed properties
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-17 15:51:47 -04:00
Sebastian 27b973f3db fix(shared): remove optional-before-required constructor parameter
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-17 15:51:09 -04:00
Sebastian 28bc360106 refactor: chrono entity classes
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-17 15:43:23 -04:00
Sebastian 7e81e130c9 fix: pass asset request to server if does not exist on system
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-17 13:24:27 -04:00
Sebastian 532e099146 feat: make default theme graphite with system mode detection
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-10 18:01:20 -04:00
Sebastian 4e3ee9fb95 feat: vallarx
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-10 00:10:00 -04:00
Sebastian 9652fae814 fix(core): support fixed-height module layouts
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-09 21:20:30 -04:00
Sebastian 65d60008da fix: layout loading settings
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-09 20:47:30 -04:00
Sebastian 4ea9dc8908 Merge pull request 'chore(deps): update dependency prettier to v3.9.5' (#115) from renovate/prettier-3.x-lockfile into main
Reviewed-on: #115
2026-07-10 00:29:05 +00:00
Sebastian 161f2a6d07 Merge pull request 'chore(deps): update dependency sass to v1.101.0' (#116) from renovate/sass-1.x-lockfile into main
Reviewed-on: #116
2026-07-10 00:28:56 +00:00
Sebastian 0f7c5dc8b7 Merge pull request 'chore(deps): update dependency vite to v8.1.4' (#117) from renovate/vite-8.x-lockfile into main
Reviewed-on: #117
2026-07-10 00:28:48 +00:00
Sebastian 3cc54103dd Merge pull request 'chore(deps): update dependency vue-router to v5.1.0' (#118) from renovate/vue-router-5.x-lockfile into main
Reviewed-on: #118
2026-07-10 00:28:40 +00:00
Sebastian 03953191f2 Merge pull request 'chore(deps): update dependency vue-tsc to v3.3.7' (#119) from renovate/vue-language-tools-monorepo into main
Reviewed-on: #119
2026-07-10 00:28:32 +00:00
Sebastian f90fb9bda9 Merge pull request 'chore(deps): update dependency vuetify to v4.1.4' (#120) from renovate/vuetify-4.x-lockfile into main
Reviewed-on: #120
2026-07-10 00:28:24 +00:00
Sebastian 58285e81d2 Merge pull request 'chore(deps): update typescript-eslint monorepo to v8.63.0' (#121) from renovate/typescript-eslint-monorepo into main
Reviewed-on: #121
2026-07-10 00:27:58 +00:00
Sebastian cda8dc2bab Merge pull request 'chore(deps): update dependency sass-loader to v17' (#122) from renovate/sass-loader-17.x into main
Reviewed-on: #122
2026-07-10 00:27:47 +00:00
Sebastian 2a32db0629 Merge pull request 'chore(deps): update dependency eslint to v10.6.0' (#114) from renovate/eslint-monorepo into main
Reviewed-on: #114
2026-07-10 00:27:38 +00:00
Sebastian 971fda604e Merge pull request 'chore(deps): update dependency @types/node to v25.9.5' (#113) from renovate/node-25.x-lockfile into main
Reviewed-on: #113
2026-07-10 00:27:30 +00:00
Sebastian 7afa1241a3 chore(deps): update dependency sass-loader to v17
PHP Unit Tests / test (pull_request) Failing after 11m11s
JS Unit Tests / test (pull_request) Failing after 11m15s
Build Test / build (pull_request) Failing after 11m29s
2026-07-10 00:26:26 +00:00
Sebastian 686b6a2937 chore(deps): update typescript-eslint monorepo to v8.63.0
PHP Unit Tests / test (pull_request) Failing after 11m48s
JS Unit Tests / test (pull_request) Failing after 11m53s
Build Test / build (pull_request) Failing after 11m55s
2026-07-10 00:26:24 +00:00
Sebastian d86b6f7030 chore(deps): update dependency vuetify to v4.1.4
PHP Unit Tests / test (pull_request) Failing after 12m7s
JS Unit Tests / test (pull_request) Failing after 12m21s
Build Test / build (pull_request) Failing after 12m24s
2026-07-10 00:26:14 +00:00
Sebastian f859660ab0 chore(deps): update dependency vue-tsc to v3.3.7
PHP Unit Tests / test (pull_request) Failing after 12m45s
JS Unit Tests / test (pull_request) Failing after 12m51s
Build Test / build (pull_request) Failing after 13m2s
2026-07-10 00:26:09 +00:00
Sebastian c853fd3639 chore(deps): update dependency vue-router to v5.1.0
PHP Unit Tests / test (pull_request) Failing after 13m6s
JS Unit Tests / test (pull_request) Failing after 13m9s
Build Test / build (pull_request) Failing after 13m32s
2026-07-10 00:26:06 +00:00
Sebastian 392a745c09 chore(deps): update dependency vite to v8.1.4
PHP Unit Tests / test (pull_request) Failing after 13m39s
JS Unit Tests / test (pull_request) Failing after 13m53s
Build Test / build (pull_request) Failing after 14m19s
2026-07-10 00:26:02 +00:00
Sebastian 9749d793a8 chore(deps): update dependency sass to v1.101.0
PHP Unit Tests / test (pull_request) Failing after 14m21s
JS Unit Tests / test (pull_request) Failing after 14m23s
Build Test / build (pull_request) Failing after 14m26s
2026-07-10 00:25:54 +00:00
Sebastian ba42cf6e1e chore(deps): update dependency prettier to v3.9.5
Build Test / build (pull_request) Failing after 10m43s
PHP Unit Tests / test (pull_request) Failing after 14m33s
JS Unit Tests / test (pull_request) Failing after 14m35s
2026-07-10 00:25:53 +00:00
Sebastian 702d1dbc58 chore(deps): update dependency eslint to v10.6.0
JS Unit Tests / test (pull_request) Failing after 10m47s
Build Test / build (pull_request) Failing after 10m49s
PHP Unit Tests / test (pull_request) Failing after 15m45s
2026-07-10 00:25:51 +00:00
Sebastian f9670ec53d chore(deps): update dependency @types/node to v25.9.5
JS Unit Tests / test (pull_request) Failing after 10m53s
Build Test / build (pull_request) Failing after 10m55s
PHP Unit Tests / test (pull_request) Failing after 15m51s
2026-07-10 00:25:48 +00:00
Sebastian 9cbda1013b Merge pull request 'chore(deps): update dependency phpunit/phpunit to v11.5.56' (#111) from renovate/phpunit-phpunit-11.x-lockfile into main
Reviewed-on: #111
2026-07-10 00:23:53 +00:00
Sebastian 3a73756e9b Merge pull request 'chore(deps): update vitest monorepo to v4.1.10' (#112) from renovate/vitest-monorepo into main
Reviewed-on: #112
2026-07-10 00:23:20 +00:00
Sebastian ffed9a7b5e chore(deps): update vitest monorepo to v4.1.10
PHP Unit Tests / test (pull_request) Failing after 14m17s
JS Unit Tests / test (pull_request) Failing after 14m19s
Build Test / build (pull_request) Failing after 14m21s
2026-07-10 00:22:21 +00:00
Sebastian 6013df548e chore(deps): update dependency phpunit/phpunit to v11.5.56
PHP Unit Tests / test (pull_request) Failing after 14m27s
JS Unit Tests / test (pull_request) Failing after 14m29s
Build Test / build (pull_request) Failing after 14m31s
2026-07-10 00:22:13 +00:00
Sebastian 72e89fa724 fix(renovate): install in runner temp
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-09 20:04:12 -04:00
Sebastian 7c14d49ee6 Merge pull request 'chore(deps): update dependency symfony/console to v7.4.14' (#108) from renovate/symfony into main
Reviewed-on: #108
2026-07-09 23:51:18 +00:00
Sebastian 0b06b47f1f Merge pull request 'chore(deps): update dependency vite-plugin-static-copy to v4.1.1' (#109) from renovate/vite-plugin-static-copy-4.x-lockfile into main
Reviewed-on: #109
2026-07-09 23:51:05 +00:00
Sebastian 6d015ffce8 Merge pull request 'chore(deps): update vitest monorepo to v4.1.9' (#110) from renovate/vitest-monorepo into main
Reviewed-on: #110
2026-07-09 23:50:52 +00:00
Sebastian 29d4c9130f refactor: documents interfaces
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-09 19:31:09 -04:00
Sebastian 93d32d2a9f fix: theming
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-07 22:49:51 -04:00
Sebastian 5a729d851e refactor: cleanup translations
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-07 17:57:43 -04:00
Sebastian d92a0331a6 refactor: console messages
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-07 17:04:34 -04:00
Sebastian 0cc255a087 feat: localizations
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-07 16:56:58 -04:00
Sebastian 6931dc9ff7 fix: expired token issue
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-05 11:36:20 -04:00
Sebastian 8a3610c3b2 chore(deps): update vitest monorepo to v4.1.9
PHP Unit Tests / test (pull_request) Successful in 44s
JS Unit Tests / test (pull_request) Failing after 14m43s
Build Test / build (pull_request) Failing after 15m3s
2026-07-05 03:02:31 +00:00
Sebastian 0f05494cf3 chore(deps): update dependency vite-plugin-static-copy to v4.1.1
Build Test / build (pull_request) Successful in 13s
PHP Unit Tests / test (pull_request) Failing after 15m5s
JS Unit Tests / test (pull_request) Failing after 15m9s
2026-07-05 03:02:25 +00:00
Sebastian 502be95d7d feat: implement event end calculator
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-04 19:02:36 -04:00
Sebastian ef3d19db9a feat: implement now epoch
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-04 19:02:07 -04:00
Sebastian 58a9c4351d refactor: use epoch timestamp
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-04 18:21:10 -04:00
Sebastian fe78426a5d feat: add deserilazation
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-03 21:33:28 -04:00
Sebastian 06c1cb272f chore(deps): update dependency symfony/console to v7.4.14
Build Test / build (pull_request) Failing after 10m14s
PHP Unit Tests / test (pull_request) Failing after 14m56s
JS Unit Tests / test (pull_request) Failing after 14m58s
2026-07-02 03:02:40 +00:00
Sebastian 8f61c4d2d5 refactor: chrono objects
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-28 17:51:23 -04:00
Sebastian 319fcb6485 Merge pull request 'chore(deps): update dependency prettier to v3.8.4' (#107) from renovate/prettier-3.x-lockfile into main
Reviewed-on: #107
2026-06-28 14:42:46 +00:00
Sebastian 18478f9985 Merge pull request 'chore(deps): update dependency phpseclib/phpseclib to v3.0.55' (#106) from renovate/phpseclib-phpseclib-3.x-lockfile into main
Reviewed-on: #106
2026-06-28 14:42:36 +00:00
Sebastian 139b4df844 refactor: use seen instead of read
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-25 23:47:52 -04:00
Sebastian a7ad86860d refactor: use unified service provider desing
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-25 23:47:24 -04:00
Sebastian 6ca83a7dd7 refactor: use unified service provider desing
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-25 23:47:17 -04:00
Sebastian 6a3e1f309e chore(deps): update dependency prettier to v3.8.4
PHP Unit Tests / test (pull_request) Failing after 13m54s
JS Unit Tests / test (pull_request) Failing after 13m56s
Build Test / build (pull_request) Failing after 14m6s
2026-06-25 03:02:46 +00:00
Sebastian 4d4999602c chore(deps): update dependency phpseclib/phpseclib to v3.0.55
PHP Unit Tests / test (pull_request) Failing after 14m35s
JS Unit Tests / test (pull_request) Failing after 14m37s
Build Test / build (pull_request) Failing after 14m45s
2026-06-25 03:02:45 +00:00
Sebastian 87d6af93b7 Merge pull request 'chore(deps): update dependency dompurify to v3.4.11' (#104) from renovate/dompurify-3.x-lockfile into main
Reviewed-on: #104
2026-06-24 15:23:15 +00:00
Sebastian 2b44d395f0 Merge pull request 'chore(deps): update dependency eslint-plugin-vue to v10.9.2' (#105) from renovate/eslint-plugin-vue-10.x-lockfile into main
Reviewed-on: #105
2026-06-24 15:23:06 +00:00
Sebastian 8ca85e894d chore(deps): update dependency eslint-plugin-vue to v10.9.2
PHP Unit Tests / test (pull_request) Failing after 14m11s
JS Unit Tests / test (pull_request) Failing after 14m15s
Build Test / build (pull_request) Failing after 14m31s
2026-06-24 03:03:06 +00:00
Sebastian cc8482f309 chore(deps): update dependency dompurify to v3.4.11
Build Test / build (pull_request) Successful in 23s
PHP Unit Tests / test (pull_request) Failing after 14m42s
JS Unit Tests / test (pull_request) Failing after 14m44s
2026-06-24 03:03:02 +00:00
Sebastian 14f25c9a44 refactor: people entity properties objects
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-21 21:47:51 -04:00
Sebastian b6c3551d36 refactor: priority to rank
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-21 21:46:30 -04:00
Sebastian a6e2a447e5 refactor: people interfaces to unified design
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-20 19:04:39 -04:00
Sebastian 802849a60d refactor: docs
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-20 19:04:05 -04:00
Sebastian a3443b1e54 fix: size property constant
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-16 18:03:18 -04:00
Sebastian cededf82c3 Merge pull request 'refactor: object property names' (#103) from refactor/object-property-names into main
Reviewed-on: #103
2026-06-16 17:21:55 +00:00
Sebastian 388e3fb8b8 refactor: object property names
Build Test / build (pull_request) Successful in 14s
JS Unit Tests / test (pull_request) Successful in 13s
PHP Unit Tests / test (pull_request) Successful in 35s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-16 13:19:40 -04:00
Sebastian bc54ba5811 Merge pull request 'refactor: message object properties' (#102) from refactor/message-object-properties into main
Reviewed-on: #102
2026-06-16 17:17:02 +00:00
Sebastian 869f2e4fb4 refactor: message object properties
JS Unit Tests / test (pull_request) Successful in 23s
Build Test / build (pull_request) Successful in 26s
PHP Unit Tests / test (pull_request) Successful in 52s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-16 13:12:30 -04:00
Sebastian 6dadd13acf Merge pull request 'chore(deps): update dependency @vue/test-utils to v2.4.11' (#100) from renovate/vue-test-utils-2.x-lockfile into main
Renovate / renovate (push) Failing after 1m24s
Reviewed-on: #100
2026-06-06 04:08:49 +00:00
Sebastian 2d1ee97afe Merge pull request 'chore(deps): update dependency dompurify to v3.4.8' (#101) from renovate/dompurify-3.x-lockfile into main
Reviewed-on: #101
2026-06-06 04:08:34 +00:00
Sebastian 8432f5a823 chore(deps): update dependency dompurify to v3.4.8
JS Unit Tests / test (pull_request) Successful in 32s
Build Test / build (pull_request) Successful in 36s
PHP Unit Tests / test (pull_request) Successful in 1m0s
2026-06-05 03:05:06 +00:00
Sebastian 30cfd06594 chore(deps): update dependency @vue/test-utils to v2.4.11
Build Test / build (pull_request) Successful in 20s
JS Unit Tests / test (pull_request) Successful in 20s
PHP Unit Tests / test (pull_request) Successful in 58s
2026-06-05 03:05:04 +00:00
Sebastian 175981b5b2 Merge pull request 'chore(deps): update dependency dompurify to v3.4.5' (#96) from renovate/dompurify-3.x-lockfile into main
Renovate / renovate (push) Failing after 1m13s
Reviewed-on: #96
2026-05-29 03:06:09 +00:00
Sebastian 0e8c91e82d Merge pull request 'chore(deps): update dependency vite to v8.0.14' (#97) from renovate/vite-8.x-lockfile into main
Reviewed-on: #97
2026-05-29 03:05:59 +00:00
Sebastian 2fc9cbe033 Merge pull request 'chore(deps): update vitest monorepo to v4.1.7' (#98) from renovate/vitest-monorepo into main
Reviewed-on: #98
2026-05-29 03:05:49 +00:00
Sebastian 9deac1682e Merge pull request 'feat: mail entity download' (#99) from feat/mail-entity-download into main
Reviewed-on: #99
2026-05-29 03:05:38 +00:00
Sebastian 812a6680a8 feat: mail entity download
Build Test / build (pull_request) Successful in 20s
JS Unit Tests / test (pull_request) Successful in 18s
PHP Unit Tests / test (pull_request) Successful in 40s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-28 23:02:37 -04:00
Sebastian a7f57aecf7 chore(deps): update vitest monorepo to v4.1.7
JS Unit Tests / test (pull_request) Successful in 23s
Build Test / build (pull_request) Successful in 26s
PHP Unit Tests / test (pull_request) Successful in 47s
2026-05-26 03:04:32 +00:00
Sebastian 9a234fb758 chore(deps): update dependency vite to v8.0.14
Build Test / build (pull_request) Successful in 31s
JS Unit Tests / test (pull_request) Successful in 30s
PHP Unit Tests / test (pull_request) Successful in 49s
2026-05-24 03:04:36 +00:00
Sebastian 87940f8eaf chore(deps): update dependency dompurify to v3.4.5
Build Test / build (pull_request) Successful in 23s
JS Unit Tests / test (pull_request) Successful in 32s
PHP Unit Tests / test (pull_request) Successful in 1m6s
2026-05-24 03:04:30 +00:00
Sebastian 8d716e3c72 Merge pull request 'refactor: list and fetch' (#95) from refactor/list-and-fetch into main
Renovate / renovate (push) Failing after 1m55s
Reviewed-on: #95
2026-05-17 21:58:01 +00:00
Sebastian 4e1d6d65d0 refactor: list and fetch
Build Test / build (pull_request) Successful in 13s
JS Unit Tests / test (pull_request) Successful in 12s
PHP Unit Tests / test (pull_request) Successful in 33s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-17 17:57:44 -04:00
Sebastian a4a87dd82d Merge pull request 'chore(deps): update dependency eslint to v10.4.0' (#92) from renovate/eslint-monorepo into main
Renovate / renovate (push) Successful in 2m55s
Reviewed-on: #92
2026-05-16 04:16:19 +00:00
Sebastian f8fb84dd11 chore(deps): update dependency eslint to v10.4.0
JS Unit Tests / test (pull_request) Successful in 23s
Build Test / build (pull_request) Successful in 26s
PHP Unit Tests / test (pull_request) Successful in 1m4s
2026-05-16 03:02:33 +00:00
Sebastian b2a1a17acd Merge pull request 'chore: bump packages' (#91) from chore/bump-packages into main
Renovate / renovate (push) Successful in 2m33s
Reviewed-on: #91
2026-05-15 13:34:57 +00:00
Sebastian cf10ad0a50 chore: bump packages
Build Test / build (pull_request) Successful in 11s
JS Unit Tests / test (pull_request) Successful in 10s
PHP Unit Tests / test (pull_request) Successful in 40s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-15 09:33:59 -04:00
Sebastian 8bb57052cd Merge pull request 'chore(deps): update dependency @vitejs/plugin-vue to v6.0.7' (#90) from renovate/vitejs-plugin-vue-6.x into main
Reviewed-on: #90
2026-05-15 12:56:22 +00:00
Sebastian dd105767d4 chore(deps): update dependency @vitejs/plugin-vue to v6.0.7
JS Unit Tests / test (pull_request) Successful in 23s
Build Test / build (pull_request) Successful in 28s
PHP Unit Tests / test (pull_request) Successful in 1m2s
2026-05-15 12:55:34 +00:00
Sebastian a2e59ba731 Merge pull request 'chore(deps): update dependency vue-tsc to v3.2.9' (#84) from renovate/vue-tsc-3.x-lockfile into main
Reviewed-on: #84
2026-05-15 03:26:42 +00:00
Sebastian 8175146eda Merge pull request 'chore(deps): update vitest monorepo to v4.1.6' (#86) from renovate/vitest-monorepo into main
Reviewed-on: #86
2026-05-15 03:26:33 +00:00
Sebastian 70ab6d537b Merge pull request 'chore(deps): update dependency @types/node to v25.8.0' (#89) from renovate/node-25.x into main
Reviewed-on: #89
2026-05-15 03:26:24 +00:00
Sebastian a8e073e793 Merge pull request 'fix(deps): update dependency vuetify to v4.0.7' (#88) from renovate/vuetify-4.x into main
Reviewed-on: #88
2026-05-15 03:26:16 +00:00
Sebastian 16ec429347 Merge pull request 'chore(deps): update typescript-eslint monorepo to v8.59.3' (#85) from renovate/typescript-eslint-monorepo into main
Reviewed-on: #85
2026-05-15 03:26:09 +00:00
Sebastian 206d22ab1e Merge pull request 'chore(deps): update dependency vite to v8.0.13' (#83) from renovate/vite-8.x into main
Reviewed-on: #83
2026-05-15 03:25:59 +00:00
Sebastian 778cd47221 Merge pull request 'chore(deps): update dependency symfony/console to v7.4.11' (#82) from renovate/symfony into main
Reviewed-on: #82
2026-05-15 03:23:16 +00:00
Sebastian 08028b280b chore(deps): update dependency @types/node to v25.8.0
Build Test / build (pull_request) Successful in 20s
JS Unit Tests / test (pull_request) Successful in 20s
PHP Unit Tests / test (pull_request) Successful in 40s
2026-05-15 03:22:43 +00:00
Sebastian d6bbfb78ed fix(deps): update dependency vuetify to v4.0.7
Build Test / build (pull_request) Successful in 19s
JS Unit Tests / test (pull_request) Successful in 12s
PHP Unit Tests / test (pull_request) Successful in 51s
2026-05-15 03:22:32 +00:00
Sebastian d800ccb249 chore(deps): update vitest monorepo to v4.1.6
Build Test / build (pull_request) Successful in 22s
JS Unit Tests / test (pull_request) Successful in 18s
PHP Unit Tests / test (pull_request) Successful in 57s
2026-05-15 03:22:24 +00:00
Sebastian 9fb3a481c2 chore(deps): update typescript-eslint monorepo to v8.59.3
Build Test / build (pull_request) Successful in 21s
JS Unit Tests / test (pull_request) Successful in 21s
PHP Unit Tests / test (pull_request) Successful in 1m0s
2026-05-15 03:22:18 +00:00
Sebastian 67f261e9ab chore(deps): update dependency vue-tsc to v3.2.9
Build Test / build (pull_request) Successful in 20s
JS Unit Tests / test (pull_request) Successful in 20s
PHP Unit Tests / test (pull_request) Successful in 56s
2026-05-15 03:22:05 +00:00
Sebastian 24c9ddd010 chore(deps): update dependency vite to v8.0.13
JS Unit Tests / test (pull_request) Successful in 21s
Build Test / build (pull_request) Successful in 25s
PHP Unit Tests / test (pull_request) Successful in 54s
2026-05-15 03:22:02 +00:00
Sebastian f29694c9d6 chore(deps): update dependency symfony/console to v7.4.11
Build Test / build (pull_request) Successful in 26s
JS Unit Tests / test (pull_request) Successful in 25s
PHP Unit Tests / test (pull_request) Successful in 1m12s
2026-05-15 03:21:57 +00:00
Sebastian 06825f9d25 Merge pull request 'chore(deps): update dependency dompurify to v3.4.3' (#80) from renovate/dompurify-3.x-lockfile into main
Reviewed-on: #80
2026-05-15 03:12:18 +00:00
Sebastian 7fd98a1465 Merge pull request 'chore(deps): update dependency sass-loader to v16.0.8' (#81) from renovate/sass-loader-16.x into main
Reviewed-on: #81
2026-05-15 03:10:37 +00:00
Sebastian 1cfa18c8cf chore(deps): update dependency sass-loader to v16.0.8
Build Test / build (pull_request) Successful in 19s
JS Unit Tests / test (pull_request) Successful in 19s
PHP Unit Tests / test (pull_request) Successful in 38s
2026-05-15 03:09:48 +00:00
Sebastian 3da9fe6211 chore(deps): update dependency dompurify to v3.4.3
Build Test / build (pull_request) Successful in 21s
JS Unit Tests / test (pull_request) Successful in 22s
PHP Unit Tests / test (pull_request) Successful in 49s
2026-05-15 03:09:45 +00:00
Sebastian 858e9fddf0 Merge pull request 'refactor: mail interfaces' (#79) from refactor/mail-interfaces into main
Renovate / renovate (push) Has started running
Reviewed-on: #79
2026-05-15 02:56:07 +00:00
Sebastian f3c882454d refactor: mail interfaces
Build Test / build (pull_request) Successful in 12s
JS Unit Tests / test (pull_request) Successful in 11s
PHP Unit Tests / test (pull_request) Successful in 39s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-14 22:54:51 -04:00
Sebastian d6005246dc Merge pull request 'refactor: service interfaces' (#78) from refactor/service-interfaces into main
Renovate / renovate (push) Waiting to run
Reviewed-on: #78
2026-05-09 21:00:33 +00:00
Sebastian b8cda71c17 refactor: service interfaces
Build Test / build (pull_request) Successful in 14s
JS Unit Tests / test (pull_request) Successful in 14s
PHP Unit Tests / test (pull_request) Successful in 48s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-09 17:00:00 -04:00
Sebastian 4e7260ad86 Merge pull request 'refactor: entity move and delete' (#77) from refactor/entity-move-and-delete into main
Renovate / renovate (push) Failing after 1m56s
Reviewed-on: #77
2026-05-08 03:58:54 +00:00
Sebastian 69d6c7fd1a refactor: entity move and delete
Build Test / build (pull_request) Successful in 18s
JS Unit Tests / test (pull_request) Successful in 16s
PHP Unit Tests / test (pull_request) Successful in 41s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-07 23:58:36 -04:00
Sebastian 6d71bb49d5 Merge pull request 'chore: downgrade console and mongodb' (#76) from chore/downgrade-mongodb into main
Renovate / renovate (push) Failing after 1m46s
Reviewed-on: #76
2026-05-07 03:35:06 +00:00
Sebastian 19e49379f4 chore: downgrade console and mongodb
Build Test / build (pull_request) Successful in 24s
JS Unit Tests / test (pull_request) Successful in 23s
PHP Unit Tests / test (pull_request) Successful in 46s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-06 23:34:51 -04:00
Sebastian cce65c3e62 Merge pull request 'chore(deps): update dependency typescript to v6' (#73) from renovate/typescript-6.x into main
Reviewed-on: #73
2026-05-07 03:17:33 +00:00
Sebastian 757d69b803 Merge pull request 'chore(deps): update dependency mongodb/mongodb to v2.3.0' (#69) from renovate/mongodb-mongodb-2.x-lockfile into main
Reviewed-on: #69
2026-05-07 03:17:23 +00:00
Sebastian 745c2947c3 Merge pull request 'chore(deps): update eslint monorepo to v10 (major)' (#74) from renovate/major-eslint-monorepo into main
Reviewed-on: #74
2026-05-07 03:17:13 +00:00
Sebastian 2b05cd39ef chore(deps): update eslint monorepo to v10
Build Test / build (pull_request) Successful in 19s
JS Unit Tests / test (pull_request) Successful in 19s
PHP Unit Tests / test (pull_request) Successful in 36s
2026-05-07 03:15:20 +00:00
Sebastian bb10f48ded chore(deps): update dependency typescript to v6
Build Test / build (pull_request) Successful in 30s
JS Unit Tests / test (pull_request) Successful in 14s
PHP Unit Tests / test (pull_request) Successful in 1m16s
2026-05-07 03:15:17 +00:00
Sebastian aec0abaca6 chore(deps): update dependency mongodb/mongodb to v2.3.0
JS Unit Tests / test (pull_request) Successful in 31s
Build Test / build (pull_request) Successful in 35s
PHP Unit Tests / test (pull_request) Successful in 1m11s
2026-05-07 03:15:10 +00:00
Sebastian e0ec4acdd4 Merge pull request 'chore(deps): update dependency jsdom to v29' (#71) from renovate/jsdom-29.x into main
Reviewed-on: #71
2026-05-07 03:11:07 +00:00
Sebastian b0d8317f51 Merge pull request 'chore(deps): update dependency sass to v1.99.0' (#70) from renovate/sass-1.x into main
Reviewed-on: #70
2026-05-07 03:10:55 +00:00
Sebastian 3e859bac10 Merge pull request 'chore(deps): update dependency phpseclib/phpseclib to v3.0.52' (#58) from renovate/phpseclib-phpseclib-3.x-lockfile into main
Reviewed-on: #58
2026-05-07 03:10:28 +00:00
Sebastian 224d2a9eeb chore(deps): update dependency sass to v1.99.0
Build Test / build (pull_request) Successful in 25s
JS Unit Tests / test (pull_request) Successful in 22s
PHP Unit Tests / test (pull_request) Successful in 55s
2026-05-07 03:08:44 +00:00
Sebastian f98734938c chore(deps): update dependency phpseclib/phpseclib to v3.0.52
JS Unit Tests / test (pull_request) Successful in 22s
Build Test / build (pull_request) Successful in 24s
PHP Unit Tests / test (pull_request) Successful in 1m33s
2026-05-07 03:08:35 +00:00
363 changed files with 10941 additions and 5608 deletions
@@ -0,0 +1,50 @@
name: PHP Integration Tests
on:
pull_request:
workflow_dispatch:
jobs:
test:
name: Integration Tests
runs-on: ubuntu-latest
services:
mongo:
image: mongo:8
options: >-
--health-cmd "mongosh --quiet --eval \"db.adminCommand('ping')\""
--health-interval 5s
--health-timeout 5s
--health-retries 12
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Set up PHP
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1
with:
php-version: '8.5'
tools: composer:v2
extensions: ctype, iconv, mongodb
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Retrieve Server Install Action
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/action-server-install
ref: main
path: action-server-install
github-server-url: https://git.ktrix.dev
- name: Configure server settings
env:
DATABASE_URI: 'mongodb://mongo:27017/?tls=false'
DATABASE_NAME: 'ktrix_ci'
APP_ENVIRONMENT: 'test'
run: php action-server-install/scripts/configure-server.php config/system.php
- name: Run tests
run: composer test:integration
+8 -2
View File
@@ -25,11 +25,17 @@ jobs:
tools: composer:v2
- name: Install Renovate
run: npm install -g renovate
run: |
npm install --global --no-audit --fund=false \
--prefix "${{ runner.temp }}/renovate-npm" \
--cache "${{ runner.temp }}/renovate-npm-cache" \
renovate
"${{ runner.temp }}/renovate-npm/bin/renovate" --version
- name: Run Renovate
env:
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
RENOVATE_PLATFORM: gitea
RENOVATE_ENDPOINT: https://git.ktrix.dev/api/v1
run: renovate ${{ gitea.repository }}
run: |
"${{ runner.temp }}/renovate-npm/bin/renovate" ${{ gitea.repository }}
+2 -3
View File
@@ -14,11 +14,10 @@ node_modules/
# Backend development
/vendor/
/config/system.php
coverage/
.phpunit.cache
*.cache
.phpunit.coverage
.php-cs-fixer.cache
.phpstan.cache
.phpactor/
# Editors
+5 -5
View File
@@ -7,21 +7,21 @@
declare(strict_types=1);
use KTXC\Server;
use KTXC\Application;
if (!is_dir(dirname(__DIR__).'/vendor')) {
fwrite(STDERR, "Dependencies are missing. Run 'composer install' first.\n");
exit(1);
}
require_once dirname(__DIR__).'/vendor/autoload.php';
$composerLoader = require_once dirname(__DIR__).'/vendor/autoload.php';
try {
$server = new Server(dirname(__DIR__));
exit($server->runConsole());
$application = Application::create(dirname(__DIR__), $composerLoader);
exit($application->runConsole());
} catch (\Throwable $e) {
fwrite(STDERR, "Fatal error: {$e->getMessage()}\n");
if (isset($server) && $server->debug()) {
if (($application ?? null)?->debug()) {
fwrite(STDERR, $e->getTraceAsString()."\n");
}
exit(1);
+6 -5
View File
@@ -5,16 +5,16 @@
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.2",
"php": ">=8.3",
"ext-ctype": "*",
"ext-iconv": "*",
"mongodb/mongodb": "^2.1",
"php-di/php-di": "*",
"phpseclib/phpseclib": "^3.0",
"symfony/console": "^8.0"
"symfony/console": "^7.0"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
"phpunit/phpunit": "^12.5.31"
},
"config": {
"allow-plugins": {
@@ -39,7 +39,8 @@
],
"post-update-cmd": [
],
"test:unit": "phpunit --configuration tests/php/phpunit.unit.xml --colors=always --testdox",
"test:coverage": "XDEBUG_MODE=coverage phpunit --configuration tests/php/phpunit.unit.xml --coverage-html .phpunit.coverage --coverage-text"
"test:unit": "phpunit --configuration tests/php/phpunit.xml --testsuite \"Unit Tests\" --colors=always --testdox",
"test:integration": "phpunit --configuration tests/php/phpunit.xml --testsuite \"Integration Tests\" --colors=always --testdox",
"test:coverage": "XDEBUG_MODE=coverage phpunit --configuration tests/php/phpunit.xml --testsuite \"Unit Tests\" --coverage-html .phpunit.coverage --coverage-text"
}
}
Generated
+280 -372
View File
File diff suppressed because it is too large Load Diff
@@ -2,14 +2,14 @@
return [
// Application Configuration
'name' => 'Ktrix',
'name' => 'Application Name',
'environment' => 'dev',
'debug' => true,
// Database Configuration
'database' => [
// MongoDB connection URI (include credentials if needed)
'uri' => 'mongodb://ktrix:ktrix@127.0.0.1:27017/?authSource=ktrix&tls=false',
'database' => 'ktrix',
'uri' => 'mongodb://username:password@database-host:27017/?authSource=database&tls=true',
'database' => 'database',
// optional driver options
'options' => [],
'driverOptions' => [],
@@ -65,13 +65,14 @@ return [
'channel' => 'app',
// ── syslog driver options ──────────────────────────────────────────────
// Identity tag passed to openlog(); visible in /var/logs/syslog and journalctl -t ktrix
'ident' => 'ktrix',
// Identity tag passed to openlog(); visible in syslog and journalctl.
'ident' => 'application',
// openlog() facility constant. Common values: LOG_USER, LOG_LOCAL0 … LOG_LOCAL7
'facility' => LOG_USER,
],
// Security Configuration
'security.salt' => 'a5418ed8c120b9d12c793ccea10571b74d0dcd4a4db7ca2f75e80fbdafb2bd9b',
// Generate a unique value for each deployment, for example: openssl rand -hex 32
'security.salt' => 'replace-with-a-random-secret',
];
+24
View File
@@ -0,0 +1,24 @@
{
"header": {
"searchPlaceholder": "Hier suchen.."
},
"systemMenu": {
"administration": "Administration",
"applications": "Anwendungen",
"personalSettings": "Persönliche Einstellungen",
"toggleAdmin": "Admin",
"toggleApps": "Apps",
"toggleSettings": "Einstellungen"
},
"notifications": {
"markAllRead": "Alle als gelesen markieren",
"title": "Benachrichtigungen",
"viewAll": "Alle anzeigen"
},
"userMenu": {
"darkMode": "Dunkles Design",
"lightMode": "Helles Design",
"logout": "Abmelden",
"settings": "Einstellungen"
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"systemMenu": {
"adminSettings": "System",
"apps": "Applications",
"personalSettings": "Settings",
"userSettings": "Settings"
},
"userMenu": {
"darkMode": "Dark Mode",
"lightMode": "Light Mode",
"logout": "Logout",
"settings": "Settings",
"systemMode": "System Mode"
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"userMenu": {
"darkMode": "Dark Mode",
"lightMode": "Light Mode",
"systemMode": "System Mode"
}
}
+154
View File
@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace KTXC;
use Composer\Autoload\ClassLoader;
use KTXC\Application\KernelOptions;
use KTXC\Application\ProjectPaths;
use KTXC\Http\Request\Request;
use KTXC\Http\Response\Response;
use KTXC\Module\ModuleAutoloader;
use KTXC\Runtime\Console\ConsoleRuntime;
use KTXC\Runtime\Http\HttpRuntime;
use Psr\Container\ContainerInterface;
final class Application
{
private readonly ProjectPaths $paths;
private readonly array $config;
private readonly Kernel $kernel;
private readonly HttpRuntime $http;
private readonly ConsoleRuntime $console;
public function __construct(
string $projectDir,
?ClassLoader $composerLoader = null,
?string $environment = null,
?bool $debug = null,
) {
$this->paths = ProjectPaths::resolve($projectDir);
$this->config = $this->loadConfig();
$environment ??= $this->config['environment'] ?? 'prod';
$debug ??= (bool) ($this->config['debug'] ?? false);
$this->kernel = new Kernel(
new KernelOptions(
$this->paths,
$environment,
$debug,
),
$this->config,
);
(new ModuleAutoloader(
$this->moduleDir(),
$composerLoader,
))->register();
$this->http = new HttpRuntime($this->kernel, $debug);
$this->console = new ConsoleRuntime($this->kernel);
}
public static function create(
string $projectDir,
?ClassLoader $composerLoader = null,
?string $environment = null,
?bool $debug = null,
): self {
return new self($projectDir, $composerLoader, $environment, $debug);
}
public function runHttp(): void
{
try {
$this->http->run();
} finally {
$this->kernel->shutdown();
}
}
public function runHttpRequest(Request $request): Response
{
return $this->http->run($request, send: false);
}
public function runConsole(): int
{
try {
return $this->console->run();
} finally {
$this->kernel->shutdown();
}
}
public function shutdown(): void
{
$this->kernel->shutdown();
}
public function kernel(): KernelInterface
{
return $this->kernel;
}
public function container(): ContainerInterface
{
return $this->kernel->container();
}
public function environment(): string
{
return $this->kernel->environment();
}
public function debug(): bool
{
return $this->kernel->debug();
}
public function projectDir(): string
{
return $this->paths->project;
}
public function moduleDir(): string
{
return $this->paths->modules();
}
public function config(?string $key = null, mixed $default = null): mixed
{
if ($key === null) {
return $this->config;
}
$value = $this->config;
foreach (explode('.', $key) as $part) {
if (!is_array($value) || !array_key_exists($part, $value)) {
return $default;
}
$value = $value[$part];
}
return $value;
}
private function loadConfig(): array
{
$path = $this->paths->configuration() . '/system.php';
if (!is_file($path)) {
return [];
}
$config = require $path;
if (!is_array($config)) {
throw new \RuntimeException("Configuration file must return an array: {$path}");
}
return $config;
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace KTXC\Application\Execution;
final readonly class ExecutionContext
{
public function __construct(
public RuntimeType $runtime,
public string $executionId,
public string $correlationId,
public ?string $causationId = null,
public ?string $requestId = null,
public ?string $operationName = null,
public array $traceMetadata = [],
) {
}
public static function fromDescriptor(ExecutionDescriptor $descriptor): self
{
return new self(
$descriptor->runtime,
$descriptor->executionId,
$descriptor->correlationId,
operationName: $descriptor->operationName,
);
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace KTXC\Application\Execution;
final readonly class ExecutionDescriptor
{
public function __construct(
public RuntimeType $runtime,
public string $executionId,
public string $correlationId,
public ?string $operationName = null,
) {
}
public static function http(?string $operationName = null): self
{
$id = self::id();
return new self(RuntimeType::HTTP, $id, $id, $operationName);
}
public static function cli(?string $operationName = null): self
{
$id = self::id();
return new self(RuntimeType::CLI, $id, $id, $operationName);
}
private static function id(): string
{
return bin2hex(random_bytes(16));
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace KTXC\Application\Execution;
final readonly class ExecutionOutcome
{
private function __construct(
public bool $successful,
public mixed $result = null,
public ?\Throwable $error = null,
) {
}
public static function success(mixed $result = null): self
{
return new self(true, $result);
}
public static function failure(\Throwable $error, mixed $result = null): self
{
return new self(false, $result, $error);
}
public static function incomplete(): self
{
return new self(false);
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace KTXC\Application\Execution;
use KTXC\KernelInterface;
final readonly class ExecutionRunner implements ExecutionRunnerInterface
{
public function __construct(
private KernelInterface $kernel,
) {
}
public function execute(
ExecutionDescriptor $descriptor,
callable $execution,
?callable $failure = null,
): mixed {
$scope = $this->kernel->beginExecution($descriptor);
$outcome = ExecutionOutcome::incomplete();
try {
$result = $execution($scope);
$outcome = ExecutionOutcome::success($result);
return $result;
} catch (\Throwable $error) {
if ($failure === null) {
$outcome = ExecutionOutcome::failure($error);
throw $error;
}
try {
$result = $failure($error, $scope);
$outcome = ExecutionOutcome::failure($error, $result);
return $result;
} catch (\Throwable $failureError) {
$outcome = ExecutionOutcome::failure($failureError);
throw $failureError;
}
} finally {
$this->kernel->terminateExecution($scope, $outcome);
}
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace KTXC\Application\Execution;
interface ExecutionRunnerInterface
{
/**
* @template TResult
*
* @param callable(ExecutionScope): TResult $execution
* @param null|callable(\Throwable, ExecutionScope): TResult $failure
*
* @return TResult
*/
public function execute(
ExecutionDescriptor $descriptor,
callable $execution,
?callable $failure = null,
): mixed;
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace KTXC\Application\Execution;
use KTXC\Context\IdentityContext;
use KTXC\Context\TenantContext;
final class ExecutionScope
{
private bool $terminated = false;
public function __construct(
public readonly ExecutionDescriptor $descriptor,
public readonly ExecutionContext $context,
private readonly TenantContext $tenantContext,
private readonly IdentityContext $identityContext,
) {
$this->tenantContext->clear();
$this->identityContext->clear();
}
public function markTerminated(): void
{
if ($this->terminated) {
throw new \LogicException('The execution scope has already been terminated.');
}
$this->terminated = true;
}
public function terminated(): bool
{
return $this->terminated;
}
public function dispose(): void
{
$this->identityContext->clear();
$this->tenantContext->clear();
}
}
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace KTXC\Application\Execution;
enum RuntimeType: string
{
case HTTP = 'http';
case CLI = 'cli';
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace KTXC\Application\Execution;
final readonly class TerminationReport
{
/**
* @param list<\Throwable> $failures
*/
public function __construct(
public int $deferredProcessed = 0,
public int $deferredRemaining = 0,
public array $failures = [],
public bool $deadlineExceeded = false,
public bool $limitExceeded = false,
) {
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace KTXC\Application;
final readonly class KernelOptions
{
public function __construct(
public ProjectPaths $paths,
public string $environment = 'prod',
public bool $debug = false,
) {
if ($this->environment === '') {
throw new \InvalidArgumentException('Kernel environment cannot be empty.');
}
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace KTXC\Application;
final readonly class ProjectPaths
{
private function __construct(
public string $project,
) {
}
public static function resolve(string $start): self
{
$directory = is_file($start) ? dirname($start) : $start;
$directory = rtrim($directory, DIRECTORY_SEPARATOR);
while ($directory !== dirname($directory)) {
if (is_file($directory . '/composer.json')) {
return new self(realpath($directory) ?: $directory);
}
$directory = dirname($directory);
}
throw new \InvalidArgumentException("Unable to resolve project root from {$start}.");
}
public function configuration(): string
{
return $this->project . '/config';
}
public function modules(): string
{
return $this->project . '/modules';
}
public function cache(string $environment): string
{
return $this->project . '/var/cache/' . $environment;
}
public function logs(): string
{
return $this->project . '/var/log';
}
public function runtime(): string
{
return $this->project . '/var';
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Event;
use KTXF\Event\EventListenerRegistry;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'events:debug',
description: 'Display the frozen event listener registry',
)]
final class EventsDebugCommand extends Command
{
public function __construct(
private readonly EventListenerRegistry $registry,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$rows = [];
foreach ($this->registry->definitions() as $listener) {
$rows[] = [
$listener->module,
$listener->event,
$listener->service . '::' . $listener->method,
$listener->delivery->value,
$listener->priority,
$listener->failurePolicy->value,
];
}
$io->table(
['Module', 'Event', 'Listener', 'Delivery', 'Priority', 'Failure'],
$rows,
);
return Command::SUCCESS;
}
}
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace KTXC\Console;
namespace KTXC\Console\Module;
use KTXC\Module\ModuleManager;
use Psr\Log\LoggerInterface;
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace KTXC\Console;
namespace KTXC\Console\Module;
use KTXC\Module\ModuleManager;
use Psr\Log\LoggerInterface;
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace KTXC\Console;
namespace KTXC\Console\Module;
use KTXC\Module\ModuleManager;
use Psr\Log\LoggerInterface;
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace KTXC\Console;
namespace KTXC\Console\Module;
use KTXC\Module\ModuleManager;
use Symfony\Component\Console\Attribute\AsCommand;
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace KTXC\Console;
namespace KTXC\Console\Module;
use KTXC\Module\ModuleManager;
use Psr\Log\LoggerInterface;
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace KTXC\Console;
namespace KTXC\Console\Module;
use KTXC\Module\ModuleManager;
use Psr\Log\LoggerInterface;
+102
View File
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Role;
use KTXC\Stores\UserAccountsStore;
use KTXC\Stores\UserRolesStore;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Role Assign Command
*
* Assigns a role to a user account.
*/
#[AsCommand(
name: 'role:assign',
description: 'Assign a role to a user',
)]
class RoleAssignCommand extends Command
{
public function __construct(
private readonly UserAccountsStore $userStore,
private readonly UserRolesStore $rolesStore,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->addArgument('identity', InputArgument::REQUIRED, 'User identity (email/username)')
->addArgument('rid', InputArgument::REQUIRED, 'Role id to assign')
->setHelp('This command assigns a role to a user account.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenant = $input->getArgument('tenant');
$identity = $input->getArgument('identity');
$rid = $input->getArgument('rid');
$io->title('Assign Role');
try {
$user = $this->userStore->fetchByIdentity($tenant, $identity);
if (!$user) {
$io->error("User '{$identity}' not found in tenant '{$tenant}'.");
return Command::FAILURE;
}
if (!$this->rolesStore->fetchByRid($tenant, $rid)) {
$io->error("Role '{$rid}' not found in tenant '{$tenant}'.");
return Command::FAILURE;
}
$roles = (array)($user['roles'] ?? []);
if (in_array($rid, $roles, true)) {
$io->text("User '{$identity}' already has role '{$rid}'.");
return Command::SUCCESS;
}
$roles[] = $rid;
if (!$this->userStore->updateUser($tenant, $user['uid'], ['roles' => array_values($roles)])) {
$io->error("Failed to assign role '{$rid}' to '{$identity}'.");
return Command::FAILURE;
}
$this->logger->info('Role assigned via console', [
'tenant' => $tenant,
'identity' => $identity,
'rid' => $rid,
'command' => $this->getName(),
]);
$io->success("Role '{$rid}' assigned to '{$identity}' successfully!");
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to assign role: ' . $e->getMessage());
$this->logger->error('Role assign failed', [
'tenant' => $tenant,
'identity' => $identity,
'rid' => $rid,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Role;
use KTXC\Service\TenantService;
use KTXC\Stores\UserRolesStore;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Role Create Command
*
* Creates a new role within a tenant.
*/
#[AsCommand(
name: 'role:create',
description: 'Create a new role in a tenant',
)]
class RoleCreateCommand extends Command
{
public function __construct(
private readonly TenantService $tenantService,
private readonly UserRolesStore $rolesStore,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->addArgument('label', InputArgument::REQUIRED, 'Display label for the role')
->addOption('description', 'd', InputOption::VALUE_REQUIRED, 'Role description', '')
->addOption('permission', 'p', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Permission(s) to grant (repeatable, e.g. --permission "*")', [])
->addOption('rid', null, InputOption::VALUE_REQUIRED, 'Explicit role id (defaults to a generated UUID)')
->addOption('system', null, InputOption::VALUE_NONE, 'Mark the role as a protected system role (cannot be updated or deleted)')
->setHelp('This command creates a new role in a tenant. Permissions are free-form strings matched by the runtime permission checker, including wildcard suffixes like "user_manager.role.*" or the full wildcard "*".')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenant = $input->getArgument('tenant');
$label = $input->getArgument('label');
$rid = $input->getOption('rid');
$io->title('Create Role');
try {
if (!$this->tenantService->fetchById($tenant)) {
$io->error("Tenant '{$tenant}' not found.");
return Command::FAILURE;
}
if ($rid !== null && $this->rolesStore->fetchByRid($tenant, $rid)) {
$io->error("Role '{$rid}' already exists in tenant '{$tenant}'.");
return Command::FAILURE;
}
$roleData = [
'label' => $label,
'description' => $input->getOption('description') ?? '',
'permissions' => $input->getOption('permission'),
'system' => $input->getOption('system'),
];
if ($rid !== null) {
$roleData['rid'] = $rid;
}
$role = $this->rolesStore->createRole($tenant, $roleData);
$this->logger->info('Role created via console', [
'tenant' => $tenant,
'rid' => $role['rid'] ?? null,
'command' => $this->getName(),
]);
$io->success("Role '{$label}' created successfully!");
$io->definitionList(
['Rid' => $role['rid'] ?? ''],
['Label' => $role['label'] ?? ''],
['System' => !empty($role['system']) ? 'yes' : 'no'],
['Permissions' => implode(', ', (array)($role['permissions'] ?? []))],
);
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to create role: ' . $e->getMessage());
$this->logger->error('Role create failed', [
'tenant' => $tenant,
'label' => $label,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
+103
View File
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Role;
use KTXC\Stores\UserRolesStore;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Role Delete Command
*
* Deletes a role from a tenant.
*/
#[AsCommand(
name: 'role:delete',
description: 'Delete a role from a tenant',
)]
class RoleDeleteCommand extends Command
{
public function __construct(
private readonly UserRolesStore $rolesStore,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->addArgument('rid', InputArgument::REQUIRED, 'Role id')
->addOption('force', 'f', InputOption::VALUE_NONE, 'Skip confirmation prompt')
->setHelp('This command deletes a role from a tenant. System roles cannot be deleted, and roles still assigned to users cannot be deleted.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenant = $input->getArgument('tenant');
$rid = $input->getArgument('rid');
$force = $input->getOption('force');
$io->title('Delete Role');
try {
$role = $this->rolesStore->fetchByRid($tenant, $rid);
if (!$role) {
$io->error("Role '{$rid}' not found in tenant '{$tenant}'.");
return Command::FAILURE;
}
if ($role['system'] ?? false) {
$io->error("Role '{$rid}' is a system role and cannot be deleted.");
return Command::FAILURE;
}
$userCount = $this->rolesStore->countUsersInRole($tenant, $rid);
if ($userCount > 0) {
$io->error("Role '{$rid}' is assigned to {$userCount} user(s) and cannot be deleted.");
return Command::FAILURE;
}
if (!$force && !$io->confirm("Are you sure you want to delete role '{$rid}' from tenant '{$tenant}'?", false)) {
$io->text('Delete cancelled.');
return Command::SUCCESS;
}
if (!$this->rolesStore->deleteRole($tenant, $rid)) {
$io->error("Failed to delete role '{$rid}'.");
return Command::FAILURE;
}
$this->logger->info('Role deleted via console', [
'tenant' => $tenant,
'rid' => $rid,
'command' => $this->getName(),
]);
$io->success("Role '{$rid}' deleted successfully!");
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to delete role: ' . $e->getMessage());
$this->logger->error('Role delete failed', [
'tenant' => $tenant,
'rid' => $rid,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Role;
use KTXC\Service\TenantService;
use KTXC\Stores\UserRolesStore;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Role List Command
*
* Lists all roles in a tenant.
*/
#[AsCommand(
name: 'role:list',
description: 'List roles in a tenant',
)]
class RoleListCommand extends Command
{
public function __construct(
private readonly TenantService $tenantService,
private readonly UserRolesStore $rolesStore
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->setHelp('This command lists all roles in a tenant.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenant = $input->getArgument('tenant');
$io->title("Roles in tenant '{$tenant}'");
try {
if (!$this->tenantService->fetchById($tenant)) {
$io->error("Tenant '{$tenant}' not found.");
return Command::FAILURE;
}
$roles = $this->rolesStore->listRoles($tenant);
if (empty($roles)) {
$io->text('No roles found.');
return Command::SUCCESS;
}
$rows = [];
foreach ($roles as $role) {
$rows[] = [
$role['rid'] ?? '',
$role['label'] ?? '',
!empty($role['system']) ? 'yes' : 'no',
implode(', ', (array)($role['permissions'] ?? [])),
];
}
$io->table(['Rid', 'Label', 'System', 'Permissions'], $rows);
$io->text(sprintf('Total: %d role(s)', count($rows)));
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to list roles: ' . $e->getMessage());
return Command::FAILURE;
}
}
}
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Role;
use KTXC\Stores\UserAccountsStore;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Role Revoke Command
*
* Revokes a role from a user account.
*/
#[AsCommand(
name: 'role:revoke',
description: 'Revoke a role from a user',
)]
class RoleRevokeCommand extends Command
{
public function __construct(
private readonly UserAccountsStore $userStore,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->addArgument('identity', InputArgument::REQUIRED, 'User identity (email/username)')
->addArgument('rid', InputArgument::REQUIRED, 'Role id to revoke')
->setHelp('This command revokes a role from a user account.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenant = $input->getArgument('tenant');
$identity = $input->getArgument('identity');
$rid = $input->getArgument('rid');
$io->title('Revoke Role');
try {
$user = $this->userStore->fetchByIdentity($tenant, $identity);
if (!$user) {
$io->error("User '{$identity}' not found in tenant '{$tenant}'.");
return Command::FAILURE;
}
$roles = (array)($user['roles'] ?? []);
if (!in_array($rid, $roles, true)) {
$io->text("User '{$identity}' does not have role '{$rid}'.");
return Command::SUCCESS;
}
$roles = array_values(array_filter($roles, fn($r) => $r !== $rid));
if (!$this->userStore->updateUser($tenant, $user['uid'], ['roles' => $roles])) {
$io->error("Failed to revoke role '{$rid}' from '{$identity}'.");
return Command::FAILURE;
}
$this->logger->info('Role revoked via console', [
'tenant' => $tenant,
'identity' => $identity,
'rid' => $rid,
'command' => $this->getName(),
]);
$io->success("Role '{$rid}' revoked from '{$identity}' successfully!");
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to revoke role: ' . $e->getMessage());
$this->logger->error('Role revoke failed', [
'tenant' => $tenant,
'identity' => $identity,
'rid' => $rid,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Tenant;
use KTXC\Resource\ProviderManager;
use KTXC\Service\TenantService;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Tenant Auth Enable Command
*
* Enables an authentication provider for a tenant's login flow.
*/
#[AsCommand(
name: 'tenant:auth:enable',
description: 'Enable an authentication provider for a tenant',
)]
class TenantAuthEnableCommand extends Command
{
public function __construct(
private readonly TenantService $tenantService,
private readonly ProviderManager $providerManager,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->addArgument('provider', InputArgument::REQUIRED, 'Authentication provider id (e.g. password, oidc, totp)')
->addOption('label', 'l', InputOption::VALUE_REQUIRED, 'Display label shown for this method during login')
->setHelp('This command enables an authentication provider for a tenant. The provider\'s module must already be installed and enabled.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenantIdentifier = $input->getArgument('tenant');
$providerId = $input->getArgument('provider');
$io->title('Enable Tenant Authentication Provider');
try {
$tenant = $this->tenantService->fetchById($tenantIdentifier);
if (!$tenant) {
$io->error("Tenant '{$tenantIdentifier}' not found.");
return Command::FAILURE;
}
if (!$this->providerManager->resolve('authentication', $providerId)) {
$io->error("Authentication provider '{$providerId}' is not registered. Is its module installed and enabled?");
return Command::FAILURE;
}
$authentication = $tenant->getConfiguration()->authentication();
$providers = $authentication->providers();
$providers[$providerId] = array_filter([
'enabled' => true,
'label' => $input->getOption('label'),
], fn($value) => $value !== null);
$authentication->jsonDeserialize(['providers' => $providers]);
$this->tenantService->deposit($tenant);
$this->logger->info('Tenant authentication provider enabled via console', [
'tenant' => $tenantIdentifier,
'provider' => $providerId,
'command' => $this->getName(),
]);
$io->success("Authentication provider '{$providerId}' enabled for tenant '{$tenantIdentifier}'.");
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to enable authentication provider: ' . $e->getMessage());
$this->logger->error('Tenant auth provider enable failed', [
'tenant' => $tenantIdentifier,
'provider' => $providerId,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Tenant;
use KTXC\Models\Tenant\DomainCollection;
use KTXC\Models\Tenant\TenantConfiguration;
use KTXC\Models\Tenant\TenantObject;
use KTXC\Service\TenantService;
use KTXC\Stores\UserAccountsStore;
use KTXC\Stores\UserRolesStore;
use KTXF\Utile\UUID;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Tenant Create Command
*
* Creates a new tenant.
*/
#[AsCommand(
name: 'tenant:create',
description: 'Create a new tenant',
)]
class TenantCreateCommand extends Command
{
public function __construct(
private readonly TenantService $tenantService,
private readonly UserRolesStore $rolesStore,
private readonly UserAccountsStore $userStore,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('domain', InputArgument::REQUIRED | InputArgument::IS_ARRAY, 'Domain(s) served by this tenant')
->addOption('label', 'l', InputOption::VALUE_REQUIRED, 'Display label for the tenant')
->addOption('description', 'd', InputOption::VALUE_REQUIRED, 'Tenant description')
->addOption('identifier', null, InputOption::VALUE_REQUIRED, 'Explicit tenant identifier (defaults to a generated UUID)')
->addOption('disabled', null, InputOption::VALUE_NONE, 'Create the tenant in a disabled state')
->addOption('admin-identity', null, InputOption::VALUE_REQUIRED, 'Identity for the bootstrap admin user', 'admin')
->addOption('no-admin-user', null, InputOption::VALUE_NONE, 'Do not create a bootstrap admin user (the admin role is still seeded)')
->setHelp('This command creates a new tenant with one or more domains. The tenant identifier is generated automatically unless --identifier is provided. An "admin" role with full permissions is seeded automatically, along with a bootstrap admin user unless --no-admin-user is passed.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$identifier = $input->getOption('identifier') ?? UUID::v4();
$domains = array_values(array_unique(array_filter(array_map('trim', $input->getArgument('domain')))));
$io->title('Create Tenant');
try {
if (empty($domains)) {
$io->error('At least one non-empty domain is required.');
return Command::FAILURE;
}
// Ensure identifier is unique
if ($this->tenantService->fetchById($identifier)) {
$io->error("Tenant '{$identifier}' already exists.");
return Command::FAILURE;
}
// Ensure domains are not claimed by another tenant
foreach ($domains as $domain) {
$existing = $this->tenantService->fetchByDomain($domain);
if ($existing) {
$io->error("Domain '{$domain}' is already assigned to tenant '{$existing->getIdentifier()}'.");
return Command::FAILURE;
}
}
$tenant = new TenantObject();
$tenant->setIdentifier($identifier);
$tenant->setEnabled(!$input->getOption('disabled'));
$tenant->setLabel($input->getOption('label') ?? $domains[0]);
$tenant->setDescription($input->getOption('description') ?? '');
$tenant->setDomains(new DomainCollection($domains));
$tenant->setConfiguration(new TenantConfiguration());
$tenant = $this->tenantService->deposit($tenant);
if (!$tenant) {
$io->error('Failed to create tenant.');
return Command::FAILURE;
}
$this->logger->info('Tenant created via console', [
'identifier' => $identifier,
'command' => $this->getName(),
]);
$io->success("Tenant '{$identifier}' created successfully!");
$io->definitionList(
['Id' => $tenant->getId()],
['Identifier' => $tenant->getIdentifier()],
['Label' => $tenant->getLabel()],
['Enabled' => $tenant->getEnabled() ? 'yes' : 'no'],
['Domains' => implode(', ', $domains)],
);
$role = $this->rolesStore->createRole($identifier, [
'rid' => 'admin',
'label' => 'Administrator',
'description' => 'Full access to all tenant features',
'permissions' => ['*'],
'system' => true,
]);
$this->logger->info('Default admin role seeded via console', [
'tenant' => $identifier,
'rid' => $role['rid'] ?? null,
'command' => $this->getName(),
]);
$io->text("Default role 'admin' seeded with full permissions.");
if (!$input->getOption('no-admin-user')) {
$adminIdentity = $input->getOption('admin-identity');
if ($this->userStore->fetchByIdentity($identifier, $adminIdentity)) {
$io->warning("User '{$adminIdentity}' already exists in tenant '{$identifier}'; skipping admin user creation.");
} else {
$this->userStore->createUser($identifier, [
'identity' => $adminIdentity,
'label' => 'Administrator',
'enabled' => true,
'roles' => ['admin'],
'profile' => [],
'settings' => [],
'provider' => null,
'provider_subject' => null,
'provider_managed_fields' => [],
]);
$this->logger->info('Bootstrap admin user created via console', [
'tenant' => $identifier,
'identity' => $adminIdentity,
'command' => $this->getName(),
]);
$io->text("Admin user '{$adminIdentity}' created with the 'admin' role.");
$io->note("Set a credential for this account using the auth provider module you have installed, e.g.: php bin/console user:password {$identifier} {$adminIdentity}");
}
}
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to create tenant: ' . $e->getMessage());
$this->logger->error('Tenant create failed', [
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Tenant;
use KTXC\Service\TenantService;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Tenant Delete Command
*
* Deletes an existing tenant.
*/
#[AsCommand(
name: 'tenant:delete',
description: 'Delete a tenant',
)]
class TenantDeleteCommand extends Command
{
public function __construct(
private readonly TenantService $tenantService,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('identifier', InputArgument::REQUIRED, 'Tenant identifier to delete')
->addOption('force', 'f', InputOption::VALUE_NONE, 'Skip confirmation prompt')
->setHelp('This command deletes a tenant. Deletion must be confirmed by typing the tenant\'s primary domain. User accounts and module data belonging to the tenant are not removed.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$identifier = $input->getArgument('identifier');
$force = $input->getOption('force');
$io->title('Delete Tenant');
try {
$tenant = $this->tenantService->fetchById($identifier);
if (!$tenant) {
$io->error("Tenant '{$identifier}' not found.");
return Command::FAILURE;
}
if (!$force) {
// Confirm by typing the tenant's primary domain (identifier for
// legacy tenants without domains)
$domains = $tenant->getDomains()?->getArrayCopy() ?? [];
$confirmation = $domains[0] ?? $identifier;
$io->definitionList(
['Identifier' => $tenant->getIdentifier()],
['Label' => $tenant->getLabel()],
['Domains' => implode(', ', $domains)],
);
$answer = $io->ask("Type '{$confirmation}' to confirm deletion");
if ($answer !== $confirmation) {
$io->error('Confirmation did not match. Delete cancelled.');
return Command::FAILURE;
}
}
$this->tenantService->destroy($tenant);
$this->logger->info('Tenant deleted via console', [
'identifier' => $identifier,
'command' => $this->getName(),
]);
$io->success("Tenant '{$identifier}' deleted successfully!");
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to delete tenant: ' . $e->getMessage());
$this->logger->error('Tenant delete failed', [
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Tenant;
use KTXC\Service\TenantService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Tenant List Command
*
* Lists all tenants.
*/
#[AsCommand(
name: 'tenant:list',
description: 'List all tenants',
)]
class TenantListCommand extends Command
{
public function __construct(
private readonly TenantService $tenantService
) {
parent::__construct();
}
protected function configure(): void
{
$this->setHelp('This command lists all tenants.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Tenants');
try {
$tenants = $this->tenantService->list();
if (empty($tenants)) {
$io->text('No tenants found.');
return Command::SUCCESS;
}
$rows = [];
foreach ($tenants as $tenant) {
$domains = $tenant->getDomains();
$rows[] = [
$tenant->getIdentifier(),
$tenant->getLabel(),
$tenant->getEnabled() ? 'yes' : 'no',
$domains ? implode(', ', $domains->getArrayCopy()) : '',
];
}
$io->table(['Identifier', 'Label', 'Enabled', 'Domains'], $rows);
$io->text(sprintf('Total: %d tenant(s)', count($rows)));
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to list tenants: ' . $e->getMessage());
return Command::FAILURE;
}
}
}
+128
View File
@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\User;
use KTXC\Service\TenantService;
use KTXC\Stores\UserAccountsStore;
use KTXC\Stores\UserRolesStore;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* User Create Command
*
* Creates a new user account within a tenant.
*/
#[AsCommand(
name: 'user:create',
description: 'Create a new user account in a tenant',
)]
class UserCreateCommand extends Command
{
public function __construct(
private readonly TenantService $tenantService,
private readonly UserAccountsStore $userStore,
private readonly UserRolesStore $rolesStore,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->addArgument('identity', InputArgument::REQUIRED, 'User identity (email/username)')
->addOption('label', 'l', InputOption::VALUE_REQUIRED, 'Display label for the user')
->addOption('role', 'r', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Role id(s) to assign', [])
->addOption('uid', null, InputOption::VALUE_REQUIRED, 'Explicit user id (defaults to a generated UUID)')
->addOption('disabled', null, InputOption::VALUE_NONE, 'Create the account in a disabled state')
->setHelp('This command creates a new user account in a tenant. Authentication credentials are managed separately by authentication provider modules.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenant = $input->getArgument('tenant');
$identity = $input->getArgument('identity');
$roles = $input->getOption('role');
$io->title('Create User');
try {
// Ensure the tenant exists
if (!$this->tenantService->fetchById($tenant)) {
$io->error("Tenant '{$tenant}' not found.");
return Command::FAILURE;
}
// Ensure identity is unique within the tenant
if ($this->userStore->fetchByIdentity($tenant, $identity)) {
$io->error("User '{$identity}' already exists in tenant '{$tenant}'.");
return Command::FAILURE;
}
// Ensure assigned roles exist
foreach ($roles as $role) {
if (!$this->rolesStore->fetchByRid($tenant, $role)) {
$io->error("Role '{$role}' not found in tenant '{$tenant}'.");
return Command::FAILURE;
}
}
$userData = [
'identity' => $identity,
'label' => $input->getOption('label') ?? $identity,
'enabled' => !$input->getOption('disabled'),
'roles' => $roles,
'profile' => [],
'settings' => [],
'provider' => null,
'provider_subject' => null,
'provider_managed_fields' => [],
];
if ($input->getOption('uid')) {
$userData['uid'] = $input->getOption('uid');
}
$user = $this->userStore->createUser($tenant, $userData);
$this->logger->info('User created via console', [
'tenant' => $tenant,
'identity' => $identity,
'uid' => $user['uid'] ?? null,
'command' => $this->getName(),
]);
$io->success("User '{$identity}' created successfully!");
$io->definitionList(
['Uid' => $user['uid'] ?? ''],
['Identity' => $user['identity'] ?? ''],
['Label' => $user['label'] ?? ''],
['Enabled' => !empty($user['enabled']) ? 'yes' : 'no'],
['Roles' => implode(', ', (array)($user['roles'] ?? []))],
);
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to create user: ' . $e->getMessage());
$this->logger->error('User create failed', [
'tenant' => $tenant,
'identity' => $identity,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\User;
use KTXC\Stores\UserAccountsStore;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* User Delete Command
*
* Deletes a user account from a tenant.
*/
#[AsCommand(
name: 'user:delete',
description: 'Delete a user account from a tenant',
)]
class UserDeleteCommand extends Command
{
public function __construct(
private readonly UserAccountsStore $userStore,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->addArgument('identity', InputArgument::REQUIRED, 'User identity (email/username)')
->addOption('force', 'f', InputOption::VALUE_NONE, 'Skip confirmation prompt')
->setHelp('This command deletes a user account from a tenant. Credentials stored by authentication provider modules are not removed.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenant = $input->getArgument('tenant');
$identity = $input->getArgument('identity');
$force = $input->getOption('force');
$io->title('Delete User');
try {
$user = $this->userStore->fetchByIdentity($tenant, $identity);
if (!$user) {
$io->error("User '{$identity}' not found in tenant '{$tenant}'.");
return Command::FAILURE;
}
if (!$force && !$io->confirm("Are you sure you want to delete user '{$identity}' from tenant '{$tenant}'?", false)) {
$io->text('Delete cancelled.');
return Command::SUCCESS;
}
if (!$this->userStore->deleteUser($tenant, $user['uid'])) {
$io->error("Failed to delete user '{$identity}'.");
return Command::FAILURE;
}
$this->logger->info('User deleted via console', [
'tenant' => $tenant,
'identity' => $identity,
'uid' => $user['uid'],
'command' => $this->getName(),
]);
$io->success("User '{$identity}' deleted successfully!");
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to delete user: ' . $e->getMessage());
$this->logger->error('User delete failed', [
'tenant' => $tenant,
'identity' => $identity,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\User;
use KTXC\Service\TenantService;
use KTXC\Stores\UserAccountsStore;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* User List Command
*
* Lists all user accounts in a tenant.
*/
#[AsCommand(
name: 'user:list',
description: 'List user accounts in a tenant',
)]
class UserListCommand extends Command
{
public function __construct(
private readonly TenantService $tenantService,
private readonly UserAccountsStore $userStore
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->setHelp('This command lists all user accounts in a tenant.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenant = $input->getArgument('tenant');
$io->title("Users in tenant '{$tenant}'");
try {
if (!$this->tenantService->fetchById($tenant)) {
$io->error("Tenant '{$tenant}' not found.");
return Command::FAILURE;
}
$users = $this->userStore->listUsers($tenant);
if (empty($users)) {
$io->text('No users found.');
return Command::SUCCESS;
}
$rows = [];
foreach ($users as $user) {
$rows[] = [
$user['uid'] ?? '',
$user['identity'] ?? '',
$user['label'] ?? '',
!empty($user['enabled']) ? 'yes' : 'no',
implode(', ', (array)($user['roles'] ?? [])),
];
}
$io->table(['Uid', 'Identity', 'Label', 'Enabled', 'Roles'], $rows);
$io->text(sprintf('Total: %d user(s)', count($rows)));
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to list users: ' . $e->getMessage());
return Command::FAILURE;
}
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace KTXC\Context;
use KTXC\Models\Identity\User;
final class IdentityContext implements IdentityContextInterface
{
private ?User $identity = null;
public function initialize(User $identity): void
{
if ($this->identity !== null) {
throw new \LogicException('The execution identity has already been initialized.');
}
$this->identity = $identity;
}
public function clear(): void
{
$this->identity = null;
}
public function present(): bool
{
return $this->identity !== null;
}
public function identity(): ?User
{
return $this->identity;
}
public function identifier(): ?string
{
return $this->identity?->getId();
}
public function requireIdentifier(): string
{
return $this->identifier()
?? throw new \LogicException('This operation requires an identity context.');
}
public function label(): ?string
{
return $this->identity?->getLabel();
}
public function mailAddress(): ?string
{
return $this->identity?->getIdentity();
}
public function nameFirst(): ?string
{
return null;
}
public function nameLast(): ?string
{
return null;
}
public function permissions(): array
{
return $this->identity?->getPermissions() ?? [];
}
public function roles(): array
{
return $this->identity?->getRoles() ?? [];
}
public function hasPermission(string $permission): bool
{
$permissions = $this->permissions();
if (in_array($permission, $permissions, true) || in_array('*', $permissions, true)) {
return true;
}
foreach ($permissions as $userPermission) {
if (str_ends_with($userPermission, '.*')) {
$prefix = substr($userPermission, 0, -2);
if (str_starts_with($permission, $prefix . '.')) {
return true;
}
}
}
return false;
}
public function hasRole(string $role): bool
{
return in_array($role, $this->roles(), true);
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace KTXC\Context;
use KTXC\Models\Identity\User;
interface IdentityContextInterface
{
public function present(): bool;
public function identity(): ?User;
public function identifier(): ?string;
public function requireIdentifier(): string;
public function label(): ?string;
public function mailAddress(): ?string;
public function nameFirst(): ?string;
public function nameLast(): ?string;
public function permissions(): array;
public function roles(): array;
public function hasPermission(string $permission): bool;
public function hasRole(string $role): bool;
}
+117
View File
@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace KTXC\Context;
use KTXC\Models\Tenant\TenantConfiguration;
use KTXC\Models\Tenant\TenantObject;
use KTXC\Service\TenantService;
final class TenantContext implements TenantContextInterface
{
private ?TenantObject $tenant = null;
private ?string $domain = null;
public function __construct(
private readonly TenantService $tenantService,
) {
}
public function resolveDomain(string $domain): bool
{
$this->clear();
$tenant = $this->tenantService->fetchByDomain($domain);
if ($tenant === null) {
return false;
}
$this->domain = $domain;
$this->tenant = $tenant;
return true;
}
public function resolveIdentifier(string $identifier): bool
{
$this->clear();
$tenant = $this->tenantService->fetchById($identifier);
if ($tenant === null) {
return false;
}
$this->domain = $identifier;
$this->tenant = $tenant;
return true;
}
public function clear(): void
{
$this->tenant = null;
$this->domain = null;
}
public function present(): bool
{
return $this->tenant !== null;
}
public function configured(): bool
{
return $this->present();
}
public function enabled(): bool
{
return $this->tenant?->getEnabled() ?? false;
}
public function domain(): ?string
{
return $this->domain;
}
public function identifier(): ?string
{
return $this->tenant?->getIdentifier();
}
public function requireIdentifier(): string
{
return $this->identifier()
?? throw new \LogicException('This operation requires a tenant context.');
}
public function label(): ?string
{
return $this->tenant?->getLabel();
}
public function configuration(): ?TenantConfiguration
{
return $this->tenant?->getConfiguration();
}
public function settings(): array
{
return $this->tenant?->getSettings() ?? [];
}
public function identityProviders(): array
{
return $this->tenant?->getConfiguration()['identity']['providers'] ?? [];
}
public function identityProviderConfig(string $providerId): ?array
{
return $this->identityProviders()[$providerId] ?? null;
}
public function isIdentityProviderEnabled(string $providerId): bool
{
$config = $this->identityProviderConfig($providerId);
return $config !== null && ($config['enabled'] ?? false);
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace KTXC\Context;
use KTXC\Models\Tenant\TenantConfiguration;
interface TenantContextInterface
{
public function present(): bool;
public function configured(): bool;
public function enabled(): bool;
public function domain(): ?string;
public function identifier(): ?string;
public function requireIdentifier(): string;
public function label(): ?string;
public function configuration(): ?TenantConfiguration;
public function settings(): array;
public function identityProviders(): array;
public function identityProviderConfig(string $providerId): ?array;
public function isIdentityProviderEnabled(string $providerId): bool;
}
+9 -9
View File
@@ -10,14 +10,14 @@ use KTXC\Http\Response\RedirectResponse;
use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AnonymousRoute;
use KTXC\Service\SecurityService;
use KTXC\SessionIdentity;
use KTXC\Context\IdentityContextInterface;
use KTXC\Http\Request\Request;
class DefaultController extends ControllerAbstract
{
public function __construct(
private readonly SecurityService $securityService,
private readonly SessionIdentity $identity,
private readonly IdentityContextInterface $identityContext,
#[Inject('rootDir')] private readonly string $rootDir,
) {}
@@ -25,11 +25,11 @@ class DefaultController extends ControllerAbstract
public function home(Request $request): Response
{
// If an authenticated identity is available, serve the private app
if ($this->identity->identifier()) {
if ($this->identityContext->identifier()) {
return new FileResponse(
$this->rootDir . '/public/private.html',
Response::HTTP_OK,
['Content-Type' => 'text/html']
['Content-Type' => 'text/html', 'Cache-Control' => 'no-store']
);
}
@@ -38,7 +38,7 @@ class DefaultController extends ControllerAbstract
$response = new FileResponse(
$this->rootDir . '/public/public.html',
Response::HTTP_OK,
['Content-Type' => 'text/html']
['Content-Type' => 'text/html', 'Cache-Control' => 'no-store']
);
// Clear any stale auth cookies since the user is not authenticated
@@ -58,7 +58,7 @@ class DefaultController extends ControllerAbstract
return new FileResponse(
$this->rootDir . '/public/public.html',
Response::HTTP_OK,
['Content-Type' => 'text/html']
['Content-Type' => 'text/html', 'Cache-Control' => 'no-store']
);
}
@@ -116,11 +116,11 @@ class DefaultController extends ControllerAbstract
public function catchAll(Request $request, string $path = ''): Response
{
// If an authenticated identity is available, serve the private app
if ($this->identity->identifier()) {
if ($this->identityContext->identifier()) {
return new FileResponse(
$this->rootDir . '/public/private.html',
Response::HTTP_OK,
['Content-Type' => 'text/html']
['Content-Type' => 'text/html', 'Cache-Control' => 'no-store']
);
}
@@ -128,7 +128,7 @@ class DefaultController extends ControllerAbstract
$response = new FileResponse(
$this->rootDir . '/public/public.html',
Response::HTTP_OK,
['Content-Type' => 'text/html']
['Content-Type' => 'text/html', 'Cache-Control' => 'no-store']
);
// Clear any stale auth cookies since the user is not authenticated
+24 -15
View File
@@ -2,28 +2,30 @@
namespace KTXC\Controllers;
use KTXC\Http\Request\Request;
use KTXC\Http\Response\JsonResponse;
use KTXC\L10N\LocaleResolver;
use KTXC\Module\ModuleManager;
use KTXC\Security\Authorization\PermissionChecker;
use KTXC\Service\UserAccountsService;
use KTXC\SessionIdentity;
use KTXC\Context\IdentityContextInterface;
use KTXF\Controller\ControllerAbstract;
use KTXC\SessionTenant;
use KTXF\Module\ModuleBrowserInterface;
use KTXC\Context\TenantContextInterface;
use KTXF\Routing\Attributes\AuthenticatedRoute;
class InitController extends ControllerAbstract
{
public function __construct(
private readonly SessionTenant $tenant,
private readonly SessionIdentity $userIdentity,
private readonly TenantContextInterface $tenantContext,
private readonly IdentityContextInterface $identityContext,
private readonly ModuleManager $moduleManager,
private readonly UserAccountsService $userService,
private readonly PermissionChecker $permissionChecker,
private readonly LocaleResolver $localeResolver,
) {}
#[AuthenticatedRoute('/init', name: 'init', methods: ['GET'])]
public function index(): JsonResponse {
public function index(Request $request): JsonResponse {
$configuration = [];
@@ -43,23 +45,30 @@ class InitController extends ControllerAbstract
}
}
// localization
$configuration['l10n'] = [
'locale' => $this->localeResolver->resolve($request),
'fallback' => LocaleResolver::FALLBACK,
'available' => $this->localeResolver->available(),
];
// tenant
$configuration['tenant'] = [
'id' => $this->tenant->identifier(),
'domain' => $this->tenant->domain(),
'label' => $this->tenant->label(),
'id' => $this->tenantContext->identifier(),
'domain' => $this->tenantContext->domain(),
'label' => $this->tenantContext->label(),
];
// user
$configuration['user'] = [
'auth' => [
'identifier' => $this->userIdentity->identifier(),
'identity' => $this->userIdentity->identity()->getIdentity(),
'label' => $this->userIdentity->label(),
'roles' => $this->userIdentity->identity()->getRoles(),
'permissions' => $this->userIdentity->identity()->getPermissions(),
'identifier' => $this->identityContext->identifier(),
'identity' => $this->identityContext->identity()->getIdentity(),
'label' => $this->identityContext->label(),
'roles' => $this->identityContext->identity()->getRoles(),
'permissions' => $this->identityContext->identity()->getPermissions(),
],
'profile' => $this->userService->getEditableFields($this->userIdentity->identifier()),
'profile' => $this->userService->getEditableFields($this->identityContext->identifier()),
'settings' => $this->userService->fetchSettings([], true),
];
@@ -4,7 +4,7 @@ namespace KTXC\Controllers;
use KTXC\Http\Response\JsonResponse;
use KTXC\Service\TenantService;
use KTXC\SessionTenant;
use KTXC\Context\TenantContextInterface;
use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AuthenticatedRoute;
@@ -19,7 +19,7 @@ use KTXF\Routing\Attributes\AuthenticatedRoute;
class TenantSettingsController extends ControllerAbstract
{
public function __construct(
private readonly SessionTenant $tenantIdentity,
private readonly TenantContextInterface $tenantContext,
private readonly TenantService $tenantService,
) {}
@@ -36,7 +36,7 @@ class TenantSettingsController extends ControllerAbstract
)]
public function read(): JsonResponse
{
$settings = $this->tenantService->fetchSettings($this->tenantIdentity->identifier());
$settings = $this->tenantService->fetchSettings($this->tenantContext->identifier());
return new JsonResponse($settings, JsonResponse::HTTP_OK);
}
@@ -49,9 +49,9 @@ class TenantSettingsController extends ControllerAbstract
* @example request body:
* {
* "data": {
* "default_mode": "dark",
* "primary_color": "#6366F1",
* "lock_user_colors": true
* "theme_default_mode": "dark",
* "theme_palette": {"light": {"colors": {"primary": "#0284C7"}}},
* "theme_lock": true
* }
* }
*
@@ -65,9 +65,9 @@ class TenantSettingsController extends ControllerAbstract
)]
public function update(array $data): JsonResponse
{
$this->tenantService->storeSettings($this->tenantIdentity->identifier(), $data);
$this->tenantService->storeSettings($this->tenantContext->identifier(), $data);
$updatedSettings = $this->tenantService->fetchSettings($this->tenantIdentity->identifier(), array_keys($data));
$updatedSettings = $this->tenantService->fetchSettings($this->tenantContext->identifier(), array_keys($data));
return new JsonResponse($updatedSettings, JsonResponse::HTTP_OK);
}
+18 -18
View File
@@ -4,8 +4,8 @@ namespace KTXC\Controllers;
use KTXC\Http\Response\JsonResponse;
use KTXC\Service\UserAccountsService;
use KTXC\SessionIdentity;
use KTXC\SessionTenant;
use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface;
use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AuthenticatedRoute;
use Psr\Log\LoggerInterface;
@@ -17,8 +17,8 @@ use Psr\Log\LoggerInterface;
class UserAccountsController extends ControllerAbstract
{
public function __construct(
private readonly SessionTenant $tenantIdentity,
private readonly SessionIdentity $userIdentity,
private readonly TenantContextInterface $tenantContext,
private readonly IdentityContextInterface $identityContext,
private readonly UserAccountsService $userService,
private readonly LoggerInterface $logger
) {}
@@ -31,7 +31,7 @@ class UserAccountsController extends ControllerAbstract
{
try {
// Check admin permission
if (!$this->userIdentity->hasPermission('user.admin')) {
if (!$this->identityContext->hasPermission('user.admin')) {
return new JsonResponse([
'status' => 'error',
'data' => ['code' => 403, 'message' => 'Insufficient permissions']
@@ -125,7 +125,7 @@ class UserAccountsController extends ControllerAbstract
*/
private function userCreate(array $data): array
{
if (!$this->userIdentity->hasPermission('user.create')) {
if (!$this->identityContext->hasPermission('user.create')) {
throw new \InvalidArgumentException('Insufficient permissions to create users');
}
@@ -142,9 +142,9 @@ class UserAccountsController extends ControllerAbstract
];
$this->logger->info('Creating user', [
'tenant' => $this->tenantIdentity->identifier(),
'tenant' => $this->tenantContext->identifier(),
'identity' => $userData['identity'],
'actor' => $this->userIdentity->identifier()
'actor' => $this->identityContext->identifier()
]);
return $this->userService->createUser($userData);
@@ -155,7 +155,7 @@ class UserAccountsController extends ControllerAbstract
*/
private function userUpdate(array $data): bool
{
if (!$this->userIdentity->hasPermission('user.update')) {
if (!$this->identityContext->hasPermission('user.update')) {
throw new \InvalidArgumentException('Insufficient permissions to update users');
}
@@ -186,9 +186,9 @@ class UserAccountsController extends ControllerAbstract
}
$this->logger->info('Updating user', [
'tenant' => $this->tenantIdentity->identifier(),
'tenant' => $this->tenantContext->identifier(),
'uid' => $uid,
'actor' => $this->userIdentity->identifier()
'actor' => $this->identityContext->identifier()
]);
return $this->userService->updateUser($uid, $updates);
@@ -199,21 +199,21 @@ class UserAccountsController extends ControllerAbstract
*/
private function userDelete(array $data): bool
{
if (!$this->userIdentity->hasPermission('user.delete')) {
if (!$this->identityContext->hasPermission('user.delete')) {
throw new \InvalidArgumentException('Insufficient permissions to delete users');
}
$uid = $data['uid'] ?? throw new \InvalidArgumentException('User ID required');
// Prevent self-deletion
if ($uid === $this->userIdentity->identifier()) {
if ($uid === $this->identityContext->identifier()) {
throw new \InvalidArgumentException('Cannot delete your own account');
}
$this->logger->info('Deleting user', [
'tenant' => $this->tenantIdentity->identifier(),
'tenant' => $this->tenantContext->identifier(),
'uid' => $uid,
'actor' => $this->userIdentity->identifier()
'actor' => $this->identityContext->identifier()
]);
return $this->userService->deleteUser($uid);
@@ -228,7 +228,7 @@ class UserAccountsController extends ControllerAbstract
*/
private function userProviderUnlink(array $data): bool
{
if (!$this->userIdentity->hasPermission('user.admin')) {
if (!$this->identityContext->hasPermission('user.admin')) {
throw new \InvalidArgumentException('Insufficient permissions');
}
@@ -241,9 +241,9 @@ class UserAccountsController extends ControllerAbstract
];
$this->logger->info('Unlinking provider', [
'tenant' => $this->tenantIdentity->identifier(),
'tenant' => $this->tenantContext->identifier(),
'uid' => $uid,
'actor' => $this->userIdentity->identifier()
'actor' => $this->identityContext->identifier()
]);
return $this->userService->updateUser($uid, $updates);
@@ -4,16 +4,16 @@ namespace KTXC\Controllers;
use KTXC\Http\Response\JsonResponse;
use KTXC\Service\UserAccountsService;
use KTXC\SessionIdentity;
use KTXC\SessionTenant;
use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface;
use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AuthenticatedRoute;
class UserProfileController extends ControllerAbstract
{
public function __construct(
private readonly SessionTenant $tenantIdentity,
private readonly SessionIdentity $userIdentity,
private readonly TenantContextInterface $tenantContext,
private readonly IdentityContextInterface $identityContext,
private readonly UserAccountsService $userService
) {}
@@ -30,7 +30,7 @@ class UserProfileController extends ControllerAbstract
)]
public function read(): JsonResponse
{
$userId = $this->userIdentity->identifier();
$userId = $this->identityContext->identifier();
// Get profile with editability metadata
$profile = $this->userService->getEditableFields($userId);
@@ -63,7 +63,7 @@ class UserProfileController extends ControllerAbstract
)]
public function update(array $data): JsonResponse
{
$userId = $this->userIdentity->identifier();
$userId = $this->identityContext->identifier();
// storeProfile automatically filters out provider-managed fields
$this->userService->storeProfile($userId, $data);
+8 -8
View File
@@ -3,8 +3,8 @@
namespace KTXC\Controllers;
use KTXC\Http\Response\JsonResponse;
use KTXC\SessionIdentity;
use KTXC\SessionTenant;
use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface;
use KTXC\Service\UserRolesService;
use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AuthenticatedRoute;
@@ -17,8 +17,8 @@ use Psr\Log\LoggerInterface;
class UserRolesController extends ControllerAbstract
{
public function __construct(
private readonly SessionTenant $tenantIdentity,
private readonly SessionIdentity $userIdentity,
private readonly TenantContextInterface $tenantContext,
private readonly IdentityContextInterface $identityContext,
private readonly UserRolesService $roleService,
private readonly LoggerInterface $logger
) {}
@@ -31,7 +31,7 @@ class UserRolesController extends ControllerAbstract
{
try {
// Check role admin permission
if (!$this->userIdentity->hasPermission('role.admin')) {
if (!$this->identityContext->hasPermission('role.admin')) {
return new JsonResponse([
'status' => 'error',
'data' => ['code' => 403, 'message' => 'Insufficient permissions']
@@ -137,7 +137,7 @@ class UserRolesController extends ControllerAbstract
*/
private function roleCreate(array $data): array
{
if (!$this->userIdentity->hasPermission('role.manage')) {
if (!$this->identityContext->hasPermission('role.manage')) {
throw new \InvalidArgumentException('Insufficient permissions to create roles');
}
@@ -155,7 +155,7 @@ class UserRolesController extends ControllerAbstract
*/
private function roleUpdate(array $data): bool
{
if (!$this->userIdentity->hasPermission('role.manage')) {
if (!$this->identityContext->hasPermission('role.manage')) {
throw new \InvalidArgumentException('Insufficient permissions to update roles');
}
@@ -182,7 +182,7 @@ class UserRolesController extends ControllerAbstract
*/
private function roleDelete(array $data): bool
{
if (!$this->userIdentity->hasPermission('role.manage')) {
if (!$this->identityContext->hasPermission('role.manage')) {
throw new \InvalidArgumentException('Insufficient permissions to delete roles');
}
@@ -5,16 +5,16 @@ namespace KTXC\Controllers;
use KTXC\Http\Request\Request;
use KTXC\Http\Response\JsonResponse;
use KTXC\Service\UserAccountsService;
use KTXC\SessionIdentity;
use KTXC\SessionTenant;
use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface;
use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AuthenticatedRoute;
class UserSettingsController extends ControllerAbstract
{
public function __construct(
private readonly SessionTenant $tenantIdentity,
private readonly SessionIdentity $userIdentity,
private readonly TenantContextInterface $tenantContext,
private readonly IdentityContextInterface $identityContext,
private readonly UserAccountsService $userService
) {}
@@ -5,7 +5,10 @@ namespace KTXC\Http\Middleware;
use KTXC\Http\Request\Request;
use KTXC\Http\Response\Response;
use KTXC\Service\SecurityService;
use KTXC\SessionIdentity;
use KTXC\Context\IdentityContext;
use KTXF\Cache\BlobCacheInterface;
use KTXF\Cache\EphemeralCacheInterface;
use KTXF\Cache\PersistentCacheInterface;
/**
* Authentication middleware
@@ -19,7 +22,10 @@ class AuthenticationMiddleware implements MiddlewareInterface
{
public function __construct(
private readonly SecurityService $securityService,
private readonly SessionIdentity $sessionIdentity
private readonly IdentityContext $identityContext,
private readonly EphemeralCacheInterface $ephemeralCache,
private readonly PersistentCacheInterface $persistentCache,
private readonly BlobCacheInterface $blobCache,
) {}
public function process(Request $request, RequestHandlerInterface $handler): Response
@@ -29,7 +35,11 @@ class AuthenticationMiddleware implements MiddlewareInterface
// Initialize session identity if authentication succeeded
if ($identity) {
$this->sessionIdentity->initialize($identity, true);
$this->identityContext->initialize($identity);
$identityId = $this->identityContext->identifier();
$this->ephemeralCache->setUserContext($identityId);
$this->persistentCache->setUserContext($identityId);
$this->blobCache->setUserContext($identityId);
}
// Continue to next middleware (authentication is optional at this stage)
@@ -6,7 +6,7 @@ use KTXC\Http\Request\Request;
use KTXC\Http\Response\Response;
use KTXC\Routing\Router;
use KTXC\Routing\Route;
use KTXC\SessionIdentity;
use KTXC\Context\IdentityContextInterface;
use KTXC\Security\Authorization\PermissionChecker;
/**
@@ -17,7 +17,7 @@ class RouterMiddleware implements MiddlewareInterface
{
public function __construct(
private readonly Router $router,
private readonly SessionIdentity $sessionIdentity,
private readonly IdentityContextInterface $identityContext,
private readonly PermissionChecker $permissionChecker
) {}
@@ -32,7 +32,7 @@ class RouterMiddleware implements MiddlewareInterface
}
// Check if route requires authentication
if ($match->authenticated && $this->sessionIdentity->identity() === null) {
if ($match->authenticated && $this->identityContext->identity() === null) {
return new Response(
Response::$statusTexts[Response::HTTP_UNAUTHORIZED],
Response::HTTP_UNAUTHORIZED
+14 -4
View File
@@ -4,7 +4,10 @@ namespace KTXC\Http\Middleware;
use KTXC\Http\Request\Request;
use KTXC\Http\Response\Response;
use KTXC\SessionTenant;
use KTXC\Context\TenantContext;
use KTXF\Cache\BlobCacheInterface;
use KTXF\Cache\EphemeralCacheInterface;
use KTXF\Cache\PersistentCacheInterface;
/**
* Tenant resolution middleware
@@ -13,16 +16,23 @@ use KTXC\SessionTenant;
class TenantMiddleware implements MiddlewareInterface
{
public function __construct(
private readonly SessionTenant $sessionTenant
private readonly TenantContext $tenantContext,
private readonly EphemeralCacheInterface $ephemeralCache,
private readonly PersistentCacheInterface $persistentCache,
private readonly BlobCacheInterface $blobCache,
) {}
public function process(Request $request, RequestHandlerInterface $handler): Response
{
// Configure tenant from request host
$this->sessionTenant->configure($request->getHost());
$this->tenantContext->resolveDomain($request->getHost());
$tenantId = $this->tenantContext->identifier();
$this->ephemeralCache->setTenantContext($tenantId);
$this->persistentCache->setTenantContext($tenantId);
$this->blobCache->setTenantContext($tenantId);
// Check if tenant is configured and enabled
if (!$this->sessionTenant->configured() || !$this->sessionTenant->enabled()) {
if (!$this->tenantContext->configured() || !$this->tenantContext->enabled()) {
return new Response(
Response::$statusTexts[Response::HTTP_UNAUTHORIZED],
Response::HTTP_UNAUTHORIZED
+158 -163
View File
@@ -9,13 +9,17 @@
namespace KTXC;
use KTXC\Http\Request\Request;
use KTXC\Http\Response\Response;
use KTXC\Http\Middleware\MiddlewarePipeline;
use KTXC\Http\Middleware\TenantMiddleware;
use KTXC\Http\Middleware\FirewallMiddleware;
use KTXC\Http\Middleware\AuthenticationMiddleware;
use KTXC\Http\Middleware\RouterMiddleware;
use KTXC\Application\KernelOptions;
use KTXC\Application\Execution\ExecutionDescriptor;
use KTXC\Application\Execution\ExecutionOutcome;
use KTXC\Application\Execution\ExecutionRunner;
use KTXC\Application\Execution\ExecutionRunnerInterface;
use KTXC\Application\Execution\ExecutionScope;
use KTXC\Application\Execution\TerminationReport;
use KTXC\Context\IdentityContext;
use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContext;
use KTXC\Context\TenantContextInterface;
use KTXC\Injection\Builder;
use KTXC\Injection\Container;
use Psr\Container\ContainerInterface;
@@ -23,7 +27,10 @@ use KTXC\Module\ModuleManager;
use Psr\Log\LoggerInterface;
use KTXC\Logger\LoggerFactory;
use KTXC\Logger\TenantAwareLogger;
use KTXF\Event\EventBus;
use KTXF\Event\DeferredEventProcessorInterface;
use KTXF\Event\EventDispatcher;
use KTXF\Event\EventDispatcherInterface;
use KTXF\Event\EventListenerRegistry;
use KTXF\Cache\EphemeralCacheInterface;
use KTXF\Cache\PersistentCacheInterface;
use KTXF\Cache\BlobCacheInterface;
@@ -31,7 +38,7 @@ use KTXF\Cache\Store\FileEphemeralCache;
use KTXF\Cache\Store\FilePersistentCache;
use KTXF\Cache\Store\FileBlobCache;
class Kernel
class Kernel implements KernelInterface
{
public const VERSION = '1.0.0';
public const VERSION_ID = 10000;
@@ -45,26 +52,17 @@ class Kernel
protected ?float $startTime = null;
protected ?ContainerInterface $container = null;
protected ?LoggerInterface $logger = null;
protected ?MiddlewarePipeline $pipeline = null;
private bool $errorHandlerInstalled = false;
private ?ExecutionScope $activeScope = null;
private ?ExecutionRunnerInterface $runner = null;
private string $projectDir;
private array $config;
public function __construct(
protected string $environment = 'prod',
protected bool $debug = false,
private readonly KernelOptions $options,
array $config = [],
?string $projectDir = null,
) {
if (!$environment) {
throw new \InvalidArgumentException(\sprintf('Invalid environment provided to "%s": the environment cannot be empty.', get_debug_type($this)));
}
$this->config = $config;
if ($projectDir !== null) {
$this->projectDir = $projectDir;
}
}
public function __clone()
@@ -72,15 +70,16 @@ class Kernel
$this->initialized = false;
$this->booted = false;
$this->container = null;
$this->runner = null;
}
private function initialize(): void
{
if ($this->debug) {
if ($this->debug()) {
$this->startTime = microtime(true);
}
if ($this->debug && !isset($_ENV['SHELL_VERBOSITY']) && !isset($_SERVER['SHELL_VERBOSITY'])) {
if ($this->debug() && !isset($_ENV['SHELL_VERBOSITY']) && !isset($_SERVER['SHELL_VERBOSITY'])) {
if (\function_exists('putenv')) {
putenv('SHELL_VERBOSITY=3');
}
@@ -89,7 +88,10 @@ class Kernel
}
// Create logger from config (driver + level-filter; per-tenant wrapping applied later in DI)
$this->logger = LoggerFactory::create($this->config, $this->folderRoot());
$this->logger = LoggerFactory::create(
$this->config,
$this->options->paths->project,
);
$this->initializeErrorHandlers();
@@ -129,23 +131,7 @@ class Kernel
return true;
});
// Handle uncaught exceptions
set_exception_handler(function (\Throwable $exception) {
$this->logger->error('Exception caught: ' . $exception->getMessage(), [
'exception' => $exception,
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'trace' => $exception->getTraceAsString(),
]);
if ($this->debug) {
echo '<pre>Uncaught Exception: ' . $exception . '</pre>';
} else {
echo 'An unexpected error occurred. Please try again later.';
}
exit(1);
});
$this->errorHandlerInstalled = true;
// Handle fatal errors
register_shutdown_function(function () {
@@ -161,11 +147,6 @@ class Kernel
$this->logger->error($message, $error);
if ($this->debug) {
echo '<pre>' . $message . '</pre>';
} else {
echo 'A fatal error occurred. Please try again later.';
}
}
});
}
@@ -180,14 +161,19 @@ class Kernel
/** @var ModuleManager $moduleManager */
$moduleManager = $this->container->get(ModuleManager::class);
$moduleManager->modulesBoot();
// Build middleware pipeline
$this->pipeline = $this->buildMiddlewarePipeline();
$this->container
->get(EventListenerRegistry::class)
->freeze($this->container);
$this->booted = true;
}
}
public function executionRunner(): ExecutionRunnerInterface
{
return $this->runner ??= new ExecutionRunner($this);
}
public function reboot(): void
{
$this->shutdown();
@@ -199,52 +185,108 @@ class Kernel
if (false === $this->initialized) {
return;
}
if ($this->activeScope !== null && !$this->activeScope->terminated()) {
throw new \LogicException('Cannot shut down the kernel while an execution scope is active.');
}
$this->initialized = false;
$this->booted = false;
$this->container = null;
if ($this->errorHandlerInstalled) {
restore_error_handler();
$this->errorHandlerInstalled = false;
}
}
public function handle(Request $request): Response
public function beginExecution(ExecutionDescriptor $descriptor): ExecutionScope
{
if (!$this->booted) {
$this->boot();
}
// Use middleware pipeline to handle the request
return $this->pipeline->handle($request);
if ($this->activeScope !== null) {
throw new \LogicException('The kernel already has an active execution scope.');
}
/**
* Build the middleware pipeline
*/
protected function buildMiddlewarePipeline(): MiddlewarePipeline
{
$pipeline = new MiddlewarePipeline($this->container);
$scope = new ExecutionScope(
$descriptor,
\KTXC\Application\Execution\ExecutionContext::fromDescriptor($descriptor),
$this->container->get(TenantContext::class),
$this->container->get(IdentityContext::class),
);
$this->container
->get(DeferredEventProcessorInterface::class)
->beginExecution($descriptor->executionId);
$this->activeScope = $scope;
// Register middleware in execution order
$pipeline->pipe(TenantMiddleware::class);
$pipeline->pipe(FirewallMiddleware::class);
$pipeline->pipe(AuthenticationMiddleware::class);
$pipeline->pipe(RouterMiddleware::class);
return $pipeline;
return $scope;
}
/**
* Process deferred events at the end of the request
*/
public function processEvents(): void
public function terminateExecution(
ExecutionScope $scope,
ExecutionOutcome $outcome,
): TerminationReport
{
if ($scope->terminated()) {
return new TerminationReport();
}
if ($this->activeScope !== $scope) {
throw new \LogicException('Cannot terminate a scope that is not active.');
}
$processed = 0;
$remaining = 0;
$deadlineExceeded = false;
$limitExceeded = false;
$failures = [];
try {
if ($this->container && $this->container->has(EventBus::class)) {
/** @var EventBus $eventBus */
$eventBus = $this->container->get(EventBus::class);
$eventBus->processDeferred();
if ($this->container && $this->container->has(DeferredEventProcessorInterface::class)) {
$result = $this->container
->get(DeferredEventProcessorInterface::class)
->processDeferred($scope->descriptor->executionId);
$processed = $result->processed;
$remaining = $result->remaining;
$deadlineExceeded = $result->deadlineExceeded;
$limitExceeded = $result->limitExceeded;
}
} catch (\Throwable $e) {
error_log('Event processing error: ' . $e->getMessage());
$failures[] = $e;
try {
$this->container
?->get(DeferredEventProcessorInterface::class)
->discardDeferred($scope->descriptor->executionId);
} catch (\Throwable $discardError) {
$failures[] = $discardError;
}
$this->logger?->error('Deferred event processing failed.', [
'exception' => $e,
'execution_id' => $scope->descriptor->executionId,
'runtime' => $scope->descriptor->runtime->value,
]);
} finally {
if ($this->container !== null) {
foreach ([
EphemeralCacheInterface::class,
PersistentCacheInterface::class,
BlobCacheInterface::class,
] as $cacheType) {
$cache = $this->container->get($cacheType);
$cache->setUserContext(null);
$cache->setTenantContext(null);
}
}
$scope->dispose();
$scope->markTerminated();
$this->activeScope = null;
}
return new TerminationReport(
deferredProcessed: $processed,
deferredRemaining: $remaining,
failures: $failures,
deadlineExceeded: $deadlineExceeded,
limitExceeded: $limitExceeded,
);
}
/**
@@ -254,30 +296,34 @@ class Kernel
*/
protected function parameters(): array
{
$projectDir = $this->options->paths->project;
$cacheDir = $this->options->paths->cache($this->environment());
$logsDir = $this->options->paths->logs();
return [
'kernel.project_dir' => realpath($this->folderRoot()) ?: $this->folderRoot(),
'kernel.environment' => $this->environment,
'kernel.project_dir' => realpath($projectDir) ?: $projectDir,
'kernel.environment' => $this->environment(),
'kernel.runtime_environment' => '%env(default:kernel.environment:APP_RUNTIME_ENV)%',
'kernel.runtime_mode' => '%env(query_string:default:container.runtime_mode:APP_RUNTIME_MODE)%',
'kernel.runtime_mode.web' => '%env(bool:default::key:web:default:kernel.runtime_mode:)%',
'kernel.runtime_mode.cli' => '%env(not:default:kernel.runtime_mode.web:)%',
'kernel.runtime_mode.worker' => '%env(bool:default::key:worker:default:kernel.runtime_mode:)%',
'kernel.debug' => $this->debug,
'kernel.build_dir' => realpath($this->getBuildDir()) ?: $this->getBuildDir(),
'kernel.cache_dir' => realpath($this->getCacheDir()) ?: $this->getCacheDir(),
'kernel.logs_dir' => realpath($this->getLogDir()) ?: $this->getLogDir(),
'kernel.charset' => $this->getCharset(),
'kernel.debug' => $this->debug(),
'kernel.build_dir' => realpath($cacheDir) ?: $cacheDir,
'kernel.cache_dir' => realpath($cacheDir) ?: $cacheDir,
'kernel.logs_dir' => realpath($logsDir) ?: $logsDir,
'kernel.charset' => 'UTF-8',
];
}
public function environment(): string
{
return $this->environment;
return $this->options->environment;
}
public function debug(): bool
{
return $this->debug;
return $this->options->debug;
}
public function container(): ContainerInterface
@@ -291,61 +337,7 @@ class Kernel
public function getStartTime(): float
{
return $this->debug && null !== $this->startTime ? $this->startTime : -\INF;
}
/**
* Gets the application root dir (path of the project's composer file).
*/
public function folderRoot(): string
{
if (!isset($this->projectDir)) {
$r = new \ReflectionObject($this);
if (!is_file($dir = $r->getFileName())) {
throw new \LogicException(\sprintf('Cannot auto-detect project dir for kernel of class "%s".', $r->name));
}
$dir = $rootDir = \dirname($dir);
while (!is_file($dir.'/composer.json')) {
if ($dir === \dirname($dir)) {
return $this->projectDir = $rootDir;
}
$dir = \dirname($dir);
}
$this->projectDir = $dir;
}
return $this->projectDir;
}
/**
* Gets the path to the configuration directory.
*/
private function getConfigDir(): string
{
return $this->folderRoot().'/config';
}
public function getCacheDir(): string
{
return $this->folderRoot().'/var/cache/'.$this->environment;
}
public function getBuildDir(): string
{
return $this->getCacheDir();
}
public function getLogDir(): string
{
return $this->folderRoot().'/var/log';
}
public function getCharset(): string
{
return 'UTF-8';
return $this->debug() && null !== $this->startTime ? $this->startTime : -\INF;
}
/**
@@ -380,9 +372,9 @@ class Kernel
protected function configureContainer(Builder $builder): void
{
// Service definitions
$projectDir = $this->folderRoot();
$projectDir = $this->options->paths->project;
$moduleDir = $projectDir . '/modules';
$environment = $this->environment;
$environment = $this->environment();
$builder->addDefinitions([
@@ -395,6 +387,9 @@ class Kernel
// Without this alias, PHP-DI will happily autowire a new empty Container when asked
Container::class => \DI\get(ContainerInterface::class),
TenantContextInterface::class => \DI\get(TenantContext::class),
IdentityContextInterface::class => \DI\get(IdentityContext::class),
LoggerInterface::class => function (ContainerInterface $c) use ($projectDir) {
$logConfig = $this->config['log'] ?? [];
@@ -405,7 +400,7 @@ class Kernel
return new TenantAwareLogger(
$this->logger,
$c->get(SessionTenant::class),
$c->get(TenantContextInterface::class),
$logDir,
$channel,
$level,
@@ -413,8 +408,8 @@ class Kernel
);
},
// EventBus as singleton for consistent event handling
EventBus::class => \DI\create(EventBus::class),
EventDispatcherInterface::class => \DI\get(EventDispatcher::class),
DeferredEventProcessorInterface::class => \DI\get(EventDispatcher::class),
// Ephemeral Cache - for short-lived data (sessions, rate limits, challenges)
EphemeralCacheInterface::class => function(ContainerInterface $c) use ($projectDir) {
$storeType = $c->has('cache.ephemeral') ? $c->get('cache.ephemeral') : 'file';
@@ -433,13 +428,13 @@ class Kernel
$cache = new $storeClass($projectDir);
// Set tenant/user context if available
if ($c->has(SessionTenant::class)) {
$tenant = $c->get(SessionTenant::class);
$cache->setTenantContext($tenant->identifier());
if ($c->has(TenantContextInterface::class)) {
$tenantContext = $c->get(TenantContextInterface::class);
$cache->setTenantContext($tenantContext->identifier());
}
if ($c->has(SessionIdentity::class)) {
$identity = $c->get(SessionIdentity::class);
$cache->setUserContext($identity->identifier());
if ($c->has(IdentityContextInterface::class)) {
$identityContext = $c->get(IdentityContextInterface::class);
$cache->setUserContext($identityContext->identifier());
}
return $cache;
@@ -462,13 +457,13 @@ class Kernel
$cache = new $storeClass($projectDir);
// Set tenant/user context if available
if ($c->has(SessionTenant::class)) {
$tenant = $c->get(SessionTenant::class);
$cache->setTenantContext($tenant->identifier());
if ($c->has(TenantContextInterface::class)) {
$tenantContext = $c->get(TenantContextInterface::class);
$cache->setTenantContext($tenantContext->identifier());
}
if ($c->has(SessionIdentity::class)) {
$identity = $c->get(SessionIdentity::class);
$cache->setUserContext($identity->identifier());
if ($c->has(IdentityContextInterface::class)) {
$identityContext = $c->get(IdentityContextInterface::class);
$cache->setUserContext($identityContext->identifier());
}
return $cache;
@@ -491,13 +486,13 @@ class Kernel
$cache = new $storeClass($projectDir);
// Set tenant/user context if available
if ($c->has(SessionTenant::class)) {
$tenant = $c->get(SessionTenant::class);
$cache->setTenantContext($tenant->identifier());
if ($c->has(TenantContextInterface::class)) {
$tenantContext = $c->get(TenantContextInterface::class);
$cache->setTenantContext($tenantContext->identifier());
}
if ($c->has(SessionIdentity::class)) {
$identity = $c->get(SessionIdentity::class);
$cache->setUserContext($identity->identifier());
if ($c->has(IdentityContextInterface::class)) {
$identityContext = $c->get(IdentityContextInterface::class);
$cache->setUserContext($identityContext->identifier());
}
return $cache;
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace KTXC;
use KTXC\Application\Execution\ExecutionDescriptor;
use KTXC\Application\Execution\ExecutionOutcome;
use KTXC\Application\Execution\ExecutionRunnerInterface;
use KTXC\Application\Execution\ExecutionScope;
use KTXC\Application\Execution\TerminationReport;
use Psr\Container\ContainerInterface;
interface KernelInterface
{
public function boot(): void;
public function executionRunner(): ExecutionRunnerInterface;
public function beginExecution(ExecutionDescriptor $descriptor): ExecutionScope;
public function terminateExecution(
ExecutionScope $scope,
ExecutionOutcome $outcome,
): TerminationReport;
public function shutdown(): void;
public function container(): ContainerInterface;
public function environment(): string;
public function debug(): bool;
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace KTXC\L10N;
use DI\Attribute\Inject;
use KTXC\Http\Request\Request;
use KTXC\Service\UserAccountsService;
/**
* Resolves the effective UI locale for the current user.
*
* Resolution chain: user setting (core.locale) -> Accept-Language
* negotiation -> fallback. Tenant default slots in between once tenant
* settings expose one (Phase 3).
*/
class LocaleResolver
{
public const FALLBACK = 'en';
public const SETTING_KEY = 'core.locale';
public function __construct(
private readonly UserAccountsService $userService,
#[Inject('rootDir')] private readonly string $rootDir,
) {}
/**
* Locales with a core catalog on disk (public/l10n/*.json), always
* including the fallback. This backs the language picker, so a locale
* is only offered when it has at least core-shell coverage.
*
* @return string[]
*/
public function available(): array
{
$locales = [self::FALLBACK];
foreach (glob($this->rootDir . '/public/l10n/*.json') ?: [] as $file) {
$locales[] = basename($file, '.json');
}
$locales = array_values(array_unique($locales));
sort($locales);
return $locales;
}
public function resolve(Request $request): string
{
$available = $this->available();
// 1. explicit user preference
$settings = $this->userService->fetchSettings([self::SETTING_KEY]);
$preference = $settings[self::SETTING_KEY] ?? null;
if (is_string($preference) && in_array($preference, $available, true)) {
return $preference;
}
// 2. Accept-Language negotiation. getPreferredLanguage() returns the
// first candidate when nothing matches, so the fallback leads the
// list; it also normalizes to underscore form (pt_BR) while catalogs
// use BCP-47 hyphens.
$candidates = array_merge([self::FALLBACK], array_diff($available, [self::FALLBACK]));
$negotiated = $request->getPreferredLanguage($candidates);
if ($negotiated !== null) {
$negotiated = str_replace('_', '-', $negotiated);
if (in_array($negotiated, $available, true)) {
return $negotiated;
}
}
// 3. fallback
return self::FALLBACK;
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ use Psr\Log\NullLogger;
*
* Per-tenant routing (TenantAwareLogger) is applied separately inside the
* DI container definition in Kernel::configureContainer(), because that
* requires access to the SessionTenant singleton which lives in the container.
* requires access to the TenantContextInterface singleton which lives in the container.
*
* Supported config keys inside $config['log']:
*
+6 -6
View File
@@ -2,7 +2,7 @@
namespace KTXC\Logger;
use KTXC\SessionTenant;
use KTXC\Context\TenantContextInterface;
use Psr\Log\LoggerInterface;
/**
@@ -20,7 +20,7 @@ use Psr\Log\LoggerInterface;
* {logDir}/tenant/{tenantIdentifier}/{channel}.jsonl
* Messages that carry tenant "system" always go to the global logger.
*
* Both behaviours rely on a live SessionTenant reference that is populated lazily
* Both behaviours rely on a live TenantContextInterface reference that is populated lazily
* by TenantMiddleware — the same pattern the cache stores use in Kernel::configureContainer().
*/
class TenantAwareLogger implements LoggerInterface
@@ -30,7 +30,7 @@ class TenantAwareLogger implements LoggerInterface
/**
* @param LoggerInterface $globalLogger Fallback logger (also used when perTenant = false).
* @param SessionTenant $sessionTenant Live reference configured by TenantMiddleware.
* @param TenantContextInterface $tenantContext Live reference configured by TenantMiddleware.
* @param string $logDir Base log directory (e.g. /var/www/app/var/log).
* @param string $channel Log file basename (e.g. 'app' → app.jsonl).
* @param string $minLevel Minimum PSR-3 level for lazily-created per-tenant loggers.
@@ -38,7 +38,7 @@ class TenantAwareLogger implements LoggerInterface
*/
public function __construct(
private readonly LoggerInterface $globalLogger,
private readonly SessionTenant $sessionTenant,
private readonly TenantContextInterface $tenantContext,
private readonly string $logDir,
private readonly string $channel = 'app',
private readonly string $minLevel = 'debug',
@@ -60,8 +60,8 @@ class TenantAwareLogger implements LoggerInterface
{
// Resolve current tenant id; fall back to 'system' for CLI / boot phase.
$tenantId = 'system';
if ($this->sessionTenant->configured()) {
$tenantId = $this->sessionTenant->identifier() ?? 'system';
if ($this->tenantContext->configured()) {
$tenantId = $this->tenantContext->identifier() ?? 'system';
}
// Inject tenant id as a reserved context key that concrete loggers extract.
+2 -2
View File
@@ -52,8 +52,8 @@ class TenantObject extends JsonSerializableObject
'enabled' => $this->enabled,
'label' => $this->label,
'description' => $this->description,
'domains' => $this->domains,
'configuration' => $this->configuration,
'domains' => $this->domains?->getArrayCopy(),
'configuration' => $this->configuration?->jsonSerialize(),
];
}
+54 -8
View File
@@ -2,6 +2,10 @@
namespace KTXC\Module;
use KTXC\Service\FirewallService;
use KTXF\Event\DeliveryMode;
use KTXF\Event\EventListenerRegistry;
use KTXF\Event\SecurityEvent;
use KTXF\Module\ModuleBrowserInterface;
use KTXF\Module\ModuleConsoleInterface;
use KTXF\Module\ModuleInstanceAbstract;
@@ -13,7 +17,36 @@ use KTXF\Module\ModuleInstanceAbstract;
*/
class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, ModuleBrowserInterface
{
public function __construct() {}
public function __construct(
private readonly EventListenerRegistry $events,
) {
}
public function boot(): void
{
$this->events->listen(
'core',
SecurityEvent::AUTH_FAILURE,
FirewallService::class,
'handleAuthFailure',
priority: 100,
);
foreach ([
SecurityEvent::AUTH_FAILURE,
SecurityEvent::AUTH_SUCCESS,
SecurityEvent::ACCESS_DENIED,
SecurityEvent::BRUTE_FORCE_DETECTED,
] as $event) {
$this->events->listen(
'core',
$event,
FirewallService::class,
'logSecurityEvent',
DeliveryMode::Deferred,
);
}
}
public function handle(): string
{
@@ -27,7 +60,7 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
public function author(): string
{
return 'Ktrix';
return 'Vallarx';
}
public function description(): string
@@ -99,12 +132,25 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
public function registerCI(): array
{
return [
\KTXC\Console\ModuleListCommand::class,
\KTXC\Console\ModuleEnableCommand::class,
\KTXC\Console\ModuleDisableCommand::class,
\KTXC\Console\ModuleInstallCommand::class,
\KTXC\Console\ModuleUninstallCommand::class,
\KTXC\Console\ModuleUpgradeCommand::class,
\KTXC\Console\Event\EventsDebugCommand::class,
\KTXC\Console\Module\ModuleListCommand::class,
\KTXC\Console\Module\ModuleEnableCommand::class,
\KTXC\Console\Module\ModuleDisableCommand::class,
\KTXC\Console\Module\ModuleInstallCommand::class,
\KTXC\Console\Module\ModuleUninstallCommand::class,
\KTXC\Console\Module\ModuleUpgradeCommand::class,
\KTXC\Console\Tenant\TenantCreateCommand::class,
\KTXC\Console\Tenant\TenantListCommand::class,
\KTXC\Console\Tenant\TenantDeleteCommand::class,
\KTXC\Console\Tenant\TenantAuthEnableCommand::class,
\KTXC\Console\User\UserCreateCommand::class,
\KTXC\Console\User\UserListCommand::class,
\KTXC\Console\User\UserDeleteCommand::class,
\KTXC\Console\Role\RoleCreateCommand::class,
\KTXC\Console\Role\RoleListCommand::class,
\KTXC\Console\Role\RoleDeleteCommand::class,
\KTXC\Console\Role\RoleAssignCommand::class,
\KTXC\Console\Role\RoleRevokeCommand::class,
];
}
+7 -5
View File
@@ -2,7 +2,7 @@
namespace KTXC\Module;
use KTXC\Server;
use Composer\Autoload\ClassLoader;
/**
* Custom autoloader for modules that allows PascalCase namespaces
@@ -20,7 +20,10 @@ class ModuleAutoloader
private array $namespaceMap = [];
private bool $scanned = false;
public function __construct(string $modulesRoot)
public function __construct(
string $modulesRoot,
private readonly ?ClassLoader $composerLoader = null,
)
{
$this->modulesRoot = rtrim($modulesRoot, '/');
}
@@ -73,10 +76,9 @@ class ModuleAutoloader
}
// Register module namespaces with Composer ClassLoader
$composerLoader = Server::getComposerLoader();
if ($composerLoader !== null) {
if ($this->composerLoader !== null) {
foreach ($this->namespaceMap as $namespace => $folderName) {
$composerLoader->addPsr4(
$this->composerLoader->addPsr4(
'KTXM\\' . $namespace . '\\',
$this->modulesRoot . '/' . $folderName . '/lib/'
);
+2 -1
View File
@@ -276,12 +276,13 @@ class ModuleManager
try {
$module->boot();
$this->logger->debug('Module booted', ['handle' => $handle]);
} catch (Exception $e) {
} catch (\Throwable $e) {
$this->logger->error('Module boot failed: ' . $handle, [
'exception' => $e,
'message' => $e->getMessage(),
'code' => $e->getCode(),
]);
throw $e;
}
}
}
+10 -2
View File
@@ -4,6 +4,7 @@ namespace KTXC\Routing;
use DI\Attribute\Inject;
use KTXC\Http\Request\Request;
use KTXC\Http\Request\RequestInputParameters;
use KTXC\Http\Response\Response;
use KTXC\Injection\Container;
use KTXC\Module\ModuleManager;
@@ -207,7 +208,8 @@ class Router
/**
* Dispatch a matched route meta and return a Response (or null if controller does not return one).
* Performs light argument resolution: Request object, route params, body fields, full body for array params.
* Performs light argument resolution: Request object, route params, body fields, query params, full body for array params.
* Precedence on name collision: route params > body fields > query params > parameter default.
*/
public function dispatch(Route $route, Request $request): ?Response
{
@@ -224,7 +226,8 @@ class Router
try {
$requestParameters = $request->getPayload();
} catch (\Throwable) {
// ignore payload errors
// ignore payload errors, fall back to an empty parameter bag
$requestParameters = new RequestInputParameters([]);
}
$reflectionMethod = new \ReflectionMethod($routeControllerName, $routeControllerMethod);
$routeParams = $route->params ?? [];
@@ -258,6 +261,11 @@ class Router
$callArgs[] = $requestParameters->get($reflectionParameterName);
continue;
}
// if method parameter matches a query string param, use that
if ($request->query->has($reflectionParameterName)) {
$callArgs[] = $request->query->get($reflectionParameterName);
continue;
}
// if method parameter did not match, but has a default value, use that
if ($reflectionParameter->isDefaultValueAvailable()) {
$callArgs[] = $reflectionParameter->getDefaultValue();
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace KTXC\Runtime\Console;
use KTXC\Application\Execution\ExecutionDescriptor;
use KTXC\Kernel;
use KTXC\KernelInterface;
use KTXC\Module\ModuleManager;
use KTXF\Module\ModuleConsoleInterface;
use Psr\Container\ContainerInterface;
use Symfony\Component\Console\Application as ConsoleApplication;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\LazyCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
final class ConsoleRuntime
{
public function __construct(
private readonly KernelInterface $kernel,
) {
}
public function run(?InputInterface $input = null, ?OutputInterface $output = null): int
{
return $this->kernel->executionRunner()->execute(
ExecutionDescriptor::cli(),
function () use ($input, $output): int {
$container = $this->kernel->container();
$console = new ConsoleApplication('Vallarx Console', Kernel::VERSION);
$console->setAutoExit(false);
/** @var ModuleManager $moduleManager */
$moduleManager = $container->get(ModuleManager::class);
foreach ($moduleManager->list() as $module) {
$instance = $module->instance();
if (!$instance instanceof ModuleConsoleInterface) {
continue;
}
foreach ($instance->registerCI() as $commandClass) {
$this->registerCommand($console, $container, $commandClass);
}
}
return $console->run($input, $output);
}
);
}
/**
* @param class-string $commandClass
*/
private function registerCommand(ConsoleApplication $console, ContainerInterface $container, string $commandClass): void {
if (!class_exists($commandClass)) {
throw new \RuntimeException("Command class not found: {$commandClass}");
}
$reflection = new \ReflectionClass($commandClass);
$attributes = $reflection->getAttributes(AsCommand::class);
if ($attributes === []) {
throw new \RuntimeException("Command {$commandClass} is missing #[AsCommand].");
}
$attribute = $attributes[0]->newInstance();
$console->add(new LazyCommand(
$attribute->name,
[],
$attribute->description ?? '',
$attribute->hidden ?? false,
static fn() => $container->get($commandClass),
));
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace KTXC\Runtime\Http;
use KTXC\Application\Execution\ExecutionDescriptor;
use KTXC\Http\Middleware\AuthenticationMiddleware;
use KTXC\Http\Middleware\FirewallMiddleware;
use KTXC\Http\Middleware\MiddlewarePipeline;
use KTXC\Http\Middleware\RouterMiddleware;
use KTXC\Http\Middleware\TenantMiddleware;
use KTXC\Http\Request\Request;
use KTXC\Http\Response\Response;
use KTXC\KernelInterface;
final class HttpRuntime
{
public function __construct(
private readonly KernelInterface $kernel,
private readonly bool $debug,
) {
}
public function run(?Request $request = null, bool $send = true): Response
{
$request ??= Request::createFromGlobals();
return $this->kernel->executionRunner()->execute(
ExecutionDescriptor::http(),
function () use ($request, $send): Response {
$response = $this->pipeline()->handle($request);
if ($send) {
$response->send();
}
return $response;
},
function (\Throwable $error) use ($send): Response {
$response = $this->errorResponse($error);
if ($send) {
$response->send();
}
return $response;
},
);
}
private function pipeline(): MiddlewarePipeline
{
$pipeline = new MiddlewarePipeline($this->kernel->container());
$pipeline->pipe(TenantMiddleware::class);
$pipeline->pipe(FirewallMiddleware::class);
$pipeline->pipe(AuthenticationMiddleware::class);
$pipeline->pipe(RouterMiddleware::class);
return $pipeline;
}
private function errorResponse(\Throwable $error): Response
{
error_log(sprintf(
'Application error: %s in %s:%d',
$error->getMessage(),
$error->getFile(),
$error->getLine(),
));
$content = $this->debug
? '<pre>' . htmlspecialchars((string) $error) . '</pre>'
: 'An error occurred. Please try again later.';
return new Response($content, Response::HTTP_INTERNAL_SERVER_ERROR, [
'Content-Type' => 'text/html; charset=UTF-8',
]);
}
}
+17 -10
View File
@@ -10,7 +10,7 @@ use KTXC\Security\Authentication\AuthenticationRequest;
use KTXC\Security\Authentication\AuthenticationResponse;
use KTXC\Service\TokenService;
use KTXC\Service\UserAccountsService;
use KTXC\SessionTenant;
use KTXC\Context\TenantContextInterface;
use KTXF\Cache\CacheScope;
use KTXF\Cache\EphemeralCacheInterface;
use KTXF\Security\Authentication\AuthenticationProviderInterface;
@@ -26,13 +26,13 @@ class AuthenticationManager
private string $securityCode;
public function __construct(
private readonly SessionTenant $tenant,
private readonly TenantContextInterface $tenantContext,
private readonly EphemeralCacheInterface $cache,
private readonly ProviderManager $providerManager,
private readonly TokenService $tokenService,
private readonly UserAccountsService $userService,
) {
$this->securityCode = $this->tenant->configuration()->security()->code();
$this->securityCode = $this->tenantContext->configuration()->security()->code();
}
// =========================================================================
@@ -75,7 +75,7 @@ class AuthenticationManager
$methods = $this->methodsConfigured();
$session = AuthenticationSession::create(
$this->tenant->identifier(),
$this->tenantContext->identifier(),
AuthenticationSession::STATE_FRESH
);
@@ -103,7 +103,7 @@ class AuthenticationManager
// Filter to non-redirect methods since redirects don't need identity first
$methods = $this->methodsConfigured();
$methods = array_values(array_filter($methods, fn($m) => $m['method'] !== 'redirect'));
$require = $this->tenant->configuration()->authentication()->methodsMinimal();
$require = $this->tenantContext->configuration()->authentication()->methodsMinimal();
// Store identity in session without validating to prevent enumeration
$session->setMethods(array_column($methods, 'id'), $require);
@@ -155,6 +155,13 @@ class AuthenticationManager
);
}
if ($session->userIdentifier === null && $session->userIdentity) {
$user = $this->userService->fetchByIdentity($session->userIdentity);
if ($user) {
$session->userIdentifier = $user->getId();
}
}
// Build provider context
$context = $this->buildProviderContext($session, $method);
@@ -422,7 +429,7 @@ class AuthenticationManager
$session->methodCompleted($method);
// Check if MFA is required
$require = $this->tenant->configuration()->authentication()->methodsMinimal();
$require = $this->tenantContext->configuration()->authentication()->methodsMinimal();
if ($require > 1) {
$remainingMethods = $this->methodsConfigured([$method]);
// Filter out redirect methods - they can't be used as secondary factors
@@ -516,7 +523,7 @@ class AuthenticationManager
$accessToken = $this->tokenService->createToken(
[
'tenant' => $this->tenant->identifier(),
'tenant' => $this->tenantContext->identifier(),
'identifier' => $user->getId(),
'identity' => $user->getIdentity(),
'label' => $user->getLabel(),
@@ -578,7 +585,7 @@ class AuthenticationManager
*/
private function getProviderConfig(string $method): array
{
$providers = $this->tenant->configuration()->authentication()->providers();
$providers = $this->tenantContext->configuration()->authentication()->providers();
return $providers[$method]['config'] ?? [];
}
@@ -628,7 +635,7 @@ class AuthenticationManager
*/
private function methodsConfigured(array $methodsCompleted = []): array
{
$tenantProviders = $this->tenant->configuration()->authentication()->providers();
$tenantProviders = $this->tenantContext->configuration()->authentication()->providers();
$methods = [];
foreach ($tenantProviders as $providerId => $providerConfiguration) {
@@ -662,7 +669,7 @@ class AuthenticationManager
private function createTokens(User $user, bool $mfaVerified = false): array
{
$payload = [
'tenant' => $this->tenant->identifier(),
'tenant' => $this->tenantContext->identifier(),
'identifier' => $user->getId(),
'identity' => $user->getIdentity(),
'label' => $user->getLabel(),
@@ -2,7 +2,7 @@
namespace KTXC\Security\Authorization;
use KTXC\SessionIdentity;
use KTXC\Context\IdentityContextInterface;
/**
* Permission Checker
@@ -11,7 +11,7 @@ use KTXC\SessionIdentity;
class PermissionChecker
{
public function __construct(
private readonly SessionIdentity $sessionIdentity
private readonly IdentityContextInterface $identityContext
) {}
/**
@@ -24,7 +24,7 @@ class PermissionChecker
*/
public function can(string $permission, mixed $resource = null): bool
{
$identity = $this->sessionIdentity->identity();
$identity = $this->identityContext->identity();
if (!$identity) {
return false;
@@ -113,7 +113,7 @@ class PermissionChecker
*/
public function getUserPermissions(): array
{
$identity = $this->sessionIdentity->identity();
$identity = $this->identityContext->identity();
if (!$identity) {
return [];
-311
View File
@@ -1,311 +0,0 @@
<?php
namespace KTXC;
use KTXC\Http\Request\Request;
use KTXC\Http\Response\Response;
use KTXC\Module\ModuleAutoloader;
use KTXC\Module\ModuleManager;
use KTXF\Module\ModuleConsoleInterface;
use Psr\Container\ContainerInterface;
use Symfony\Component\Console\Application as ConsoleApplication;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\LazyCommand;
/**
* Server class - entry point for the framework
* Handles configuration loading and kernel lifecycle
*/
class Server
{
private static $composerLoader = null;
private static ?self $instance = null;
private Kernel $kernel;
private array $config;
private string $rootDir;
public function __construct(string $rootDir, ?string $environment = null, ?bool $debug = null)
{
self::$instance = $this;
$this->rootDir = $this->resolveProjectRoot($rootDir);
// Load configuration
$this->config = $this->loadConfig();
// Determine environment and debug mode
$environment = $environment ?? $this->config['environment'] ?? 'prod';
$debug = $debug ?? $this->config['debug'] ?? false;
// Create kernel with configuration
$this->kernel = new Kernel($environment, $debug, $this->config, $rootDir);
// Register module autoloader for both HTTP and CLI contexts
$moduleAutoloader = new ModuleAutoloader($this->moduleDir());
$moduleAutoloader->register();
}
/**
* Run the application - handle incoming request and send response
*/
public function runHttp(): void
{
try {
$request = Request::createFromGlobals();
$response = $this->handle($request);
$response->send();
$this->terminate();
} catch (\Throwable $e) {
// Last resort error handling for kernel initialization failures
error_log('Application error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
$content = $this->kernel->debug()
? '<pre>' . htmlspecialchars((string) $e) . '</pre>'
: 'An error occurred. Please try again later.';
$response = new Response($content, Response::HTTP_INTERNAL_SERVER_ERROR, [
'Content-Type' => 'text/html; charset=UTF-8',
]);
$response->send();
exit(1);
}
}
/**
* Run as a console application (CLI runtime).
*/
public function runConsole(): int
{
$this->kernel()->boot();
$container = $this->container();
$console = new ConsoleApplication('Ktrix Console', Kernel::VERSION);
/** @var ModuleManager $moduleManager */
$moduleManager = $container->get(ModuleManager::class);
foreach ($moduleManager->list() as $module) {
$instance = $module->instance();
if (!$instance instanceof ModuleConsoleInterface) {
continue;
}
try {
foreach ($instance->registerCI() as $commandClass) {
if (!class_exists($commandClass)) {
fwrite(STDERR, "Warning: Command class not found: {$commandClass}\n");
continue;
}
$this->registerLazyCommand($console, $container, $commandClass);
}
} catch (\Throwable $e) {
fwrite(STDERR, "Warning: Failed to load commands from module {$module->handle()}: {$e->getMessage()}\n");
}
}
return $console->run();
}
/**
* Handle a request
*/
public function handle(Request $request): Response
{
return $this->kernel->handle($request);
}
/**
* Terminate the application - process deferred events
*/
public function terminate(): void
{
$this->kernel->processEvents();
}
/**
* Get the kernel instance
*/
public function kernel(): Kernel
{
return $this->kernel;
}
/**
* Get the container instance
*/
public function container(): ContainerInterface
{
return $this->kernel->container();
}
/**
* Get the application root directory
*/
public function rootDir(): string
{
return $this->rootDir;
}
/**
* Get the modules directory
*/
public function moduleDir(): string
{
return $this->rootDir . '/modules';
}
public function varDir(): string
{
return $this->rootDir . '/var';
}
public function logDir(): string
{
return $this->varDir() . '/logs';
}
/**
* Get configuration value
*/
public function config(?string $key = null, mixed $default = null): mixed
{
if ($key === null) {
return $this->config;
}
// Support dot notation: 'database.uri'
$keys = explode('.', $key);
$value = $this->config;
foreach ($keys as $k) {
if (!is_array($value) || !array_key_exists($k, $value)) {
return $default;
}
$value = $value[$k];
}
return $value;
}
/**
* Get environment
*/
public function environment(): string
{
return $this->kernel->environment();
}
/**
* Check if debug mode is enabled
*/
public function debug(): bool
{
return $this->kernel->debug();
}
/**
* Load configuration from config directory
*/
protected function loadConfig(): array
{
$configFile = $this->rootDir . '/config/system.php';
if (!file_exists($configFile)) {
error_log('Configuration file not found: ' . $configFile);
return [];
}
$config = include $configFile;
if (!is_array($config)) {
throw new \RuntimeException('Configuration file must return an array');
}
return $config;
}
/**
* Resolve the project root directory.
*
* Some entrypoints may pass the public/ directory or another subdirectory.
* We walk up the directory tree until we find composer.json.
*/
private function resolveProjectRoot(string $startDir): string
{
$dir = rtrim($startDir, '/');
if ($dir === '') {
return $startDir;
}
// If startDir is a file path, use its directory.
if (is_file($dir)) {
$dir = dirname($dir);
}
$current = $dir;
while (true) {
if (is_file($current . '/composer.json')) {
return $current;
}
$parent = dirname($current);
if ($parent === $current) {
// Reached filesystem root
return $dir;
}
$current = $parent;
}
}
/**
* Set the Composer ClassLoader instance
*/
public static function setComposerLoader($loader): void
{
self::$composerLoader = $loader;
}
/**
* Get the Composer ClassLoader instance
*/
public static function getComposerLoader()
{
return self::$composerLoader;
}
/**
* Get the current Application instance
*/
public static function getInstance(): ?self
{
return self::$instance;
}
/**
* Register a single command via lazy loading using its #[AsCommand] attribute.
*/
private function registerLazyCommand(
ConsoleApplication $console,
ContainerInterface $container,
string $commandClass
): void {
try {
$ref = new \ReflectionClass($commandClass);
$attrs = $ref->getAttributes(AsCommand::class);
if (empty($attrs)) {
fwrite(STDERR, "Warning: Command {$commandClass} missing #[AsCommand] attribute\n");
return;
}
$attr = $attrs[0]->newInstance();
$console->add(new LazyCommand(
$attr->name,
[],
$attr->description ?? '',
$attr->hidden ?? false,
fn() => $container->get($commandClass)
));
} catch (\Throwable $e) {
fwrite(STDERR, "Warning: Failed to register command {$commandClass}: {$e->getMessage()}\n");
}
}
}
+14 -14
View File
@@ -5,7 +5,7 @@ namespace KTXC\Service;
use KTXC\Db\DataStore;
use KTXC\Db\Collection;
use KTXC\Db\UTCDateTime;
use KTXC\SessionTenant;
use KTXC\Context\TenantContextInterface;
class ConfigurationService
{
@@ -24,7 +24,7 @@ class ConfigurationService
public function __construct(
DataStore $store,
private readonly SessionTenant $tenant
private readonly TenantContextInterface $tenantContext
) {
// DataStore provides selectCollection method
$this->collection = $store->selectCollection(self::TABLE_NAME);
@@ -36,10 +36,10 @@ class ConfigurationService
*/
public function get(string $path, string $key, mixed $default = null, ?string $tenant = null): mixed
{
if ($tenant === null && !$this->tenant->isConfigured()) {
if ($tenant === null && !$this->tenantContext->configured()) {
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
} elseif ($tenant === null) {
$tenant = $this->tenant->identifier();
$tenant = $this->tenantContext->identifier();
}
$doc = $this->collection->findOne(['did' => $tenant, 'path' => $path, 'key' => $key]);
@@ -54,10 +54,10 @@ class ConfigurationService
*/
public function set(string $path, string $key, mixed $value, mixed $default = null, ?string $tenant = null): bool
{
if ($tenant === null && !$this->tenant->isConfigured()) {
if ($tenant === null && !$this->tenantContext->configured()) {
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
} elseif ($tenant === null) {
$tenant = $this->tenant->identifier();
$tenant = $this->tenantContext->identifier();
}
$type = $this->determineType($value);
@@ -84,10 +84,10 @@ class ConfigurationService
*/
public function getByPath(?string $path = null, bool $subset = false, ?string $tenant = null): array
{
if ($tenant === null && !$this->tenant->isConfigured()) {
if ($tenant === null && !$this->tenantContext->configured()) {
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
} elseif ($tenant === null) {
$tenant = $this->tenant->identifier();
$tenant = $this->tenantContext->identifier();
}
$filter = ['did' => $tenant];
@@ -116,10 +116,10 @@ class ConfigurationService
*/
public function delete(string $path, string $key, ?string $tenant = null): bool
{
if ($tenant === null && !$this->tenant->isConfigured()) {
if ($tenant === null && !$this->tenantContext->configured()) {
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
} elseif ($tenant === null) {
$tenant = $this->tenant->identifier();
$tenant = $this->tenantContext->identifier();
}
$this->collection->deleteOne(['did' => $tenant, 'path' => $path, 'key' => $key]);
@@ -131,10 +131,10 @@ class ConfigurationService
*/
public function deleteByPath(string $path, bool $includeSubPaths = false, ?string $tenant = null): bool
{
if ($tenant === null && !$this->tenant->isConfigured()) {
if ($tenant === null && !$this->tenantContext->configured()) {
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
} elseif ($tenant === null) {
$tenant = $this->tenant->identifier();
$tenant = $this->tenantContext->identifier();
}
$filter = ['did' => $tenant];
@@ -155,10 +155,10 @@ class ConfigurationService
*/
public function exists(string $path, string $key, ?string $tenant = null): bool
{
if ($tenant === null && !$this->tenant->isConfigured()) {
if ($tenant === null && !$this->tenantContext->configured()) {
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
} elseif ($tenant === null) {
$tenant = $this->tenant->identifier();
$tenant = $this->tenantContext->identifier();
}
return $this->collection->countDocuments(['did' => $tenant, 'path' => $path, 'key' => $key]) > 0;
+25 -49
View File
@@ -8,8 +8,8 @@ use KTXC\Http\Request\Request;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Models\Firewall\FirewallLogObject;
use KTXC\Stores\FirewallStore;
use KTXC\SessionTenant;
use KTXF\Event\EventBus;
use KTXC\Context\TenantContextInterface;
use KTXF\Event\EventDispatcherInterface;
use KTXF\Event\SecurityEvent;
use KTXF\IpUtils;
@@ -41,33 +41,9 @@ class FirewallService
public function __construct(
private readonly FirewallStore $store,
private readonly SessionTenant $tenant,
private readonly EventBus $eventBus
private readonly TenantContextInterface $tenantContext,
private readonly EventDispatcherInterface $events,
) {
// Listen for auth failures to detect brute force
$this->eventBus->subscribe(
SecurityEvent::AUTH_FAILURE,
[$this, 'handleAuthFailure'],
100 // High priority
);
// Log all security events asynchronously
$this->eventBus->subscribeAsync(
SecurityEvent::AUTH_FAILURE,
[$this, 'logSecurityEvent']
);
$this->eventBus->subscribeAsync(
SecurityEvent::AUTH_SUCCESS,
[$this, 'logSecurityEvent']
);
$this->eventBus->subscribeAsync(
SecurityEvent::ACCESS_DENIED,
[$this, 'logSecurityEvent']
);
$this->eventBus->subscribeAsync(
SecurityEvent::BRUTE_FORCE_DETECTED,
[$this, 'logSecurityEvent']
);
}
/**
@@ -100,7 +76,7 @@ class FirewallService
return new FirewallAnalyzeResult(true);
}
$tenantId = $this->tenant->identifier();
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
return new FirewallAnalyzeResult(true);
}
@@ -158,7 +134,7 @@ class FirewallService
public function handleAuthFailure(SecurityEvent $event): void
{
$ipAddress = $event->getIpAddress();
$tenantId = $event->getTenantId() ?? $this->tenant->identifier();
$tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
if (!$ipAddress || !$tenantId) {
return;
@@ -198,8 +174,8 @@ class FirewallService
): void {
// Publish brute force event
$event = SecurityEvent::bruteForceDetected($ipAddress, $failureCount, $windowSeconds);
$event->setTenantId($this->tenant->identifier());
$this->eventBus->publish($event);
$event->setTenantId($this->tenantContext->identifier());
$this->events->dispatch($event);
// Auto-block the IP
$blockDuration = $this->getConfig(
@@ -220,7 +196,7 @@ class FirewallService
*/
public function logSecurityEvent(SecurityEvent $event): void
{
$tenantId = $event->getTenantId() ?? $this->tenant->identifier();
$tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
if (!$tenantId) {
return;
}
@@ -283,8 +259,8 @@ class FirewallService
$rule->getId(),
$rule->getReason()
);
$event->setTenantId($this->tenant->identifier());
$this->eventBus->publish($event);
$event->setTenantId($this->tenantContext->identifier());
$this->events->dispatch($event);
}
// ========================================
@@ -300,7 +276,7 @@ class FirewallService
?string $createdBy = null,
?int $durationSeconds = null
): FirewallRuleObject {
$tenantId = $this->tenant->identifier();
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
}
@@ -340,7 +316,7 @@ class FirewallService
$event->setIpAddress($ipAddress)
->setReason($reason)
->setTenantId($tenantId);
$this->eventBus->publish($event);
$this->events->dispatch($event);
return $rule;
}
@@ -353,7 +329,7 @@ class FirewallService
?string $reason = null,
?string $createdBy = null
): FirewallRuleObject {
$tenantId = $this->tenant->identifier();
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
}
@@ -376,7 +352,7 @@ class FirewallService
$event->setIpAddress($ipAddress)
->setReason($reason)
->setTenantId($tenantId);
$this->eventBus->publish($event);
$this->events->dispatch($event);
return $rule;
}
@@ -389,7 +365,7 @@ class FirewallService
?string $reason = null,
?string $createdBy = null
): FirewallRuleObject {
$tenantId = $this->tenant->identifier();
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
}
@@ -419,7 +395,7 @@ class FirewallService
?string $createdBy = null,
?int $durationSeconds = null
): FirewallRuleObject {
$tenantId = $this->tenant->identifier();
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
}
@@ -448,7 +424,7 @@ class FirewallService
$event->setDeviceFingerprint($fingerprint)
->setReason($reason)
->setTenantId($tenantId);
$this->eventBus->publish($event);
$this->events->dispatch($event);
return $rule;
}
@@ -464,7 +440,7 @@ class FirewallService
}
// Verify tenant ownership
if ($rule->getTenantId() !== $this->tenant->identifier()) {
if ($rule->getTenantId() !== $this->tenantContext->identifier()) {
return false;
}
@@ -485,7 +461,7 @@ class FirewallService
}
// Verify tenant ownership
if ($rule->getTenantId() !== $this->tenant->identifier()) {
if ($rule->getTenantId() !== $this->tenantContext->identifier()) {
return false;
}
@@ -501,7 +477,7 @@ class FirewallService
*/
public function listRules(bool $activeOnly = true): array
{
$tenantId = $this->tenant->identifier();
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
return [];
}
@@ -518,7 +494,7 @@ class FirewallService
?string $result = null,
int $limit = 100
): array {
$tenantId = $this->tenant->identifier();
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
return [];
}
@@ -531,7 +507,7 @@ class FirewallService
*/
public function getBlockedCount(?\DateTimeImmutable $since = null): int
{
$tenantId = $this->tenant->identifier();
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
return 0;
}
@@ -556,7 +532,7 @@ class FirewallService
*/
private function getConfig(string $key, mixed $default = null): mixed
{
$config = $this->tenant->configuration();
$config = $this->tenantContext->configuration();
$parts = explode('.', $key);
foreach ($parts as $part) {
@@ -576,7 +552,7 @@ class FirewallService
private function getActiveRules(): array
{
if ($this->rulesCache === null) {
$tenantId = $this->tenant->identifier();
$tenantId = $this->tenantContext->identifier();
$this->rulesCache = $tenantId
? $this->store->listRules($tenantId, true)
: [];
+4 -4
View File
@@ -7,7 +7,7 @@ namespace KTXC\Service;
use KTXC\Http\Request\Request;
use KTXC\Models\Identity\User;
use KTXC\Resource\ProviderManager;
use KTXC\SessionTenant;
use KTXC\Context\TenantContextInterface;
use KTXF\Security\Authentication\AuthenticationProviderInterface;
/**
@@ -23,12 +23,12 @@ class SecurityService
private string $securityCode;
public function __construct(
private readonly SessionTenant $sessionTenant,
private readonly TenantContextInterface $tenantContext,
private readonly TokenService $tokenService,
private readonly UserAccountsService $userService,
private readonly ProviderManager $providerManager,
) {
$this->securityCode = $this->sessionTenant->configuration()->security()->code();
$this->securityCode = $this->tenantContext->configuration()->security()->code();
}
/**
@@ -118,7 +118,7 @@ class SecurityService
continue;
}
$context = new \KTXF\Security\Authentication\ProviderContext(
tenantId: $this->sessionTenant->identifier(),
tenantId: $this->tenantContext->identifier(),
userIdentity: $identity,
);
$result = $provider->verify($context, $credentials);
+25
View File
@@ -21,6 +21,31 @@ class TenantService
return $this->store->fetch($identifier);
}
/**
* List all tenants keyed by id
*
* @return array<string, TenantObject>
*/
public function list(): array
{
return $this->store->list();
}
public function deposit(TenantObject $tenant): ?TenantObject
{
$domains = $tenant->getDomains();
if ($domains === null || count($domains) === 0) {
throw new \InvalidArgumentException('A tenant must have at least one domain.');
}
return $this->store->deposit($tenant);
}
public function destroy(TenantObject $tenant): void
{
$this->store->destroy($tenant);
}
// =========================================================================
// Settings
// =========================================================================
+2 -2
View File
@@ -2,7 +2,7 @@
namespace KTXC\Service;
use KTXC\SessionTenant;
use KTXC\Context\TenantContextInterface;
use KTXF\Cache\CacheScope;
use KTXF\Cache\EphemeralCacheInterface;
@@ -26,7 +26,7 @@ class TokenService
private string $algorithm = 'HS256';
public function __construct(
private readonly SessionTenant $sessionTenant,
private readonly TenantContextInterface $tenantContext,
private readonly EphemeralCacheInterface $cache,
) {
}
+16 -16
View File
@@ -3,16 +3,16 @@
namespace KTXC\Service;
use KTXC\Models\Identity\User;
use KTXC\SessionIdentity;
use KTXC\SessionTenant;
use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface;
use KTXC\Stores\UserAccountsStore;
class UserAccountsService
{
public function __construct(
private readonly SessionTenant $tenantIdentity,
private readonly SessionIdentity $userIdentity,
private readonly TenantContextInterface $tenantContext,
private readonly IdentityContextInterface $identityContext,
private readonly UserAccountsStore $userStore
) {
}
@@ -26,7 +26,7 @@ class UserAccountsService
*/
public function listUsers(array $filters = []): array
{
$users = $this->userStore->listUsers($this->tenantIdentity->identifier(), $filters);
$users = $this->userStore->listUsers($this->tenantContext->identifier(), $filters);
// Remove sensitive data
foreach ($users as &$user) {
@@ -38,7 +38,7 @@ class UserAccountsService
public function fetchByIdentity(string $identifier): User | null
{
$data = $this->userStore->fetchByIdentity($this->tenantIdentity->identifier(), $identifier);
$data = $this->userStore->fetchByIdentity($this->tenantContext->identifier(), $identifier);
if (!$data) {
return null;
}
@@ -50,32 +50,32 @@ class UserAccountsService
public function fetchByIdentifier(string $identifier): array | null
{
return $this->userStore->fetchByIdentifier($this->tenantIdentity->identifier(), $identifier);
return $this->userStore->fetchByIdentifier($this->tenantContext->identifier(), $identifier);
}
public function fetchByIdentityRaw(string $identifier): array | null
{
return $this->userStore->fetchByIdentity($this->tenantIdentity->identifier(), $identifier);
return $this->userStore->fetchByIdentity($this->tenantContext->identifier(), $identifier);
}
public function fetchByProviderSubject(string $provider, string $subject): ?array
{
return $this->userStore->fetchByProviderSubject($this->tenantIdentity->identifier(), $provider, $subject);
return $this->userStore->fetchByProviderSubject($this->tenantContext->identifier(), $provider, $subject);
}
public function createUser(array $userData): array
{
return $this->userStore->createUser($this->tenantIdentity->identifier(), $userData);
return $this->userStore->createUser($this->tenantContext->identifier(), $userData);
}
public function updateUser(string $uid, array $updates): bool
{
return $this->userStore->updateUser($this->tenantIdentity->identifier(), $uid, $updates);
return $this->userStore->updateUser($this->tenantContext->identifier(), $uid, $updates);
}
public function deleteUser(string $uid): bool
{
return $this->userStore->deleteUser($this->tenantIdentity->identifier(), $uid);
return $this->userStore->deleteUser($this->tenantContext->identifier(), $uid);
}
// =========================================================================
@@ -84,7 +84,7 @@ class UserAccountsService
public function fetchProfile(string $uid): ?array
{
return $this->userStore->fetchProfile($this->tenantIdentity->identifier(), $uid);
return $this->userStore->fetchProfile($this->tenantContext->identifier(), $uid);
}
public function storeProfile(string $uid, array $profileFields): bool
@@ -109,7 +109,7 @@ class UserAccountsService
return false;
}
return $this->userStore->storeProfile($this->tenantIdentity->identifier(), $uid, $editableFields);
return $this->userStore->storeProfile($this->tenantContext->identifier(), $uid, $editableFields);
}
// =========================================================================
@@ -118,12 +118,12 @@ class UserAccountsService
public function fetchSettings(array $settings = [], bool $flatten = false): array | null
{
return $this->userStore->fetchSettings($this->tenantIdentity->identifier(), $this->userIdentity->identifier(), $settings, $flatten);
return $this->userStore->fetchSettings($this->tenantContext->identifier(), $this->identityContext->identifier(), $settings, $flatten);
}
public function storeSettings(array $settings): bool
{
return $this->userStore->storeSettings($this->tenantIdentity->identifier(), $this->userIdentity->identifier(), $settings);
return $this->userStore->storeSettings($this->tenantContext->identifier(), $this->identityContext->identifier(), $settings);
}
// =========================================================================
+12 -12
View File
@@ -2,7 +2,7 @@
namespace KTXC\Service;
use KTXC\SessionTenant;
use KTXC\Context\TenantContextInterface;
use KTXC\Stores\UserRolesStore;
use Psr\Log\LoggerInterface;
@@ -12,7 +12,7 @@ use Psr\Log\LoggerInterface;
class UserRolesService
{
public function __construct(
private readonly SessionTenant $tenantIdentity,
private readonly TenantContextInterface $tenantContext,
private readonly UserRolesStore $roleStore,
private readonly LoggerInterface $logger
) {}
@@ -26,7 +26,7 @@ class UserRolesService
*/
public function listRoles(): array
{
return $this->roleStore->listRoles($this->tenantIdentity->identifier());
return $this->roleStore->listRoles($this->tenantContext->identifier());
}
/**
@@ -34,7 +34,7 @@ class UserRolesService
*/
public function getRole(string $rid): ?array
{
return $this->roleStore->fetchByRid($this->tenantIdentity->identifier(), $rid);
return $this->roleStore->fetchByRid($this->tenantContext->identifier(), $rid);
}
/**
@@ -45,11 +45,11 @@ class UserRolesService
$this->validateRoleData($roleData);
$this->logger->info('Creating role', [
'tenant' => $this->tenantIdentity->identifier(),
'tenant' => $this->tenantContext->identifier(),
'label' => $roleData['label'] ?? 'Unnamed'
]);
return $this->roleStore->createRole($this->tenantIdentity->identifier(), $roleData);
return $this->roleStore->createRole($this->tenantContext->identifier(), $roleData);
}
/**
@@ -70,11 +70,11 @@ class UserRolesService
$this->validateRoleData($updates, false);
$this->logger->info('Updating role', [
'tenant' => $this->tenantIdentity->identifier(),
'tenant' => $this->tenantContext->identifier(),
'rid' => $rid
]);
return $this->roleStore->updateRole($this->tenantIdentity->identifier(), $rid, $updates);
return $this->roleStore->updateRole($this->tenantContext->identifier(), $rid, $updates);
}
/**
@@ -93,17 +93,17 @@ class UserRolesService
}
// Check if role is assigned to users
$userCount = $this->roleStore->countUsersInRole($this->tenantIdentity->identifier(), $rid);
$userCount = $this->roleStore->countUsersInRole($this->tenantContext->identifier(), $rid);
if ($userCount > 0) {
throw new \InvalidArgumentException("Cannot delete role assigned to {$userCount} user(s)");
}
$this->logger->info('Deleting role', [
'tenant' => $this->tenantIdentity->identifier(),
'tenant' => $this->tenantContext->identifier(),
'rid' => $rid
]);
return $this->roleStore->deleteRole($this->tenantIdentity->identifier(), $rid);
return $this->roleStore->deleteRole($this->tenantContext->identifier(), $rid);
}
/**
@@ -111,7 +111,7 @@ class UserRolesService
*/
public function getRoleUserCount(string $rid): int
{
return $this->roleStore->countUsersInRole($this->tenantIdentity->identifier(), $rid);
return $this->roleStore->countUsersInRole($this->tenantContext->identifier(), $rid);
}
/**
-94
View File
@@ -1,94 +0,0 @@
<?php
namespace KTXC;
use KTXC\Models\Identity\User;
class SessionIdentity
{
private bool $identityLock = false;
private ?User $identityData = null;
public function initialize(User $identity, bool $lock = true): void
{
if ($this->identityLock) {
throw new \RuntimeException('Identity is already locked and cannot be changed.');
}
$this->identityData = $identity;
$this->identityLock = $lock;
}
public function identity(): ?User
{
return $this->identityData;
}
public function identifier(): ?string
{
return $this->identityData?->getId();
}
public function label(): ?string
{
return $this->identityData?->getLabel();
}
public function mailAddress(): ?string
{
return $this->identityData?->getIdentity();
}
public function nameFirst(): ?string
{
return null;
}
public function nameLast(): ?string
{
return null;
}
public function permissions(): array
{
return $this->identityData?->getPermissions() ?? [];
}
public function roles(): array
{
return $this->identityData?->getRoles() ?? [];
}
public function hasPermission(string $permission): bool
{
$permissions = $this->permissions();
// Exact match
if (in_array($permission, $permissions)) {
return true;
}
// Wildcard match
foreach ($permissions as $userPerm) {
if (str_ends_with($userPerm, '.*')) {
$prefix = substr($userPerm, 0, -2);
if (str_starts_with($permission, $prefix . '.')) {
return true;
}
}
}
// Full wildcard
if (in_array('*', $permissions)) {
return true;
}
return false;
}
public function hasRole(string $role): bool
{
return in_array($role, $this->roles());
}
}
-145
View File
@@ -1,145 +0,0 @@
<?php
namespace KTXC;
use KTXC\Models\Tenant\TenantConfiguration;
use KTXC\Models\Tenant\TenantObject;
use KTXC\Service\TenantService;
class SessionTenant
{
private ?TenantObject $tenant = null;
private ?string $domain = null;
private bool $configured = false;
public function __construct(
private readonly TenantService $tenantService
) {}
/**
* Configure the tenant information
* This method is called by the SecurityMiddleware after validation
*/
public function configure(string $domain): void
{
if ($this->configured) {
return;
}
$tenant = $this->tenantService->fetchByDomain($domain);
if ($tenant) {
$this->domain = $domain;
$this->tenant = $tenant;
$this->configured = true;
} else {
$this->domain = null;
$this->tenant = null;
$this->configured = false;
}
}
/**
* Configure the tenant by its identifier (for console / CLI usage).
*/
public function configureById(string $identifier): void
{
if ($this->configured) {
return;
}
$tenant = $this->tenantService->fetchById($identifier);
if ($tenant) {
$this->domain = $identifier;
$this->tenant = $tenant;
$this->configured = true;
} else {
$this->domain = null;
$this->tenant = null;
$this->configured = false;
}
}
/**
* Is the tenant configured
*/
public function configured(): bool
{
return $this->configured;
}
/**
* Is the tenant enabled
*/
public function enabled(): bool
{
return $this->tenant?->getEnabled() ?? false;
}
/**
* Current tenant domain
*/
public function domain(): ?string
{
return $this->domain;
}
/**
* Current tenant identifier
*/
public function identifier(): ?string
{
return $this->tenant?->getIdentifier();
}
/**
* Current tenant label
*/
public function label(): ?string
{
return $this->tenant?->getLabel();
}
/**
* Current tenant configuration
*/
public function configuration(): TenantConfiguration
{
return $this->tenant?->getConfiguration();
}
/**
* Current tenant settings
*/
public function settings(): array
{
return $this->tenant?->getSettings() ?? [];
}
/**
* Get all identity providers configuration for this tenant
* @return array<string, array> Map of provider ID to provider config
*/
public function identityProviders(): array
{
return $this->tenant?->getConfiguration()['identity']['providers'] ?? [];
}
/**
* Get configuration for a specific identity provider
*
* @param string $providerId Provider identifier (e.g., 'default', 'oidc')
* @return array|null Provider configuration or null if not found
*/
public function identityProviderConfig(string $providerId): ?array
{
$providers = $this->identityProviders();
return $providers[$providerId] ?? null;
}
/**
* Check if an identity provider is enabled for this tenant
*/
public function isIdentityProviderEnabled(string $providerId): bool
{
$config = $this->identityProviderConfig($providerId);
return $config !== null && ($config['enabled'] ?? false);
}
}
+8 -3
View File
@@ -3,6 +3,7 @@
namespace KTXC\Stores;
use KTXC\Db\DataStore;
use KTXC\Db\ObjectId;
use KTXC\Models\Tenant\TenantObject;
class TenantStore
@@ -52,7 +53,9 @@ class TenantStore
private function create(TenantObject $entry): ?TenantObject
{
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->insertOne($entry->jsonSerialize());
$document = $entry->jsonSerialize();
unset($document['id']);
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->insertOne($document);
$entry->setId((string)$result->getInsertedId());
return $entry;
}
@@ -61,7 +64,9 @@ class TenantStore
{
$id = $entry->getId();
if (!$id) { return null; }
$this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(['_id' => $id], ['$set' => $entry->jsonSerialize()]);
$document = $entry->jsonSerialize();
unset($document['id']);
$this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(['_id' => new ObjectId($id)], ['$set' => $document]);
return $entry;
}
@@ -69,7 +74,7 @@ class TenantStore
{
$id = $entry->getId();
if (!$id) { return; }
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([ '_id' => $id]);
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne(['_id' => new ObjectId($id)]);
}
// =========================================================================
+22 -4
View File
@@ -177,8 +177,8 @@ class UserAccountsStore
$userData['uid'] = $userData['uid'] ?? UUID::v4();
$userData['enabled'] = $userData['enabled'] ?? true;
$userData['roles'] = $userData['roles'] ?? [];
$userData['profile'] = $userData['profile'] ?? [];
$userData['settings'] = $userData['settings'] ?? [];
$userData['profile'] = (object) ($userData['profile'] ?? []);
$userData['settings'] = (object) ($userData['settings'] ?? []);
$this->store->selectCollection('user_accounts')->insertOne($userData);
@@ -232,12 +232,21 @@ class UserAccountsStore
return false;
}
$collection = $this->store->selectCollection('user_accounts');
// Repair legacy documents where 'profile' was stored as an empty BSON array:
// dot-notation $set below cannot add a named field to an array.
$collection->updateOne(
['tid' => $tenant, 'uid' => $uid, 'profile' => []],
['$set' => ['profile' => (object) []]]
);
$updates = [];
foreach ($profileFields as $key => $value) {
$updates["profile.{$key}"] = $value;
}
$result = $this->store->selectCollection('user_accounts')->updateOne(
$result = $collection->updateOne(
['tid' => $tenant, 'uid' => $uid],
['$set' => $updates]
);
@@ -307,12 +316,21 @@ class UserAccountsStore
return false;
}
$collection = $this->store->selectCollection('user_accounts');
// Repair legacy documents where 'settings' was stored as an empty BSON array:
// dot-notation $set below cannot add a named field to an array.
$collection->updateOne(
['tid' => $tenant, 'uid' => $uid, 'settings' => []],
['$set' => ['settings' => (object) []]]
);
$updates = [];
foreach ($settings as $key => $value) {
$updates["settings.{$key}"] = $value;
}
$result = $this->store->selectCollection('user_accounts')->updateOne(
$result = $collection->updateOne(
['tid' => $tenant, 'uid' => $uid],
['$set' => $updates]
);
+3 -5
View File
@@ -1,11 +1,9 @@
<?php
use KTXC\Server;
use KTXC\Application;
// Capture Composer ClassLoader instance for compatibility
$composerLoader = require_once __DIR__ . '/../vendor/autoload.php';
$server = new Server(dirname(__DIR__));
Server::setComposerLoader($composerLoader);
$server->runHttp();
$application = Application::create(dirname(__DIR__), $composerLoader);
$application->runHttp();
-64
View File
@@ -5,69 +5,5 @@
<script setup lang="ts">
import { RouterView } from 'vue-router';
import { onMounted, watch } from 'vue';
import { useTheme } from 'vuetify';
import SharedSnackbar from '@KTXC/components/shared/SharedSnackbar.vue';
import { useLayoutStore } from '@KTXC/stores/layoutStore';
import { useUserStore } from '@KTXC/stores/userStore';
import { useTenantStore } from '@KTXC/stores/tenantStore';
const theme = useTheme();
const layoutStore = useLayoutStore();
const userStore = useUserStore();
const tenantStore = useTenantStore();
// Maps user/tenant setting keys → Vuetify color token names
const COLOR_SETTINGS: Array<{ token: string; key: string }> = [
{ token: 'primary', key: 'primary_color' },
{ token: 'secondary', key: 'secondary_color' },
];
/**
* Apply brand color overrides from stored preferences to all Vuetify theme
* variants (light & dark). Tenant colors take priority when lock is active.
*/
function applyThemeColors(): void {
const locked = tenantStore.getSetting('lock_user_colors') as boolean | null;
for (const { token, key } of COLOR_SETTINGS) {
const value = locked
? ((tenantStore.getSetting(key) as string | null) ?? (userStore.getSetting(key) as string | null))
: ((userStore.getSetting(key) as string | null) ?? (tenantStore.getSetting(key) as string | null));
if (value) {
for (const variant of Object.keys(theme.themes.value)) {
theme.themes.value[variant].colors[token] = value;
}
}
}
}
/** Apply font preference via CSS custom property and body style. */
function applyFont(): void {
const font =
((userStore.getSetting('font') as string | null) ??
(tenantStore.getSetting('font') as string | null));
if (font && font !== 'Public Sans') {
document.documentElement.style.setProperty('--themer-font', font);
document.body.style.fontFamily = `"${font}", sans-serif`;
}
}
onMounted(() => {
// Apply saved theme mode
if (layoutStore.theme) {
theme.global.name.value = layoutStore.theme;
}
applyThemeColors();
applyFont();
});
// Re-apply whenever tenant settings change (e.g. admin saves new brand colors)
watch(() => tenantStore.settings, () => {
applyThemeColors();
applyFont();
}, { deep: true });
</script>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path d="M3 5H10V10H14V5H22V10H26V5H33V19L18 33L3 19Z" fill="#818CF8"/><path d="M11 14L18 21L25 14V19.5L18 26.5L11 19.5Z" fill="#4F46E5"/></svg>

After

Width:  |  Height:  |  Size: 205 B

+2
View File
@@ -3,4 +3,6 @@
*/
export { useClipboard } from './useClipboard'
export { useL10n, t } from './useL10n'
export type { L10N, TranslateParams } from './useL10n'
export { useUser } from './useUser'
+42
View File
@@ -0,0 +1,42 @@
/**
* Translation composable over the shared i18n runtime.
* This is the only surface components and modules use; vue-i18n itself
* stays confined to the plugin.
*/
import { i18n } from '@KTXC/l10n/runtime';
export type TranslateParams = Record<string, unknown>;
export interface L10N {
/**
* Translate a catalog key, rendering the inline English default when the
* key is absent from every loaded catalog. Keys are namespaced with the
* handle bound at useL10n(); the `en` catalog is generated from these
* defaults by scripts/l10n-extract.mjs — keep both literal.
*/
t: (key: string, defaultText: string, params?: TranslateParams) => string;
/** Locale-aware date/time formatting (Intl-backed). */
d: typeof i18n.global.d;
/** Locale-aware number formatting (Intl-backed). */
n: typeof i18n.global.n;
}
/**
* Translate a fully-qualified catalog key (one that already carries its
* namespace, e.g. an integration entry's `l10n` key, which the integration
* store prefixes with the module handle). `defaultText` is rendered when the
* key is undefined or absent from every loaded catalog. Such keys are
* dynamic, so their catalog entries live in the module's hand-maintained
* en.manual.json rather than the extracted catalog.
*/
export function t(key: string | undefined, defaultText?: string, params: TranslateParams = {}): string {
if (!key) return defaultText ?? '';
return i18n.global.t(key, params, { default: defaultText ?? key });
}
export function useL10n(namespace: string): L10N {
const scoped = (key: string, defaultText: string, params: TranslateParams = {}): string =>
t(`${namespace}.${key}`, defaultText, params);
return { t: scoped, d: i18n.global.d, n: i18n.global.n };
}
+2
View File
@@ -0,0 +1,2 @@
/** Locale used before runtime localization configuration is available. */
export const DEFAULT_LOCALE = 'en';
+37
View File
@@ -0,0 +1,37 @@
import { createI18n } from 'vue-i18n';
import { en as vuetifyEn, de as vuetifyDe } from 'vuetify/locale';
import { DEFAULT_LOCALE } from '@KTXC/l10n/config';
/**
* The single vue-i18n instance shared by the core shell and all modules.
*/
export const i18n = createI18n({
legacy: false,
globalInjection: false,
locale: DEFAULT_LOCALE,
fallbackLocale: DEFAULT_LOCALE,
missingWarn: import.meta.env.DEV,
fallbackWarn: false,
messages: {
// Vuetify component strings ($vuetify.*) ride along in the same
// instance, consumed through the createVueI18nAdapter in the vuetify
// plugin. Statically imported: adding a locale means adding it here.
en: { $vuetify: vuetifyEn },
de: { $vuetify: vuetifyDe },
},
});
/** Merge a catalog's messages under a namespace for the given locale. */
export function mergeCatalog(locale: string, namespace: string, messages: Record<string, unknown>): void {
i18n.global.mergeLocaleMessage(locale, { [namespace]: messages });
}
/** Switch the active locale of the shared instance. */
export function applyLocale(locale: string): void {
i18n.global.locale.value = locale;
}
/** Update the locale used when a translation is missing from the active locale. */
export function applyFallbackLocale(locale: string): void {
i18n.global.fallbackLocale.value = locale;
}
+27 -33
View File
@@ -1,42 +1,36 @@
<template>
<div class="logo">
<RouterLink :to="{ name: 'home' }" aria-label="logo">
<svg width="118" height="35" viewBox="0 0 118 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M4.63564 15.8644L6.94797 13.552L6.95038 13.5496H11.3006L9.56969 15.2806L9.12278 15.7275L7.35024 17.5L7.56977 17.7201L17.5 27.6498L27.6498 17.5L25.8766 15.7275L25.7518 15.602L23.6994 13.5496H28.0496L28.052 13.552L29.8644 15.3644L32 17.5L17.5 32L3 17.5L4.63564 15.8644ZM17.5 3L25.8784 11.3784H21.5282L17.5 7.35024L13.4718 11.3784H9.12158L17.5 3Z"
:fill="darkprimary"
></path>
<path
d="M7.35025 17.5L9.1228 15.7275L9.5697 15.2805L7.83937 13.5496H6.95039L6.94798 13.552L4.63564 15.8644L6.8551 18.073L7.35025 17.5Z"
:fill="darkprimary"
></path>
<path
d="M25.8767 15.7275L27.6498 17.5L27.4743 17.6755L27.4749 17.6761L29.8644 15.3644L28.0521 13.552L28.0497 13.5496H27.8736L25.7518 15.602L25.8767 15.7275Z"
:fill="darkprimary"
></path>
<path d="M6.94549 13.5496L6.9479 13.552L9.12272 15.7275L17.4999 24.1041L28.0544 13.5496H6.94549Z" :fill="primary"></path>
<path
d="M46.5781 10V26H49.3594V14.9844H49.5078L53.9297 25.9531H56.0078L60.4297 15.0078H60.5781V26H63.3594V10H59.8125L55.0625 21.5937H54.875L50.125 10H46.5781ZM69.8438 26.2422C71.7266 26.2422 72.8516 25.3594 73.3672 24.3516H73.4609V26H76.1797V17.9687C76.1797 14.7969 73.5937 13.8438 71.3047 13.8438C68.7813 13.8438 66.8437 14.9687 66.2188 17.1562L68.8594 17.5312C69.1406 16.7109 69.9375 16.0078 71.3203 16.0078C72.6328 16.0078 73.3516 16.6797 73.3516 17.8594V17.9062C73.3516 18.7188 72.5 18.7578 70.3828 18.9844C68.0547 19.2344 65.8281 19.9297 65.8281 22.6328C65.8281 24.9922 67.5547 26.2422 69.8438 26.2422ZM70.5781 24.1641C69.3984 24.1641 68.5547 23.625 68.5547 22.5859C68.5547 21.5 69.5 21.0469 70.7656 20.8672C71.5078 20.7656 72.9922 20.5781 73.3594 20.2812V21.6953C73.3594 23.0312 72.2813 24.1641 70.5781 24.1641ZM81.8516 18.9687C81.8516 17.2344 82.8984 16.2344 84.3906 16.2344C85.8516 16.2344 86.7266 17.1953 86.7266 18.7969V26H89.5547V18.3594C89.5625 15.4844 87.9219 13.8438 85.4453 13.8438C83.6484 13.8438 82.4141 14.7031 81.8672 16.0391H81.7266V14H79.0234V26H81.8516V18.9687ZM98.4219 14H96.0547V11.125H93.2266V14H91.5234V16.1875H93.2266V22.8594C93.2109 25.1172 94.8516 26.2266 96.9766 26.1641C97.7813 26.1406 98.3359 25.9844 98.6406 25.8828L98.1641 23.6719C98.0078 23.7109 97.6875 23.7812 97.3359 23.7812C96.625 23.7812 96.0547 23.5312 96.0547 22.3906V16.1875H98.4219V14ZM100.787 26H103.615V14H100.787V26ZM102.209 12.2969C103.107 12.2969 103.842 11.6094 103.842 10.7656C103.842 9.91406 103.107 9.22656 102.209 9.22656C101.303 9.22656 100.568 9.91406 100.568 10.7656C100.568 11.6094 101.303 12.2969 102.209 12.2969ZM116.008 17.1719C115.617 15.1406 113.992 13.8438 111.18 13.8438C108.289 13.8438 106.32 15.2656 106.328 17.4844C106.32 19.2344 107.398 20.3906 109.703 20.8672L111.75 21.2969C112.852 21.5391 113.367 21.9844 113.367 22.6641C113.367 23.4844 112.477 24.1016 111.133 24.1016C109.836 24.1016 108.992 23.5391 108.75 22.4609L105.992 22.7266C106.344 24.9297 108.195 26.2344 111.141 26.2344C114.141 26.2344 116.258 24.6797 116.266 22.4062C116.258 20.6953 115.156 19.6484 112.891 19.1562L110.844 18.7188C109.625 18.4453 109.141 18.0234 109.148 17.3281C109.141 16.5156 110.039 15.9531 111.219 15.9531C112.523 15.9531 113.211 16.6641 113.43 17.4531L116.008 17.1719Z"
fill="#000"
fill-opacity="0.85"
></path>
<defs>
<linearGradient id="paint0_linear" x1="8.62526" y1="14.0888" x2="5.56709" y2="17.1469" gradientUnits="userSpaceOnUse">
<stop stop-color="darkprimary"></stop>
<stop offset="0.9637" stop-color="darkprimary" stop-opacity="0"></stop>
</linearGradient>
<linearGradient id="paint1_linear" x1="26.2675" y1="14.1279" x2="28.7404" y2="16.938" gradientUnits="userSpaceOnUse">
<stop stop-color="darkprimary"></stop>
<stop offset="1" stop-color="darkprimary" stop-opacity="0"></stop>
</linearGradient>
</defs>
<RouterLink :to="{ name: 'home' }" aria-label="Vallarx home" class="logo-link">
<svg width="30" height="30" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-hidden="true">
<!-- Crenellated wall tapering to a "V" point: vallum + arx -->
<path class="mark-wall" d="M3 5H10V10H14V5H22V10H26V5H33V19L18 33L3 19Z" />
<path class="mark-valley" d="M11 14L18 21L25 14V19.5L18 26.5L11 19.5Z" />
</svg>
<span class="logo-text">Vallarx</span>
</RouterLink>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { RouterLink } from 'vue-router';
const primary = ref('rgb(var(--v-theme-primary))');
const darkprimary = ref('rgb(var(--v-theme-darkprimary))');
</script>
<style scoped lang="scss">
.logo-link {
display: inline-flex;
align-items: center;
gap: 10px;
text-decoration: none;
}
.mark-wall {
fill: rgb(var(--v-theme-primary));
}
.mark-valley {
fill: rgb(var(--v-theme-primary-darken-1));
}
.logo-text {
font-size: 1.25rem;
font-weight: 700;
letter-spacing: 0.015em;
line-height: 1;
color: rgb(var(--v-theme-on-surface));
}
</style>
+74 -39
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useLayoutStore } from '@KTXC/stores/layoutStore';
import { useLayoutStore, type MenuMode } from '@KTXC/stores/layoutStore';
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
import { useL10n } from '@KTXC/composables/useL10n';
import Logo from '@KTXC/layouts/logo/LogoDark.vue';
import SystemMenuGroupStatic from './LayoutSystemMenuGroupStatic.vue';
import SystemMenuGroupDynamic from './LayoutSystemMenuGroupDynamic.vue';
@@ -9,6 +10,7 @@ import SystemMenuItem from './LayoutSystemMenuItem.vue';
const layoutStore = useLayoutStore();
const integrationStore = useIntegrationStore();
const { t } = useL10n('core');
// Get all entries based on current menu mode
const menuEntries = computed(() => {
@@ -23,33 +25,12 @@ const menuEntries = computed(() => {
}
});
// Menu mode display info
const menuModeInfo = computed(() => {
switch (layoutStore.menuMode) {
case 'user-settings':
return {
icon: 'mdi-account-cog',
label: 'Personal Settings',
toggleLabel: 'Admin',
toggleIcon: 'mdi-shield-crown',
};
case 'admin-settings':
return {
icon: 'mdi-shield-crown',
label: 'Administration',
toggleLabel: 'Apps',
toggleIcon: 'mdi-view-dashboard',
};
case 'apps':
default:
return {
icon: 'mdi-view-dashboard',
label: 'Applications',
toggleLabel: 'Settings',
toggleIcon: 'mdi-account-cog',
};
}
});
// Static list of menu modes shown as icon buttons
const menuModes = computed((): Array<{ value: MenuMode; icon: string; label: string }> => [
{ value: 'apps', icon: 'mdi-view-dashboard', label: t('systemMenu.apps', 'Applications') },
{ value: 'user-settings', icon: 'mdi-account-cog', label: t('systemMenu.personalSettings', 'Settings') },
{ value: 'admin-settings', icon: 'mdi-shield-crown', label: t('systemMenu.adminSettings', 'System') },
]);
</script>
<script lang="ts">
@@ -101,20 +82,74 @@ export default {
</v-list>
</perfect-scrollbar>
<!-- Menu Mode Toggle -->
<!-- Menu Mode Switcher -->
<template v-slot:append>
<v-divider />
<v-list density="compact" class="pa-2">
<v-list-item
rounded
color="primary"
@click="layoutStore.toggleMenuMode()"
:prepend-icon="menuModeInfo.toggleIcon"
class="menu-mode-toggle"
<div class="menu-mode-switcher d-flex justify-space-around align-center py-2">
<v-tooltip
v-for="mode in menuModes"
:key="mode.value"
location="right"
>
<v-list-item-title class="text-body-2">{{ menuModeInfo.toggleLabel }}</v-list-item-title>
</v-list-item>
</v-list>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
icon
variant="text"
density="comfortable"
:color="layoutStore.menuMode === mode.value ? 'primary' : undefined"
:class="['menu-mode-btn', { 'menu-mode-btn--active': layoutStore.menuMode === mode.value }]"
@click="layoutStore.setMenuMode(mode.value)"
>
<v-icon>{{ mode.icon }}</v-icon>
</v-btn>
</template>
<span>{{ mode.label }}</span>
</v-tooltip>
</div>
</template>
</v-navigation-drawer>
</template>
<style scoped>
.menu-mode-btn {
opacity: 0.6;
transition: opacity 0.2s ease, background-color 0.2s ease;
}
.menu-mode-btn:hover {
opacity: 1;
background-color: rgba(var(--v-theme-on-surface), 0.06);
}
.menu-mode-btn--active {
opacity: 1;
}
/* Collapsed rail: only the active mode icon fits without crowding */
.leftSidebar.v-navigation-drawer--rail:not(.v-navigation-drawer--is-hovering) .menu-mode-switcher {
justify-content: center;
}
.leftSidebar.v-navigation-drawer--rail:not(.v-navigation-drawer--is-hovering) .menu-mode-btn:not(.menu-mode-btn--active) {
display: none;
}
/* Sub-menu items: Vuetify's default group indent (prepend width + indent
size) stacks up to ~72px; bring it back down closer to top-level items */
.scrollnavbar :deep(.v-list-group__items .v-list-item) {
padding-inline-start: 40px;
}
/* Collapsed rail (not hovering): sub-items have no room to show icon or
text, so they render as an empty highlighted bar when active. Hide the
expanded group entirely and highlight the parent icon instead. */
.leftSidebar.v-navigation-drawer--rail:not(.v-navigation-drawer--is-hovering) :deep(.v-list-group__items) {
display: none;
}
.leftSidebar.v-navigation-drawer--rail:not(.v-navigation-drawer--is-hovering)
:deep(.v-list-group:has(.v-list-item--active) > .v-list-item.v-list-group__header) {
color: rgb(var(--v-theme-primary));
}
</style>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { IntegrationGroup } from '@KTXC/types/integrationTypes';
import { t } from '@KTXC/composables/useL10n';
import NavItem from './LayoutSystemMenuItem.vue';
const props = defineProps<{ group: IntegrationGroup; level?: number }>();
@@ -20,7 +21,7 @@ const props = defineProps<{ group: IntegrationGroup; level?: number }>();
<v-icon v-if="group.icon" :icon="group.icon"></v-icon>
</template>
<!---Title -->
<v-list-item-title class="mr-auto">{{ group.label }}</v-list-item-title>
<v-list-item-title class="mr-auto">{{ t(group.l10n, group.label) }}</v-list-item-title>
<!---If Caption-->
<v-list-item-subtitle v-if="group.caption" class="text-caption mt-n1 hide-menu">
{{ group.caption }}
@@ -1,12 +1,13 @@
<script setup lang="ts">
import type { IntegrationGroup } from '@KTXC/types/integrationTypes';
import { t } from '@KTXC/composables/useL10n';
import LayoutSystemMenuItem from './LayoutSystemMenuItem.vue';
const props = defineProps<{ group: IntegrationGroup }>();
</script>
<template>
<v-list-subheader color="lightText" class="smallCap text-subtitle-2">{{ props.group.label }}</v-list-subheader>
<v-list-subheader color="lightText" class="smallCap text-subtitle-2">{{ t(props.group.l10n, props.group.label) }}</v-list-subheader>
<LayoutSystemMenuItem
v-for="(item, i) in props.group.items"
:key="i"
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { IntegrationItem } from '@KTXC/types/integrationTypes';
import { t } from '@KTXC/composables/useL10n';
const props = defineProps<{ item: IntegrationItem; level?: number }>();
</script>
@@ -20,7 +21,7 @@ const props = defineProps<{ item: IntegrationItem; level?: number }>();
<template v-slot:prepend>
<v-icon v-if="props.item.icon" :icon="props.item.icon"></v-icon>
</template>
<v-list-item-title>{{ item.label }}</v-list-item-title>
<v-list-item-title>{{ t(item.l10n, item.label) }}</v-list-item-title>
<!---If Caption-->
<v-list-item-subtitle v-if="item.caption" class="text-caption mt-n1 hide-menu">
{{ item.caption }}
+26 -15
View File
@@ -1,13 +1,14 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useTheme } from 'vuetify';
import { useUserStore } from '@KTXC/stores/userStore';
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
import { useLayoutStore } from '@KTXC/stores/layoutStore';
import { useRouter } from 'vue-router';
import { useL10n, t as tGlobal } from '@KTXC/composables/useL10n';
import defaultAvatar from '@KTXC/assets/images/users/avatar-1.png';
const theme = useTheme();
const { t } = useL10n('core');
const router = useRouter();
const userStore = useUserStore();
const integrationStore = useIntegrationStore();
@@ -24,12 +25,22 @@ const userName = computed(() => {
});
const userEmail = computed(() => userStore.getProfileField('email') || '');
// Theme toggle
const isDarkMode = computed(() => theme.global.name.value === 'dark');
const toggleTheme = () => {
const newTheme = theme.global.name.value === 'light' ? 'dark' : 'light';
theme.global.name.value = newTheme;
layoutStore.setTheme(newTheme);
// Theme mode cycle: light -> dark -> system, driven by the stored preference
// (not the resolved theme name, which never reads 'system')
const THEME_MODES = ['light', 'dark', 'system'] as const;
type ThemeMode = (typeof THEME_MODES)[number];
const MODE_PRESENTATION: Record<ThemeMode, { icon: string; l10n: string; label: string }> = {
light: { icon: 'mdi-weather-sunny', l10n: 'userMenu.lightMode', label: 'Light Mode' },
dark: { icon: 'mdi-weather-night', l10n: 'userMenu.darkMode', label: 'Dark Mode' },
system: { icon: 'mdi-theme-light-dark', l10n: 'userMenu.systemMode', label: 'System Mode' },
};
const themeMode = computed(() => (layoutStore.theme ?? 'light') as ThemeMode);
const nextThemeMode = computed(() => THEME_MODES[(THEME_MODES.indexOf(themeMode.value) + 1) % THEME_MODES.length]);
const cycleTheme = () => {
// App.vue watches layoutStore.theme and applies it via theme.change()
layoutStore.setTheme(nextThemeMode.value);
};
// Navigate to settings
@@ -74,17 +85,17 @@ const goToSettings = () => {
<template v-slot:prepend>
<v-icon v-if="item.icon">{{ item.icon }}</v-icon>
</template>
<v-list-item-title class="text-h6">{{ item.label }}</v-list-item-title>
<v-list-item-title class="text-h6">{{ tGlobal(item.l10n, item.label) }}</v-list-item-title>
</v-list-item>
<v-divider v-if="profileMenuItems.length" class="my-2" />
<!-- Theme Toggle -->
<v-list-item @click="toggleTheme" color="primary" rounded="0">
<!-- Theme Mode Cycle (light -> dark -> system) -->
<v-list-item @click="cycleTheme" color="primary" rounded="0">
<template v-slot:prepend>
<v-icon>{{ isDarkMode ? 'mdi-weather-sunny' : 'mdi-weather-night' }}</v-icon>
<v-icon>{{ MODE_PRESENTATION[nextThemeMode].icon }}</v-icon>
</template>
<v-list-item-title class="text-h6">{{ isDarkMode ? 'Light Mode' : 'Dark Mode' }}</v-list-item-title>
<v-list-item-title class="text-h6">{{ t(MODE_PRESENTATION[nextThemeMode].l10n, MODE_PRESENTATION[nextThemeMode].label) }}</v-list-item-title>
</v-list-item>
<!-- Go to Settings -->
@@ -92,7 +103,7 @@ const goToSettings = () => {
<template v-slot:prepend>
<v-icon>mdi-cog-outline</v-icon>
</template>
<v-list-item-title class="text-h6">Settings</v-list-item-title>
<v-list-item-title class="text-h6">{{ t('userMenu.settings', 'Settings') }}</v-list-item-title>
</v-list-item>
<v-divider class="my-2" />
@@ -102,7 +113,7 @@ const goToSettings = () => {
<template v-slot:prepend>
<v-icon>mdi-logout</v-icon>
</template>
<v-list-item-title class="text-h6">Logout</v-list-item-title>
<v-list-item-title class="text-h6">{{ t('userMenu.logout', 'Logout') }}</v-list-item-title>
</v-list-item>
</v-list>
</perfect-scrollbar>
+9 -1
View File
@@ -2,6 +2,9 @@ import { createVuetify } from 'vuetify'
import { VBtn } from 'vuetify/components/VBtn'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
import { createVueI18nAdapter } from 'vuetify/locale/adapters/vue-i18n'
import { useI18n } from 'vue-i18n'
import { i18n } from '@KTXC/l10n/runtime'
import defaults from './defaults'
import { icons } from './icons'
import { themes } from './theme'
@@ -17,8 +20,13 @@ export default createVuetify({
},
defaults,
icons,
locale: {
adapter: createVueI18nAdapter({ i18n, useI18n }),
},
theme: {
defaultTheme: 'light',
// Follow the browser/OS color scheme by default; matters especially for
// the public (unauthenticated) app, where no theme store ever runs.
defaultTheme: 'system',
themes,
},
})
+29 -29
View File
@@ -1,22 +1,22 @@
import type { ThemeDefinition } from 'vuetify'
export const staticPrimaryColor = '#6366F1'
export const staticPrimaryDarkenColor = '#4F46E5'
export const staticPrimaryColor = '#334155'
export const staticPrimaryDarkenColor = '#1E293B'
export const themes: Record<string, ThemeDefinition> = {
light: {
dark: false,
colors: {
// Primary brand colors - Modern indigo
// Primary brand colors - Graphite slate
'primary': staticPrimaryColor,
'on-primary': '#FFFFFF',
'primary-darken-1': staticPrimaryDarkenColor,
'primary-lighten-1': '#818CF8',
'primary-lighten-1': '#64748B',
// Secondary - Purple accent
'secondary': '#8B5CF6',
'secondary-darken-1': '#7C3AED',
'secondary-lighten-1': '#A78BFA',
// Secondary - Lighter slate accent
'secondary': '#64748B',
'secondary-darken-1': '#475569',
'secondary-lighten-1': '#94A3B8',
'on-secondary': '#FFFFFF',
// Semantic colors
@@ -30,7 +30,7 @@ export const themes: Record<string, ThemeDefinition> = {
'on-error': '#FFFFFF',
// Surface & backgrounds
'background': '#F8FAFC',
'background': '#F6F7F9',
'on-background': '#0F172A',
'surface': '#FFFFFF',
'on-surface': '#0F172A',
@@ -76,16 +76,16 @@ export const themes: Record<string, ThemeDefinition> = {
dark: true,
colors: {
// Primary brand colors - Lighter shades for dark mode
'primary': '#818CF8',
'on-primary': '#FFFFFF',
'primary-darken-1': '#6366F1',
'primary-lighten-1': '#A5B4FC',
'primary': '#94A3B8',
'on-primary': '#0F172A',
'primary-darken-1': '#64748B',
'primary-lighten-1': '#CBD5E1',
// Secondary - Purple accent
'secondary': '#A78BFA',
'secondary-darken-1': '#8B5CF6',
'secondary-lighten-1': '#C4B5FD',
'on-secondary': '#FFFFFF',
// Secondary - Lighter slate accent
'secondary': '#CBD5E1',
'secondary-darken-1': '#94A3B8',
'secondary-lighten-1': '#E2E8F0',
'on-secondary': '#0F172A',
// Semantic colors - Adjusted for dark mode
'success': '#34D399',
@@ -97,13 +97,13 @@ export const themes: Record<string, ThemeDefinition> = {
'error': '#F87171',
'on-error': '#FFFFFF',
// Surface & backgrounds - Dark slate palette
'background': '#0F172A',
// Surface & backgrounds - Dark graphite palette
'background': '#0B0F17',
'on-background': '#F1F5F9',
'surface': '#1E293B',
'surface': '#151A23',
'on-surface': '#F1F5F9',
'surface-bright': '#334155',
'surface-variant': '#1E293B',
'surface-bright': '#232A36',
'surface-variant': '#151A23',
'on-surface-variant': '#94A3B8',
// Grey scale
@@ -111,17 +111,17 @@ export const themes: Record<string, ThemeDefinition> = {
'grey-darken-1': '#CBD5E1',
'grey-lighten-1': '#64748B',
'grey-lighten-2': '#475569',
'grey-lighten-3': '#334155',
'grey-lighten-4': '#1E293B',
'grey-lighten-5': '#0F172A',
'grey-lighten-3': '#2A3240',
'grey-lighten-4': '#151A23',
'grey-lighten-5': '#0B0F17',
// Component specific
'perfect-scrollbar-thumb': '#475569',
'track-bg': '#334155',
'perfect-scrollbar-thumb': '#3A4353',
'track-bg': '#232A36',
},
variables: {
'border-color': '#334155',
'border-color': '#2A3240',
'border-opacity': 0.12,
'high-emphasis-opacity': 0.87,
'medium-emphasis-opacity': 0.60,
+2 -2
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<script type="importmap">
{
"imports": {
@@ -14,7 +14,7 @@
}
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>K-Trix</title>
<title>Vallarx</title>
</head>
<body>
<div id="app"></div>
+20
View File
@@ -7,7 +7,12 @@ import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
import { useModuleStore } from '@KTXC/stores/moduleStore'
import { useTenantStore } from '@KTXC/stores/tenantStore'
import { useUserStore } from '@KTXC/stores/userStore'
import { useL10nStore } from '@KTXC/stores/l10nStore'
import { useThemeStore } from '@KTXC/stores/themeStore'
import { useLayoutStore } from '@KTXC/stores/layoutStore'
import { i18n } from '@KTXC/l10n/runtime'
import { fetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper'
import { FetchError } from '@KTXC/utils/helpers/fetch-wrapper-core'
import { initializeModules } from '@KTXC/utils/modules'
import { createSessionMonitor } from '@KTXC/services/authManager'
import App from './App.vue'
@@ -21,6 +26,7 @@ const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(PerfectScrollbarPlugin)
app.use(i18n)
app.use(vuetify)
const globalWindow = window as typeof window & {
@@ -36,12 +42,20 @@ globalWindow.Pinia = PiniaLib as unknown
const moduleStore = useModuleStore();
const tenantStore = useTenantStore();
const userStore = useUserStore();
const l10nStore = useL10nStore();
const themeStore = useThemeStore();
const layoutStore = useLayoutStore();
try {
const payload = await fetchWrapper.get('/init');
moduleStore.init(payload?.modules ?? {});
tenantStore.init(payload?.tenant ?? null);
userStore.init(payload?.user ?? {});
layoutStore.hydrateFromSettings();
themeStore.boot();
// Resolve locale and load catalogs before modules boot
await l10nStore.init(payload?.l10n ?? null);
// Initialize auth session monitor
const sessionMonitor = createSessionMonitor({ onLogout: () => userStore.logout() });
@@ -62,5 +76,11 @@ globalWindow.Pinia = PiniaLib as unknown
app.mount('#app');
} catch (e) {
console.error('Bootstrap failed:', e);
// The private shell is unusable without /init. If the server rejected
// the session, drop the stale local identity and return to login.
if (e instanceof FetchError && (e.status === 401 || e.status === 403)) {
userStore.clearAuth();
window.location.href = '/login';
}
}
})();
+2 -2
View File
@@ -2,9 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.svg" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ktrix Cloud</title>
<title>Vallarx</title>
</head>
<body>
<div id="app"></div>
+13 -15
View File
@@ -2,32 +2,30 @@ import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import { router } from './router';
import { AUTH_STORAGE_KEY } from '@KTXC/stores/userStore';
import { useL10nStore } from '@KTXC/stores/l10nStore';
import { i18n } from '@KTXC/l10n/runtime';
import vuetify from './plugins/vuetify/index';
// Material Design Icons (Vuetify mdi icon set)
import '@mdi/font/css/materialdesignicons.min.css';
import '@fontsource/public-sans/index.css'
// The public app is served when the user has no valid server session.
// Clear any stale identity data from localStorage to ensure the client
// state matches the server's determination that the user is unauthenticated.
//localStorage.removeItem('identityStore.self');
// The public app is only served when the server has determined the user is
// unauthenticated. Clear any stale identity persisted by a previous session,
// otherwise the shared router guard would treat the user as authenticated
// and render the private shell without any of its state loaded.
localStorage.removeItem(AUTH_STORAGE_KEY);
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
app.use(router);
app.use(i18n);
app.use(vuetify);
// Wait for router to be ready, then ensure we're on a public route
//router.isReady().then(() => {
// If the current route requires auth, redirect to login
// This handles the case where user navigates to / with an expired session
//const currentRoute = router.currentRoute.value;
//const requiresAuth = currentRoute.matched.some(record => record.meta?.requiresAuth);
//if (requiresAuth || currentRoute.path === '/') {
// router.replace('/login');
//}
//});
(async () => {
// No session yet: locale comes from browser preferences alone
await useL10nStore().init(null);
app.mount('#app');
})();
+5
View File
@@ -12,8 +12,13 @@ export { useTenantStore } from '../stores/tenantStore'
export { useUserStore } from '../stores/userStore'
export { useIntegrationStore } from '../stores/integrationStore'
export { useLayoutStore } from '../stores/layoutStore'
export { useL10nStore } from '../stores/l10nStore'
export { useThemeStore, DEFAULT_FONT, encodePaletteSet } from '../stores/themeStore'
export type { ThemePalette, ThemePaletteSet, ThemeVariant } from '../stores/themeStore'
// Composables
export { useL10n, t } from '../composables/useL10n'
export type { L10N, TranslateParams } from '../composables/useL10n'
export { useUser } from '../composables/useUser'
export { useClipboard } from '../composables/useClipboard'
export { useSnackbar } from '../composables/useSnackbar'

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