1 Commits

Author SHA1 Message Date
Sebastian ae9bd0e8d6 chore(deps): update dependency mongodb/mongodb to v2.3.0
Build Test / build (pull_request) Successful in 14s
JS Unit Tests / test (pull_request) Successful in 28s
PHP Unit Tests / test (pull_request) Successful in 1m22s
2026-05-07 03:08:42 +00:00
361 changed files with 5608 additions and 10887 deletions
@@ -1,50 +0,0 @@
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
+2 -8
View File
@@ -25,17 +25,11 @@ jobs:
tools: composer:v2
- name: Install 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
run: npm install -g renovate
- name: Run Renovate
env:
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
RENOVATE_PLATFORM: gitea
RENOVATE_ENDPOINT: https://git.ktrix.dev/api/v1
run: |
"${{ runner.temp }}/renovate-npm/bin/renovate" ${{ gitea.repository }}
run: renovate ${{ gitea.repository }}
+4 -3
View File
@@ -14,10 +14,11 @@ node_modules/
# Backend development
/vendor/
/config/system.php
coverage/
*.cache
.phpunit.cache
.phpunit.coverage
.php-cs-fixer.cache
.phpstan.cache
.phpactor/
# Editors
@@ -33,4 +34,4 @@ logs
# Runtime
/modules/
/storage/
/var/
/var/
+5 -5
View File
@@ -7,21 +7,21 @@
declare(strict_types=1);
use KTXC\Application;
use KTXC\Server;
if (!is_dir(dirname(__DIR__).'/vendor')) {
fwrite(STDERR, "Dependencies are missing. Run 'composer install' first.\n");
exit(1);
}
$composerLoader = require_once dirname(__DIR__).'/vendor/autoload.php';
require_once dirname(__DIR__).'/vendor/autoload.php';
try {
$application = Application::create(dirname(__DIR__), $composerLoader);
exit($application->runConsole());
$server = new Server(dirname(__DIR__));
exit($server->runConsole());
} catch (\Throwable $e) {
fwrite(STDERR, "Fatal error: {$e->getMessage()}\n");
if (($application ?? null)?->debug()) {
if (isset($server) && $server->debug()) {
fwrite(STDERR, $e->getTraceAsString()."\n");
}
exit(1);
+5 -6
View File
@@ -5,16 +5,16 @@
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.3",
"php": ">=8.2",
"ext-ctype": "*",
"ext-iconv": "*",
"mongodb/mongodb": "^2.1",
"php-di/php-di": "*",
"phpseclib/phpseclib": "^3.0",
"symfony/console": "^7.0"
"symfony/console": "^8.0"
},
"require-dev": {
"phpunit/phpunit": "^12.5.31"
"phpunit/phpunit": "^11.0"
},
"config": {
"allow-plugins": {
@@ -39,8 +39,7 @@
],
"post-update-cmd": [
],
"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"
"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"
}
}
Generated
+390 -302
View File
File diff suppressed because it is too large Load Diff
@@ -2,14 +2,14 @@
return [
// Application Configuration
'name' => 'Application Name',
'name' => 'Ktrix',
'environment' => 'dev',
'debug' => true,
// Database Configuration
'database' => [
// MongoDB connection URI (include credentials if needed)
'uri' => 'mongodb://username:password@database-host:27017/?authSource=database&tls=true',
'database' => 'database',
'uri' => 'mongodb://ktrix:ktrix@127.0.0.1:27017/?authSource=ktrix&tls=false',
'database' => 'ktrix',
// optional driver options
'options' => [],
'driverOptions' => [],
@@ -65,14 +65,13 @@ return [
'channel' => 'app',
// ── syslog driver options ──────────────────────────────────────────────
// Identity tag passed to openlog(); visible in syslog and journalctl.
'ident' => 'application',
// Identity tag passed to openlog(); visible in /var/logs/syslog and journalctl -t ktrix
'ident' => 'ktrix',
// openlog() facility constant. Common values: LOG_USER, LOG_LOCAL0 … LOG_LOCAL7
'facility' => LOG_USER,
],
// Security Configuration
// Generate a unique value for each deployment, for example: openssl rand -hex 32
'security.salt' => 'replace-with-a-random-secret',
'security.salt' => 'a5418ed8c120b9d12c793ccea10571b74d0dcd4a4db7ca2f75e80fbdafb2bd9b',
];
-24
View File
@@ -1,24 +0,0 @@
{
"header": {
"searchPlaceholder": "Hier suchen.."
},
"systemMenu": {
"administration": "Administration",
"applications": "Anwendungen",
"personalSettings": "Persönliche Einstellungen",
"toggleAdmin": "Admin",
"toggleApps": "Apps",
"toggleSettings": "Einstellungen"
},
"notifications": {
"markAllRead": "Alle als gelesen markieren",
"title": "Benachrichtigungen",
"viewAll": "Alle anzeigen"
},
"userMenu": {
"darkMode": "Dunkles Design",
"lightMode": "Helles Design",
"logout": "Abmelden",
"settings": "Einstellungen"
}
}
-15
View File
@@ -1,15 +0,0 @@
{
"systemMenu": {
"adminSettings": "System",
"apps": "Applications",
"personalSettings": "Settings",
"userSettings": "Settings"
},
"userMenu": {
"darkMode": "Dark Mode",
"lightMode": "Light Mode",
"logout": "Logout",
"settings": "Settings",
"systemMode": "System Mode"
}
}
-7
View File
@@ -1,7 +0,0 @@
{
"userMenu": {
"darkMode": "Dark Mode",
"lightMode": "Light Mode",
"systemMode": "System Mode"
}
}
-154
View File
@@ -1,154 +0,0 @@
<?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;
}
}
@@ -1,29 +0,0 @@
<?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,
);
}
}
@@ -1,35 +0,0 @@
<?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));
}
}
@@ -1,30 +0,0 @@
<?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);
}
}
@@ -1,50 +0,0 @@
<?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);
}
}
}
@@ -1,22 +0,0 @@
<?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;
}
@@ -1,43 +0,0 @@
<?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();
}
}
@@ -1,11 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Application\Execution;
enum RuntimeType: string
{
case HTTP = 'http';
case CLI = 'cli';
}
@@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Application\Execution;
final readonly class TerminationReport
{
/**
* @param list<\Throwable> $failures
*/
public function __construct(
public int $deferredProcessed = 0,
public int $deferredRemaining = 0,
public array $failures = [],
public bool $deadlineExceeded = false,
public bool $limitExceeded = false,
) {
}
}
-18
View File
@@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Application;
final readonly class KernelOptions
{
public function __construct(
public ProjectPaths $paths,
public string $environment = 'prod',
public bool $debug = false,
) {
if ($this->environment === '') {
throw new \InvalidArgumentException('Kernel environment cannot be empty.');
}
}
}
-53
View File
@@ -1,53 +0,0 @@
<?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';
}
}
@@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Event;
use KTXF\Event\EventListenerRegistry;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'events:debug',
description: 'Display the frozen event listener registry',
)]
final class EventsDebugCommand extends Command
{
public function __construct(
private readonly EventListenerRegistry $registry,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$rows = [];
foreach ($this->registry->definitions() as $listener) {
$rows[] = [
$listener->module,
$listener->event,
$listener->service . '::' . $listener->method,
$listener->delivery->value,
$listener->priority,
$listener->failurePolicy->value,
];
}
$io->table(
['Module', 'Event', 'Listener', 'Delivery', 'Priority', 'Failure'],
$rows,
);
return Command::SUCCESS;
}
}
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace KTXC\Console\Module;
namespace KTXC\Console;
use KTXC\Module\ModuleManager;
use Psr\Log\LoggerInterface;
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace KTXC\Console\Module;
namespace KTXC\Console;
use KTXC\Module\ModuleManager;
use Psr\Log\LoggerInterface;
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace KTXC\Console\Module;
namespace KTXC\Console;
use KTXC\Module\ModuleManager;
use Psr\Log\LoggerInterface;
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace KTXC\Console\Module;
namespace KTXC\Console;
use KTXC\Module\ModuleManager;
use Symfony\Component\Console\Attribute\AsCommand;
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace KTXC\Console\Module;
namespace KTXC\Console;
use KTXC\Module\ModuleManager;
use Psr\Log\LoggerInterface;
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace KTXC\Console\Module;
namespace KTXC\Console;
use KTXC\Module\ModuleManager;
use Psr\Log\LoggerInterface;
-102
View File
@@ -1,102 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Role;
use KTXC\Stores\UserAccountsStore;
use KTXC\Stores\UserRolesStore;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Role Assign Command
*
* Assigns a role to a user account.
*/
#[AsCommand(
name: 'role:assign',
description: 'Assign a role to a user',
)]
class RoleAssignCommand extends Command
{
public function __construct(
private readonly UserAccountsStore $userStore,
private readonly UserRolesStore $rolesStore,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->addArgument('identity', InputArgument::REQUIRED, 'User identity (email/username)')
->addArgument('rid', InputArgument::REQUIRED, 'Role id to assign')
->setHelp('This command assigns a role to a user account.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenant = $input->getArgument('tenant');
$identity = $input->getArgument('identity');
$rid = $input->getArgument('rid');
$io->title('Assign Role');
try {
$user = $this->userStore->fetchByIdentity($tenant, $identity);
if (!$user) {
$io->error("User '{$identity}' not found in tenant '{$tenant}'.");
return Command::FAILURE;
}
if (!$this->rolesStore->fetchByRid($tenant, $rid)) {
$io->error("Role '{$rid}' not found in tenant '{$tenant}'.");
return Command::FAILURE;
}
$roles = (array)($user['roles'] ?? []);
if (in_array($rid, $roles, true)) {
$io->text("User '{$identity}' already has role '{$rid}'.");
return Command::SUCCESS;
}
$roles[] = $rid;
if (!$this->userStore->updateUser($tenant, $user['uid'], ['roles' => array_values($roles)])) {
$io->error("Failed to assign role '{$rid}' to '{$identity}'.");
return Command::FAILURE;
}
$this->logger->info('Role assigned via console', [
'tenant' => $tenant,
'identity' => $identity,
'rid' => $rid,
'command' => $this->getName(),
]);
$io->success("Role '{$rid}' assigned to '{$identity}' successfully!");
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to assign role: ' . $e->getMessage());
$this->logger->error('Role assign failed', [
'tenant' => $tenant,
'identity' => $identity,
'rid' => $rid,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
-109
View File
@@ -1,109 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Role;
use KTXC\Service\TenantService;
use KTXC\Stores\UserRolesStore;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Role Create Command
*
* Creates a new role within a tenant.
*/
#[AsCommand(
name: 'role:create',
description: 'Create a new role in a tenant',
)]
class RoleCreateCommand extends Command
{
public function __construct(
private readonly TenantService $tenantService,
private readonly UserRolesStore $rolesStore,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->addArgument('label', InputArgument::REQUIRED, 'Display label for the role')
->addOption('description', 'd', InputOption::VALUE_REQUIRED, 'Role description', '')
->addOption('permission', 'p', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Permission(s) to grant (repeatable, e.g. --permission "*")', [])
->addOption('rid', null, InputOption::VALUE_REQUIRED, 'Explicit role id (defaults to a generated UUID)')
->addOption('system', null, InputOption::VALUE_NONE, 'Mark the role as a protected system role (cannot be updated or deleted)')
->setHelp('This command creates a new role in a tenant. Permissions are free-form strings matched by the runtime permission checker, including wildcard suffixes like "user_manager.role.*" or the full wildcard "*".')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenant = $input->getArgument('tenant');
$label = $input->getArgument('label');
$rid = $input->getOption('rid');
$io->title('Create Role');
try {
if (!$this->tenantService->fetchById($tenant)) {
$io->error("Tenant '{$tenant}' not found.");
return Command::FAILURE;
}
if ($rid !== null && $this->rolesStore->fetchByRid($tenant, $rid)) {
$io->error("Role '{$rid}' already exists in tenant '{$tenant}'.");
return Command::FAILURE;
}
$roleData = [
'label' => $label,
'description' => $input->getOption('description') ?? '',
'permissions' => $input->getOption('permission'),
'system' => $input->getOption('system'),
];
if ($rid !== null) {
$roleData['rid'] = $rid;
}
$role = $this->rolesStore->createRole($tenant, $roleData);
$this->logger->info('Role created via console', [
'tenant' => $tenant,
'rid' => $role['rid'] ?? null,
'command' => $this->getName(),
]);
$io->success("Role '{$label}' created successfully!");
$io->definitionList(
['Rid' => $role['rid'] ?? ''],
['Label' => $role['label'] ?? ''],
['System' => !empty($role['system']) ? 'yes' : 'no'],
['Permissions' => implode(', ', (array)($role['permissions'] ?? []))],
);
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to create role: ' . $e->getMessage());
$this->logger->error('Role create failed', [
'tenant' => $tenant,
'label' => $label,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
-103
View File
@@ -1,103 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Role;
use KTXC\Stores\UserRolesStore;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Role Delete Command
*
* Deletes a role from a tenant.
*/
#[AsCommand(
name: 'role:delete',
description: 'Delete a role from a tenant',
)]
class RoleDeleteCommand extends Command
{
public function __construct(
private readonly UserRolesStore $rolesStore,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->addArgument('rid', InputArgument::REQUIRED, 'Role id')
->addOption('force', 'f', InputOption::VALUE_NONE, 'Skip confirmation prompt')
->setHelp('This command deletes a role from a tenant. System roles cannot be deleted, and roles still assigned to users cannot be deleted.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenant = $input->getArgument('tenant');
$rid = $input->getArgument('rid');
$force = $input->getOption('force');
$io->title('Delete Role');
try {
$role = $this->rolesStore->fetchByRid($tenant, $rid);
if (!$role) {
$io->error("Role '{$rid}' not found in tenant '{$tenant}'.");
return Command::FAILURE;
}
if ($role['system'] ?? false) {
$io->error("Role '{$rid}' is a system role and cannot be deleted.");
return Command::FAILURE;
}
$userCount = $this->rolesStore->countUsersInRole($tenant, $rid);
if ($userCount > 0) {
$io->error("Role '{$rid}' is assigned to {$userCount} user(s) and cannot be deleted.");
return Command::FAILURE;
}
if (!$force && !$io->confirm("Are you sure you want to delete role '{$rid}' from tenant '{$tenant}'?", false)) {
$io->text('Delete cancelled.');
return Command::SUCCESS;
}
if (!$this->rolesStore->deleteRole($tenant, $rid)) {
$io->error("Failed to delete role '{$rid}'.");
return Command::FAILURE;
}
$this->logger->info('Role deleted via console', [
'tenant' => $tenant,
'rid' => $rid,
'command' => $this->getName(),
]);
$io->success("Role '{$rid}' deleted successfully!");
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to delete role: ' . $e->getMessage());
$this->logger->error('Role delete failed', [
'tenant' => $tenant,
'rid' => $rid,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
-82
View File
@@ -1,82 +0,0 @@
<?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;
}
}
}
@@ -1,95 +0,0 @@
<?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;
}
}
}
@@ -1,98 +0,0 @@
<?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;
}
}
}
@@ -1,171 +0,0 @@
<?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;
}
}
}
@@ -1,100 +0,0 @@
<?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;
}
}
}
@@ -1,71 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Tenant;
use KTXC\Service\TenantService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Tenant List Command
*
* Lists all tenants.
*/
#[AsCommand(
name: 'tenant:list',
description: 'List all tenants',
)]
class TenantListCommand extends Command
{
public function __construct(
private readonly TenantService $tenantService
) {
parent::__construct();
}
protected function configure(): void
{
$this->setHelp('This command lists all tenants.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Tenants');
try {
$tenants = $this->tenantService->list();
if (empty($tenants)) {
$io->text('No tenants found.');
return Command::SUCCESS;
}
$rows = [];
foreach ($tenants as $tenant) {
$domains = $tenant->getDomains();
$rows[] = [
$tenant->getIdentifier(),
$tenant->getLabel(),
$tenant->getEnabled() ? 'yes' : 'no',
$domains ? implode(', ', $domains->getArrayCopy()) : '',
];
}
$io->table(['Identifier', 'Label', 'Enabled', 'Domains'], $rows);
$io->text(sprintf('Total: %d tenant(s)', count($rows)));
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to list tenants: ' . $e->getMessage());
return Command::FAILURE;
}
}
}
-128
View File
@@ -1,128 +0,0 @@
<?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;
}
}
}
@@ -1,93 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\User;
use KTXC\Stores\UserAccountsStore;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* User Delete Command
*
* Deletes a user account from a tenant.
*/
#[AsCommand(
name: 'user:delete',
description: 'Delete a user account from a tenant',
)]
class UserDeleteCommand extends Command
{
public function __construct(
private readonly UserAccountsStore $userStore,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->addArgument('identity', InputArgument::REQUIRED, 'User identity (email/username)')
->addOption('force', 'f', InputOption::VALUE_NONE, 'Skip confirmation prompt')
->setHelp('This command deletes a user account from a tenant. Credentials stored by authentication provider modules are not removed.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenant = $input->getArgument('tenant');
$identity = $input->getArgument('identity');
$force = $input->getOption('force');
$io->title('Delete User');
try {
$user = $this->userStore->fetchByIdentity($tenant, $identity);
if (!$user) {
$io->error("User '{$identity}' not found in tenant '{$tenant}'.");
return Command::FAILURE;
}
if (!$force && !$io->confirm("Are you sure you want to delete user '{$identity}' from tenant '{$tenant}'?", false)) {
$io->text('Delete cancelled.');
return Command::SUCCESS;
}
if (!$this->userStore->deleteUser($tenant, $user['uid'])) {
$io->error("Failed to delete user '{$identity}'.");
return Command::FAILURE;
}
$this->logger->info('User deleted via console', [
'tenant' => $tenant,
'identity' => $identity,
'uid' => $user['uid'],
'command' => $this->getName(),
]);
$io->success("User '{$identity}' deleted successfully!");
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to delete user: ' . $e->getMessage());
$this->logger->error('User delete failed', [
'tenant' => $tenant,
'identity' => $identity,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
-83
View File
@@ -1,83 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\User;
use KTXC\Service\TenantService;
use KTXC\Stores\UserAccountsStore;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* User List Command
*
* Lists all user accounts in a tenant.
*/
#[AsCommand(
name: 'user:list',
description: 'List user accounts in a tenant',
)]
class UserListCommand extends Command
{
public function __construct(
private readonly TenantService $tenantService,
private readonly UserAccountsStore $userStore
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->setHelp('This command lists all user accounts in a tenant.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenant = $input->getArgument('tenant');
$io->title("Users in tenant '{$tenant}'");
try {
if (!$this->tenantService->fetchById($tenant)) {
$io->error("Tenant '{$tenant}' not found.");
return Command::FAILURE;
}
$users = $this->userStore->listUsers($tenant);
if (empty($users)) {
$io->text('No users found.');
return Command::SUCCESS;
}
$rows = [];
foreach ($users as $user) {
$rows[] = [
$user['uid'] ?? '',
$user['identity'] ?? '',
$user['label'] ?? '',
!empty($user['enabled']) ? 'yes' : 'no',
implode(', ', (array)($user['roles'] ?? [])),
];
}
$io->table(['Uid', 'Identity', 'Label', 'Enabled', 'Roles'], $rows);
$io->text(sprintf('Total: %d user(s)', count($rows)));
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to list users: ' . $e->getMessage());
return Command::FAILURE;
}
}
}
-101
View File
@@ -1,101 +0,0 @@
<?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);
}
}
@@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Context;
use KTXC\Models\Identity\User;
interface IdentityContextInterface
{
public function present(): bool;
public function identity(): ?User;
public function identifier(): ?string;
public function requireIdentifier(): string;
public function label(): ?string;
public function mailAddress(): ?string;
public function nameFirst(): ?string;
public function nameLast(): ?string;
public function permissions(): array;
public function roles(): array;
public function hasPermission(string $permission): bool;
public function hasRole(string $role): bool;
}
-117
View File
@@ -1,117 +0,0 @@
<?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);
}
}
@@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Context;
use KTXC\Models\Tenant\TenantConfiguration;
interface TenantContextInterface
{
public function present(): bool;
public function configured(): bool;
public function enabled(): bool;
public function domain(): ?string;
public function identifier(): ?string;
public function requireIdentifier(): string;
public function label(): ?string;
public function configuration(): ?TenantConfiguration;
public function settings(): array;
public function identityProviders(): array;
public function identityProviderConfig(string $providerId): ?array;
public function isIdentityProviderEnabled(string $providerId): bool;
}
+9 -9
View File
@@ -10,14 +10,14 @@ use KTXC\Http\Response\RedirectResponse;
use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AnonymousRoute;
use KTXC\Service\SecurityService;
use KTXC\Context\IdentityContextInterface;
use KTXC\SessionIdentity;
use KTXC\Http\Request\Request;
class DefaultController extends ControllerAbstract
{
public function __construct(
private readonly SecurityService $securityService,
private readonly IdentityContextInterface $identityContext,
private readonly SessionIdentity $identity,
#[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->identityContext->identifier()) {
if ($this->identity->identifier()) {
return new FileResponse(
$this->rootDir . '/public/private.html',
Response::HTTP_OK,
['Content-Type' => 'text/html', 'Cache-Control' => 'no-store']
['Content-Type' => 'text/html']
);
}
@@ -38,7 +38,7 @@ class DefaultController extends ControllerAbstract
$response = new FileResponse(
$this->rootDir . '/public/public.html',
Response::HTTP_OK,
['Content-Type' => 'text/html', 'Cache-Control' => 'no-store']
['Content-Type' => 'text/html']
);
// 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', 'Cache-Control' => 'no-store']
['Content-Type' => 'text/html']
);
}
@@ -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->identityContext->identifier()) {
if ($this->identity->identifier()) {
return new FileResponse(
$this->rootDir . '/public/private.html',
Response::HTTP_OK,
['Content-Type' => 'text/html', 'Cache-Control' => 'no-store']
['Content-Type' => 'text/html']
);
}
@@ -128,7 +128,7 @@ class DefaultController extends ControllerAbstract
$response = new FileResponse(
$this->rootDir . '/public/public.html',
Response::HTTP_OK,
['Content-Type' => 'text/html', 'Cache-Control' => 'no-store']
['Content-Type' => 'text/html']
);
// Clear any stale auth cookies since the user is not authenticated
+15 -24
View File
@@ -2,30 +2,28 @@
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\Context\IdentityContextInterface;
use KTXC\SessionIdentity;
use KTXF\Controller\ControllerAbstract;
use KTXC\Context\TenantContextInterface;
use KTXC\SessionTenant;
use KTXF\Module\ModuleBrowserInterface;
use KTXF\Routing\Attributes\AuthenticatedRoute;
class InitController extends ControllerAbstract
{
public function __construct(
private readonly TenantContextInterface $tenantContext,
private readonly IdentityContextInterface $identityContext,
private readonly SessionTenant $tenant,
private readonly SessionIdentity $userIdentity,
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(Request $request): JsonResponse {
public function index(): JsonResponse {
$configuration = [];
@@ -45,30 +43,23 @@ class InitController extends ControllerAbstract
}
}
// localization
$configuration['l10n'] = [
'locale' => $this->localeResolver->resolve($request),
'fallback' => LocaleResolver::FALLBACK,
'available' => $this->localeResolver->available(),
];
// tenant
$configuration['tenant'] = [
'id' => $this->tenantContext->identifier(),
'domain' => $this->tenantContext->domain(),
'label' => $this->tenantContext->label(),
'id' => $this->tenant->identifier(),
'domain' => $this->tenant->domain(),
'label' => $this->tenant->label(),
];
// user
$configuration['user'] = [
'auth' => [
'identifier' => $this->identityContext->identifier(),
'identity' => $this->identityContext->identity()->getIdentity(),
'label' => $this->identityContext->label(),
'roles' => $this->identityContext->identity()->getRoles(),
'permissions' => $this->identityContext->identity()->getPermissions(),
'identifier' => $this->userIdentity->identifier(),
'identity' => $this->userIdentity->identity()->getIdentity(),
'label' => $this->userIdentity->label(),
'roles' => $this->userIdentity->identity()->getRoles(),
'permissions' => $this->userIdentity->identity()->getPermissions(),
],
'profile' => $this->userService->getEditableFields($this->identityContext->identifier()),
'profile' => $this->userService->getEditableFields($this->userIdentity->identifier()),
'settings' => $this->userService->fetchSettings([], true),
];
@@ -4,7 +4,7 @@ namespace KTXC\Controllers;
use KTXC\Http\Response\JsonResponse;
use KTXC\Service\TenantService;
use KTXC\Context\TenantContextInterface;
use KTXC\SessionTenant;
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 TenantContextInterface $tenantContext,
private readonly SessionTenant $tenantIdentity,
private readonly TenantService $tenantService,
) {}
@@ -36,7 +36,7 @@ class TenantSettingsController extends ControllerAbstract
)]
public function read(): JsonResponse
{
$settings = $this->tenantService->fetchSettings($this->tenantContext->identifier());
$settings = $this->tenantService->fetchSettings($this->tenantIdentity->identifier());
return new JsonResponse($settings, JsonResponse::HTTP_OK);
}
@@ -49,9 +49,9 @@ class TenantSettingsController extends ControllerAbstract
* @example request body:
* {
* "data": {
* "theme_default_mode": "dark",
* "theme_palette": {"light": {"colors": {"primary": "#0284C7"}}},
* "theme_lock": true
* "default_mode": "dark",
* "primary_color": "#6366F1",
* "lock_user_colors": true
* }
* }
*
@@ -65,9 +65,9 @@ class TenantSettingsController extends ControllerAbstract
)]
public function update(array $data): JsonResponse
{
$this->tenantService->storeSettings($this->tenantContext->identifier(), $data);
$this->tenantService->storeSettings($this->tenantIdentity->identifier(), $data);
$updatedSettings = $this->tenantService->fetchSettings($this->tenantContext->identifier(), array_keys($data));
$updatedSettings = $this->tenantService->fetchSettings($this->tenantIdentity->identifier(), array_keys($data));
return new JsonResponse($updatedSettings, JsonResponse::HTTP_OK);
}
+18 -18
View File
@@ -4,8 +4,8 @@ namespace KTXC\Controllers;
use KTXC\Http\Response\JsonResponse;
use KTXC\Service\UserAccountsService;
use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface;
use KTXC\SessionIdentity;
use KTXC\SessionTenant;
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 TenantContextInterface $tenantContext,
private readonly IdentityContextInterface $identityContext,
private readonly SessionTenant $tenantIdentity,
private readonly SessionIdentity $userIdentity,
private readonly UserAccountsService $userService,
private readonly LoggerInterface $logger
) {}
@@ -31,7 +31,7 @@ class UserAccountsController extends ControllerAbstract
{
try {
// Check admin permission
if (!$this->identityContext->hasPermission('user.admin')) {
if (!$this->userIdentity->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->identityContext->hasPermission('user.create')) {
if (!$this->userIdentity->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->tenantContext->identifier(),
'tenant' => $this->tenantIdentity->identifier(),
'identity' => $userData['identity'],
'actor' => $this->identityContext->identifier()
'actor' => $this->userIdentity->identifier()
]);
return $this->userService->createUser($userData);
@@ -155,7 +155,7 @@ class UserAccountsController extends ControllerAbstract
*/
private function userUpdate(array $data): bool
{
if (!$this->identityContext->hasPermission('user.update')) {
if (!$this->userIdentity->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->tenantContext->identifier(),
'tenant' => $this->tenantIdentity->identifier(),
'uid' => $uid,
'actor' => $this->identityContext->identifier()
'actor' => $this->userIdentity->identifier()
]);
return $this->userService->updateUser($uid, $updates);
@@ -199,21 +199,21 @@ class UserAccountsController extends ControllerAbstract
*/
private function userDelete(array $data): bool
{
if (!$this->identityContext->hasPermission('user.delete')) {
if (!$this->userIdentity->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->identityContext->identifier()) {
if ($uid === $this->userIdentity->identifier()) {
throw new \InvalidArgumentException('Cannot delete your own account');
}
$this->logger->info('Deleting user', [
'tenant' => $this->tenantContext->identifier(),
'tenant' => $this->tenantIdentity->identifier(),
'uid' => $uid,
'actor' => $this->identityContext->identifier()
'actor' => $this->userIdentity->identifier()
]);
return $this->userService->deleteUser($uid);
@@ -228,7 +228,7 @@ class UserAccountsController extends ControllerAbstract
*/
private function userProviderUnlink(array $data): bool
{
if (!$this->identityContext->hasPermission('user.admin')) {
if (!$this->userIdentity->hasPermission('user.admin')) {
throw new \InvalidArgumentException('Insufficient permissions');
}
@@ -241,9 +241,9 @@ class UserAccountsController extends ControllerAbstract
];
$this->logger->info('Unlinking provider', [
'tenant' => $this->tenantContext->identifier(),
'tenant' => $this->tenantIdentity->identifier(),
'uid' => $uid,
'actor' => $this->identityContext->identifier()
'actor' => $this->userIdentity->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\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface;
use KTXC\SessionIdentity;
use KTXC\SessionTenant;
use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AuthenticatedRoute;
class UserProfileController extends ControllerAbstract
{
public function __construct(
private readonly TenantContextInterface $tenantContext,
private readonly IdentityContextInterface $identityContext,
private readonly SessionTenant $tenantIdentity,
private readonly SessionIdentity $userIdentity,
private readonly UserAccountsService $userService
) {}
@@ -30,7 +30,7 @@ class UserProfileController extends ControllerAbstract
)]
public function read(): JsonResponse
{
$userId = $this->identityContext->identifier();
$userId = $this->userIdentity->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->identityContext->identifier();
$userId = $this->userIdentity->identifier();
// storeProfile automatically filters out provider-managed fields
$this->userService->storeProfile($userId, $data);
+8 -8
View File
@@ -3,8 +3,8 @@
namespace KTXC\Controllers;
use KTXC\Http\Response\JsonResponse;
use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface;
use KTXC\SessionIdentity;
use KTXC\SessionTenant;
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 TenantContextInterface $tenantContext,
private readonly IdentityContextInterface $identityContext,
private readonly SessionTenant $tenantIdentity,
private readonly SessionIdentity $userIdentity,
private readonly UserRolesService $roleService,
private readonly LoggerInterface $logger
) {}
@@ -31,7 +31,7 @@ class UserRolesController extends ControllerAbstract
{
try {
// Check role admin permission
if (!$this->identityContext->hasPermission('role.admin')) {
if (!$this->userIdentity->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->identityContext->hasPermission('role.manage')) {
if (!$this->userIdentity->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->identityContext->hasPermission('role.manage')) {
if (!$this->userIdentity->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->identityContext->hasPermission('role.manage')) {
if (!$this->userIdentity->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\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface;
use KTXC\SessionIdentity;
use KTXC\SessionTenant;
use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AuthenticatedRoute;
class UserSettingsController extends ControllerAbstract
{
public function __construct(
private readonly TenantContextInterface $tenantContext,
private readonly IdentityContextInterface $identityContext,
private readonly SessionTenant $tenantIdentity,
private readonly SessionIdentity $userIdentity,
private readonly UserAccountsService $userService
) {}
@@ -5,10 +5,7 @@ namespace KTXC\Http\Middleware;
use KTXC\Http\Request\Request;
use KTXC\Http\Response\Response;
use KTXC\Service\SecurityService;
use KTXC\Context\IdentityContext;
use KTXF\Cache\BlobCacheInterface;
use KTXF\Cache\EphemeralCacheInterface;
use KTXF\Cache\PersistentCacheInterface;
use KTXC\SessionIdentity;
/**
* Authentication middleware
@@ -22,10 +19,7 @@ class AuthenticationMiddleware implements MiddlewareInterface
{
public function __construct(
private readonly SecurityService $securityService,
private readonly IdentityContext $identityContext,
private readonly EphemeralCacheInterface $ephemeralCache,
private readonly PersistentCacheInterface $persistentCache,
private readonly BlobCacheInterface $blobCache,
private readonly SessionIdentity $sessionIdentity
) {}
public function process(Request $request, RequestHandlerInterface $handler): Response
@@ -35,11 +29,7 @@ class AuthenticationMiddleware implements MiddlewareInterface
// Initialize session identity if authentication succeeded
if ($identity) {
$this->identityContext->initialize($identity);
$identityId = $this->identityContext->identifier();
$this->ephemeralCache->setUserContext($identityId);
$this->persistentCache->setUserContext($identityId);
$this->blobCache->setUserContext($identityId);
$this->sessionIdentity->initialize($identity, true);
}
// 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\Context\IdentityContextInterface;
use KTXC\SessionIdentity;
use KTXC\Security\Authorization\PermissionChecker;
/**
@@ -17,7 +17,7 @@ class RouterMiddleware implements MiddlewareInterface
{
public function __construct(
private readonly Router $router,
private readonly IdentityContextInterface $identityContext,
private readonly SessionIdentity $sessionIdentity,
private readonly PermissionChecker $permissionChecker
) {}
@@ -32,7 +32,7 @@ class RouterMiddleware implements MiddlewareInterface
}
// Check if route requires authentication
if ($match->authenticated && $this->identityContext->identity() === null) {
if ($match->authenticated && $this->sessionIdentity->identity() === null) {
return new Response(
Response::$statusTexts[Response::HTTP_UNAUTHORIZED],
Response::HTTP_UNAUTHORIZED
+4 -14
View File
@@ -4,10 +4,7 @@ namespace KTXC\Http\Middleware;
use KTXC\Http\Request\Request;
use KTXC\Http\Response\Response;
use KTXC\Context\TenantContext;
use KTXF\Cache\BlobCacheInterface;
use KTXF\Cache\EphemeralCacheInterface;
use KTXF\Cache\PersistentCacheInterface;
use KTXC\SessionTenant;
/**
* Tenant resolution middleware
@@ -16,23 +13,16 @@ use KTXF\Cache\PersistentCacheInterface;
class TenantMiddleware implements MiddlewareInterface
{
public function __construct(
private readonly TenantContext $tenantContext,
private readonly EphemeralCacheInterface $ephemeralCache,
private readonly PersistentCacheInterface $persistentCache,
private readonly BlobCacheInterface $blobCache,
private readonly SessionTenant $sessionTenant
) {}
public function process(Request $request, RequestHandlerInterface $handler): Response
{
// Configure tenant from request host
$this->tenantContext->resolveDomain($request->getHost());
$tenantId = $this->tenantContext->identifier();
$this->ephemeralCache->setTenantContext($tenantId);
$this->persistentCache->setTenantContext($tenantId);
$this->blobCache->setTenantContext($tenantId);
$this->sessionTenant->configure($request->getHost());
// Check if tenant is configured and enabled
if (!$this->tenantContext->configured() || !$this->tenantContext->enabled()) {
if (!$this->sessionTenant->configured() || !$this->sessionTenant->enabled()) {
return new Response(
Response::$statusTexts[Response::HTTP_UNAUTHORIZED],
Response::HTTP_UNAUTHORIZED
+164 -159
View File
@@ -9,17 +9,13 @@
namespace KTXC;
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\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\Injection\Builder;
use KTXC\Injection\Container;
use Psr\Container\ContainerInterface;
@@ -27,10 +23,7 @@ use KTXC\Module\ModuleManager;
use Psr\Log\LoggerInterface;
use KTXC\Logger\LoggerFactory;
use KTXC\Logger\TenantAwareLogger;
use KTXF\Event\DeferredEventProcessorInterface;
use KTXF\Event\EventDispatcher;
use KTXF\Event\EventDispatcherInterface;
use KTXF\Event\EventListenerRegistry;
use KTXF\Event\EventBus;
use KTXF\Cache\EphemeralCacheInterface;
use KTXF\Cache\PersistentCacheInterface;
use KTXF\Cache\BlobCacheInterface;
@@ -38,7 +31,7 @@ use KTXF\Cache\Store\FileEphemeralCache;
use KTXF\Cache\Store\FilePersistentCache;
use KTXF\Cache\Store\FileBlobCache;
class Kernel implements KernelInterface
class Kernel
{
public const VERSION = '1.0.0';
public const VERSION_ID = 10000;
@@ -52,17 +45,26 @@ class Kernel implements KernelInterface
protected ?float $startTime = null;
protected ?ContainerInterface $container = null;
protected ?LoggerInterface $logger = null;
private bool $errorHandlerInstalled = false;
private ?ExecutionScope $activeScope = null;
private ?ExecutionRunnerInterface $runner = null;
protected ?MiddlewarePipeline $pipeline = null;
private string $projectDir;
private array $config;
public function __construct(
private readonly KernelOptions $options,
protected string $environment = 'prod',
protected bool $debug = false,
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()
@@ -70,16 +72,15 @@ class Kernel implements KernelInterface
$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');
}
@@ -88,10 +89,7 @@ class Kernel implements KernelInterface
}
// Create logger from config (driver + level-filter; per-tenant wrapping applied later in DI)
$this->logger = LoggerFactory::create(
$this->config,
$this->options->paths->project,
);
$this->logger = LoggerFactory::create($this->config, $this->folderRoot());
$this->initializeErrorHandlers();
@@ -131,7 +129,23 @@ class Kernel implements KernelInterface
return true;
});
$this->errorHandlerInstalled = 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);
});
// Handle fatal errors
register_shutdown_function(function () {
@@ -147,6 +161,11 @@ class Kernel implements KernelInterface
$this->logger->error($message, $error);
if ($this->debug) {
echo '<pre>' . $message . '</pre>';
} else {
echo 'A fatal error occurred. Please try again later.';
}
}
});
}
@@ -161,19 +180,14 @@ class Kernel implements KernelInterface
/** @var ModuleManager $moduleManager */
$moduleManager = $this->container->get(ModuleManager::class);
$moduleManager->modulesBoot();
$this->container
->get(EventListenerRegistry::class)
->freeze($this->container);
// Build middleware pipeline
$this->pipeline = $this->buildMiddlewarePipeline();
$this->booted = true;
}
}
public function executionRunner(): ExecutionRunnerInterface
{
return $this->runner ??= new ExecutionRunner($this);
}
public function reboot(): void
{
$this->shutdown();
@@ -185,108 +199,52 @@ class Kernel implements KernelInterface
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 beginExecution(ExecutionDescriptor $descriptor): ExecutionScope
public function handle(Request $request): Response
{
if (!$this->booted) {
$this->boot();
}
if ($this->activeScope !== null) {
throw new \LogicException('The kernel already has an active execution scope.');
}
$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;
// Use middleware pipeline to handle the request
return $this->pipeline->handle($request);
}
public function terminateExecution(
ExecutionScope $scope,
ExecutionOutcome $outcome,
): TerminationReport
/**
* Build the middleware pipeline
*/
protected function buildMiddlewarePipeline(): MiddlewarePipeline
{
if ($scope->terminated()) {
return new TerminationReport();
}
if ($this->activeScope !== $scope) {
throw new \LogicException('Cannot terminate a scope that is not active.');
}
$processed = 0;
$remaining = 0;
$deadlineExceeded = false;
$limitExceeded = false;
$failures = [];
$pipeline = new MiddlewarePipeline($this->container);
// Register middleware in execution order
$pipeline->pipe(TenantMiddleware::class);
$pipeline->pipe(FirewallMiddleware::class);
$pipeline->pipe(AuthenticationMiddleware::class);
$pipeline->pipe(RouterMiddleware::class);
return $pipeline;
}
/**
* Process deferred events at the end of the request
*/
public function processEvents(): void
{
try {
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;
if ($this->container && $this->container->has(EventBus::class)) {
/** @var EventBus $eventBus */
$eventBus = $this->container->get(EventBus::class);
$eventBus->processDeferred();
}
} catch (\Throwable $e) {
$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;
error_log('Event processing error: ' . $e->getMessage());
}
return new TerminationReport(
deferredProcessed: $processed,
deferredRemaining: $remaining,
failures: $failures,
deadlineExceeded: $deadlineExceeded,
limitExceeded: $limitExceeded,
);
}
/**
@@ -296,34 +254,30 @@ class Kernel implements KernelInterface
*/
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($projectDir) ?: $projectDir,
'kernel.environment' => $this->environment(),
'kernel.project_dir' => realpath($this->folderRoot()) ?: $this->folderRoot(),
'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($cacheDir) ?: $cacheDir,
'kernel.cache_dir' => realpath($cacheDir) ?: $cacheDir,
'kernel.logs_dir' => realpath($logsDir) ?: $logsDir,
'kernel.charset' => 'UTF-8',
'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(),
];
}
public function environment(): string
{
return $this->options->environment;
return $this->environment;
}
public function debug(): bool
{
return $this->options->debug;
return $this->debug;
}
public function container(): ContainerInterface
@@ -337,7 +291,61 @@ class Kernel implements KernelInterface
public function getStartTime(): float
{
return $this->debug() && null !== $this->startTime ? $this->startTime : -\INF;
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';
}
/**
@@ -372,9 +380,9 @@ class Kernel implements KernelInterface
protected function configureContainer(Builder $builder): void
{
// Service definitions
$projectDir = $this->options->paths->project;
$projectDir = $this->folderRoot();
$moduleDir = $projectDir . '/modules';
$environment = $this->environment();
$environment = $this->environment;
$builder->addDefinitions([
@@ -387,9 +395,6 @@ class Kernel implements KernelInterface
// 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'] ?? [];
@@ -400,7 +405,7 @@ class Kernel implements KernelInterface
return new TenantAwareLogger(
$this->logger,
$c->get(TenantContextInterface::class),
$c->get(SessionTenant::class),
$logDir,
$channel,
$level,
@@ -408,8 +413,8 @@ class Kernel implements KernelInterface
);
},
EventDispatcherInterface::class => \DI\get(EventDispatcher::class),
DeferredEventProcessorInterface::class => \DI\get(EventDispatcher::class),
// EventBus as singleton for consistent event handling
EventBus::class => \DI\create(EventBus::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';
@@ -428,13 +433,13 @@ class Kernel implements KernelInterface
$cache = new $storeClass($projectDir);
// Set tenant/user context if available
if ($c->has(TenantContextInterface::class)) {
$tenantContext = $c->get(TenantContextInterface::class);
$cache->setTenantContext($tenantContext->identifier());
if ($c->has(SessionTenant::class)) {
$tenant = $c->get(SessionTenant::class);
$cache->setTenantContext($tenant->identifier());
}
if ($c->has(IdentityContextInterface::class)) {
$identityContext = $c->get(IdentityContextInterface::class);
$cache->setUserContext($identityContext->identifier());
if ($c->has(SessionIdentity::class)) {
$identity = $c->get(SessionIdentity::class);
$cache->setUserContext($identity->identifier());
}
return $cache;
@@ -457,13 +462,13 @@ class Kernel implements KernelInterface
$cache = new $storeClass($projectDir);
// Set tenant/user context if available
if ($c->has(TenantContextInterface::class)) {
$tenantContext = $c->get(TenantContextInterface::class);
$cache->setTenantContext($tenantContext->identifier());
if ($c->has(SessionTenant::class)) {
$tenant = $c->get(SessionTenant::class);
$cache->setTenantContext($tenant->identifier());
}
if ($c->has(IdentityContextInterface::class)) {
$identityContext = $c->get(IdentityContextInterface::class);
$cache->setUserContext($identityContext->identifier());
if ($c->has(SessionIdentity::class)) {
$identity = $c->get(SessionIdentity::class);
$cache->setUserContext($identity->identifier());
}
return $cache;
@@ -486,13 +491,13 @@ class Kernel implements KernelInterface
$cache = new $storeClass($projectDir);
// Set tenant/user context if available
if ($c->has(TenantContextInterface::class)) {
$tenantContext = $c->get(TenantContextInterface::class);
$cache->setTenantContext($tenantContext->identifier());
if ($c->has(SessionTenant::class)) {
$tenant = $c->get(SessionTenant::class);
$cache->setTenantContext($tenant->identifier());
}
if ($c->has(IdentityContextInterface::class)) {
$identityContext = $c->get(IdentityContextInterface::class);
$cache->setUserContext($identityContext->identifier());
if ($c->has(SessionIdentity::class)) {
$identity = $c->get(SessionIdentity::class);
$cache->setUserContext($identity->identifier());
}
return $cache;
-34
View File
@@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC;
use KTXC\Application\Execution\ExecutionDescriptor;
use KTXC\Application\Execution\ExecutionOutcome;
use KTXC\Application\Execution\ExecutionRunnerInterface;
use KTXC\Application\Execution\ExecutionScope;
use KTXC\Application\Execution\TerminationReport;
use Psr\Container\ContainerInterface;
interface KernelInterface
{
public function boot(): void;
public function executionRunner(): ExecutionRunnerInterface;
public function beginExecution(ExecutionDescriptor $descriptor): ExecutionScope;
public function terminateExecution(
ExecutionScope $scope,
ExecutionOutcome $outcome,
): TerminationReport;
public function shutdown(): void;
public function container(): ContainerInterface;
public function environment(): string;
public function debug(): bool;
}
-71
View File
@@ -1,71 +0,0 @@
<?php
namespace KTXC\L10N;
use DI\Attribute\Inject;
use KTXC\Http\Request\Request;
use KTXC\Service\UserAccountsService;
/**
* Resolves the effective UI locale for the current user.
*
* Resolution chain: user setting (core.locale) -> Accept-Language
* negotiation -> fallback. Tenant default slots in between once tenant
* settings expose one (Phase 3).
*/
class LocaleResolver
{
public const FALLBACK = 'en';
public const SETTING_KEY = 'core.locale';
public function __construct(
private readonly UserAccountsService $userService,
#[Inject('rootDir')] private readonly string $rootDir,
) {}
/**
* Locales with a core catalog on disk (public/l10n/*.json), always
* including the fallback. This backs the language picker, so a locale
* is only offered when it has at least core-shell coverage.
*
* @return string[]
*/
public function available(): array
{
$locales = [self::FALLBACK];
foreach (glob($this->rootDir . '/public/l10n/*.json') ?: [] as $file) {
$locales[] = basename($file, '.json');
}
$locales = array_values(array_unique($locales));
sort($locales);
return $locales;
}
public function resolve(Request $request): string
{
$available = $this->available();
// 1. explicit user preference
$settings = $this->userService->fetchSettings([self::SETTING_KEY]);
$preference = $settings[self::SETTING_KEY] ?? null;
if (is_string($preference) && in_array($preference, $available, true)) {
return $preference;
}
// 2. Accept-Language negotiation. getPreferredLanguage() returns the
// first candidate when nothing matches, so the fallback leads the
// list; it also normalizes to underscore form (pt_BR) while catalogs
// use BCP-47 hyphens.
$candidates = array_merge([self::FALLBACK], array_diff($available, [self::FALLBACK]));
$negotiated = $request->getPreferredLanguage($candidates);
if ($negotiated !== null) {
$negotiated = str_replace('_', '-', $negotiated);
if (in_array($negotiated, $available, true)) {
return $negotiated;
}
}
// 3. fallback
return self::FALLBACK;
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ use Psr\Log\NullLogger;
*
* Per-tenant routing (TenantAwareLogger) is applied separately inside the
* DI container definition in Kernel::configureContainer(), because that
* requires access to the TenantContextInterface singleton which lives in the container.
* requires access to the SessionTenant singleton which lives in the container.
*
* Supported config keys inside $config['log']:
*
+6 -6
View File
@@ -2,7 +2,7 @@
namespace KTXC\Logger;
use KTXC\Context\TenantContextInterface;
use KTXC\SessionTenant;
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 TenantContextInterface reference that is populated lazily
* Both behaviours rely on a live SessionTenant 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 TenantContextInterface $tenantContext Live reference configured by TenantMiddleware.
* @param SessionTenant $sessionTenant 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 TenantContextInterface $tenantContext,
private readonly SessionTenant $sessionTenant,
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->tenantContext->configured()) {
$tenantId = $this->tenantContext->identifier() ?? 'system';
if ($this->sessionTenant->configured()) {
$tenantId = $this->sessionTenant->identifier() ?? 'system';
}
// Inject tenant id as a reserved context key that concrete loggers extract.
+2 -2
View File
@@ -52,8 +52,8 @@ class TenantObject extends JsonSerializableObject
'enabled' => $this->enabled,
'label' => $this->label,
'description' => $this->description,
'domains' => $this->domains?->getArrayCopy(),
'configuration' => $this->configuration?->jsonSerialize(),
'domains' => $this->domains,
'configuration' => $this->configuration,
];
}
+8 -54
View File
@@ -2,10 +2,6 @@
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;
@@ -17,36 +13,7 @@ use KTXF\Module\ModuleInstanceAbstract;
*/
class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, ModuleBrowserInterface
{
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 __construct() {}
public function handle(): string
{
@@ -60,7 +27,7 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
public function author(): string
{
return 'Vallarx';
return 'Ktrix';
}
public function description(): string
@@ -132,25 +99,12 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
public function registerCI(): array
{
return [
\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,
\KTXC\Console\ModuleListCommand::class,
\KTXC\Console\ModuleEnableCommand::class,
\KTXC\Console\ModuleDisableCommand::class,
\KTXC\Console\ModuleInstallCommand::class,
\KTXC\Console\ModuleUninstallCommand::class,
\KTXC\Console\ModuleUpgradeCommand::class,
];
}
+5 -7
View File
@@ -2,7 +2,7 @@
namespace KTXC\Module;
use Composer\Autoload\ClassLoader;
use KTXC\Server;
/**
* Custom autoloader for modules that allows PascalCase namespaces
@@ -20,10 +20,7 @@ class ModuleAutoloader
private array $namespaceMap = [];
private bool $scanned = false;
public function __construct(
string $modulesRoot,
private readonly ?ClassLoader $composerLoader = null,
)
public function __construct(string $modulesRoot)
{
$this->modulesRoot = rtrim($modulesRoot, '/');
}
@@ -76,9 +73,10 @@ class ModuleAutoloader
}
// Register module namespaces with Composer ClassLoader
if ($this->composerLoader !== null) {
$composerLoader = Server::getComposerLoader();
if ($composerLoader !== null) {
foreach ($this->namespaceMap as $namespace => $folderName) {
$this->composerLoader->addPsr4(
$composerLoader->addPsr4(
'KTXM\\' . $namespace . '\\',
$this->modulesRoot . '/' . $folderName . '/lib/'
);
+1 -2
View File
@@ -276,13 +276,12 @@ class ModuleManager
try {
$module->boot();
$this->logger->debug('Module booted', ['handle' => $handle]);
} catch (\Throwable $e) {
} catch (Exception $e) {
$this->logger->error('Module boot failed: ' . $handle, [
'exception' => $e,
'message' => $e->getMessage(),
'code' => $e->getCode(),
]);
throw $e;
}
}
}
+2 -10
View File
@@ -4,7 +4,6 @@ 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;
@@ -208,8 +207,7 @@ 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, query params, full body for array params.
* Precedence on name collision: route params > body fields > query params > parameter default.
* Performs light argument resolution: Request object, route params, body fields, full body for array params.
*/
public function dispatch(Route $route, Request $request): ?Response
{
@@ -226,8 +224,7 @@ class Router
try {
$requestParameters = $request->getPayload();
} catch (\Throwable) {
// ignore payload errors, fall back to an empty parameter bag
$requestParameters = new RequestInputParameters([]);
// ignore payload errors
}
$reflectionMethod = new \ReflectionMethod($routeControllerName, $routeControllerMethod);
$routeParams = $route->params ?? [];
@@ -261,11 +258,6 @@ 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();
@@ -1,76 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Runtime\Console;
use KTXC\Application\Execution\ExecutionDescriptor;
use KTXC\Kernel;
use KTXC\KernelInterface;
use KTXC\Module\ModuleManager;
use KTXF\Module\ModuleConsoleInterface;
use Psr\Container\ContainerInterface;
use Symfony\Component\Console\Application as ConsoleApplication;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\LazyCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
final class ConsoleRuntime
{
public function __construct(
private readonly KernelInterface $kernel,
) {
}
public function run(?InputInterface $input = null, ?OutputInterface $output = null): int
{
return $this->kernel->executionRunner()->execute(
ExecutionDescriptor::cli(),
function () use ($input, $output): int {
$container = $this->kernel->container();
$console = new ConsoleApplication('Vallarx Console', Kernel::VERSION);
$console->setAutoExit(false);
/** @var ModuleManager $moduleManager */
$moduleManager = $container->get(ModuleManager::class);
foreach ($moduleManager->list() as $module) {
$instance = $module->instance();
if (!$instance instanceof ModuleConsoleInterface) {
continue;
}
foreach ($instance->registerCI() as $commandClass) {
$this->registerCommand($console, $container, $commandClass);
}
}
return $console->run($input, $output);
}
);
}
/**
* @param class-string $commandClass
*/
private function registerCommand(ConsoleApplication $console, ContainerInterface $container, string $commandClass): void {
if (!class_exists($commandClass)) {
throw new \RuntimeException("Command class not found: {$commandClass}");
}
$reflection = new \ReflectionClass($commandClass);
$attributes = $reflection->getAttributes(AsCommand::class);
if ($attributes === []) {
throw new \RuntimeException("Command {$commandClass} is missing #[AsCommand].");
}
$attribute = $attributes[0]->newInstance();
$console->add(new LazyCommand(
$attribute->name,
[],
$attribute->description ?? '',
$attribute->hidden ?? false,
static fn() => $container->get($commandClass),
));
}
}
-78
View File
@@ -1,78 +0,0 @@
<?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 -17
View File
@@ -10,7 +10,7 @@ use KTXC\Security\Authentication\AuthenticationRequest;
use KTXC\Security\Authentication\AuthenticationResponse;
use KTXC\Service\TokenService;
use KTXC\Service\UserAccountsService;
use KTXC\Context\TenantContextInterface;
use KTXC\SessionTenant;
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 TenantContextInterface $tenantContext,
private readonly SessionTenant $tenant,
private readonly EphemeralCacheInterface $cache,
private readonly ProviderManager $providerManager,
private readonly TokenService $tokenService,
private readonly UserAccountsService $userService,
) {
$this->securityCode = $this->tenantContext->configuration()->security()->code();
$this->securityCode = $this->tenant->configuration()->security()->code();
}
// =========================================================================
@@ -75,7 +75,7 @@ class AuthenticationManager
$methods = $this->methodsConfigured();
$session = AuthenticationSession::create(
$this->tenantContext->identifier(),
$this->tenant->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->tenantContext->configuration()->authentication()->methodsMinimal();
$require = $this->tenant->configuration()->authentication()->methodsMinimal();
// Store identity in session without validating to prevent enumeration
$session->setMethods(array_column($methods, 'id'), $require);
@@ -155,13 +155,6 @@ 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);
@@ -429,7 +422,7 @@ class AuthenticationManager
$session->methodCompleted($method);
// Check if MFA is required
$require = $this->tenantContext->configuration()->authentication()->methodsMinimal();
$require = $this->tenant->configuration()->authentication()->methodsMinimal();
if ($require > 1) {
$remainingMethods = $this->methodsConfigured([$method]);
// Filter out redirect methods - they can't be used as secondary factors
@@ -523,7 +516,7 @@ class AuthenticationManager
$accessToken = $this->tokenService->createToken(
[
'tenant' => $this->tenantContext->identifier(),
'tenant' => $this->tenant->identifier(),
'identifier' => $user->getId(),
'identity' => $user->getIdentity(),
'label' => $user->getLabel(),
@@ -585,7 +578,7 @@ class AuthenticationManager
*/
private function getProviderConfig(string $method): array
{
$providers = $this->tenantContext->configuration()->authentication()->providers();
$providers = $this->tenant->configuration()->authentication()->providers();
return $providers[$method]['config'] ?? [];
}
@@ -635,7 +628,7 @@ class AuthenticationManager
*/
private function methodsConfigured(array $methodsCompleted = []): array
{
$tenantProviders = $this->tenantContext->configuration()->authentication()->providers();
$tenantProviders = $this->tenant->configuration()->authentication()->providers();
$methods = [];
foreach ($tenantProviders as $providerId => $providerConfiguration) {
@@ -669,7 +662,7 @@ class AuthenticationManager
private function createTokens(User $user, bool $mfaVerified = false): array
{
$payload = [
'tenant' => $this->tenantContext->identifier(),
'tenant' => $this->tenant->identifier(),
'identifier' => $user->getId(),
'identity' => $user->getIdentity(),
'label' => $user->getLabel(),
@@ -2,7 +2,7 @@
namespace KTXC\Security\Authorization;
use KTXC\Context\IdentityContextInterface;
use KTXC\SessionIdentity;
/**
* Permission Checker
@@ -11,7 +11,7 @@ use KTXC\Context\IdentityContextInterface;
class PermissionChecker
{
public function __construct(
private readonly IdentityContextInterface $identityContext
private readonly SessionIdentity $sessionIdentity
) {}
/**
@@ -24,7 +24,7 @@ class PermissionChecker
*/
public function can(string $permission, mixed $resource = null): bool
{
$identity = $this->identityContext->identity();
$identity = $this->sessionIdentity->identity();
if (!$identity) {
return false;
@@ -113,7 +113,7 @@ class PermissionChecker
*/
public function getUserPermissions(): array
{
$identity = $this->identityContext->identity();
$identity = $this->sessionIdentity->identity();
if (!$identity) {
return [];
+311
View File
@@ -0,0 +1,311 @@
<?php
namespace KTXC;
use KTXC\Http\Request\Request;
use KTXC\Http\Response\Response;
use KTXC\Module\ModuleAutoloader;
use KTXC\Module\ModuleManager;
use KTXF\Module\ModuleConsoleInterface;
use Psr\Container\ContainerInterface;
use Symfony\Component\Console\Application as ConsoleApplication;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\LazyCommand;
/**
* Server class - entry point for the framework
* Handles configuration loading and kernel lifecycle
*/
class Server
{
private static $composerLoader = null;
private static ?self $instance = null;
private Kernel $kernel;
private array $config;
private string $rootDir;
public function __construct(string $rootDir, ?string $environment = null, ?bool $debug = null)
{
self::$instance = $this;
$this->rootDir = $this->resolveProjectRoot($rootDir);
// Load configuration
$this->config = $this->loadConfig();
// Determine environment and debug mode
$environment = $environment ?? $this->config['environment'] ?? 'prod';
$debug = $debug ?? $this->config['debug'] ?? false;
// Create kernel with configuration
$this->kernel = new Kernel($environment, $debug, $this->config, $rootDir);
// Register module autoloader for both HTTP and CLI contexts
$moduleAutoloader = new ModuleAutoloader($this->moduleDir());
$moduleAutoloader->register();
}
/**
* Run the application - handle incoming request and send response
*/
public function runHttp(): void
{
try {
$request = Request::createFromGlobals();
$response = $this->handle($request);
$response->send();
$this->terminate();
} catch (\Throwable $e) {
// Last resort error handling for kernel initialization failures
error_log('Application error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
$content = $this->kernel->debug()
? '<pre>' . htmlspecialchars((string) $e) . '</pre>'
: 'An error occurred. Please try again later.';
$response = new Response($content, Response::HTTP_INTERNAL_SERVER_ERROR, [
'Content-Type' => 'text/html; charset=UTF-8',
]);
$response->send();
exit(1);
}
}
/**
* Run as a console application (CLI runtime).
*/
public function runConsole(): int
{
$this->kernel()->boot();
$container = $this->container();
$console = new ConsoleApplication('Ktrix Console', Kernel::VERSION);
/** @var ModuleManager $moduleManager */
$moduleManager = $container->get(ModuleManager::class);
foreach ($moduleManager->list() as $module) {
$instance = $module->instance();
if (!$instance instanceof ModuleConsoleInterface) {
continue;
}
try {
foreach ($instance->registerCI() as $commandClass) {
if (!class_exists($commandClass)) {
fwrite(STDERR, "Warning: Command class not found: {$commandClass}\n");
continue;
}
$this->registerLazyCommand($console, $container, $commandClass);
}
} catch (\Throwable $e) {
fwrite(STDERR, "Warning: Failed to load commands from module {$module->handle()}: {$e->getMessage()}\n");
}
}
return $console->run();
}
/**
* Handle a request
*/
public function handle(Request $request): Response
{
return $this->kernel->handle($request);
}
/**
* Terminate the application - process deferred events
*/
public function terminate(): void
{
$this->kernel->processEvents();
}
/**
* Get the kernel instance
*/
public function kernel(): Kernel
{
return $this->kernel;
}
/**
* Get the container instance
*/
public function container(): ContainerInterface
{
return $this->kernel->container();
}
/**
* Get the application root directory
*/
public function rootDir(): string
{
return $this->rootDir;
}
/**
* Get the modules directory
*/
public function moduleDir(): string
{
return $this->rootDir . '/modules';
}
public function varDir(): string
{
return $this->rootDir . '/var';
}
public function logDir(): string
{
return $this->varDir() . '/logs';
}
/**
* Get configuration value
*/
public function config(?string $key = null, mixed $default = null): mixed
{
if ($key === null) {
return $this->config;
}
// Support dot notation: 'database.uri'
$keys = explode('.', $key);
$value = $this->config;
foreach ($keys as $k) {
if (!is_array($value) || !array_key_exists($k, $value)) {
return $default;
}
$value = $value[$k];
}
return $value;
}
/**
* Get environment
*/
public function environment(): string
{
return $this->kernel->environment();
}
/**
* Check if debug mode is enabled
*/
public function debug(): bool
{
return $this->kernel->debug();
}
/**
* Load configuration from config directory
*/
protected function loadConfig(): array
{
$configFile = $this->rootDir . '/config/system.php';
if (!file_exists($configFile)) {
error_log('Configuration file not found: ' . $configFile);
return [];
}
$config = include $configFile;
if (!is_array($config)) {
throw new \RuntimeException('Configuration file must return an array');
}
return $config;
}
/**
* Resolve the project root directory.
*
* Some entrypoints may pass the public/ directory or another subdirectory.
* We walk up the directory tree until we find composer.json.
*/
private function resolveProjectRoot(string $startDir): string
{
$dir = rtrim($startDir, '/');
if ($dir === '') {
return $startDir;
}
// If startDir is a file path, use its directory.
if (is_file($dir)) {
$dir = dirname($dir);
}
$current = $dir;
while (true) {
if (is_file($current . '/composer.json')) {
return $current;
}
$parent = dirname($current);
if ($parent === $current) {
// Reached filesystem root
return $dir;
}
$current = $parent;
}
}
/**
* Set the Composer ClassLoader instance
*/
public static function setComposerLoader($loader): void
{
self::$composerLoader = $loader;
}
/**
* Get the Composer ClassLoader instance
*/
public static function getComposerLoader()
{
return self::$composerLoader;
}
/**
* Get the current Application instance
*/
public static function getInstance(): ?self
{
return self::$instance;
}
/**
* Register a single command via lazy loading using its #[AsCommand] attribute.
*/
private function registerLazyCommand(
ConsoleApplication $console,
ContainerInterface $container,
string $commandClass
): void {
try {
$ref = new \ReflectionClass($commandClass);
$attrs = $ref->getAttributes(AsCommand::class);
if (empty($attrs)) {
fwrite(STDERR, "Warning: Command {$commandClass} missing #[AsCommand] attribute\n");
return;
}
$attr = $attrs[0]->newInstance();
$console->add(new LazyCommand(
$attr->name,
[],
$attr->description ?? '',
$attr->hidden ?? false,
fn() => $container->get($commandClass)
));
} catch (\Throwable $e) {
fwrite(STDERR, "Warning: Failed to register command {$commandClass}: {$e->getMessage()}\n");
}
}
}
+14 -14
View File
@@ -5,7 +5,7 @@ namespace KTXC\Service;
use KTXC\Db\DataStore;
use KTXC\Db\Collection;
use KTXC\Db\UTCDateTime;
use KTXC\Context\TenantContextInterface;
use KTXC\SessionTenant;
class ConfigurationService
{
@@ -24,7 +24,7 @@ class ConfigurationService
public function __construct(
DataStore $store,
private readonly TenantContextInterface $tenantContext
private readonly SessionTenant $tenant
) {
// 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->tenantContext->configured()) {
if ($tenant === null && !$this->tenant->isConfigured()) {
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
} elseif ($tenant === null) {
$tenant = $this->tenantContext->identifier();
$tenant = $this->tenant->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->tenantContext->configured()) {
if ($tenant === null && !$this->tenant->isConfigured()) {
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
} elseif ($tenant === null) {
$tenant = $this->tenantContext->identifier();
$tenant = $this->tenant->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->tenantContext->configured()) {
if ($tenant === null && !$this->tenant->isConfigured()) {
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
} elseif ($tenant === null) {
$tenant = $this->tenantContext->identifier();
$tenant = $this->tenant->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->tenantContext->configured()) {
if ($tenant === null && !$this->tenant->isConfigured()) {
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
} elseif ($tenant === null) {
$tenant = $this->tenantContext->identifier();
$tenant = $this->tenant->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->tenantContext->configured()) {
if ($tenant === null && !$this->tenant->isConfigured()) {
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
} elseif ($tenant === null) {
$tenant = $this->tenantContext->identifier();
$tenant = $this->tenant->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->tenantContext->configured()) {
if ($tenant === null && !$this->tenant->isConfigured()) {
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
} elseif ($tenant === null) {
$tenant = $this->tenantContext->identifier();
$tenant = $this->tenant->identifier();
}
return $this->collection->countDocuments(['did' => $tenant, 'path' => $path, 'key' => $key]) > 0;
+49 -25
View File
@@ -8,8 +8,8 @@ use KTXC\Http\Request\Request;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Models\Firewall\FirewallLogObject;
use KTXC\Stores\FirewallStore;
use KTXC\Context\TenantContextInterface;
use KTXF\Event\EventDispatcherInterface;
use KTXC\SessionTenant;
use KTXF\Event\EventBus;
use KTXF\Event\SecurityEvent;
use KTXF\IpUtils;
@@ -41,9 +41,33 @@ class FirewallService
public function __construct(
private readonly FirewallStore $store,
private readonly TenantContextInterface $tenantContext,
private readonly EventDispatcherInterface $events,
private readonly SessionTenant $tenant,
private readonly EventBus $eventBus
) {
// 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']
);
}
/**
@@ -76,7 +100,7 @@ class FirewallService
return new FirewallAnalyzeResult(true);
}
$tenantId = $this->tenantContext->identifier();
$tenantId = $this->tenant->identifier();
if (!$tenantId) {
return new FirewallAnalyzeResult(true);
}
@@ -134,7 +158,7 @@ class FirewallService
public function handleAuthFailure(SecurityEvent $event): void
{
$ipAddress = $event->getIpAddress();
$tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
$tenantId = $event->getTenantId() ?? $this->tenant->identifier();
if (!$ipAddress || !$tenantId) {
return;
@@ -174,8 +198,8 @@ class FirewallService
): void {
// Publish brute force event
$event = SecurityEvent::bruteForceDetected($ipAddress, $failureCount, $windowSeconds);
$event->setTenantId($this->tenantContext->identifier());
$this->events->dispatch($event);
$event->setTenantId($this->tenant->identifier());
$this->eventBus->publish($event);
// Auto-block the IP
$blockDuration = $this->getConfig(
@@ -196,7 +220,7 @@ class FirewallService
*/
public function logSecurityEvent(SecurityEvent $event): void
{
$tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
$tenantId = $event->getTenantId() ?? $this->tenant->identifier();
if (!$tenantId) {
return;
}
@@ -259,8 +283,8 @@ class FirewallService
$rule->getId(),
$rule->getReason()
);
$event->setTenantId($this->tenantContext->identifier());
$this->events->dispatch($event);
$event->setTenantId($this->tenant->identifier());
$this->eventBus->publish($event);
}
// ========================================
@@ -276,7 +300,7 @@ class FirewallService
?string $createdBy = null,
?int $durationSeconds = null
): FirewallRuleObject {
$tenantId = $this->tenantContext->identifier();
$tenantId = $this->tenant->identifier();
if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
}
@@ -316,7 +340,7 @@ class FirewallService
$event->setIpAddress($ipAddress)
->setReason($reason)
->setTenantId($tenantId);
$this->events->dispatch($event);
$this->eventBus->publish($event);
return $rule;
}
@@ -329,7 +353,7 @@ class FirewallService
?string $reason = null,
?string $createdBy = null
): FirewallRuleObject {
$tenantId = $this->tenantContext->identifier();
$tenantId = $this->tenant->identifier();
if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
}
@@ -352,7 +376,7 @@ class FirewallService
$event->setIpAddress($ipAddress)
->setReason($reason)
->setTenantId($tenantId);
$this->events->dispatch($event);
$this->eventBus->publish($event);
return $rule;
}
@@ -365,7 +389,7 @@ class FirewallService
?string $reason = null,
?string $createdBy = null
): FirewallRuleObject {
$tenantId = $this->tenantContext->identifier();
$tenantId = $this->tenant->identifier();
if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
}
@@ -395,7 +419,7 @@ class FirewallService
?string $createdBy = null,
?int $durationSeconds = null
): FirewallRuleObject {
$tenantId = $this->tenantContext->identifier();
$tenantId = $this->tenant->identifier();
if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
}
@@ -424,7 +448,7 @@ class FirewallService
$event->setDeviceFingerprint($fingerprint)
->setReason($reason)
->setTenantId($tenantId);
$this->events->dispatch($event);
$this->eventBus->publish($event);
return $rule;
}
@@ -440,7 +464,7 @@ class FirewallService
}
// Verify tenant ownership
if ($rule->getTenantId() !== $this->tenantContext->identifier()) {
if ($rule->getTenantId() !== $this->tenant->identifier()) {
return false;
}
@@ -461,7 +485,7 @@ class FirewallService
}
// Verify tenant ownership
if ($rule->getTenantId() !== $this->tenantContext->identifier()) {
if ($rule->getTenantId() !== $this->tenant->identifier()) {
return false;
}
@@ -477,7 +501,7 @@ class FirewallService
*/
public function listRules(bool $activeOnly = true): array
{
$tenantId = $this->tenantContext->identifier();
$tenantId = $this->tenant->identifier();
if (!$tenantId) {
return [];
}
@@ -494,7 +518,7 @@ class FirewallService
?string $result = null,
int $limit = 100
): array {
$tenantId = $this->tenantContext->identifier();
$tenantId = $this->tenant->identifier();
if (!$tenantId) {
return [];
}
@@ -507,7 +531,7 @@ class FirewallService
*/
public function getBlockedCount(?\DateTimeImmutable $since = null): int
{
$tenantId = $this->tenantContext->identifier();
$tenantId = $this->tenant->identifier();
if (!$tenantId) {
return 0;
}
@@ -532,7 +556,7 @@ class FirewallService
*/
private function getConfig(string $key, mixed $default = null): mixed
{
$config = $this->tenantContext->configuration();
$config = $this->tenant->configuration();
$parts = explode('.', $key);
foreach ($parts as $part) {
@@ -552,7 +576,7 @@ class FirewallService
private function getActiveRules(): array
{
if ($this->rulesCache === null) {
$tenantId = $this->tenantContext->identifier();
$tenantId = $this->tenant->identifier();
$this->rulesCache = $tenantId
? $this->store->listRules($tenantId, true)
: [];
+4 -4
View File
@@ -7,7 +7,7 @@ namespace KTXC\Service;
use KTXC\Http\Request\Request;
use KTXC\Models\Identity\User;
use KTXC\Resource\ProviderManager;
use KTXC\Context\TenantContextInterface;
use KTXC\SessionTenant;
use KTXF\Security\Authentication\AuthenticationProviderInterface;
/**
@@ -23,12 +23,12 @@ class SecurityService
private string $securityCode;
public function __construct(
private readonly TenantContextInterface $tenantContext,
private readonly SessionTenant $sessionTenant,
private readonly TokenService $tokenService,
private readonly UserAccountsService $userService,
private readonly ProviderManager $providerManager,
) {
$this->securityCode = $this->tenantContext->configuration()->security()->code();
$this->securityCode = $this->sessionTenant->configuration()->security()->code();
}
/**
@@ -118,7 +118,7 @@ class SecurityService
continue;
}
$context = new \KTXF\Security\Authentication\ProviderContext(
tenantId: $this->tenantContext->identifier(),
tenantId: $this->sessionTenant->identifier(),
userIdentity: $identity,
);
$result = $provider->verify($context, $credentials);
-25
View File
@@ -21,31 +21,6 @@ class TenantService
return $this->store->fetch($identifier);
}
/**
* List all tenants keyed by id
*
* @return array<string, TenantObject>
*/
public function list(): array
{
return $this->store->list();
}
public function deposit(TenantObject $tenant): ?TenantObject
{
$domains = $tenant->getDomains();
if ($domains === null || count($domains) === 0) {
throw new \InvalidArgumentException('A tenant must have at least one domain.');
}
return $this->store->deposit($tenant);
}
public function destroy(TenantObject $tenant): void
{
$this->store->destroy($tenant);
}
// =========================================================================
// Settings
// =========================================================================
+2 -2
View File
@@ -2,7 +2,7 @@
namespace KTXC\Service;
use KTXC\Context\TenantContextInterface;
use KTXC\SessionTenant;
use KTXF\Cache\CacheScope;
use KTXF\Cache\EphemeralCacheInterface;
@@ -26,7 +26,7 @@ class TokenService
private string $algorithm = 'HS256';
public function __construct(
private readonly TenantContextInterface $tenantContext,
private readonly SessionTenant $sessionTenant,
private readonly EphemeralCacheInterface $cache,
) {
}
+16 -16
View File
@@ -3,16 +3,16 @@
namespace KTXC\Service;
use KTXC\Models\Identity\User;
use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface;
use KTXC\SessionIdentity;
use KTXC\SessionTenant;
use KTXC\Stores\UserAccountsStore;
class UserAccountsService
{
public function __construct(
private readonly TenantContextInterface $tenantContext,
private readonly IdentityContextInterface $identityContext,
private readonly SessionTenant $tenantIdentity,
private readonly SessionIdentity $userIdentity,
private readonly UserAccountsStore $userStore
) {
}
@@ -26,7 +26,7 @@ class UserAccountsService
*/
public function listUsers(array $filters = []): array
{
$users = $this->userStore->listUsers($this->tenantContext->identifier(), $filters);
$users = $this->userStore->listUsers($this->tenantIdentity->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->tenantContext->identifier(), $identifier);
$data = $this->userStore->fetchByIdentity($this->tenantIdentity->identifier(), $identifier);
if (!$data) {
return null;
}
@@ -50,32 +50,32 @@ class UserAccountsService
public function fetchByIdentifier(string $identifier): array | null
{
return $this->userStore->fetchByIdentifier($this->tenantContext->identifier(), $identifier);
return $this->userStore->fetchByIdentifier($this->tenantIdentity->identifier(), $identifier);
}
public function fetchByIdentityRaw(string $identifier): array | null
{
return $this->userStore->fetchByIdentity($this->tenantContext->identifier(), $identifier);
return $this->userStore->fetchByIdentity($this->tenantIdentity->identifier(), $identifier);
}
public function fetchByProviderSubject(string $provider, string $subject): ?array
{
return $this->userStore->fetchByProviderSubject($this->tenantContext->identifier(), $provider, $subject);
return $this->userStore->fetchByProviderSubject($this->tenantIdentity->identifier(), $provider, $subject);
}
public function createUser(array $userData): array
{
return $this->userStore->createUser($this->tenantContext->identifier(), $userData);
return $this->userStore->createUser($this->tenantIdentity->identifier(), $userData);
}
public function updateUser(string $uid, array $updates): bool
{
return $this->userStore->updateUser($this->tenantContext->identifier(), $uid, $updates);
return $this->userStore->updateUser($this->tenantIdentity->identifier(), $uid, $updates);
}
public function deleteUser(string $uid): bool
{
return $this->userStore->deleteUser($this->tenantContext->identifier(), $uid);
return $this->userStore->deleteUser($this->tenantIdentity->identifier(), $uid);
}
// =========================================================================
@@ -84,7 +84,7 @@ class UserAccountsService
public function fetchProfile(string $uid): ?array
{
return $this->userStore->fetchProfile($this->tenantContext->identifier(), $uid);
return $this->userStore->fetchProfile($this->tenantIdentity->identifier(), $uid);
}
public function storeProfile(string $uid, array $profileFields): bool
@@ -109,7 +109,7 @@ class UserAccountsService
return false;
}
return $this->userStore->storeProfile($this->tenantContext->identifier(), $uid, $editableFields);
return $this->userStore->storeProfile($this->tenantIdentity->identifier(), $uid, $editableFields);
}
// =========================================================================
@@ -118,12 +118,12 @@ class UserAccountsService
public function fetchSettings(array $settings = [], bool $flatten = false): array | null
{
return $this->userStore->fetchSettings($this->tenantContext->identifier(), $this->identityContext->identifier(), $settings, $flatten);
return $this->userStore->fetchSettings($this->tenantIdentity->identifier(), $this->userIdentity->identifier(), $settings, $flatten);
}
public function storeSettings(array $settings): bool
{
return $this->userStore->storeSettings($this->tenantContext->identifier(), $this->identityContext->identifier(), $settings);
return $this->userStore->storeSettings($this->tenantIdentity->identifier(), $this->userIdentity->identifier(), $settings);
}
// =========================================================================
+12 -12
View File
@@ -2,7 +2,7 @@
namespace KTXC\Service;
use KTXC\Context\TenantContextInterface;
use KTXC\SessionTenant;
use KTXC\Stores\UserRolesStore;
use Psr\Log\LoggerInterface;
@@ -12,7 +12,7 @@ use Psr\Log\LoggerInterface;
class UserRolesService
{
public function __construct(
private readonly TenantContextInterface $tenantContext,
private readonly SessionTenant $tenantIdentity,
private readonly UserRolesStore $roleStore,
private readonly LoggerInterface $logger
) {}
@@ -26,7 +26,7 @@ class UserRolesService
*/
public function listRoles(): array
{
return $this->roleStore->listRoles($this->tenantContext->identifier());
return $this->roleStore->listRoles($this->tenantIdentity->identifier());
}
/**
@@ -34,7 +34,7 @@ class UserRolesService
*/
public function getRole(string $rid): ?array
{
return $this->roleStore->fetchByRid($this->tenantContext->identifier(), $rid);
return $this->roleStore->fetchByRid($this->tenantIdentity->identifier(), $rid);
}
/**
@@ -45,11 +45,11 @@ class UserRolesService
$this->validateRoleData($roleData);
$this->logger->info('Creating role', [
'tenant' => $this->tenantContext->identifier(),
'tenant' => $this->tenantIdentity->identifier(),
'label' => $roleData['label'] ?? 'Unnamed'
]);
return $this->roleStore->createRole($this->tenantContext->identifier(), $roleData);
return $this->roleStore->createRole($this->tenantIdentity->identifier(), $roleData);
}
/**
@@ -70,11 +70,11 @@ class UserRolesService
$this->validateRoleData($updates, false);
$this->logger->info('Updating role', [
'tenant' => $this->tenantContext->identifier(),
'tenant' => $this->tenantIdentity->identifier(),
'rid' => $rid
]);
return $this->roleStore->updateRole($this->tenantContext->identifier(), $rid, $updates);
return $this->roleStore->updateRole($this->tenantIdentity->identifier(), $rid, $updates);
}
/**
@@ -93,17 +93,17 @@ class UserRolesService
}
// Check if role is assigned to users
$userCount = $this->roleStore->countUsersInRole($this->tenantContext->identifier(), $rid);
$userCount = $this->roleStore->countUsersInRole($this->tenantIdentity->identifier(), $rid);
if ($userCount > 0) {
throw new \InvalidArgumentException("Cannot delete role assigned to {$userCount} user(s)");
}
$this->logger->info('Deleting role', [
'tenant' => $this->tenantContext->identifier(),
'tenant' => $this->tenantIdentity->identifier(),
'rid' => $rid
]);
return $this->roleStore->deleteRole($this->tenantContext->identifier(), $rid);
return $this->roleStore->deleteRole($this->tenantIdentity->identifier(), $rid);
}
/**
@@ -111,7 +111,7 @@ class UserRolesService
*/
public function getRoleUserCount(string $rid): int
{
return $this->roleStore->countUsersInRole($this->tenantContext->identifier(), $rid);
return $this->roleStore->countUsersInRole($this->tenantIdentity->identifier(), $rid);
}
/**
+94
View File
@@ -0,0 +1,94 @@
<?php
namespace KTXC;
use KTXC\Models\Identity\User;
class SessionIdentity
{
private bool $identityLock = false;
private ?User $identityData = null;
public function initialize(User $identity, bool $lock = true): void
{
if ($this->identityLock) {
throw new \RuntimeException('Identity is already locked and cannot be changed.');
}
$this->identityData = $identity;
$this->identityLock = $lock;
}
public function identity(): ?User
{
return $this->identityData;
}
public function identifier(): ?string
{
return $this->identityData?->getId();
}
public function label(): ?string
{
return $this->identityData?->getLabel();
}
public function mailAddress(): ?string
{
return $this->identityData?->getIdentity();
}
public function nameFirst(): ?string
{
return null;
}
public function nameLast(): ?string
{
return null;
}
public function permissions(): array
{
return $this->identityData?->getPermissions() ?? [];
}
public function roles(): array
{
return $this->identityData?->getRoles() ?? [];
}
public function hasPermission(string $permission): bool
{
$permissions = $this->permissions();
// Exact match
if (in_array($permission, $permissions)) {
return true;
}
// Wildcard match
foreach ($permissions as $userPerm) {
if (str_ends_with($userPerm, '.*')) {
$prefix = substr($userPerm, 0, -2);
if (str_starts_with($permission, $prefix . '.')) {
return true;
}
}
}
// Full wildcard
if (in_array('*', $permissions)) {
return true;
}
return false;
}
public function hasRole(string $role): bool
{
return in_array($role, $this->roles());
}
}
+145
View File
@@ -0,0 +1,145 @@
<?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 -8
View File
@@ -3,7 +3,6 @@
namespace KTXC\Stores;
use KTXC\Db\DataStore;
use KTXC\Db\ObjectId;
use KTXC\Models\Tenant\TenantObject;
class TenantStore
@@ -53,9 +52,7 @@ class TenantStore
private function create(TenantObject $entry): ?TenantObject
{
$document = $entry->jsonSerialize();
unset($document['id']);
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->insertOne($document);
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->insertOne($entry->jsonSerialize());
$entry->setId((string)$result->getInsertedId());
return $entry;
}
@@ -64,9 +61,7 @@ class TenantStore
{
$id = $entry->getId();
if (!$id) { return null; }
$document = $entry->jsonSerialize();
unset($document['id']);
$this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(['_id' => new ObjectId($id)], ['$set' => $document]);
$this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(['_id' => $id], ['$set' => $entry->jsonSerialize()]);
return $entry;
}
@@ -74,7 +69,7 @@ class TenantStore
{
$id = $entry->getId();
if (!$id) { return; }
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne(['_id' => new ObjectId($id)]);
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([ '_id' => $id]);
}
// =========================================================================
+6 -24
View File
@@ -177,8 +177,8 @@ class UserAccountsStore
$userData['uid'] = $userData['uid'] ?? UUID::v4();
$userData['enabled'] = $userData['enabled'] ?? true;
$userData['roles'] = $userData['roles'] ?? [];
$userData['profile'] = (object) ($userData['profile'] ?? []);
$userData['settings'] = (object) ($userData['settings'] ?? []);
$userData['profile'] = $userData['profile'] ?? [];
$userData['settings'] = $userData['settings'] ?? [];
$this->store->selectCollection('user_accounts')->insertOne($userData);
@@ -232,25 +232,16 @@ 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 = $collection->updateOne(
$result = $this->store->selectCollection('user_accounts')->updateOne(
['tid' => $tenant, 'uid' => $uid],
['$set' => $updates]
);
return $result->getModifiedCount() > 0;
}
@@ -316,25 +307,16 @@ 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 = $collection->updateOne(
$result = $this->store->selectCollection('user_accounts')->updateOne(
['tid' => $tenant, 'uid' => $uid],
['$set' => $updates]
);
// Return true if document was matched (exists), even if not modified
return $result->getMatchedCount() > 0;
}
+5 -3
View File
@@ -1,9 +1,11 @@
<?php
use KTXC\Application;
use KTXC\Server;
// Capture Composer ClassLoader instance for compatibility
$composerLoader = require_once __DIR__ . '/../vendor/autoload.php';
$application = Application::create(dirname(__DIR__), $composerLoader);
$application->runHttp();
$server = new Server(dirname(__DIR__));
Server::setComposerLoader($composerLoader);
$server->runHttp();
+64
View File
@@ -5,5 +5,69 @@
<script setup lang="ts">
import { RouterView } from 'vue-router';
import { onMounted, watch } from 'vue';
import { useTheme } from 'vuetify';
import SharedSnackbar from '@KTXC/components/shared/SharedSnackbar.vue';
import { useLayoutStore } from '@KTXC/stores/layoutStore';
import { useUserStore } from '@KTXC/stores/userStore';
import { useTenantStore } from '@KTXC/stores/tenantStore';
const theme = useTheme();
const layoutStore = useLayoutStore();
const userStore = useUserStore();
const tenantStore = useTenantStore();
// Maps user/tenant setting keys → Vuetify color token names
const COLOR_SETTINGS: Array<{ token: string; key: string }> = [
{ token: 'primary', key: 'primary_color' },
{ token: 'secondary', key: 'secondary_color' },
];
/**
* Apply brand color overrides from stored preferences to all Vuetify theme
* variants (light & dark). Tenant colors take priority when lock is active.
*/
function applyThemeColors(): void {
const locked = tenantStore.getSetting('lock_user_colors') as boolean | null;
for (const { token, key } of COLOR_SETTINGS) {
const value = locked
? ((tenantStore.getSetting(key) as string | null) ?? (userStore.getSetting(key) as string | null))
: ((userStore.getSetting(key) as string | null) ?? (tenantStore.getSetting(key) as string | null));
if (value) {
for (const variant of Object.keys(theme.themes.value)) {
theme.themes.value[variant].colors[token] = value;
}
}
}
}
/** Apply font preference via CSS custom property and body style. */
function applyFont(): void {
const font =
((userStore.getSetting('font') as string | null) ??
(tenantStore.getSetting('font') as string | null));
if (font && font !== 'Public Sans') {
document.documentElement.style.setProperty('--themer-font', font);
document.body.style.fontFamily = `"${font}", sans-serif`;
}
}
onMounted(() => {
// Apply saved theme mode
if (layoutStore.theme) {
theme.global.name.value = layoutStore.theme;
}
applyThemeColors();
applyFont();
});
// Re-apply whenever tenant settings change (e.g. admin saves new brand colors)
watch(() => tenantStore.settings, () => {
applyThemeColors();
applyFont();
}, { deep: true });
</script>
-1
View File
@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 205 B

-2
View File
@@ -3,6 +3,4 @@
*/
export { useClipboard } from './useClipboard'
export { useL10n, t } from './useL10n'
export type { L10N, TranslateParams } from './useL10n'
export { useUser } from './useUser'
-42
View File
@@ -1,42 +0,0 @@
/**
* Translation composable over the shared i18n runtime.
* This is the only surface components and modules use; vue-i18n itself
* stays confined to the plugin.
*/
import { i18n } from '@KTXC/l10n/runtime';
export type TranslateParams = Record<string, unknown>;
export interface L10N {
/**
* Translate a catalog key, rendering the inline English default when the
* key is absent from every loaded catalog. Keys are namespaced with the
* handle bound at useL10n(); the `en` catalog is generated from these
* defaults by scripts/l10n-extract.mjs — keep both literal.
*/
t: (key: string, defaultText: string, params?: TranslateParams) => string;
/** Locale-aware date/time formatting (Intl-backed). */
d: typeof i18n.global.d;
/** Locale-aware number formatting (Intl-backed). */
n: typeof i18n.global.n;
}
/**
* Translate a fully-qualified catalog key (one that already carries its
* namespace, e.g. an integration entry's `l10n` key, which the integration
* store prefixes with the module handle). `defaultText` is rendered when the
* key is undefined or absent from every loaded catalog. Such keys are
* dynamic, so their catalog entries live in the module's hand-maintained
* en.manual.json rather than the extracted catalog.
*/
export function t(key: string | undefined, defaultText?: string, params: TranslateParams = {}): string {
if (!key) return defaultText ?? '';
return i18n.global.t(key, params, { default: defaultText ?? key });
}
export function useL10n(namespace: string): L10N {
const scoped = (key: string, defaultText: string, params: TranslateParams = {}): string =>
t(`${namespace}.${key}`, defaultText, params);
return { t: scoped, d: i18n.global.d, n: i18n.global.n };
}
-2
View File
@@ -1,2 +0,0 @@
/** Locale used before runtime localization configuration is available. */
export const DEFAULT_LOCALE = 'en';
-37
View File
@@ -1,37 +0,0 @@
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;
}
+33 -27
View File
@@ -1,36 +1,42 @@
<template>
<div class="logo">
<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" />
<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>
</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>
+39 -74
View File
@@ -1,8 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useLayoutStore, type MenuMode } from '@KTXC/stores/layoutStore';
import { useLayoutStore } 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';
@@ -10,7 +9,6 @@ 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(() => {
@@ -25,12 +23,33 @@ const menuEntries = computed(() => {
}
});
// 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') },
]);
// 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',
};
}
});
</script>
<script lang="ts">
@@ -82,74 +101,20 @@ export default {
</v-list>
</perfect-scrollbar>
<!-- Menu Mode Switcher -->
<!-- Menu Mode Toggle -->
<template v-slot:append>
<v-divider />
<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 density="compact" class="pa-2">
<v-list-item
rounded
color="primary"
@click="layoutStore.toggleMenuMode()"
:prepend-icon="menuModeInfo.toggleIcon"
class="menu-mode-toggle"
>
<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>
<v-list-item-title class="text-body-2">{{ menuModeInfo.toggleLabel }}</v-list-item-title>
</v-list-item>
</v-list>
</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,6 +1,5 @@
<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 }>();
@@ -21,7 +20,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">{{ t(group.l10n, group.label) }}</v-list-item-title>
<v-list-item-title class="mr-auto">{{ 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,13 +1,12 @@
<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">{{ t(props.group.l10n, props.group.label) }}</v-list-subheader>
<v-list-subheader color="lightText" class="smallCap text-subtitle-2">{{ props.group.label }}</v-list-subheader>
<LayoutSystemMenuItem
v-for="(item, i) in props.group.items"
:key="i"
@@ -1,6 +1,5 @@
<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>
@@ -21,7 +20,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>{{ t(item.l10n, item.label) }}</v-list-item-title>
<v-list-item-title>{{ 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 }}
+15 -26
View File
@@ -1,14 +1,13 @@
<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 { t } = useL10n('core');
const theme = useTheme();
const router = useRouter();
const userStore = useUserStore();
const integrationStore = useIntegrationStore();
@@ -25,22 +24,12 @@ const userName = computed(() => {
});
const userEmail = computed(() => userStore.getProfileField('email') || '');
// 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);
// 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);
};
// Navigate to settings
@@ -85,17 +74,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">{{ tGlobal(item.l10n, item.label) }}</v-list-item-title>
<v-list-item-title class="text-h6">{{ item.label }}</v-list-item-title>
</v-list-item>
<v-divider v-if="profileMenuItems.length" class="my-2" />
<!-- Theme Mode Cycle (light -> dark -> system) -->
<v-list-item @click="cycleTheme" color="primary" rounded="0">
<!-- Theme Toggle -->
<v-list-item @click="toggleTheme" color="primary" rounded="0">
<template v-slot:prepend>
<v-icon>{{ MODE_PRESENTATION[nextThemeMode].icon }}</v-icon>
<v-icon>{{ isDarkMode ? 'mdi-weather-sunny' : 'mdi-weather-night' }}</v-icon>
</template>
<v-list-item-title class="text-h6">{{ t(MODE_PRESENTATION[nextThemeMode].l10n, MODE_PRESENTATION[nextThemeMode].label) }}</v-list-item-title>
<v-list-item-title class="text-h6">{{ isDarkMode ? 'Light Mode' : 'Dark Mode' }}</v-list-item-title>
</v-list-item>
<!-- Go to Settings -->
@@ -103,7 +92,7 @@ const goToSettings = () => {
<template v-slot:prepend>
<v-icon>mdi-cog-outline</v-icon>
</template>
<v-list-item-title class="text-h6">{{ t('userMenu.settings', 'Settings') }}</v-list-item-title>
<v-list-item-title class="text-h6">Settings</v-list-item-title>
</v-list-item>
<v-divider class="my-2" />
@@ -113,7 +102,7 @@ const goToSettings = () => {
<template v-slot:prepend>
<v-icon>mdi-logout</v-icon>
</template>
<v-list-item-title class="text-h6">{{ t('userMenu.logout', 'Logout') }}</v-list-item-title>
<v-list-item-title class="text-h6">Logout</v-list-item-title>
</v-list-item>
</v-list>
</perfect-scrollbar>
+1 -9
View File
@@ -2,9 +2,6 @@ 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'
@@ -20,13 +17,8 @@ export default createVuetify({
},
defaults,
icons,
locale: {
adapter: createVueI18nAdapter({ i18n, useI18n }),
},
theme: {
// Follow the browser/OS color scheme by default; matters especially for
// the public (unauthenticated) app, where no theme store ever runs.
defaultTheme: 'system',
defaultTheme: 'light',
themes,
},
})
+29 -29
View File
@@ -1,22 +1,22 @@
import type { ThemeDefinition } from 'vuetify'
export const staticPrimaryColor = '#334155'
export const staticPrimaryDarkenColor = '#1E293B'
export const staticPrimaryColor = '#6366F1'
export const staticPrimaryDarkenColor = '#4F46E5'
export const themes: Record<string, ThemeDefinition> = {
light: {
dark: false,
colors: {
// Primary brand colors - Graphite slate
// Primary brand colors - Modern indigo
'primary': staticPrimaryColor,
'on-primary': '#FFFFFF',
'primary-darken-1': staticPrimaryDarkenColor,
'primary-lighten-1': '#64748B',
'primary-lighten-1': '#818CF8',
// Secondary - Lighter slate accent
'secondary': '#64748B',
'secondary-darken-1': '#475569',
'secondary-lighten-1': '#94A3B8',
// Secondary - Purple accent
'secondary': '#8B5CF6',
'secondary-darken-1': '#7C3AED',
'secondary-lighten-1': '#A78BFA',
'on-secondary': '#FFFFFF',
// Semantic colors
@@ -30,7 +30,7 @@ export const themes: Record<string, ThemeDefinition> = {
'on-error': '#FFFFFF',
// Surface & backgrounds
'background': '#F6F7F9',
'background': '#F8FAFC',
'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': '#94A3B8',
'on-primary': '#0F172A',
'primary-darken-1': '#64748B',
'primary-lighten-1': '#CBD5E1',
'primary': '#818CF8',
'on-primary': '#FFFFFF',
'primary-darken-1': '#6366F1',
'primary-lighten-1': '#A5B4FC',
// Secondary - Lighter slate accent
'secondary': '#CBD5E1',
'secondary-darken-1': '#94A3B8',
'secondary-lighten-1': '#E2E8F0',
'on-secondary': '#0F172A',
// Secondary - Purple accent
'secondary': '#A78BFA',
'secondary-darken-1': '#8B5CF6',
'secondary-lighten-1': '#C4B5FD',
'on-secondary': '#FFFFFF',
// 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 graphite palette
'background': '#0B0F17',
// Surface & backgrounds - Dark slate palette
'background': '#0F172A',
'on-background': '#F1F5F9',
'surface': '#151A23',
'surface': '#1E293B',
'on-surface': '#F1F5F9',
'surface-bright': '#232A36',
'surface-variant': '#151A23',
'surface-bright': '#334155',
'surface-variant': '#1E293B',
'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': '#2A3240',
'grey-lighten-4': '#151A23',
'grey-lighten-5': '#0B0F17',
'grey-lighten-3': '#334155',
'grey-lighten-4': '#1E293B',
'grey-lighten-5': '#0F172A',
// Component specific
'perfect-scrollbar-thumb': '#3A4353',
'track-bg': '#232A36',
'perfect-scrollbar-thumb': '#475569',
'track-bg': '#334155',
},
variables: {
'border-color': '#2A3240',
'border-color': '#334155',
'border-opacity': 0.12,
'high-emphasis-opacity': 0.87,
'medium-emphasis-opacity': 0.60,
+2 -2
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/favicon.ico" />
<script type="importmap">
{
"imports": {
@@ -14,7 +14,7 @@
}
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vallarx</title>
<title>K-Trix</title>
</head>
<body>
<div id="app"></div>
+1 -21
View File
@@ -7,12 +7,7 @@ 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'
@@ -26,7 +21,6 @@ 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 & {
@@ -42,20 +36,12 @@ 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() });
@@ -76,11 +62,5 @@ globalWindow.Pinia = PiniaLib as unknown
app.mount('#app');
} catch (e) {
console.error('Bootstrap failed:', e);
// The private shell is unusable without /init. If the server rejected
// the session, drop the stale local identity and return to login.
if (e instanceof FetchError && (e.status === 401 || e.status === 403)) {
userStore.clearAuth();
window.location.href = '/login';
}
}
})();
+2 -2
View File
@@ -2,9 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vallarx</title>
<title>Ktrix Cloud</title>
</head>
<body>
<div id="app"></div>
+16 -14
View File
@@ -2,30 +2,32 @@ 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 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);
// 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');
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
app.use(router);
app.use(i18n);
app.use(vuetify);
(async () => {
// No session yet: locale comes from browser preferences alone
await useL10nStore().init(null);
app.mount('#app');
})();
// 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');
-5
View File
@@ -12,13 +12,8 @@ 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