From 149d4515de2022a8f69fe39cca5dcc4f4438bb9c Mon Sep 17 00:00:00 2001 From: fs Date: Wed, 22 Apr 2026 22:40:15 +0200 Subject: [PATCH] =?UTF-8?q?refactor(settings):=20remove=20global=20appeara?= =?UTF-8?q?nce=20settings=20=E2=80=94=20tenant=20is=20sole=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the global app_theme, app_theme_user and app_primary_color settings from the admin/settings area and the underlying service/cache/API/i18n/docs layers. The tenant columns (tenants.primary_color, default_theme, allow_user_theme) become the single source of truth for appearance. Branding helpers are tenant-only and fall back to a hardcoded 'light' theme with no brand color when no tenant context is available (login, error, pre-session pages). The APP_THEME env var is removed. Scope: - UI: Appearance tab gone from /admin/settings; tenant edit form drops the global-fallback color preview. - Service: SettingsAppGateway no longer exposes setting-backed accessors for theme/user-theme/primary-color. Theme catalog utilities (listThemes, isAllowedTheme, normalizeTheme, resolveDefaultTheme) stay and are used across Tenant / Auth / User flows; resolveDefaultTheme now hardcodes 'light' with a catalog fallback (no global/env lookup). - AdminSettingsService: buildPageData, sanitization, audit snapshot, apply step and cache payload are all stripped of the three keys. Dead helper applyAppPrimaryColor + normalizePrimaryColor removed. - Cache: SettingCacheService HOT_PATH_KEYS drops the three keys. - API: /settings (GET/PUT) and /settings/public (GET) no longer expose appearance fields; OpenAPI schemas pruned accordingly. - Audit redaction list shortened. - DB: db/init/init.sql seed rows removed. No migration for existing installs — legacy rows stay inert. - i18n + docs: setting.app_theme, setting.app_theme_user, setting.app_primary_color removed from de+en locales; reference and explanation docs updated. - Tests: covering tests for the removed methods pruned; ThemeResolutionTest rewritten for tenant-only semantics. One unrelated PHP-CS-Fixer issue in UserRegistrar.php cleaned up to keep QG-006 green. Workflow: .agents/runs/SETTINGS-APPEARANCE-TENANT-ONLY/ (gitignored). Gates: QG-001..003, QG-006, QG-008 all pass (1985 tests, 28764 assertions). Reviews: code, security, acceptance all pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Container/Registrars/UserRegistrar.php | 2 +- core/Service/Auth/AuthSettingsGateway.php | 5 - core/Service/Auth/SsoUserLinkService.php | 4 +- .../Service/Settings/AdminSettingsService.php | 37 ---- core/Service/Settings/SettingCacheService.php | 3 - core/Service/Settings/SettingKeys.php | 3 - core/Service/Settings/SettingsAppGateway.php | 166 ++++++------------ core/Service/User/UserAccountService.php | 3 +- core/Service/User/UserSettingsGateway.php | 5 - core/Support/helpers/app.php | 6 +- core/Support/helpers/branding.php | 37 ++-- db/init/init.sql | 3 - docs/explanation-einstellungen-speicherung.md | 7 +- docs/openapi.yaml | 12 -- docs/reference-konfiguration.md | 3 - i18n/default_de.json | 14 +- i18n/default_en.json | 14 +- .../Service/SystemAuditRedactionService.php | 3 - pages/admin/settings/index(default).phtml | 70 -------- pages/admin/tenants/_form.phtml | 4 +- pages/api/v1/settings/index().php | 9 +- pages/api/v1/settings/public().php | 4 +- .../Service/Auth/AuthSettingsGatewayTest.php | 4 +- tests/Service/Auth/SsoUserLinkServiceTest.php | 9 +- .../AdminSettingsServiceApiLifecycleTest.php | 9 - .../Settings/AdminSettingsServiceAppTest.php | 81 --------- .../AdminSettingsServiceSecurityTest.php | 9 - .../AdminSettingsServiceSessionPolicyTest.php | 9 - .../Settings/SettingCacheServiceTest.php | 7 +- .../Settings/SettingsAppGatewayTest.php | 45 +---- .../SettingsGatewayNormalizationTest.php | 43 +---- tests/Service/User/UserAccountServiceTest.php | 9 +- .../Service/User/UserSettingsGatewayTest.php | 18 -- tests/Support/ThemeResolutionTest.php | 58 ++++-- 34 files changed, 164 insertions(+), 551 deletions(-) diff --git a/core/App/Container/Registrars/UserRegistrar.php b/core/App/Container/Registrars/UserRegistrar.php index d3de2d6..63807de 100644 --- a/core/App/Container/Registrars/UserRegistrar.php +++ b/core/App/Container/Registrars/UserRegistrar.php @@ -11,6 +11,7 @@ use MintyPHP\Repository\User\UserReadRepository; use MintyPHP\Repository\User\UserWriteRepository; use MintyPHP\Service\Auth\TenantSsoService; use MintyPHP\Service\Branding\BrandingLogoService; +use MintyPHP\Service\Tenant\TenantScopeService; use MintyPHP\Service\User\UserAccessPdfService; use MintyPHP\Service\User\UserAccessTemplateService; use MintyPHP\Service\User\UserAccountService; @@ -26,7 +27,6 @@ use MintyPHP\Service\User\UserProfileViewService; use MintyPHP\Service\User\UserRepositoryFactory; use MintyPHP\Service\User\UserServicesFactory; use MintyPHP\Service\User\UserTenantContextService; -use MintyPHP\Service\Tenant\TenantScopeService; final class UserRegistrar implements ContainerRegistrar { diff --git a/core/Service/Auth/AuthSettingsGateway.php b/core/Service/Auth/AuthSettingsGateway.php index ff01d5f..bad59a6 100644 --- a/core/Service/Auth/AuthSettingsGateway.php +++ b/core/Service/Auth/AuthSettingsGateway.php @@ -44,11 +44,6 @@ class AuthSettingsGateway return $this->settingsApiPolicyGateway->getApiTokenDefaultTtlDays(); } - public function getAppTheme(): string - { - return (string) $this->settingsAppGateway->getAppTheme(); - } - public function defaultTheme(): string { return $this->settingsAppGateway->resolveDefaultTheme(); diff --git a/core/Service/Auth/SsoUserLinkService.php b/core/Service/Auth/SsoUserLinkService.php index 8365562..f822c09 100644 --- a/core/Service/Auth/SsoUserLinkService.php +++ b/core/Service/Auth/SsoUserLinkService.php @@ -101,7 +101,7 @@ class SsoUserLinkService 'password' => $this->randomPassword(), 'locale' => $this->normalizeLocale((string) ($claims['preferred_language'] ?? '')), 'totp_secret' => '', - 'theme' => $this->resolveInitialTheme($this->settingsGateway->getAppTheme()), + 'theme' => $this->resolveInitialTheme(null), 'primary_tenant_id' => $tenantId, 'current_tenant_id' => $tenantId, 'active' => 1, @@ -293,7 +293,7 @@ class SsoUserLinkService 'password' => $this->randomPassword(), 'locale' => null, 'totp_secret' => '', - 'theme' => $this->resolveInitialTheme($this->settingsGateway->getAppTheme()), + 'theme' => $this->resolveInitialTheme(null), 'primary_tenant_id' => $tenantId, 'current_tenant_id' => $tenantId, 'active' => 1, diff --git a/core/Service/Settings/AdminSettingsService.php b/core/Service/Settings/AdminSettingsService.php index 13bebd1..bca0bb2 100644 --- a/core/Service/Settings/AdminSettingsService.php +++ b/core/Service/Settings/AdminSettingsService.php @@ -55,10 +55,7 @@ class AdminSettingsService 'default_department_id' => $this->settingsDefaultsGateway->getDefaultDepartmentId(), 'app_title' => $this->settingsMetadataGateway->getValue(SettingKeys::APP_TITLE_KEY), 'app_locale' => $this->settingsMetadataGateway->getValue(SettingKeys::APP_LOCALE_KEY), - 'app_theme' => $this->settingsMetadataGateway->getValue(SettingKeys::APP_THEME_KEY), - 'app_theme_user' => $this->settingsAppGateway->isUserThemeAllowed(), 'app_registration' => $this->settingsAppGateway->isRegistrationEnabled(), - 'app_primary_color' => $this->settingsMetadataGateway->getValue(SettingKeys::APP_PRIMARY_COLOR_KEY), 'api_token_default_ttl_days' => $this->settingsApiPolicyGateway->getApiTokenDefaultTtlDays(), 'api_token_max_ttl_days' => $this->settingsApiPolicyGateway->getApiTokenMaxTtlDays(), 'user_inactivity_deactivate_days' => $this->settingsUserLifecycleGateway->getUserInactivityDeactivateDays(), @@ -88,10 +85,7 @@ class AdminSettingsService 'default_department' => $this->settingsMetadataGateway->get(SettingKeys::DEFAULT_DEPARTMENT_KEY), 'app_title' => $this->settingsMetadataGateway->get(SettingKeys::APP_TITLE_KEY), 'app_locale' => $this->settingsMetadataGateway->get(SettingKeys::APP_LOCALE_KEY), - 'app_theme' => $this->settingsMetadataGateway->get(SettingKeys::APP_THEME_KEY), - 'app_theme_user' => $this->settingsMetadataGateway->get(SettingKeys::APP_THEME_USER_KEY), 'app_registration' => $this->settingsMetadataGateway->get(SettingKeys::APP_REGISTRATION_KEY), - 'app_primary_color' => $this->settingsMetadataGateway->get(SettingKeys::APP_PRIMARY_COLOR_KEY), 'api_token_default_ttl_days' => $this->settingsMetadataGateway->get(SettingKeys::API_TOKEN_DEFAULT_TTL_DAYS_KEY), 'api_token_max_ttl_days' => $this->settingsMetadataGateway->get(SettingKeys::API_TOKEN_MAX_TTL_DAYS_KEY), 'user_inactivity_deactivate_days' => $this->settingsMetadataGateway->get(SettingKeys::USER_INACTIVITY_DEACTIVATE_DAYS_KEY), @@ -132,7 +126,6 @@ class AdminSettingsService array_push($errors, ...$this->applyApiAndLifecycleSettings($input)); array_push($errors, ...$this->applySessionAndLoginSettings($input)); array_push($errors, ...$this->applyAuditAndTelemetrySettings($input)); - array_push($errors, ...$this->applyAppPrimaryColor($input)); $this->applySmtpSettings($input); array_push($errors, ...$this->applyMicrosoftSettings($input)); $this->updateSettingsCache($input); @@ -159,10 +152,7 @@ class AdminSettingsService 'default_department_id' => (int) ($post['default_department_id'] ?? 0), 'app_title' => trim((string) ($post['app_title'] ?? '')), 'app_locale' => trim((string) ($post['app_locale'] ?? '')), - 'app_theme' => trim((string) ($post['app_theme'] ?? '')), - 'app_theme_user' => isset($post['app_theme_user']), 'app_registration' => isset($post['app_registration']), - 'app_primary_color' => $this->normalizePrimaryColor($post['app_primary_color'] ?? ''), 'api_token_default_ttl_days' => (int) ($post['api_token_default_ttl_days'] ?? 0), 'api_token_max_ttl_days' => (int) ($post['api_token_max_ttl_days'] ?? 0), 'api_cors_allowed_origins' => $this->normalizeScalarText($post['api_cors_allowed_origins'] ?? ''), @@ -203,10 +193,7 @@ class AdminSettingsService 'default_role_id' => $this->settingsDefaultsGateway->getDefaultRoleId(), 'default_department_id' => $this->settingsDefaultsGateway->getDefaultDepartmentId(), 'app_locale' => $this->settingsAppGateway->getAppLocale(), - 'app_theme' => $this->settingsAppGateway->getAppTheme(), - 'app_theme_user' => $this->settingsAppGateway->isUserThemeAllowed(), 'app_registration' => $this->settingsAppGateway->isRegistrationEnabled(), - 'app_primary_color' => $this->settingsAppGateway->getAppPrimaryColor(), 'api_token_default_ttl_days' => $this->settingsApiPolicyGateway->getApiTokenDefaultTtlDays(), 'api_token_max_ttl_days' => $this->settingsApiPolicyGateway->getApiTokenMaxTtlDays(), 'user_inactivity_deactivate_days' => $this->settingsUserLifecycleGateway->getUserInactivityDeactivateDays(), @@ -230,8 +217,6 @@ class AdminSettingsService $this->settingsDefaultsGateway->setDefaultDepartmentId($input['default_department_id'] > 0 ? $input['default_department_id'] : null); $this->settingsAppGateway->setAppTitle($input['app_title']); $this->settingsAppGateway->setAppLocale($input['app_locale']); - $this->settingsAppGateway->setAppTheme($input['app_theme']); - $this->settingsAppGateway->setUserThemeAllowed($input['app_theme_user']); $this->settingsAppGateway->setRegistrationEnabled($input['app_registration']); } @@ -316,16 +301,6 @@ class AdminSettingsService return $errors; } - /** @return list */ - private function applyAppPrimaryColor(array &$input): array - { - if (!$this->settingsAppGateway->setAppPrimaryColor($input['app_primary_color'])) { - $input['app_primary_color'] = ''; - return [['message' => 'Primary color is invalid', 'key' => 'app_primary_invalid']]; - } - return []; - } - private function applySmtpSettings(array $input): void { $this->settingsSmtpGateway->setSmtpHost($input['smtp_host']); @@ -364,10 +339,7 @@ class AdminSettingsService $this->settingCacheService->update([ 'app_title' => $input['app_title'] !== '' ? $input['app_title'] : null, 'app_locale' => $input['app_locale'] !== '' ? $input['app_locale'] : null, - 'app_theme' => $input['app_theme'] !== '' ? $input['app_theme'] : null, - 'app_theme_user' => $input['app_theme_user'] ? '1' : '0', 'app_registration' => $input['app_registration'] ? '1' : '0', - 'app_primary_color' => $input['app_primary_color'] !== '' ? $input['app_primary_color'] : null, 'api_token_default_ttl_days' => (string) $this->settingsApiPolicyGateway->getApiTokenDefaultTtlDays(), 'api_token_max_ttl_days' => (string) $this->settingsApiPolicyGateway->getApiTokenMaxTtlDays(), 'api_cors_allowed_origins' => $this->settingsMetadataGateway->getValue(SettingKeys::API_CORS_ALLOWED_ORIGINS_KEY), @@ -406,13 +378,4 @@ class AdminSettingsService return trim((string) $value); } - private function normalizePrimaryColor(mixed $value): string - { - $color = $this->normalizeScalarText($value); - if (in_array(strtolower($color), ['null', 'undefined'], true)) { - return ''; - } - - return $color; - } } diff --git a/core/Service/Settings/SettingCacheService.php b/core/Service/Settings/SettingCacheService.php index 30f8f92..309238f 100644 --- a/core/Service/Settings/SettingCacheService.php +++ b/core/Service/Settings/SettingCacheService.php @@ -7,10 +7,7 @@ class SettingCacheService private const HOT_PATH_KEYS = [ SettingKeys::APP_TITLE_KEY, SettingKeys::APP_LOCALE_KEY, - SettingKeys::APP_THEME_KEY, - SettingKeys::APP_THEME_USER_KEY, SettingKeys::APP_REGISTRATION_KEY, - SettingKeys::APP_PRIMARY_COLOR_KEY, ]; private ?array $settings = null; diff --git a/core/Service/Settings/SettingKeys.php b/core/Service/Settings/SettingKeys.php index 5158cc5..52e4ef2 100644 --- a/core/Service/Settings/SettingKeys.php +++ b/core/Service/Settings/SettingKeys.php @@ -9,10 +9,7 @@ final class SettingKeys public const DEFAULT_DEPARTMENT_KEY = 'default_department_id'; public const APP_TITLE_KEY = 'app_title'; public const APP_LOCALE_KEY = 'app_locale'; - public const APP_THEME_KEY = 'app_theme'; - public const APP_THEME_USER_KEY = 'app_theme_user'; public const APP_REGISTRATION_KEY = 'app_registration'; - public const APP_PRIMARY_COLOR_KEY = 'app_primary_color'; public const API_TOKEN_DEFAULT_TTL_DAYS_KEY = 'api_token_default_ttl_days'; public const API_TOKEN_MAX_TTL_DAYS_KEY = 'api_token_max_ttl_days'; public const API_CORS_ALLOWED_ORIGINS_KEY = 'api_cors_allowed_origins'; diff --git a/core/Service/Settings/SettingsAppGateway.php b/core/Service/Settings/SettingsAppGateway.php index 84bca1c..0f330f9 100644 --- a/core/Service/Settings/SettingsAppGateway.php +++ b/core/Service/Settings/SettingsAppGateway.php @@ -4,6 +4,19 @@ namespace MintyPHP\Service\Settings; use MintyPHP\I18n; +/** + * App-wide (global) settings gateway. + * + * Appearance SETTINGS (theme, user-theme toggle, primary color) are NOT + * managed globally — they live on the tenant (tenants.primary_color, + * default_theme, allow_user_theme) and are resolved by branding helpers. + * + * The theme CATALOG utilities (listThemes, isAllowedTheme, normalizeTheme, + * resolveDefaultTheme) stay because consumers across Tenant / Auth / User + * flows need to validate a theme value and resolve a safe default. There is + * no global setting lookup in these helpers anymore — the default is a + * hardcoded 'light' falling back to the first available catalog entry. + */ class SettingsAppGateway { public function __construct( @@ -53,87 +66,6 @@ class SettingsAppGateway return $this->settingsMetadataGateway->set(SettingKeys::APP_LOCALE_KEY, $value, $desc); } - public function getAppTheme(): ?string - { - $value = $this->settingsMetadataGateway->getValue(SettingKeys::APP_THEME_KEY); - $value = $value !== null ? strtolower(trim($value)) : ''; - if (!$this->isAllowedTheme($value)) { - return null; - } - - return $value; - } - - public function setAppTheme(?string $theme, ?string $description = null): bool - { - $value = $theme !== null ? strtolower(trim($theme)) : ''; - if ($value === '' || !$this->isAllowedTheme($value)) { - $value = null; - } - - $desc = $description ?? 'setting.app_theme'; - return $this->settingsMetadataGateway->set(SettingKeys::APP_THEME_KEY, $value, $desc); - } - - /** - * @return array - */ - public function listThemes(): array - { - return $this->themeConfigService->all(); - } - - public function isAllowedTheme(?string $theme): bool - { - $value = strtolower(trim((string) $theme)); - return $value !== '' && in_array($value, $this->allowedThemes(), true); - } - - public function resolveDefaultTheme(): string - { - $settingTheme = $this->getAppTheme(); - if ($settingTheme !== null) { - return $settingTheme; - } - - $envTheme = strtolower(trim((string) (getenv('APP_THEME') ?: ''))); - if ($this->isAllowedTheme($envTheme)) { - return $envTheme; - } - - $allowedThemes = $this->allowedThemes(); - if (in_array('light', $allowedThemes, true)) { - return 'light'; - } - - return (string) ($allowedThemes[0] ?? 'light'); - } - - public function normalizeTheme(?string $theme): string - { - $value = strtolower(trim((string) $theme)); - if ($this->isAllowedTheme($value)) { - return $value; - } - - return $this->resolveDefaultTheme(); - } - - public function isUserThemeAllowed(): bool - { - return settingToBool( - $this->settingsMetadataGateway->getValue(SettingKeys::APP_THEME_USER_KEY), - true - ); - } - - public function setUserThemeAllowed(bool $allowed, ?string $description = null): bool - { - $value = $allowed ? '1' : '0'; - $desc = $description ?? 'setting.app_theme_user'; - return $this->settingsMetadataGateway->set(SettingKeys::APP_THEME_USER_KEY, $value, $desc); - } - public function isRegistrationEnabled(): bool { return settingToBool( @@ -149,44 +81,46 @@ class SettingsAppGateway return $this->settingsMetadataGateway->set(SettingKeys::APP_REGISTRATION_KEY, $value, $desc); } - public function getAppPrimaryColor(): ?string + /** + * Catalog of available themes — purely code-level (config/themes.php), + * not a global setting. + * + * @return array + */ + public function listThemes(): array { - $value = $this->settingsMetadataGateway->getValue(SettingKeys::APP_PRIMARY_COLOR_KEY); - $value = $value !== null ? strtolower(trim($value)) : ''; - if ($value === '') { - return null; - } - if (!preg_match('/^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i', $value)) { - return null; - } - - return $value; + return $this->themeConfigService->all(); } - public function setAppPrimaryColor(?string $color, ?string $description = null): bool + public function isAllowedTheme(?string $theme): bool { - $value = $color !== null ? strtolower(trim($color)) : ''; - if ($value !== '' && $value[0] !== '#') { - if (preg_match('/^[0-9a-f]{3}$/i', $value) - || preg_match('/^[0-9a-f]{4}$/i', $value) - || preg_match('/^[0-9a-f]{6}$/i', $value) - || preg_match('/^[0-9a-f]{8}$/i', $value)) { - $value = '#' . $value; - } - } - if ($value === '') { - $value = null; - } elseif (!preg_match('/^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i', $value)) { - return false; - } - - $desc = $description ?? 'setting.app_primary_color'; - return $this->settingsMetadataGateway->set(SettingKeys::APP_PRIMARY_COLOR_KEY, $value, $desc); + $value = strtolower(trim((string) $theme)); + return $value !== '' && in_array($value, $this->allowedThemes(), true); } - private function allowedThemes(): array + /** + * Default theme for flows that need ONE value without a tenant context + * (user creation defaults, SSO-linked account provisioning, etc.). + * Hardcoded to 'light' with a catalog fallback — no global setting. + */ + public function resolveDefaultTheme(): string { - return array_keys($this->listThemes()); + $allowed = $this->allowedThemes(); + if (in_array('light', $allowed, true)) { + return 'light'; + } + + return (string) ($allowed[0] ?? 'light'); + } + + public function normalizeTheme(?string $theme): string + { + $value = strtolower(trim((string) $theme)); + if ($this->isAllowedTheme($value)) { + return $value; + } + + return $this->resolveDefaultTheme(); } public function normalizeLocale(?string $locale): string @@ -199,4 +133,12 @@ class SettingsAppGateway return $value; } + + /** + * @return list + */ + private function allowedThemes(): array + { + return array_keys($this->listThemes()); + } } diff --git a/core/Service/User/UserAccountService.php b/core/Service/User/UserAccountService.php index 22ddfee..a030e15 100644 --- a/core/Service/User/UserAccountService.php +++ b/core/Service/User/UserAccountService.php @@ -613,7 +613,6 @@ class UserAccountService return ['ok' => false, 'error' => $errors[0]]; } - $defaultTheme = $this->settingsGateway->getAppTheme(); $defaultTenantId = $this->settingsGateway->getDefaultTenantId(); $activeChangedAt = gmdate('Y-m-d H:i:s'); @@ -629,7 +628,7 @@ class UserAccountService 'password' => $password, 'locale' => I18n::$locale, 'totp_secret' => '', - 'theme' => $this->normalizeTheme($defaultTheme ?? 'light'), + 'theme' => $this->normalizeTheme(null), 'primary_tenant_id' => $defaultTenantId ?: null, 'active' => 1, 'created_by' => null, diff --git a/core/Service/User/UserSettingsGateway.php b/core/Service/User/UserSettingsGateway.php index af2d774..805a33b 100644 --- a/core/Service/User/UserSettingsGateway.php +++ b/core/Service/User/UserSettingsGateway.php @@ -30,11 +30,6 @@ class UserSettingsGateway return $this->settingsDefaultsGateway->getDefaultDepartmentId(); } - public function getAppTheme(): ?string - { - return $this->settingsAppGateway->getAppTheme(); - } - /** * @return array */ diff --git a/core/Support/helpers/app.php b/core/Support/helpers/app.php index 5d4aba1..704fd8c 100644 --- a/core/Support/helpers/app.php +++ b/core/Support/helpers/app.php @@ -265,6 +265,10 @@ function appDefaultLocale(): ?string /** * Feature flag: allow users to choose their own theme. + * + * Resolved from the current tenant; defaults to true when no tenant is in + * session or the tenant has no explicit value. Appearance is tenant-scoped — + * there is no global setting fallback. */ function allowUserTheme(): bool { @@ -273,7 +277,7 @@ function allowUserTheme(): bool return settingToBool((string) $tenantValue, true); } - return settingToBool(appSetting('app_theme_user'), true); + return true; } /** diff --git a/core/Support/helpers/branding.php b/core/Support/helpers/branding.php index 41cb526..adf7d9a 100644 --- a/core/Support/helpers/branding.php +++ b/core/Support/helpers/branding.php @@ -22,7 +22,10 @@ function appThemes(): array } /** - * Resolve default theme from settings, then APP_THEME, then light. + * Resolve default theme: tenant value, else hardcoded 'light'. + * + * Appearance is tenant-scoped — there is no global fallback layer. Pages + * without a tenant context (login, error) land on 'light'. */ function appDefaultTheme(): string { @@ -31,13 +34,7 @@ function appDefaultTheme(): string if ($tenantTheme !== '' && isset($themes[$tenantTheme])) { return $tenantTheme; } - $setting = appSetting('app_theme'); - if ($setting !== null && isset($themes[$setting])) { - return $setting; - } - $envTheme = getenv('APP_THEME') ?: 'light'; - $envTheme = strtolower(trim((string) $envTheme)); - return isset($themes[$envTheme]) ? $envTheme : 'light'; + return 'light'; } /** @@ -59,28 +56,24 @@ function currentTheme(): string } /** - * Resolve active primary color (tenant override -> app setting). + * Resolve active primary color from the current tenant. + * + * Returns null when no tenant is in session (login / error pages) or when + * the tenant has no primary_color set. Callers must be null-safe — there is + * no global fallback; appPrimaryCssVars() emits an empty string in that case + * so Pico defaults apply. */ function appPrimaryColor(): ?string { - // Tenant-scoped branding has precedence over global settings. $tenantColor = $_SESSION['current_tenant']['primary_color'] ?? null; - if ($tenantColor !== null) { - $tenantColor = strtolower(trim((string) $tenantColor)); - if (preg_match('/^#([0-9a-f]{3}|[0-9a-f]{6})$/i', $tenantColor)) { - return $tenantColor; - } - } - - $value = appSetting('app_primary_color'); - if ($value === null) { + if ($tenantColor === null) { return null; } - $value = strtolower(trim($value)); - if (!preg_match('/^#([0-9a-f]{3}|[0-9a-f]{6})$/i', $value)) { + $tenantColor = strtolower(trim((string) $tenantColor)); + if (!preg_match('/^#([0-9a-f]{3}|[0-9a-f]{6})$/i', $tenantColor)) { return null; } - return $value; + return $tenantColor; } /** diff --git a/db/init/init.sql b/db/init/init.sql index 074385e..588cc79 100644 --- a/db/init/init.sql +++ b/db/init/init.sql @@ -1671,10 +1671,7 @@ INSERT INTO `settings` (`key`, `value`, `description`) VALUES ('app_title', 'CoreCore', 'setting.app_title'), ('app_locale', 'de', 'setting.app_locale'), - ('app_theme', 'light', 'setting.app_theme'), - ('app_theme_user', '1', 'setting.app_theme_user'), ('app_registration', '1', 'setting.app_registration'), - ('app_primary_color', '#105433', 'setting.app_primary_color'), ('api_token_default_ttl_days', '90', 'setting.api_token_default_ttl_days'), ('api_token_max_ttl_days', '365', 'setting.api_token_max_ttl_days'), ('session_idle_timeout_minutes', '30', 'setting.session_idle_timeout_minutes'), diff --git a/docs/explanation-einstellungen-speicherung.md b/docs/explanation-einstellungen-speicherung.md index 0aa5c4a..2c97545 100644 --- a/docs/explanation-einstellungen-speicherung.md +++ b/docs/explanation-einstellungen-speicherung.md @@ -21,10 +21,11 @@ Die Kombination ist bewusst so gebaut: - Verwendet wird er über `appSetting(...)` nur für globale Basiswerte wie: - `app_title` - `app_locale` - - `app_theme` - - `app_theme_user` - `app_registration` - - `app_primary_color` + + Appearance-Settings (Theme, Primärfarbe, Erlaubnis für User-Themes) sind + **tenant-scoped** und werden nicht global gecached — sie liegen auf dem + Tenant (`tenants.primary_color`, `default_theme`, `allow_user_theme`). ## Was **nicht** im Cache der Datei liegen muss diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 420fda4..961c9c0 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1324,14 +1324,8 @@ components: type: string app_locale: type: string - app_theme: - type: string - app_theme_user_allowed: - type: boolean app_registration_enabled: type: boolean - app_primary_color: - type: string api_token_default_ttl_days: type: integer nullable: true @@ -1391,14 +1385,8 @@ components: type: string app_locale: type: string - app_theme: - type: string - app_theme_user_allowed: - type: boolean app_registration_enabled: type: boolean - app_primary_color: - type: string api_token_default_ttl_days: type: integer nullable: true diff --git a/docs/reference-konfiguration.md b/docs/reference-konfiguration.md index b9f2878..5e08b8c 100644 --- a/docs/reference-konfiguration.md +++ b/docs/reference-konfiguration.md @@ -102,10 +102,7 @@ Beim Fresh-Setup über `db/init/init.sql` werden zusätzlich folgende Settings i - `default_department_id` -> Seed-Abteilung mit Code `MUSTER` - `default_role_id` -> Seed-Rolle mit Code `USER` - `app_locale` -> `de` -- `app_theme` -> `light` -- `app_theme_user` -> `1` - `app_registration` -> `1` (Self-Registration aktiviert) -- `app_primary_color` -> `#105433` - `api_token_default_ttl_days` -> `90` - `api_token_max_ttl_days` -> `365` - `api_cors_allowed_origins` -> `http://localhost:8080`, `http://127.0.0.1:8080` diff --git a/i18n/default_de.json b/i18n/default_de.json index 1191c1f..5473a88 100644 --- a/i18n/default_de.json +++ b/i18n/default_de.json @@ -411,13 +411,10 @@ "setting.app_title": "App-Titel für den Browser-Tab", "setting.app_locale": "Standard-Sprache, wenn keine Sprache gesetzt ist", "Default language": "Standard-Sprache", - "setting.app_theme": "Standard-Theme, wenn der Benutzer keine Präferenz hat", - "setting.app_theme_user": "Benutzer dürfen ihr eigenes Theme wählen", "setting.app_registration": "Öffentliche Selbstregistrierung erlauben", "setting.api_token_default_ttl_days": "Standard-Laufzeit für neue API-Tokens in Tagen (Default: 90)", "setting.api_token_max_ttl_days": "Maximale Laufzeit für API-Tokens in Tagen (Hard Cap)", "setting.api_cors_allowed_origins": "Erlaubte Browser-Origins für API-CORS (eine Origin pro Zeile)", - "setting.app_primary_color": "Überschreibt die Standard-Primärfarbe (Hex wie #2FA4A4)", "API token default lifetime (days)": "Standardlaufzeit für API-Tokens (Tage)", "API token maximum lifetime (days)": "Maximale Laufzeit für API-Tokens (Tage)", "Allowed CORS origins": "Erlaubte CORS-Origins", @@ -1270,5 +1267,14 @@ "Priority": "Priorität", "Note": "Notiz", "Optional note...": "Optionale Notiz...", - "Security level updated": "Sicherheitsstufe aktualisiert" + "Security level updated": "Sicherheitsstufe aktualisiert", + "About": "Über", + "Call": "Anrufen", + "Copy email address": "E-Mail-Adresse kopieren", + "Copy phone number": "Telefonnummer kopieren", + "Copy mobile number": "Mobilnummer kopieren", + "Hired": "Eingestellt", + "Primary": "Primär", + "Send email": "E-Mail senden", + "Summary": "Zusammenfassung" } diff --git a/i18n/default_en.json b/i18n/default_en.json index 68ff459..18d626c 100644 --- a/i18n/default_en.json +++ b/i18n/default_en.json @@ -411,13 +411,10 @@ "setting.app_title": "App title shown in the browser title bar", "setting.app_locale": "Default language used when no user or URL locale is set", "Default language": "Default language", - "setting.app_theme": "Default theme used when a user has no theme preference", - "setting.app_theme_user": "Allow users to choose their own theme", "setting.app_registration": "Allow public self-registration", "setting.api_token_default_ttl_days": "Default lifetime for newly created API tokens in days (default: 90)", "setting.api_token_max_ttl_days": "Maximum lifetime for API tokens in days (hard cap)", "setting.api_cors_allowed_origins": "Allowed browser origins for API CORS (one origin per line)", - "setting.app_primary_color": "Overrides the default primary color (hex like #2FA4A4)", "API token default lifetime (days)": "API token default lifetime (days)", "API token maximum lifetime (days)": "API token maximum lifetime (days)", "Allowed CORS origins": "Allowed CORS origins", @@ -1270,5 +1267,14 @@ "Priority": "Priority", "Note": "Note", "Optional note...": "Optional note...", - "Security level updated": "Security level updated" + "Security level updated": "Security level updated", + "About": "About", + "Call": "Call", + "Copy email address": "Copy email address", + "Copy phone number": "Copy phone number", + "Copy mobile number": "Copy mobile number", + "Hired": "Hired", + "Primary": "Primary", + "Send email": "Send email", + "Summary": "Summary" } diff --git a/modules/audit/lib/Module/Audit/Service/SystemAuditRedactionService.php b/modules/audit/lib/Module/Audit/Service/SystemAuditRedactionService.php index c26cf56..f46c219 100644 --- a/modules/audit/lib/Module/Audit/Service/SystemAuditRedactionService.php +++ b/modules/audit/lib/Module/Audit/Service/SystemAuditRedactionService.php @@ -38,10 +38,7 @@ class SystemAuditRedactionService 'default_role_id', 'default_department_id', 'app_locale', - 'app_theme', - 'app_theme_user', 'app_registration', - 'app_primary_color', 'api_token_default_ttl_days', 'api_token_max_ttl_days', 'user_inactivity_deactivate_days', diff --git a/pages/admin/settings/index(default).phtml b/pages/admin/settings/index(default).phtml index 0dc1462..5f4398e 100644 --- a/pages/admin/settings/index(default).phtml +++ b/pages/admin/settings/index(default).phtml @@ -19,8 +19,6 @@ $defaultRoleId = (int) ($values['default_role_id'] ?? 0); $defaultDepartmentId = (int) ($values['default_department_id'] ?? 0); $appTitle = (string) ($values['app_title'] ?? ''); $appLocale = (string) ($values['app_locale'] ?? ''); -$appTheme = (string) ($values['app_theme'] ?? ''); -$appThemeUser = !empty($values['app_theme_user']); $appRegistration = !empty($values['app_registration']); $apiTokenDefaultTtlDays = (int) ($values['api_token_default_ttl_days'] ?? 90); $apiTokenMaxTtlDays = (int) ($values['api_token_max_ttl_days'] ?? 365); @@ -50,10 +48,8 @@ $frontendTelemetryAllowedEvents = array_values(array_unique(array_filter(array_m if ($frontendTelemetryAllowedEvents === []) { $frontendTelemetryAllowedEvents = ['warn_once', 'ajax_error']; } -$appPrimaryColor = (string) ($values['app_primary_color'] ?? ''); $microsoftSharedClientId = (string) ($values['microsoft_shared_client_id'] ?? ''); $microsoftAuthority = (string) ($values['microsoft_authority'] ?? ''); -$themes = appThemes(); $smtpHost = (string) ($values['smtp_host'] ?? ''); $smtpPort = (string) ($values['smtp_port'] ?? ''); $smtpUser = (string) ($values['smtp_user'] ?? ''); @@ -67,8 +63,6 @@ $defaultRoleDesc = $settings['default_role']['description'] ?? 'setting.default_ $defaultDepartmentDesc = $settings['default_department']['description'] ?? 'setting.default_department'; $appTitleDesc = $settings['app_title']['description'] ?? 'setting.app_title'; $appLocaleDesc = $settings['app_locale']['description'] ?? 'setting.app_locale'; -$appThemeDesc = $settings['app_theme']['description'] ?? 'setting.app_theme'; -$appThemeUserDesc = $settings['app_theme_user']['description'] ?? 'setting.app_theme_user'; $appRegistrationDesc = $settings['app_registration']['description'] ?? 'setting.app_registration'; $apiTokenDefaultTtlDaysDesc = $settings['api_token_default_ttl_days']['description'] ?? 'setting.api_token_default_ttl_days'; $apiTokenMaxTtlDaysDesc = $settings['api_token_max_ttl_days']['description'] ?? 'setting.api_token_max_ttl_days'; @@ -81,7 +75,6 @@ $rememberTokenLifetimeDaysDesc = $settings['remember_token_lifetime_days']['desc $apiCorsAllowedOriginsDesc = $settings['api_cors_allowed_origins']['description'] ?? 'setting.api_cors_allowed_origins'; $systemAuditEnabledDesc = $settings['system_audit_enabled']['description'] ?? 'setting.system_audit_enabled'; $systemAuditRetentionDaysDesc = $settings['system_audit_retention_days']['description'] ?? 'setting.system_audit_retention_days'; -$appPrimaryColorDesc = $settings['app_primary_color']['description'] ?? 'setting.app_primary_color'; $microsoftSharedClientIdDesc = $settings['microsoft_shared_client_id']['description'] ?? 'setting.microsoft_shared_client_id'; $microsoftSharedClientSecretDesc = $settings['microsoft_shared_client_secret_enc']['description'] ?? 'setting.microsoft_shared_client_secret_enc'; $microsoftAuthorityDesc = $settings['microsoft_authority']['description'] ?? 'setting.microsoft_authority'; @@ -126,7 +119,6 @@ $disabledAttr = $isReadOnly ? 'disabled' : '';
- @@ -254,68 +246,6 @@ $disabledAttr = $isReadOnly ? 'disabled' : '';
-
-
- - - - - - - -
-
- -
- -
- - -
- -
-
- -
- - - - - - -
-
- -
-
- - - -
-
-
-
-
diff --git a/pages/admin/tenants/_form.phtml b/pages/admin/tenants/_form.phtml index b50c20a..df7181f 100644 --- a/pages/admin/tenants/_form.phtml +++ b/pages/admin/tenants/_form.phtml @@ -35,7 +35,9 @@ $readonlyAttr = $isReadOnly ? 'readonly' : ''; $disabledAttr = $isReadOnly ? 'disabled' : ''; $themes = appThemes(); $primaryColor = (string) ($values['primary_color'] ?? ''); -$defaultPrimaryColor = appSetting('app_primary_color') ?? '#2fa4a4'; +// No global fallback — appearance is tenant-scoped. The color picker's +// "use default" preview uses a neutral hex so the UI is never empty. +$defaultPrimaryColor = '#2fa4a4'; $useDefaultPrimaryColor = $primaryColor === ''; $tenantDefaultTheme = strtolower(trim((string) ($values['default_theme'] ?? ''))); if ($tenantDefaultTheme !== '' && !isset($themes[$tenantDefaultTheme])) { diff --git a/pages/api/v1/settings/index().php b/pages/api/v1/settings/index().php index 13b3524..16db69b 100644 --- a/pages/api/v1/settings/index().php +++ b/pages/api/v1/settings/index().php @@ -59,13 +59,13 @@ if ($method === 'PUT' || $method === 'PATCH') { $settingsSmtpGateway, ); + // Appearance settings (theme, user-theme toggle, primary color) are NOT + // accepted here — they are tenant-scoped. See the tenant edit endpoints + // for per-tenant appearance overrides. $setters = [ 'app_title' => static fn (?string $v) => $settingsAppGateway->setAppTitle($v), 'app_locale' => static fn (?string $v) => $settingsAppGateway->setAppLocale($v), - 'app_theme' => static fn (?string $v) => $settingsAppGateway->setAppTheme($v), - 'app_theme_user_allowed' => static fn ($v) => $settingsAppGateway->setUserThemeAllowed((bool) $v), 'app_registration_enabled' => static fn ($v) => $settingsAppGateway->setRegistrationEnabled((bool) $v), - 'app_primary_color' => static fn (?string $v) => $settingsAppGateway->setAppPrimaryColor($v), 'api_token_default_ttl_days' => static fn ($v) => $settingsApiPolicyGateway->setApiTokenDefaultTtlDays($v !== null ? (int) $v : null), 'api_token_max_ttl_days' => static fn ($v) => $settingsApiPolicyGateway->setApiTokenMaxTtlDays($v !== null ? (int) $v : null), 'api_cors_allowed_origins' => static fn (?string $v) => $settingsApiPolicyGateway->setApiCorsAllowedOrigins($v), @@ -150,10 +150,7 @@ function buildSettingsResponse( return [ 'app_title' => $settingsAppGateway->getAppTitle(), 'app_locale' => $settingsAppGateway->getAppLocale(), - 'app_theme' => $settingsAppGateway->getAppTheme(), - 'app_theme_user_allowed' => $settingsAppGateway->isUserThemeAllowed(), 'app_registration_enabled' => $settingsAppGateway->isRegistrationEnabled(), - 'app_primary_color' => $settingsAppGateway->getAppPrimaryColor(), 'api_token_default_ttl_days' => $settingsApiPolicyGateway->getApiTokenDefaultTtlDays(), 'api_token_max_ttl_days' => $settingsApiPolicyGateway->getApiTokenMaxTtlDays(), 'api_cors_allowed_origins' => $settingsApiPolicyGateway->getApiCorsAllowedOrigins(), diff --git a/pages/api/v1/settings/public().php b/pages/api/v1/settings/public().php index d79ad30..592f8e5 100644 --- a/pages/api/v1/settings/public().php +++ b/pages/api/v1/settings/public().php @@ -11,11 +11,11 @@ $settingsAppGateway = app(SettingsAppGateway::class); header('Cache-Control: public, max-age=60'); +// Appearance (theme, primary color) is tenant-scoped — the public settings +// endpoint exposes only the non-tenant, non-appearance surface. ApiResponse::success([ 'data' => [ 'app_title' => $settingsAppGateway->getAppTitle(), - 'app_primary_color' => $settingsAppGateway->getAppPrimaryColor(), - 'default_theme' => $settingsAppGateway->getAppTheme(), 'registration_enabled' => $settingsAppGateway->isRegistrationEnabled(), ], ]); diff --git a/tests/Service/Auth/AuthSettingsGatewayTest.php b/tests/Service/Auth/AuthSettingsGatewayTest.php index 1187e90..98dc04f 100644 --- a/tests/Service/Auth/AuthSettingsGatewayTest.php +++ b/tests/Service/Auth/AuthSettingsGatewayTest.php @@ -52,16 +52,14 @@ final class AuthSettingsGatewayTest extends TestCase $this->assertSame(30, $gateway->getApiTokenDefaultTtlDays()); } - public function testAppThemeAccessorsDelegate(): void + public function testThemeHelpersDelegate(): void { $app = $this->createMock(SettingsAppGateway::class); - $app->method('getAppTheme')->willReturn('dark'); $app->method('resolveDefaultTheme')->willReturn('light'); $app->expects($this->once())->method('normalizeTheme')->with('DARK')->willReturn('dark'); $gateway = $this->makeGateway(app: $app); - $this->assertSame('dark', $gateway->getAppTheme()); $this->assertSame('light', $gateway->defaultTheme()); $this->assertSame('dark', $gateway->normalizeTheme('DARK')); } diff --git a/tests/Service/Auth/SsoUserLinkServiceTest.php b/tests/Service/Auth/SsoUserLinkServiceTest.php index 820945d..f2eed1d 100644 --- a/tests/Service/Auth/SsoUserLinkServiceTest.php +++ b/tests/Service/Auth/SsoUserLinkServiceTest.php @@ -437,8 +437,7 @@ class SsoUserLinkServiceTest extends TestCase $settingsGateway = $this->createMock(AuthSettingsGateway::class); $settingsGateway->method('getDefaultRoleId')->willReturn(5); $settingsGateway->method('getDefaultDepartmentId')->willReturn(3); - $settingsGateway->method('getAppTheme')->willReturn('auto'); - $settingsGateway->expects($this->any())->method('normalizeTheme')->with('auto')->willReturn('auto'); + $settingsGateway->expects($this->any())->method('normalizeTheme')->willReturn('light'); $tenantSsoService = $this->createMock(TenantSsoService::class); $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft'); @@ -483,7 +482,6 @@ class SsoUserLinkServiceTest extends TestCase $settingsGateway = $this->createMock(AuthSettingsGateway::class); $settingsGateway->method('getDefaultRoleId')->willReturn(0); $settingsGateway->method('getDefaultDepartmentId')->willReturn(0); - $settingsGateway->method('getAppTheme')->willReturn('light'); $settingsGateway->method('normalizeTheme')->willReturn('light'); $tenantSsoService = $this->createMock(TenantSsoService::class); @@ -533,7 +531,6 @@ class SsoUserLinkServiceTest extends TestCase $settingsGateway = $this->createMock(AuthSettingsGateway::class); $settingsGateway->method('getDefaultRoleId')->willReturn(0); $settingsGateway->method('getDefaultDepartmentId')->willReturn(0); - $settingsGateway->method('getAppTheme')->willReturn('auto'); $settingsGateway->method('normalizeTheme')->willReturn('auto'); $tenantSsoService = $this->createMock(TenantSsoService::class); @@ -580,7 +577,6 @@ class SsoUserLinkServiceTest extends TestCase $settingsGateway = $this->createMock(AuthSettingsGateway::class); $settingsGateway->method('getDefaultRoleId')->willReturn(0); $settingsGateway->method('getDefaultDepartmentId')->willReturn(0); - $settingsGateway->method('getAppTheme')->willReturn('auto'); $settingsGateway->method('normalizeTheme')->willReturn('auto'); $tenantSsoService = $this->createMock(TenantSsoService::class); @@ -617,7 +613,6 @@ class SsoUserLinkServiceTest extends TestCase $userWriteRepo->method('create')->willReturn(false); $settingsGateway = $this->createMock(AuthSettingsGateway::class); - $settingsGateway->method('getAppTheme')->willReturn('auto'); $settingsGateway->method('normalizeTheme')->willReturn('auto'); $tenantSsoService = $this->createMock(TenantSsoService::class); @@ -650,7 +645,6 @@ class SsoUserLinkServiceTest extends TestCase $userWriteRepo->method('create')->willReturn(0); $settingsGateway = $this->createMock(AuthSettingsGateway::class); - $settingsGateway->method('getAppTheme')->willReturn('auto'); $settingsGateway->method('normalizeTheme')->willReturn('auto'); $tenantSsoService = $this->createMock(TenantSsoService::class); @@ -903,7 +897,6 @@ class SsoUserLinkServiceTest extends TestCase $settingsGateway = $this->createMock(AuthSettingsGateway::class); $settingsGateway->method('getDefaultRoleId')->willReturn(0); $settingsGateway->method('getDefaultDepartmentId')->willReturn(0); - $settingsGateway->method('getAppTheme')->willReturn('light'); $settingsGateway->method('normalizeTheme')->willReturn('light'); $tenantSsoService = $this->createMock(TenantSsoService::class); diff --git a/tests/Service/Settings/AdminSettingsServiceApiLifecycleTest.php b/tests/Service/Settings/AdminSettingsServiceApiLifecycleTest.php index 0577b2c..f062b6d 100644 --- a/tests/Service/Settings/AdminSettingsServiceApiLifecycleTest.php +++ b/tests/Service/Settings/AdminSettingsServiceApiLifecycleTest.php @@ -313,8 +313,6 @@ class AdminSettingsServiceApiLifecycleTest extends TestCase 'default_department_id' => '0', 'app_title' => 'Demo', 'app_locale' => 'de', - 'app_theme' => 'light', - 'app_theme_user' => '1', 'app_registration' => '1', 'api_token_default_ttl_days' => '90', 'api_token_max_ttl_days' => '365', @@ -328,7 +326,6 @@ class AdminSettingsServiceApiLifecycleTest extends TestCase 'frontend_telemetry_enabled' => '1', 'frontend_telemetry_sample_rate' => '0.2', 'frontend_telemetry_allowed_events' => ['warn_once'], - 'app_primary_color' => '#2FA4A4', 'smtp_host' => '', 'smtp_port' => '', 'smtp_user' => '', @@ -359,16 +356,10 @@ class AdminSettingsServiceApiLifecycleTest extends TestCase $settingsAppGateway = $this->createConfiguredMock(SettingsAppGateway::class, [ 'getAppLocale' => 'de', - 'getAppTheme' => 'light', - 'isUserThemeAllowed' => true, 'isRegistrationEnabled' => true, - 'getAppPrimaryColor' => '#2FA4A4', 'setAppTitle' => true, 'setAppLocale' => true, - 'setAppTheme' => true, - 'setUserThemeAllowed' => true, 'setRegistrationEnabled' => true, - 'setAppPrimaryColor' => true, ]); $settingsApiPolicyGateway = $settingsApiPolicyGateway ?? $this->createConfiguredMock(SettingsApiPolicyGateway::class, [ diff --git a/tests/Service/Settings/AdminSettingsServiceAppTest.php b/tests/Service/Settings/AdminSettingsServiceAppTest.php index a6e02f3..91e3c6f 100644 --- a/tests/Service/Settings/AdminSettingsServiceAppTest.php +++ b/tests/Service/Settings/AdminSettingsServiceAppTest.php @@ -25,62 +25,6 @@ use PHPUnit\Framework\TestCase; class AdminSettingsServiceAppTest extends TestCase { - // --------------------------------------------------------------- - // Primary color - // --------------------------------------------------------------- - - public function testUpdateFromAdminPersistsValidPrimaryColor(): void - { - $appGateway = $this->createMock(SettingsAppGateway::class); - $appGateway->method('getAppLocale')->willReturn('de'); - $appGateway->method('getAppTheme')->willReturn('light'); - $appGateway->method('isUserThemeAllowed')->willReturn(true); - $appGateway->method('isRegistrationEnabled')->willReturn(true); - $appGateway->method('getAppPrimaryColor')->willReturn('#2FA4A4'); - $appGateway->method('setAppTitle')->willReturn(true); - $appGateway->method('setAppLocale')->willReturn(true); - $appGateway->method('setAppTheme')->willReturn(true); - $appGateway->method('setUserThemeAllowed')->willReturn(true); - $appGateway->method('setRegistrationEnabled')->willReturn(true); - $appGateway->expects($this->once()) - ->method('setAppPrimaryColor') - ->with('#2FA4A4') - ->willReturn(true); - - $service = $this->newService(settingsAppGateway: $appGateway); - $result = $service->updateFromAdmin($this->validPostData([ - 'app_primary_color' => '#2FA4A4', - ])); - - $this->assertSame([], $result['errors'] ?? []); - } - - public function testUpdateFromAdminReturnsErrorForInvalidPrimaryColor(): void - { - $appGateway = $this->createMock(SettingsAppGateway::class); - $appGateway->method('getAppLocale')->willReturn('de'); - $appGateway->method('getAppTheme')->willReturn('light'); - $appGateway->method('isUserThemeAllowed')->willReturn(true); - $appGateway->method('isRegistrationEnabled')->willReturn(true); - $appGateway->method('getAppPrimaryColor')->willReturn('#2FA4A4'); - $appGateway->method('setAppTitle')->willReturn(true); - $appGateway->method('setAppLocale')->willReturn(true); - $appGateway->method('setAppTheme')->willReturn(true); - $appGateway->method('setUserThemeAllowed')->willReturn(true); - $appGateway->method('setRegistrationEnabled')->willReturn(true); - $appGateway->expects($this->once()) - ->method('setAppPrimaryColor') - ->with('not-a-color') - ->willReturn(false); - - $service = $this->newService(settingsAppGateway: $appGateway); - $result = $service->updateFromAdmin($this->validPostData([ - 'app_primary_color' => 'not-a-color', - ])); - - $this->assertContains('app_primary_invalid', $this->extractErrorKeys($result)); - } - // --------------------------------------------------------------- // buildPageData: sorted tenants/roles/departments // --------------------------------------------------------------- @@ -165,10 +109,7 @@ class AdminSettingsServiceAppTest extends TestCase 'default_department_id', 'app_title', 'app_locale', - 'app_theme', - 'app_theme_user', 'app_registration', - 'app_primary_color', 'api_token_default_ttl_days', 'api_token_max_ttl_days', 'user_inactivity_deactivate_days', @@ -248,19 +189,6 @@ class AdminSettingsServiceAppTest extends TestCase // Helpers // --------------------------------------------------------------- - /** @return list */ - private function extractErrorKeys(array $result): array - { - $errors = is_array($result['errors'] ?? null) ? $result['errors'] : []; - $errorKeys = []; - foreach ($errors as $error) { - if (is_array($error) && isset($error['key'])) { - $errorKeys[] = (string) $error['key']; - } - } - return $errorKeys; - } - /** * @param array $overrides * @return array @@ -273,8 +201,6 @@ class AdminSettingsServiceAppTest extends TestCase 'default_department_id' => '0', 'app_title' => 'Demo', 'app_locale' => 'de', - 'app_theme' => 'light', - 'app_theme_user' => '1', 'app_registration' => '1', 'api_token_default_ttl_days' => '90', 'api_token_max_ttl_days' => '365', @@ -288,7 +214,6 @@ class AdminSettingsServiceAppTest extends TestCase 'frontend_telemetry_enabled' => '1', 'frontend_telemetry_sample_rate' => '0.2', 'frontend_telemetry_allowed_events' => ['warn_once'], - 'app_primary_color' => '#2FA4A4', 'smtp_host' => '', 'smtp_port' => '', 'smtp_user' => '', @@ -324,16 +249,10 @@ class AdminSettingsServiceAppTest extends TestCase $settingsAppGateway = $settingsAppGateway ?? $this->createConfiguredMock(SettingsAppGateway::class, [ 'getAppLocale' => 'de', - 'getAppTheme' => 'light', - 'isUserThemeAllowed' => true, 'isRegistrationEnabled' => true, - 'getAppPrimaryColor' => '#2FA4A4', 'setAppTitle' => true, 'setAppLocale' => true, - 'setAppTheme' => true, - 'setUserThemeAllowed' => true, 'setRegistrationEnabled' => true, - 'setAppPrimaryColor' => true, ]); $settingsApiPolicyGateway = $this->createConfiguredMock(SettingsApiPolicyGateway::class, [ diff --git a/tests/Service/Settings/AdminSettingsServiceSecurityTest.php b/tests/Service/Settings/AdminSettingsServiceSecurityTest.php index b025bc0..e7368f0 100644 --- a/tests/Service/Settings/AdminSettingsServiceSecurityTest.php +++ b/tests/Service/Settings/AdminSettingsServiceSecurityTest.php @@ -396,8 +396,6 @@ class AdminSettingsServiceSecurityTest extends TestCase 'default_department_id' => '0', 'app_title' => 'Demo', 'app_locale' => 'de', - 'app_theme' => 'light', - 'app_theme_user' => '1', 'app_registration' => '1', 'api_token_default_ttl_days' => '90', 'api_token_max_ttl_days' => '365', @@ -411,7 +409,6 @@ class AdminSettingsServiceSecurityTest extends TestCase 'frontend_telemetry_enabled' => '1', 'frontend_telemetry_sample_rate' => '0.2', 'frontend_telemetry_allowed_events' => ['warn_once'], - 'app_primary_color' => '#2FA4A4', 'smtp_host' => '', 'smtp_port' => '', 'smtp_user' => '', @@ -443,16 +440,10 @@ class AdminSettingsServiceSecurityTest extends TestCase $settingsAppGateway = $this->createConfiguredMock(SettingsAppGateway::class, [ 'getAppLocale' => 'de', - 'getAppTheme' => 'light', - 'isUserThemeAllowed' => true, 'isRegistrationEnabled' => true, - 'getAppPrimaryColor' => '#2FA4A4', 'setAppTitle' => true, 'setAppLocale' => true, - 'setAppTheme' => true, - 'setUserThemeAllowed' => true, 'setRegistrationEnabled' => true, - 'setAppPrimaryColor' => true, ]); $settingsApiPolicyGateway = $this->createConfiguredMock(SettingsApiPolicyGateway::class, [ diff --git a/tests/Service/Settings/AdminSettingsServiceSessionPolicyTest.php b/tests/Service/Settings/AdminSettingsServiceSessionPolicyTest.php index 3efd7d3..84ff088 100644 --- a/tests/Service/Settings/AdminSettingsServiceSessionPolicyTest.php +++ b/tests/Service/Settings/AdminSettingsServiceSessionPolicyTest.php @@ -193,8 +193,6 @@ class AdminSettingsServiceSessionPolicyTest extends TestCase 'default_department_id' => '0', 'app_title' => 'Demo', 'app_locale' => 'de', - 'app_theme' => 'light', - 'app_theme_user' => '1', 'app_registration' => '1', 'api_token_default_ttl_days' => '90', 'api_token_max_ttl_days' => '365', @@ -208,7 +206,6 @@ class AdminSettingsServiceSessionPolicyTest extends TestCase 'frontend_telemetry_enabled' => '1', 'frontend_telemetry_sample_rate' => '0.2', 'frontend_telemetry_allowed_events' => ['warn_once'], - 'app_primary_color' => '#2FA4A4', 'smtp_host' => '', 'smtp_port' => '', 'smtp_user' => '', @@ -240,16 +237,10 @@ class AdminSettingsServiceSessionPolicyTest extends TestCase $settingsAppGateway = $this->createConfiguredMock(SettingsAppGateway::class, [ 'getAppLocale' => 'de', - 'getAppTheme' => 'light', - 'isUserThemeAllowed' => true, 'isRegistrationEnabled' => true, - 'getAppPrimaryColor' => '#2FA4A4', 'setAppTitle' => true, 'setAppLocale' => true, - 'setAppTheme' => true, - 'setUserThemeAllowed' => true, 'setRegistrationEnabled' => true, - 'setAppPrimaryColor' => true, ]); $settingsApiPolicyGateway = $this->createConfiguredMock(SettingsApiPolicyGateway::class, [ diff --git a/tests/Service/Settings/SettingCacheServiceTest.php b/tests/Service/Settings/SettingCacheServiceTest.php index 18eb2ac..6d5e1c7 100644 --- a/tests/Service/Settings/SettingCacheServiceTest.php +++ b/tests/Service/Settings/SettingCacheServiceTest.php @@ -45,16 +45,13 @@ class SettingCacheServiceTest extends TestCase $file = $this->tempFilePath(); $metadataGateway = $this->createMock(SettingsMetadataGateway::class); - $metadataGateway->expects($this->exactly(6)) + $metadataGateway->expects($this->exactly(3)) ->method('getValue') ->willReturnCallback(static function (string $key): ?string { return match ($key) { SettingKeys::APP_TITLE_KEY => 'Demo App', SettingKeys::APP_LOCALE_KEY => 'de', - SettingKeys::APP_THEME_KEY => 'dark', - SettingKeys::APP_THEME_USER_KEY => '1', SettingKeys::APP_REGISTRATION_KEY => '0', - SettingKeys::APP_PRIMARY_COLOR_KEY => '#112233', default => null, }; }); @@ -66,7 +63,7 @@ class SettingCacheServiceTest extends TestCase /** @var array $cache */ $cache = include $file; $this->assertSame('de', $cache[SettingKeys::APP_LOCALE_KEY] ?? null); - $this->assertSame('#112233', $cache[SettingKeys::APP_PRIMARY_COLOR_KEY] ?? null); + $this->assertSame('0', $cache[SettingKeys::APP_REGISTRATION_KEY] ?? null); } private function tempFilePath(): string diff --git a/tests/Service/Settings/SettingsAppGatewayTest.php b/tests/Service/Settings/SettingsAppGatewayTest.php index bfa4481..f431703 100644 --- a/tests/Service/Settings/SettingsAppGatewayTest.php +++ b/tests/Service/Settings/SettingsAppGatewayTest.php @@ -10,19 +10,12 @@ use PHPUnit\Framework\TestCase; class SettingsAppGatewayTest extends TestCase { - private function createGateway(SettingRepositoryInterface $settings, ?ThemeConfigGateway $themeConfig = null): SettingsAppGateway - { - $themeConfig ??= $this->createDefaultThemeConfig(); - - return new SettingsAppGateway(new SettingsMetadataGateway($settings), $themeConfig); - } - - private function createDefaultThemeConfig(): ThemeConfigGateway + private function createGateway(SettingRepositoryInterface $settings): SettingsAppGateway { $themeConfig = $this->createMock(ThemeConfigGateway::class); $themeConfig->method('all')->willReturn(['light' => 'Light', 'dark' => 'Dark']); - return $themeConfig; + return new SettingsAppGateway(new SettingsMetadataGateway($settings), $themeConfig); } public function testGetAppTitleReturnsNullWhenEmpty(): void @@ -69,38 +62,4 @@ class SettingsAppGatewayTest extends TestCase $gateway = $this->createGateway($settings); $this->assertFalse($gateway->isRegistrationEnabled()); } - - public function testGetAppPrimaryColorRejectsInvalidFormat(): void - { - $settings = $this->createMock(SettingRepositoryInterface::class); - $settings->expects($this->any())->method('getValue')->with('app_primary_color')->willReturn('not-a-color'); - - $gateway = $this->createGateway($settings); - $this->assertNull($gateway->getAppPrimaryColor()); - } - - public function testGetAppPrimaryColorReturnsValidHex(): void - { - $settings = $this->createMock(SettingRepositoryInterface::class); - $settings->expects($this->any())->method('getValue')->with('app_primary_color')->willReturn('#ff5500'); - - $gateway = $this->createGateway($settings); - $this->assertSame('#ff5500', $gateway->getAppPrimaryColor()); - } - - public function testIsAllowedThemeReturnsTrueForKnownTheme(): void - { - $settings = $this->createMock(SettingRepositoryInterface::class); - - $gateway = $this->createGateway($settings); - $this->assertTrue($gateway->isAllowedTheme('dark')); - } - - public function testIsAllowedThemeReturnsFalseForUnknownTheme(): void - { - $settings = $this->createMock(SettingRepositoryInterface::class); - - $gateway = $this->createGateway($settings); - $this->assertFalse($gateway->isAllowedTheme('neon')); - } } diff --git a/tests/Service/Settings/SettingsGatewayNormalizationTest.php b/tests/Service/Settings/SettingsGatewayNormalizationTest.php index c7ec217..734fc83 100644 --- a/tests/Service/Settings/SettingsGatewayNormalizationTest.php +++ b/tests/Service/Settings/SettingsGatewayNormalizationTest.php @@ -25,52 +25,17 @@ final class SettingsGatewayNormalizationTest extends TestCase self::assertTrue($gateway->setAppLocale(' ')); } - public function testAppThemeStoresNullForUnknownTheme(): void - { - $settings = $this->createMock(SettingRepositoryInterface::class); - $settings->expects($this->once()) - ->method('set') - ->with('app_theme', null, 'setting.app_theme') - ->willReturn(true); - - $gateway = $this->newAppGateway($settings); - self::assertTrue($gateway->setAppTheme('unknown')); - } - - public function testAppBooleanFlagsUseExpectedDefaults(): void + public function testAppRegistrationDefaultIsTrueWhenUnset(): void { $settings = $this->createMock(SettingRepositoryInterface::class); $settings->method('getValue')->willReturnMap([ - ['app_theme_user', null], ['app_registration', null], ]); $gateway = $this->newAppGateway($settings); - self::assertTrue($gateway->isUserThemeAllowed()); self::assertTrue($gateway->isRegistrationEnabled()); } - public function testAppPrimaryColorNormalizesHexWithoutHash(): void - { - $settings = $this->createMock(SettingRepositoryInterface::class); - $settings->expects($this->once()) - ->method('set') - ->with('app_primary_color', '#abc', 'setting.app_primary_color') - ->willReturn(true); - - $gateway = $this->newAppGateway($settings); - self::assertTrue($gateway->setAppPrimaryColor('abc')); - } - - public function testAppPrimaryColorRejectsInvalidValue(): void - { - $settings = $this->createMock(SettingRepositoryInterface::class); - $settings->expects($this->never())->method('set'); - - $gateway = $this->newAppGateway($settings); - self::assertFalse($gateway->setAppPrimaryColor('not-a-color')); - } - public function testSystemAuditUsesFallbacksAndRejectsOutOfRangeRetention(): void { $settings = $this->createMock(SettingRepositoryInterface::class); @@ -159,9 +124,9 @@ final class SettingsGatewayNormalizationTest extends TestCase private function newAppGateway(SettingRepositoryInterface $settingRepository): SettingsAppGateway { - $themeConfigService = $this->createMock(ThemeConfigGateway::class); - $themeConfigService->method('all')->willReturn(['light' => 'Light', 'dark' => 'Dark']); + $themeConfig = $this->createMock(ThemeConfigGateway::class); + $themeConfig->method('all')->willReturn(['light' => 'Light', 'dark' => 'Dark']); - return new SettingsAppGateway(new SettingsMetadataGateway($settingRepository), $themeConfigService); + return new SettingsAppGateway(new SettingsMetadataGateway($settingRepository), $themeConfig); } } diff --git a/tests/Service/User/UserAccountServiceTest.php b/tests/Service/User/UserAccountServiceTest.php index e6cdf02..ac8e663 100644 --- a/tests/Service/User/UserAccountServiceTest.php +++ b/tests/Service/User/UserAccountServiceTest.php @@ -315,7 +315,6 @@ class UserAccountServiceTest extends TestCase $passwordService->method('validatePassword')->willReturn([]); $settingsGateway = $this->createMock(UserSettingsGateway::class); - $settingsGateway->method('getAppTheme')->willReturn('dark'); $settingsGateway->method('getDefaultTenantId')->willReturn(3); $settingsGateway->method('getDefaultRoleId')->willReturn(4); $settingsGateway->method('getDefaultDepartmentId')->willReturn(5); @@ -326,8 +325,11 @@ class UserAccountServiceTest extends TestCase $writeRepository->expects($this->once()) ->method('create') ->with($this->callback(static function (array $data): bool { + // New users created via register() get the default 'light' theme. + // Appearance is tenant-scoped, not globally configurable, so + // normalizeTheme(null) resolves to 'light'. return (string) ($data['email'] ?? '') === 'user@example.com' - && (string) ($data['theme'] ?? '') === 'dark' + && (string) ($data['theme'] ?? '') === 'light' && (int) ($data['primary_tenant_id'] ?? 0) === 3 && (int) ($data['active'] ?? 0) === 1; })) @@ -376,7 +378,6 @@ class UserAccountServiceTest extends TestCase $settingsGateway->method('getDefaultTenantId')->willReturn(null); $settingsGateway->method('getDefaultRoleId')->willReturn(null); $settingsGateway->method('getDefaultDepartmentId')->willReturn(null); - $settingsGateway->method('getAppTheme')->willReturn('light'); $settingsGateway->method('normalizeTheme') ->willReturnCallback(static fn (?string $theme): string => $theme ?? 'light'); @@ -439,7 +440,6 @@ class UserAccountServiceTest extends TestCase $passwordService->method('validatePassword')->willReturn([]); $settingsGateway = $this->createMock(UserSettingsGateway::class); - $settingsGateway->method('getAppTheme')->willReturn('dark'); $settingsGateway->method('getDefaultTenantId')->willReturn(3); $settingsGateway->method('getDefaultRoleId')->willReturn(4); $settingsGateway->method('getDefaultDepartmentId')->willReturn(5); @@ -659,7 +659,6 @@ class UserAccountServiceTest extends TestCase $settingsGateway->method('getDefaultTenantId')->willReturn(null); $settingsGateway->method('getDefaultRoleId')->willReturn(null); $settingsGateway->method('getDefaultDepartmentId')->willReturn(null); - $settingsGateway->method('getAppTheme')->willReturn('light'); } if ($scopeGateway === null) { diff --git a/tests/Service/User/UserSettingsGatewayTest.php b/tests/Service/User/UserSettingsGatewayTest.php index 8d66c81..f5cc658 100644 --- a/tests/Service/User/UserSettingsGatewayTest.php +++ b/tests/Service/User/UserSettingsGatewayTest.php @@ -55,24 +55,6 @@ class UserSettingsGatewayTest extends TestCase $this->assertSame(8, $gateway->getDefaultDepartmentId()); } - public function testGetAppThemeDelegates(): void - { - $app = $this->createMock(SettingsAppGateway::class); - $app->method('getAppTheme')->willReturn('dark'); - - $gateway = new UserSettingsGateway($this->createMock(SettingsDefaultsGateway::class), $app, $this->createMock(SettingsUserLifecycleGateway::class)); - $this->assertSame('dark', $gateway->getAppTheme()); - } - - public function testGetAppThemeReturnsNullWhenUnset(): void - { - $app = $this->createMock(SettingsAppGateway::class); - $app->method('getAppTheme')->willReturn(null); - - $gateway = new UserSettingsGateway($this->createMock(SettingsDefaultsGateway::class), $app, $this->createMock(SettingsUserLifecycleGateway::class)); - $this->assertNull($gateway->getAppTheme()); - } - public function testAppThemesDelegatesToListThemes(): void { $themes = ['light' => 'Light', 'dark' => 'Dark']; diff --git a/tests/Support/ThemeResolutionTest.php b/tests/Support/ThemeResolutionTest.php index 61f2c6e..8395880 100644 --- a/tests/Support/ThemeResolutionTest.php +++ b/tests/Support/ThemeResolutionTest.php @@ -7,6 +7,14 @@ use MintyPHP\Service\Settings\SettingCacheService; use MintyPHP\Service\Settings\ThemeConfigGateway; use PHPUnit\Framework\TestCase; +/** + * Appearance resolution is tenant-scoped: + * + * - appDefaultTheme() uses tenant.default_theme, else hardcoded 'light'. + * - allowUserTheme() uses tenant.allow_user_theme, else defaults to true. + * - Global app settings no longer participate — this class verifies the + * fallback chain and confirms global settings are inert. + */ class ThemeResolutionTest extends TestCase { use AppContainerIsolationTrait; @@ -36,21 +44,22 @@ class ThemeResolutionTest extends TestCase // -- appDefaultTheme priority chain -- - public function testDefaultThemeFallsBackToLight(): void + public function testDefaultThemeFallsBackToLightWithoutTenant(): void { $this->assertSame('light', appDefaultTheme()); } - public function testDefaultThemeReadsAppSetting(): void + public function testDefaultThemeIgnoresGlobalAppSetting(): void { + // A stale global setting in the cache must no longer influence the + // resolution — appearance is tenant-scoped, no global fallback. $this->setAppSettings(['app_theme' => 'dark']); - $this->assertSame('dark', appDefaultTheme()); + $this->assertSame('light', appDefaultTheme()); } - public function testDefaultThemeTenantOverridesAppSetting(): void + public function testDefaultThemeUsesTenantValue(): void { - $this->setAppSettings(['app_theme' => 'light']); $_SESSION['current_tenant'] = ['default_theme' => 'dark']; $this->assertSame('dark', appDefaultTheme()); @@ -65,10 +74,9 @@ class ThemeResolutionTest extends TestCase public function testDefaultThemeIgnoresEmptyTenantTheme(): void { - $this->setAppSettings(['app_theme' => 'dark']); $_SESSION['current_tenant'] = ['default_theme' => '']; - $this->assertSame('dark', appDefaultTheme()); + $this->assertSame('light', appDefaultTheme()); } // -- currentTheme with user override -- @@ -107,7 +115,7 @@ class ThemeResolutionTest extends TestCase // -- allowUserTheme feature flag -- - public function testAllowUserThemeDefaultsToTrue(): void + public function testAllowUserThemeDefaultsToTrueWithoutTenant(): void { $this->assertTrue(allowUserTheme()); } @@ -126,19 +134,33 @@ class ThemeResolutionTest extends TestCase $this->assertFalse(allowUserTheme()); } - public function testAllowUserThemeTenantOverridesAppSetting(): void - { - $this->setAppSettings(['app_theme_user' => '1']); - $_SESSION['current_tenant'] = ['allow_user_theme' => '0']; - - $this->assertFalse(allowUserTheme()); - } - - public function testAllowUserThemeFallsBackToAppSettingWhenTenantNull(): void + public function testAllowUserThemeIgnoresGlobalAppSetting(): void { + // Legacy global setting must not override the tenant-null default. $this->setAppSettings(['app_theme_user' => '0']); - $this->assertFalse(allowUserTheme()); + $this->assertTrue(allowUserTheme()); + } + + public function testAppPrimaryColorIgnoresGlobalAppSetting(): void + { + $this->setAppSettings(['app_primary_color' => '#112233']); + + $this->assertNull(appPrimaryColor()); + } + + public function testAppPrimaryColorReadsTenantValue(): void + { + $_SESSION['current_tenant'] = ['primary_color' => '#abcdef']; + + $this->assertSame('#abcdef', appPrimaryColor()); + } + + public function testAppPrimaryColorRejectsInvalidTenantValue(): void + { + $_SESSION['current_tenant'] = ['primary_color' => 'not-a-color']; + + $this->assertNull(appPrimaryColor()); } // -- Tenant switch scenarios --