Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a54fe7b239 | |||
| b8afd75b77 | |||
| eb99eb5a2e | |||
| 65c1b5fb75 | |||
| 98826143a8 | |||
| e47d0ed619 | |||
| 071c225b2d | |||
| b58f6e9479 | |||
| f958cdd6a3 | |||
| 4ffbb906bf | |||
| 076f14831b | |||
| 0b6c89bf27 | |||
| c982a9b6a7 | |||
| add3380393 | |||
| 300bb98a49 | |||
| a723051d11 | |||
| bf14b72a03 | |||
| a346195c93 | |||
| 34c68c9c2c | |||
| 48700bf35e | |||
| 5aab0a5722 | |||
| d723793b5c | |||
| 78677c5da9 | |||
| 84eb2d3396 | |||
| caeb8b7e67 | |||
| d93a8cc441 | |||
| 8fc000a9d1 | |||
| 64c490f98d | |||
| ca1ec469d5 | |||
| 770edf1bb9 | |||
| a4f2edd6c8 | |||
| 8452694f76 | |||
| c27597e4f9 | |||
| 598f0653e3 | |||
| 9f353797f0 | |||
| bd26ccb2df | |||
| 35b438389b | |||
| 512a403a19 | |||
| dbb0d65d62 | |||
| e88f459a35 | |||
| a6b9c7a30b | |||
| 4e79ff727f | |||
| 314e200be9 | |||
| 71aaf46cf9 | |||
| e4f3ce9e79 | |||
| b570150258 | |||
| e81b848a02 | |||
| bb847922b2 | |||
| 009cff4747 | |||
| 0072308c67 | |||
| 9f92e20383 | |||
| 44e4e91e34 | |||
| 05dd82a0c5 | |||
| 934fb24217 | |||
| 2c640125e8 | |||
| 49e9291214 | |||
| 584edce5d6 | |||
| eda7e0e8cc | |||
| f9f824a2f0 | |||
| 27b973f3db | |||
| 28bc360106 | |||
| 7e81e130c9 | |||
| 532e099146 | |||
| 4e3ee9fb95 | |||
| 9652fae814 | |||
| 65d60008da | |||
| 4ea9dc8908 | |||
| 161f2a6d07 | |||
| 0f7c5dc8b7 | |||
| 3cc54103dd | |||
| 03953191f2 | |||
| f90fb9bda9 | |||
| 58285e81d2 | |||
| cda8dc2bab | |||
| 2a32db0629 | |||
| 971fda604e | |||
| 7afa1241a3 | |||
| 686b6a2937 | |||
| d86b6f7030 | |||
| f859660ab0 | |||
| c853fd3639 | |||
| 392a745c09 | |||
| 9749d793a8 | |||
| ba42cf6e1e | |||
| 702d1dbc58 | |||
| f9670ec53d | |||
| 9cbda1013b | |||
| 3a73756e9b | |||
| ffed9a7b5e | |||
| 6013df548e | |||
| 72e89fa724 | |||
| 7c14d49ee6 | |||
| 0b06b47f1f | |||
| 6d015ffce8 | |||
| 29d4c9130f | |||
| 93d32d2a9f | |||
| 5a729d851e | |||
| d92a0331a6 | |||
| 0cc255a087 | |||
| 6931dc9ff7 | |||
| 0f05494cf3 | |||
| 06c1cb272f |
@@ -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
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
+5
-4
@@ -5,7 +5,7 @@
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true,
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"php": ">=8.3",
|
||||
"ext-ctype": "*",
|
||||
"ext-iconv": "*",
|
||||
"mongodb/mongodb": "^2.1",
|
||||
@@ -14,7 +14,7 @@
|
||||
"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
+256
-356
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',
|
||||
];
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"userMenu": {
|
||||
"darkMode": "Dark Mode",
|
||||
"lightMode": "Light Mode",
|
||||
"systemMode": "System Mode"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console;
|
||||
namespace KTXC\Console\Module;
|
||||
|
||||
use KTXC\Module\ModuleManager;
|
||||
use Psr\Log\LoggerInterface;
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console;
|
||||
namespace KTXC\Console\Module;
|
||||
|
||||
use KTXC\Module\ModuleManager;
|
||||
use Psr\Log\LoggerInterface;
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console;
|
||||
namespace KTXC\Console\Module;
|
||||
|
||||
use KTXC\Module\ModuleManager;
|
||||
use Psr\Log\LoggerInterface;
|
||||
+1
-1
@@ -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;
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console;
|
||||
namespace KTXC\Console\Module;
|
||||
|
||||
use KTXC\Module\ModuleManager;
|
||||
use Psr\Log\LoggerInterface;
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console;
|
||||
namespace KTXC\Console\Module;
|
||||
|
||||
use KTXC\Module\ModuleManager;
|
||||
use Psr\Log\LoggerInterface;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
if ($this->activeScope !== null) {
|
||||
throw new \LogicException('The kernel already has an active execution scope.');
|
||||
}
|
||||
|
||||
// Use middleware pipeline to handle the request
|
||||
return $this->pipeline->handle($request);
|
||||
$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;
|
||||
|
||||
return $scope;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the middleware pipeline
|
||||
*/
|
||||
protected function buildMiddlewarePipeline(): MiddlewarePipeline
|
||||
public function terminateExecution(
|
||||
ExecutionScope $scope,
|
||||
ExecutionOutcome $outcome,
|
||||
): TerminationReport
|
||||
{
|
||||
$pipeline = new MiddlewarePipeline($this->container);
|
||||
if ($scope->terminated()) {
|
||||
return new TerminationReport();
|
||||
}
|
||||
if ($this->activeScope !== $scope) {
|
||||
throw new \LogicException('Cannot terminate a scope that is not active.');
|
||||
}
|
||||
|
||||
// Register middleware in execution order
|
||||
$pipeline->pipe(TenantMiddleware::class);
|
||||
$pipeline->pipe(FirewallMiddleware::class);
|
||||
$pipeline->pipe(AuthenticationMiddleware::class);
|
||||
$pipeline->pipe(RouterMiddleware::class);
|
||||
$processed = 0;
|
||||
$remaining = 0;
|
||||
$deadlineExceeded = false;
|
||||
$limitExceeded = false;
|
||||
$failures = [];
|
||||
|
||||
return $pipeline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process deferred events at the end of the request
|
||||
*/
|
||||
public function processEvents(): void
|
||||
{
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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']:
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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/'
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
: [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 |
@@ -3,4 +3,6 @@
|
||||
*/
|
||||
|
||||
export { useClipboard } from './useClipboard'
|
||||
export { useL10n, t } from './useL10n'
|
||||
export type { L10N, TranslateParams } from './useL10n'
|
||||
export { useUser } from './useUser'
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Locale used before runtime localization configuration is available. */
|
||||
export const DEFAULT_LOCALE = 'en';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
+14
-16
@@ -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');
|
||||
//}
|
||||
//});
|
||||
|
||||
app.mount('#app');
|
||||
(async () => {
|
||||
// No session yet: locale comes from browser preferences alone
|
||||
await useL10nStore().init(null);
|
||||
app.mount('#app');
|
||||
})();
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user