fileManager = $fileManager; $this->ticketsRepo = $ticketsRepo; $this->ideaRepo = $ideaRepo; } /** * @throws BindingResolutionException * * @internal Not @api: invoked only by Setting\Controllers\Logo, which authorizes * the upload (admin+). The company logo is a global setting, so this * must not be callable over JSON-RPC by any authenticated user. */ public function setLogo($file): bool { try { $uploadedFile = $file['file']; // Create a UploadedFile instance $symfonyFile = new UploadedFile( $uploadedFile['tmp_name'], $uploadedFile['name'], $uploadedFile['type'], $uploadedFile['error'], true ); $logo = $this->fileManager->upload($symfonyFile, 'public'); if ($logo['newPath'] !== false) { // Save the setting $this->settingsRepo->saveSetting('companysettings.logoPath', $logo['newPath']); $logoPath = $this->fileManager->getFileUrl($logo['newPath'], 'public', (60 * 24)); // Update the session session(['companysettings.logoPath' => $logoPath]); return true; } return false; } catch (\Exception $e) { Log::error($e); return false; } } /** * @internal Internal-only; resets the company logo. Reached only via the company-settings * page (gated company.settings.view/edit) — not on the JSON-RPC surface. */ public function resetLogo(): void { $this->settingsRepo->deleteSetting('companysettings.logoPath'); session()->forget('companysettings.logoPath'); session(['companysettings.logoPath' => '']); } /** * @internal Foundational key/value primitive — writes ANY setting (company.*, usersettings.*, * projectsettings.*, licenses, …). Internal-only; deliberately excluded from the * JSON-RPC surface (RPC write-any-setting would be a privilege-escalation hole). * Callers that touch sensitive keys gate at their own boundary. */ public function saveSetting($key, $value): bool { return $this->settingsRepo->saveSetting($key, $value); } /** * @return false|mixed * * @internal Foundational key/value primitive — reads ANY setting. Internal-only; * deliberately excluded from the JSON-RPC surface (RPC read-any-setting could * leak license keys / OIDC/LDAP config secrets stored as settings). */ public function getSetting($key, $default = false): mixed { return $this->settingsRepo->getSetting($key, $default); } /** * @internal Foundational key/value primitive — deletes ANY setting. Internal-only; * deliberately excluded from the JSON-RPC surface. */ public function deleteSetting($key): void { $this->settingsRepo->deleteSetting($key); } /** * @internal Infrastructure accessor (returns the repository instance) — not an API surface. */ public function getSettingsRepo(): SettingRepository { return $this->settingsRepo; } /** * @internal Infrastructure mutator (swaps the repository dependency) — not an API surface. */ public function setSettingsRepo(SettingRepository $settingsRepo): void { $this->settingsRepo = $settingsRepo; } /** * Gets the company id (Sets if it's not set) * **/ public function getCompanyId(): string { $companyId = $this->getSetting('companysettings.telemetry.anonymousId'); if (! $companyId) { $companyId = Uuid::uuid4()->toString(); $this->saveSetting('companysettings.telemetry.anonymousId', $companyId); } return $companyId; } public function onboardingHandler() { $completedOnboarding = $this->settingsRepo->getSetting('companysettings.completedOnboarding'); $isFirstLogin = $this->settingsRepo->getSetting('user.'.session('userdata.id').'.firstLoginCompleted'); if ($isFirstLogin && $completedOnboarding) { $isFirstLogin = false; } return self::dispatchFilter('completeOnboardingHandler', $isFirstLogin); } /** * Resolve the current label value for a given module/label key. * * Reads the current label name from the appropriate source label set * (ticket state labels or idea labels) * for the given project. * * @param string $module The label module (ticketlabels|idealabels). * @param int $labelKey The label key within the module's label set. * @param int $projectId The project the labels belong to. * @return string The current label name, or an empty string when not found. * * @api */ #[RequiresPermission(SettingPermissions::PROJECT_LABELS, projectIdParam: 'projectId')] public function getProjectLabel(string $module, int|string $labelKey, int $projectId): string { if ($module === 'ticketlabels') { $stateLabels = $this->ticketsRepo->getStateLabels(); if (isset($stateLabels[$labelKey]['name'])) { return $stateLabels[$labelKey]['name']; } return ''; } if ($module === 'idealabels') { $stateLabels = $this->ideaRepo->getCanvasLabels(); if (isset($stateLabels[$labelKey]['name'])) { return $stateLabels[$labelKey]['name']; } return ''; } return ''; } /** * Persist a renamed project label for a given module/label key. * * Fetches the source label set, updates the requested label, serializes the * result, and writes it back under the project's settings key. Also handles * the per-module cache/session invalidation and (for idea labels) the * normalization of the label array shape. * * @param string $module The label module (ticketlabels|idealabels). * @param int $labelKey The label key within the module's label set. * @param string $newLabel The new (already sanitized) label value. * @param int $projectId The project the labels belong to. * * @api */ #[RequiresPermission(SettingPermissions::PROJECT_LABELS, projectIdParam: 'projectId')] public function saveProjectLabel(string $module, int|string $labelKey, string $newLabel, int $projectId): void { if ($module === 'ticketlabels') { $currentStateLabels = $this->ticketsRepo->getStateLabels(); if (isset($currentStateLabels[$labelKey]) && is_array($currentStateLabels[$labelKey])) { $currentStateLabels[$labelKey]['name'] = $newLabel; $this->settingsRepo->saveSetting( 'projectsettings.'.$projectId.'.ticketlabels', serialize($currentStateLabels) ); Cache::forget('projectsettings.'.$projectId.'.ticketlabels'); } return; } if ($module === 'idealabels') { $stateLabels = $this->ideaRepo->getCanvasLabels(); // Only rename a label that actually exists, mirroring the ticketlabels branch // above. Writing an unknown key persisted junk into the project setting, which is // how the board ended up permanently broken (#3685). if (! isset($stateLabels[$labelKey])) { return; } $newStateLabels = []; foreach ($stateLabels as $key => $label) { $newStateLabels[$key] = $label['name']; } $newStateLabels[$labelKey] = $newLabel; session()->forget('projectsettings.idealabels'); $this->settingsRepo->saveSetting( 'projectsettings.'.$projectId.'.idealabels', serialize($newStateLabels) ); } } /** * Build the company settings view model. * * Encapsulates the company-settings defaulting/fallback chain: colors * (including legacy mainColor fallback), sitename, language, message * frequency, default notification event types, and the default notification * relevance level. * * @param string $logoUrl The resolved logo URL from the theme. * @return array{ * companySettings: array, * defaultNotificationTypes: array, * defaultRelevance: string * } * * @api */ #[RequiresPermission(SettingPermissions::COMPANY_VIEW, global: true)] public function getCompanySettings(string $logoUrl): array { $companySettings = [ 'logo' => $logoUrl, 'primarycolor' => session('companysettings.primarycolor') ?? '', 'secondarycolor' => session('companysettings.secondarycolor') ?? '', 'name' => session('companysettings.sitename'), 'language' => session('companysettings.language'), 'telemetryActive' => true, 'messageFrequency' => '', ]; $mainColor = $this->settingsRepo->getSetting('companysettings.mainColor'); if ($mainColor !== false) { $companySettings['primarycolor'] = '#'.$mainColor; $companySettings['secondarycolor'] = '#'.$mainColor; } $primaryColor = $this->settingsRepo->getSetting('companysettings.primarycolor'); if ($primaryColor !== false) { $companySettings['primarycolor'] = $primaryColor; } $secondaryColor = $this->settingsRepo->getSetting('companysettings.secondarycolor'); if ($secondaryColor !== false) { $companySettings['secondarycolor'] = $secondaryColor; } $sitename = $this->settingsRepo->getSetting('companysettings.sitename'); if ($sitename !== false) { $companySettings['name'] = $sitename; } $language = $this->settingsRepo->getSetting('companysettings.language'); if ($language !== false) { $companySettings['language'] = $language; } $messageFrequency = $this->settingsRepo->getSetting('companysettings.messageFrequency'); if ($messageFrequency !== false) { $companySettings['messageFrequency'] = $messageFrequency; } // Load default notification event types $defaultNotificationTypes = $this->settingsRepo->getSetting('companysettings.defaultNotificationEventTypes'); $allCategories = array_keys(Notification::NOTIFICATION_CATEGORIES); if ($defaultNotificationTypes) { $defaultNotificationTypes = json_decode($defaultNotificationTypes, true); } if (! is_array($defaultNotificationTypes)) { $defaultNotificationTypes = $allCategories; } // Load default notification relevance level $defaultRelevance = $this->settingsRepo->getSetting('companysettings.defaultNotificationRelevance'); if (! $defaultRelevance || ! Notification::isValidRelevanceLevel($defaultRelevance)) { $defaultRelevance = Notification::RELEVANCE_ALL; } return [ 'companySettings' => $companySettings, 'defaultNotificationTypes' => $defaultNotificationTypes, 'defaultRelevance' => $defaultRelevance, ]; } /** * Persist company settings from a submitted form. * * Owns the post-time persistence: look & feel (color save + legacy mainColor * cleanup + session sync), main details (sitename/language/messageFrequency), * localization cache invalidation, notification event-type filtering and * relevance validation, session sync, and telemetry opt-out orchestration. * * @param array $params The submitted form parameters. * @return bool True when a settings block was persisted, false when nothing changed. * * @throws \Exception When telemetry opt-out fails. * * @api */ #[RequiresPermission(SettingPermissions::COMPANY_EDIT, global: true)] public function saveCompanySettings(array $params): bool { $saved = false; // Look & feel updates if (isset($params['primarycolor']) && $params['primarycolor'] != '') { $this->settingsRepo->saveSetting('companysettings.primarycolor', htmlentities(addslashes($params['primarycolor']))); $this->settingsRepo->saveSetting('companysettings.secondarycolor', htmlentities(addslashes($params['secondarycolor']))); // Check if main color is still in the system // if so remove. This call should be removed in a few versions. $mainColor = $this->settingsRepo->getSetting('companysettings.mainColor'); if ($mainColor !== false) { $this->settingsRepo->deleteSetting('companysettings.mainColor'); } session(['companysettings.primarycolor' => htmlentities(addslashes($params['primarycolor']))]); session(['companysettings.secondarycolor' => htmlentities(addslashes($params['secondarycolor']))]); $saved = true; } // Main Details if (isset($params['name']) && $params['name'] != '' && isset($params['language']) && $params['language'] != '') { $this->settingsRepo->saveSetting('companysettings.sitename', htmlspecialchars(addslashes($params['name']))); $this->settingsRepo->saveSetting('companysettings.language', htmlentities(addslashes($params['language']))); $this->settingsRepo->saveSetting('companysettings.messageFrequency', (int) $params['messageFrequency']); // Clear the localization cache so middleware re-fetches on next request session()->forget('localization.cached'); // Save default notification event types $defaultEventTypes = $params['defaultNotificationEventTypes'] ?? []; if (! is_array($defaultEventTypes)) { $defaultEventTypes = []; } $validCategories = array_keys(Notification::NOTIFICATION_CATEGORIES); $defaultEventTypes = array_values(array_intersect($defaultEventTypes, $validCategories)); $this->settingsRepo->saveSetting( 'companysettings.defaultNotificationEventTypes', json_encode($defaultEventTypes) ); // Save default notification relevance level $defaultRelevance = $params['defaultNotificationRelevance'] ?? Notification::RELEVANCE_ALL; if (! Notification::isValidRelevanceLevel($defaultRelevance)) { $defaultRelevance = Notification::RELEVANCE_ALL; } $this->settingsRepo->saveSetting('companysettings.defaultNotificationRelevance', $defaultRelevance); session(['companysettings.sitename' => htmlspecialchars(addslashes($params['name']))]); session(['companysettings.language' => htmlentities(addslashes($params['language']))]); if (! empty($params['telemetryActive'])) { $this->settingsRepo->saveSetting('companysettings.telemetry.active', 'true'); } else { // Set remote telemetry to false. // Resolved lazily to avoid a circular service dependency // (ReportService depends on this Setting service). app()->make(ReportService::class)->optOutTelemetry(); } $saved = true; } return $saved; } }