OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)

This commit is contained in:
wangruiguo
2026-09-03 18:49:20 +08:00
commit d647428529
3501 changed files with 1988906 additions and 0 deletions

View File

@@ -0,0 +1,96 @@
<?php
namespace Leantime\Domain\Setting\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Domain\Setting\Permissions\SettingPermissions;
use Leantime\Domain\Setting\Services\Setting as SettingService;
class EditBoxLabel extends Controller
{
private SettingService $settingsSvc;
/**
* init - initialize private variables
*/
public function init(SettingService $settingsSvc): void
{
$this->settingsSvc = $settingsSvc;
}
/**
* get - handle get requests
*/
#[RequiresPermission(SettingPermissions::PROJECT_LABELS)]
public function get($params)
{
$currentLabel = '';
if (isset($params['module']) && isset($params['label'])) {
$module = htmlspecialchars($params['module'], ENT_QUOTES, 'UTF-8');
$label = $this->sanitizeLabelKey($params['label']);
$currentLabel = $this->settingsSvc->getProjectLabel($module, $label, (int) session('currentProject'));
}
$this->tpl->assign('currentLabel', $currentLabel);
return $this->tpl->displayPartial('setting.editBoxDialog');
}
/**
* post - handle post requests
*/
#[RequiresPermission(SettingPermissions::PROJECT_LABELS)]
public function post($params)
{
// If module and label are set its an update
$sanitizedString = '';
if (isset($_GET['module']) && isset($_GET['label'])) {
$module = htmlspecialchars($_GET['module'], ENT_QUOTES, 'UTF-8');
$labelKey = $this->sanitizeLabelKey($_GET['label']);
$sanitizedString = htmlspecialchars(strip_tags($params['newLabel'] ?? ''), ENT_QUOTES, 'UTF-8');
$this->settingsSvc->saveProjectLabel($module, $labelKey, $sanitizedString, (int) session('currentProject'));
$this->tpl->setNotification($this->language->__('notifications.label_changed_successfully'), 'success');
}
$this->tpl->assign('currentLabel', $sanitizedString);
return $this->tpl->displayPartial('setting.editBoxDialog');
}
/**
* Normalize a label key without destroying it.
*
* Ticket labels are keyed by integers, but idea labels are keyed by the canvasTypes
* strings ('idea', 'validation', …). The previous
* `(int) filter_var(..., FILTER_SANITIZE_NUMBER_INT)` turned every idea key into 0,
* which then got persisted and permanently 500'd the board (#3685). Numeric keys are
* returned as ints so ticket labels behave exactly as before; anything else is passed
* through as a trimmed string for the service to look up. An unknown key simply
* matches nothing, so this cannot be used to write an arbitrary label.
*/
private function sanitizeLabelKey(mixed $label): int|string
{
if (! is_scalar($label)) {
return '';
}
$label = trim((string) $label);
return is_numeric($label) ? (int) $label : $label;
}
/**
* put - handle put requests
*/
public function put($params) {}
/**
* delete - handle delete requests
*/
public function delete($params) {}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace Leantime\Domain\Setting\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Core\UI\Theme;
use Leantime\Domain\Api\Services\Api as ApiService;
use Leantime\Domain\Notifications\Models\Notification;
use Leantime\Domain\Setting\Permissions\SettingPermissions;
use Leantime\Domain\Setting\Services\Setting as SettingService;
class EditCompanySettings extends Controller
{
private ApiService $APIService;
private SettingService $settingsSvc;
private Theme $theme;
/**
* init - initialize private variables
*/
public function init(
ApiService $APIService,
SettingService $settingsSvc,
Theme $theme,
): void {
$this->APIService = $APIService;
$this->settingsSvc = $settingsSvc;
$this->theme = $theme;
}
/**
* get - handle get requests
*/
#[RequiresPermission(SettingPermissions::COMPANY_VIEW, global: true)]
public function get($params)
{
if (isset($_GET['resetLogo'])) {
// Resetting the logo is a WRITE, so it must require edit — not the view that gates
// this GET handler. Matters once view/edit are split to different roles in the admin
// UI (the seeded matrix grants both to admin+owner, so this is defense-in-depth).
if (! can('company.settings.edit')) {
return $this->tpl->display('errors.error403', responseCode: 403);
}
$this->settingsSvc->resetLogo();
return Frontcontroller::redirect(BASE_URL.'/setting/editCompanySettings#look');
}
$companySettingsView = $this->settingsSvc->getCompanySettings($this->theme->getLogoUrl());
$apiKeys = $this->APIService->getAPIKeys();
$this->tpl->assign('apiKeys', $apiKeys);
$this->tpl->assign('languageList', $this->language->getLanguageList());
$this->tpl->assign('companySettings', $companySettingsView['companySettings']);
$this->tpl->assign('notificationCategories', Notification::NOTIFICATION_CATEGORIES);
$this->tpl->assign('defaultNotificationTypes', $companySettingsView['defaultNotificationTypes']);
$this->tpl->assign('defaultRelevance', $companySettingsView['defaultRelevance']);
$this->tpl->assign('relevanceLevels', [
Notification::RELEVANCE_ALL => 'label.notifications_all_activity',
Notification::RELEVANCE_MY_WORK => 'label.notifications_my_work',
]);
return $this->tpl->display('setting.editCompanySettings');
}
/**
* post - handle post requests
*/
#[RequiresPermission(SettingPermissions::COMPANY_EDIT, global: true)]
public function post($params)
{
// The telemetry opt-out path keys off the raw POST flag; mirror it into params.
$params['telemetryActive'] = isset($_POST['telemetryActive']);
$saved = $this->settingsSvc->saveCompanySettings($params);
if ($saved) {
$this->tpl->setNotification($this->language->__('notifications.company_settings_edited_successfully'), 'success');
}
return Frontcontroller::redirect(BASE_URL.'/setting/editCompanySettings');
}
/**
* put - handle put requests
*/
public function put($params) {}
/**
* delete - handle delete requests
*/
public function delete($params) {}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace Leantime\Domain\Setting\Controllers;
use Leantime\Domain\Setting\Services\Setting as SettingService;
use Symfony\Component\HttpFoundation\Response;
/**
* Handles the company logo upload.
*
* A native Laravel controller (constructor DI, route-bound action). Relocated from the
* retired Api\Controllers\Setting. Bound in Setting/routes.php at the canonical
* /setting/logo plus the backward-compatible /api/setting alias used by the company-settings
* logo cropper. The 501 GET/PATCH/DELETE stubs from the old controller are not carried over.
*
* The company logo is a global setting, so the upload requires company.settings.edit (admin+)
* — the legacy endpoint had no role check, letting any authenticated user overwrite it.
*/
class Logo
{
public function __construct(private SettingService $settingService) {}
/**
* POST — store an uploaded company logo (multipart field "file").
*/
public function post(): Response
{
if (! can('company.settings.edit')) {
return response()->json(['status' => 'unauthorized'], 403);
}
if (! isset($_FILES['file'])) {
return response()->json(['status' => 'failure'], 400);
}
$_FILES['file']['name'] = 'logo.png';
$this->settingService->setLogo($_FILES);
session(['msg' => 'PICTURE_CHANGED']);
session(['msgT' => 'success']);
return response()->json(['status' => 'ok']);
}
}

View File

@@ -0,0 +1,85 @@
leantime.settingController = (function () {
// Variables (underscore for private variables)
var publicThing = "not secret";
var _privateThing = "secret";
var _uploadResult;
//Functions
var readURL = function (input) {
clearCroppie();
if (input.files && input.files[0]) {
var reader = new FileReader();
var profileImg = jQuery('#logoImg');
reader.onload = function (e) {
//profileImg.attr('src', e.currentTarget.result);
_uploadResult = profileImg
.croppie(
{
enableExif: true,
enforceBoundary: false,
viewport:{
width: 220,
height: 40,
type: 'square'
},
boundary: {
width: 400,
height: 200
}
}
);
_uploadResult.croppie(
'bind',
{
url: e.currentTarget.result
}
);
jQuery("#previousImage").hide();
};
reader.readAsDataURL(input.files[0]);
}
};
var clearCroppie = function () {
jQuery('#logoImg').croppie('destroy');
jQuery("#previousImage").show();
};
var saveCroppie = function () {
jQuery('#save-logo').addClass('running');
jQuery('#logoImg').attr('src', leantime.appUrl + '/images/loaders/loader28.gif');
_uploadResult.croppie(
'result',
{
type: "blob",
circle: false,
size: "original",
quality:1
}
).then(
function (result) {
leantime.settingService.saveLogo(result);
}
);
};
// Make public what you want to have public, everything else is private
return {
readURL: readURL,
clearCroppie: clearCroppie,
saveCroppie: saveCroppie
};
})();

View File

@@ -0,0 +1,39 @@
leantime.settingRepository = (function () {
// Variables (underscore for private variables)
var publicThing = "not secret";
var _privateThing = "secret";
//Constructor
(function () {
})();
//Functions
var saveLogo = function (photo) {
var formData = new FormData();
formData.append('file', photo);
jQuery.ajax(
{
type: 'POST',
url: leantime.appUrl + '/setting/logo',
data: formData,
processData: false,
contentType: false,
success: function (resp) {
jQuery('#save-logo').removeClass('running');
location.reload();
},
error: function (err) {
console.log(err);
}
}
);
};
// Make public what you want to have public, everything else is private
return {
saveLogo: saveLogo
};
})();

View File

@@ -0,0 +1,22 @@
leantime.settingService = (function () {
// Variables (underscore for private variables)
var publicThing = "not secret";
var _privateThing = "secret";
//Constructor
(function () {
})();
//Functions
var saveLogo = function (photo) {
leantime.settingRepository.saveLogo(photo);
};
// Make public what you want to have public, everything else is private
return {
saveLogo: saveLogo
};
})();

View File

@@ -0,0 +1,47 @@
<?php
namespace Leantime\Domain\Setting\Permissions;
use Leantime\Core\Auth\Permissions\Permission;
use Leantime\Core\Auth\Permissions\ProvidesPermissions;
/**
* The Setting permission vocabulary — spans two scopes.
*
* - COMPANY settings (`company.settings.*`) — company-wide (projectScoped = false), resolved
* against the GLOBAL role. Covers the editCompanySettings screen + the company logo.
* - PROJECT labels (`projectsettings.labels.manage`) — project-scoped (projectScoped = true),
* resolved against the user's role IN the project. Renaming a project's ticket/idea state
* labels (the EditBoxLabel dialog). The verb is `manage` (not `edit`) on purpose: it keeps
* the capability at manager+ and stops it leaking to editor via the project
* create/edit/delete grant.
*
* The generic getSetting/saveSetting/deleteSetting key/value primitives are deliberately NOT in
* this vocabulary — they read/write ANY setting and are internal infrastructure, excluded from
* the JSON-RPC surface entirely (see the Setting service).
*/
final class SettingPermissions implements ProvidesPermissions
{
/** View company-wide settings (branding, language, notification defaults). */
public const COMPANY_VIEW = 'company.settings.view';
/** Edit company-wide settings (incl. the company logo). */
public const COMPANY_EDIT = 'company.settings.edit';
/** Rename a project's ticket/idea state labels — manager+ in the project. */
public const PROJECT_LABELS = 'projectsettings.labels.manage';
public function domain(): string
{
return 'setting';
}
public function permissions(): array
{
return [
new Permission(self::COMPANY_VIEW, 'View company settings', false),
new Permission(self::COMPANY_EDIT, 'Edit company settings', false),
new Permission(self::PROJECT_LABELS, 'Rename project labels', true),
];
}
}

View File

@@ -0,0 +1,170 @@
<?php
namespace Leantime\Domain\Setting\Repositories;
use Exception;
use Illuminate\Database\ConnectionInterface;
use Leantime\Core\Db\Db as DbCore;
use Leantime\Domain\Setting\Services\SettingCache;
class Setting
{
private ConnectionInterface $db;
private SettingCache $cache;
public array $applications = [
'general' => 'General',
];
/**
* __construct - neu db connection
*/
public function __construct(DbCore $db, SettingCache $cache)
{
$this->db = $db->getConnection();
$this->cache = $cache;
}
/**
* @return false|mixed
*/
public function getSetting(string $type, mixed $default = false): mixed
{
if ($this->checkIfInstalled() === false) {
return false;
}
// Check cache first
$cachedValue = $this->cache->get($type);
if ($cachedValue !== null) {
return $cachedValue;
}
try {
$result = $this->db->table('zp_settings')
->where('key', $type)
->limit(1)
->first();
if ($result !== null && isset($result->value)) {
// Store in cache for future requests
$this->cache->set($type, $result->value);
return $result->value;
}
// value is not in the db, which is fine. Let's cache that too
$this->cache->set($type, false);
return $default;
} catch (Exception $e) {
report($e);
return false;
}
}
public function saveSetting(string $type, mixed $value): bool
{
if ($this->checkIfInstalled() === false) {
return false;
}
$return = $this->db->table('zp_settings')
->updateOrInsert(
['key' => $type],
['value' => $value]
);
// Update cache
$this->cache->set($type, $value);
return $return;
}
/**
* Retrieves multiple settings in a single query.
*
* @param array<string> $keys The setting keys to fetch.
* @return array<string, mixed> Map of key => value for found settings.
*/
public function getSettingsForKeys(array $keys): array
{
if (empty($keys) || $this->checkIfInstalled() === false) {
return [];
}
$results = [];
// Check cache first for all keys
$uncachedKeys = [];
foreach ($keys as $key) {
$cachedValue = $this->cache->get($key);
if ($cachedValue !== null) {
$results[$key] = $cachedValue;
} else {
$uncachedKeys[] = $key;
}
}
if (empty($uncachedKeys)) {
return $results;
}
try {
$rows = $this->db->table('zp_settings')
->whereIn('key', $uncachedKeys)
->get(['key', 'value']);
$foundKeys = [];
foreach ($rows as $row) {
$results[$row->key] = $row->value;
$this->cache->set($row->key, $row->value);
$foundKeys[] = $row->key;
}
// Cache misses as false
foreach (array_diff($uncachedKeys, $foundKeys) as $missingKey) {
$this->cache->set($missingKey, false);
}
} catch (Exception $e) {
report($e);
}
return $results;
}
public function deleteSetting(string $type): void
{
$this->db->table('zp_settings')
->where('key', $type)
->delete();
// Remove from cache
$this->cache->forget($type);
}
/**
* checkIfInstalled checks if zp user table exists (and assumes that leantime is installed)
*/
public function checkIfInstalled(): bool
{
$cachedValue = $this->cache->get('isInstalled');
if ($cachedValue !== null) {
return true;
}
try {
$this->db->table('zp_user')->count();
$this->cache->set('isInstalled', true);
return true;
} catch (Exception $e) {
report($e);
return false;
}
}
}

View File

@@ -0,0 +1,441 @@
<?php
namespace Leantime\Domain\Setting\Services;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Events\DispatchesEvents;
use Leantime\Core\Files\Contracts\FileManagerInterface;
use Leantime\Domain\Ideas\Repositories\Ideas as IdeaRepository;
use Leantime\Domain\Notifications\Models\Notification;
use Leantime\Domain\Reports\Services\Reports as ReportService;
use Leantime\Domain\Setting\Permissions\SettingPermissions;
use Leantime\Domain\Setting\Repositories\Setting as SettingRepository;
use Leantime\Domain\Tickets\Repositories\Tickets as TicketRepository;
use Ramsey\Uuid\Uuid;
use Symfony\Component\HttpFoundation\File\UploadedFile;
/**
* @api
*/
class Setting
{
use DispatchesEvents;
private FileManagerInterface $fileManager;
private TicketRepository $ticketsRepo;
private IdeaRepository $ideaRepo;
public function __construct(
public SettingRepository $settingsRepo,
FileManagerInterface $fileManager,
TicketRepository $ticketsRepo,
IdeaRepository $ideaRepo
) {
$this->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<string, mixed>,
* defaultNotificationTypes: array<int, string>,
* 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<string, mixed> $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;
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace Leantime\Domain\Setting\Services;
use Illuminate\Support\Facades\Cache;
/**
* SettingCache - Provides a two-tier cache for settings.
*
* Tier 1: In-memory array (eliminates redundant I/O within a single request)
* Tier 2: Laravel cache store (file/Redis, persists across requests with 1hr TTL)
*
* The same setting key is often read 5-10 times within a single request
* (Theme, Localization, Menu all reading overlapping keys). The in-memory
* tier eliminates all redundant file/Redis reads after the first access.
*/
class SettingCache
{
private const CACHE_KEY_PREFIX = 'setting:';
private const CACHE_TTL = 3600; // 1 hour
/**
* In-memory request-level cache.
* Stores values for the duration of the PHP request to avoid
* redundant file/Redis cache reads for the same key.
*
* Uses a sentinel to distinguish "key cached as null" from "key not cached".
*
* @var array<string, mixed>
*/
private array $inMemory = [];
private const NOT_FOUND = '__SETTING_CACHE_NOT_FOUND__';
/**
* Get setting from cache.
* Checks in-memory first, then falls through to Laravel cache.
*/
public function get(string $key): mixed
{
// Tier 1: In-memory (zero I/O)
if (array_key_exists($key, $this->inMemory)) {
$value = $this->inMemory[$key];
return $value === self::NOT_FOUND ? null : $value;
}
// Tier 2: Laravel cache (file/Redis)
$value = Cache::get(self::CACHE_KEY_PREFIX.$key);
// Store in memory for subsequent reads within this request
$this->inMemory[$key] = $value ?? self::NOT_FOUND;
return $value;
}
/**
* Store setting in both in-memory and Laravel cache.
*/
public function set(string $key, mixed $value): void
{
$this->inMemory[$key] = $value;
Cache::put(self::CACHE_KEY_PREFIX.$key, $value, self::CACHE_TTL);
}
/**
* Remove setting from both in-memory and Laravel cache.
*/
public function forget(string $key): void
{
unset($this->inMemory[$key]);
Cache::forget(self::CACHE_KEY_PREFIX.$key);
}
/**
* Clear all settings from cache.
*/
public function flush(): void
{
$this->inMemory = [];
Cache::tags(['settings'])->flush();
}
}

View File

@@ -0,0 +1,22 @@
@extends($layout)
@section('content')
<h4 class="widgettitle title-light">{!! __('headlines.edit_label') !!}</h4>
{!! $tpl->displayNotification() !!}
<form class="formModal" method="post" action="{{ BASE_URL }}/setting/editBoxLabel?{{ http_build_query(['module' => request()->query('module', ''), 'label' => request()->query('label', '')]) }}">
<label>{!! __('label.label') !!}</label>
<x-global::forms.text-input name="newLabel" value="{{ $currentLabel }}" /><br />
<div class="row">
<div class="col-md-6">
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" />
</div>
</div>
</form>
@endsection

View File

@@ -0,0 +1,264 @@
@extends($layout)
@section('content')
<div class="pageheader">
<div class="pageicon"><span class="fa fa-cogs"></span></div>
<div class="pagetitle">
<h5>{!! __('label.administration') !!}</h5>
<h1>{!! __('headlines.company_settings') !!}</h1>
</div>
</div>
<div class="maincontent">
{!! $tpl->displayNotification() !!}
<div class="maincontentinner">
<div class="row">
<div class="col-md-12">
<div class="tabbedwidget tab-primary companyTabs">
<ul>
<li><a href="#details"><span class="fa fa-building"></span> {!! __('tabs.details') !!}</a></li>
<li><a href="#apiKeys"><i class="fa-solid fa-key"></i> {!! __('tabs.apiKeys') !!}</a></li>
@dispatchEvent('tabs')
</ul>
<div id="details">
<div class="row">
<div class="col-md-8">
<form class="" method="post" id="" action="{{ BASE_URL }}/setting/editCompanySettings#details" >
<p>{!! __('text.these_are_system_wide_settings') !!}</p>
<br />
<input type="hidden" value="1" name="saveSettings" />
<h4 class="widgettitle title-light"><span
class="fa fa-building"></span>{!! __('subtitles.companydetails') !!}
</h4>
<div class="row">
<div class="col-md-2">
<label>{!! __('label.language') !!}</label>
</div>
<div class="col-md-8">
<select name="language" id="language">
@foreach ($languageList as $languagKey => $languageValue)
<option
value="{{ $languagKey }}"
@if ($companySettings['language'] == $languagKey) selected='selected' @endif>{{ $languageValue }}</option>
@endforeach
</select>
</div>
</div>
<div class="row">
<div class="col-md-2">
<label>{!! __('label.company_name') !!}</label>
</div>
<div class="col-md-8">
<x-global::forms.text-input name="name" id="companyName" value="{{ $companySettings['name'] }}" class="pull-left" />
<small>{!! __('text.company_name_helper') !!}</small>
</div>
</div>
<br />
<h4 class="widgettitle title-light"><span
class="fa fa-cog"></span>{!! __('subtitles.defaults') !!}
</h4>
<div class="row">
<div class="col-md-2">
<label for="messageFrequency">{!! __('label.messages_frequency') !!}</label>
</div>
<div class="col-md-8">
<span class='field'>
<select name="messageFrequency" class="input" id="messageFrequency" style="width: 220px">
<option value="">--{!! __('label.choose_option') !!}--</option>
<option value="300" @if ($companySettings['messageFrequency'] == '300') selected @endif>{!! __('label.5min') !!}</option>
<option value="900" @if ($companySettings['messageFrequency'] == '900') selected @endif>{!! __('label.15min') !!}</option>
<option value="1800" @if ($companySettings['messageFrequency'] == '1800') selected @endif>{!! __('label.30min') !!}</option>
<option value="3600" @if ($companySettings['messageFrequency'] == '3600') selected @endif>{!! __('label.1h') !!}</option>
<option value="10800" @if ($companySettings['messageFrequency'] == '10800') selected @endif>{!! __('label.3h') !!}</option>
<option value="36000" @if ($companySettings['messageFrequency'] == '36000') selected @endif>{!! __('label.6h') !!}</option>
<option value="43200" @if ($companySettings['messageFrequency'] == '43200') selected @endif>{!! __('label.12h') !!}</option>
<option value="86400" @if ($companySettings['messageFrequency'] == '86400') selected @endif>{!! __('label.24h') !!}</option>
<option value="172800" @if ($companySettings['messageFrequency'] == '172800') selected @endif>{!! __('label.48h') !!}</option>
<option value="604800" @if ($companySettings['messageFrequency'] == '604800') selected @endif>{!! __('label.1w') !!}</option>
</select> <br/>
</span>
</div>
</div>
<br />
<h4 class="widgettitle title-light"><span
class="fa fa-bell"></span>{!! __('label.default_notification_types') !!}
</h4>
<p>{!! __('label.default_notification_types_description') !!}</p>
<div class="row">
<div class="col-md-8">
@php
$categoryLabels = [
'tasks' => 'label.notification_category_tasks',
'comments' => 'label.notification_category_comments',
'goals' => 'label.notification_category_goals',
'ideas' => 'label.notification_category_ideas',
'projects' => 'label.notification_category_projects',
'boards' => 'label.notification_category_boards',
];
@endphp
@foreach ($notificationCategories as $categoryKey => $config)
<div class="form-group">
<label style="display:flex; align-items:flex-start; gap:8px; cursor:pointer; padding:4px 0;">
<input type="checkbox"
name="defaultNotificationEventTypes[]"
value="{{ $categoryKey }}"
style="margin-top:3px;"
@if (in_array($categoryKey, $defaultNotificationTypes)) checked="checked" @endif
/>
<span>
<strong>{!! __($categoryLabels[$categoryKey] ?? $categoryKey) !!}</strong><br />
<small style="color:#888;">{!! __($config['description'] ?? '') !!}</small>
</span>
</label>
</div>
@endforeach
</div>
</div>
<br />
<h4 class="widgettitle title-light"><span
class="fa fa-sliders"></span>{!! __('label.default_notification_relevance') !!}
</h4>
<p>{!! __('label.default_notification_relevance_description') !!}</p>
<div class="row">
<div class="col-md-8">
<div class="form-group">
<select name="defaultNotificationRelevance" class="form-control" style="max-width:300px;">
@foreach ($relevanceLevels as $level => $labelKey)
<option value="{{ $level }}" @if ($defaultRelevance === $level) selected @endif>
{!! __($labelKey) !!}
</option>
@endforeach
</select>
</div>
</div>
</div>
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" id="saveBtn" />
</form>
</div>
<div class="col-md-4">
<form class="" method="post" id="" action="{{ BASE_URL }}/setting/editCompanySettings" >
<input type="hidden" value="1" name="saveLogo" />
<h5 class="widgettitle title-light">{!! __('headlines.logo') !!}</h5>
<br />
<div class="row">
<div class="col-md-12">
@if ($companySettings['logo'] != '')
<img src='{{ $companySettings['logo'] }}' class='logoImg' alt='Logo' id="previousImage" width="260"/>
@else
{!! __('text.no_logo') !!}
@endif
<div id="logoImg" style="height:auto;">
</div>
<br />
<div class="par">
<label>{!! __('label.upload_new_logo') !!}</label>
<div class='fileupload fileupload-new' data-provides='fileupload'>
<input type="hidden"/>
<div class="input-append">
<div class="uneditable-input span3">
<i class="fa-file fileupload-exists"></i>
<span class="fileupload-preview"></span>
</div>
<span class="btn btn-default btn-file">
<span class="fileupload-new">{!! __('buttons.select_file') !!}</span>
<span class='fileupload-exists'>{!! __('buttons.change') !!}</span>
<input type='file' name='file' onchange="leantime.settingController.readURL(this)" />
</span>
<a href='#' style="margin-left:5px;" class='btn btn-default fileupload-exists' data-dismiss='fileupload' onclick="leantime.usersController.clearCroppie()">{!! __('buttons.remove') !!}</a>
</div>
<p class='stdformbutton'>
<span id="save-logo" class="btn btn-primary fileupload-exists ld-ext-right">
<span onclick="leantime.settingController.saveCroppie()">{!! __('buttons.save') !!}</span>
<span class="ld ld-ring ld-spin"> </span>
</span>
<input id="picSubmit" type="submit" name="savePic" class="hidden" value="{{ __('buttons.upload') }}" />
</p>
</div>
</div>
</div>
</div>
</form>
<hr />
{!! __('text.logo_reset') !!}<br /><br />
<x-global::forms.button tag="a" link="{{ BASE_URL }}/setting/editCompanySettings?resetLogo=1" contentRole="default">{!! __('buttons.reset_logo') !!}</x-global::forms.button>
</div>
</div>
</div>
<div id="apiKeys">
<x-global::forms.button tag="a" link="#/api/newApiKey" contentRole="primary">Generate API Key</x-global::forms.button>
<br /> <br />
<ul class="sortableTicketList">
@foreach ($apiKeys as $apiKey)
<li>
<div class="ticketBox">
<div class="inlineDropDownContainer">
<a href="javascript:void(0)" class="dropdown-toggle ticketDropDown" data-toggle="dropdown">
<i class="fa fa-ellipsis-v" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu">
<li><a href="#/api/apiKey/{{ $apiKey['id'] }}"><i class="fa fa-edit"></i> Edit Key</a></li>
<li><a href="{{ BASE_URL }}/api/delAPIKey/{{ $apiKey['id'] }}" class="delete"><i class="fa fa-trash"></i> Delete Key</a></li>
</ul>
</div>
<a href="#/api/apiKey/{{ $apiKey['id'] }}"><strong>{{ $apiKey['firstname'] }}</strong></a><br />
lt_{{ $apiKey['username'] }}***
| {!! __('labels.created_on') !!}: {{ format($apiKey['createdOn'])->date() }} | {!! __('labels.last_used') !!}: {{ format($apiKey['lastlogin'])->date() }}
</div>
</li>
@endforeach
</ul>
</div>
@dispatchEvent('tabsContent')
</div>
</div>
</div>
</div>
</div>
@once
@push('scripts')
<script>
jQuery(document).ready(function() {
jQuery(".companyTabs").tabs({
activate: function (event, ui) {
window.location.hash = ui.newPanel.selector;
}
});
});
</script>
@endpush
@endonce
@endsection

View File

@@ -0,0 +1,21 @@
<?php
use Illuminate\Support\Facades\Route;
use Leantime\Domain\Setting\Controllers\Logo;
/*
|--------------------------------------------------------------------------
| Setting Domain Routes
|--------------------------------------------------------------------------
|
| The company logo upload was relocated here from the retired Api\Controllers\Setting.
| The canonical route is /setting/logo. The /api/setting alias is kept so the existing
| company-settings logo cropper (settingRepository.js) keeps working without a JS change.
|
*/
// Canonical
Route::post('/setting/logo', [Logo::class, 'post'])->name('setting.logo');
// Backward-compat alias for the retired /api/setting endpoint
Route::post('/api/setting', [Logo::class, 'post'])->name('setting.logo.legacy');