OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
96
app/Domain/Setting/Controllers/EditBoxLabel.php
Normal file
96
app/Domain/Setting/Controllers/EditBoxLabel.php
Normal 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) {}
|
||||
}
|
||||
99
app/Domain/Setting/Controllers/EditCompanySettings.php
Normal file
99
app/Domain/Setting/Controllers/EditCompanySettings.php
Normal 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) {}
|
||||
}
|
||||
45
app/Domain/Setting/Controllers/Logo.php
Normal file
45
app/Domain/Setting/Controllers/Logo.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
85
app/Domain/Setting/Js/settingController.js
Normal file
85
app/Domain/Setting/Js/settingController.js
Normal 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
|
||||
};
|
||||
})();
|
||||
39
app/Domain/Setting/Js/settingRepository.js
Normal file
39
app/Domain/Setting/Js/settingRepository.js
Normal 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
|
||||
};
|
||||
})();
|
||||
22
app/Domain/Setting/Js/settingService.js
Normal file
22
app/Domain/Setting/Js/settingService.js
Normal 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
|
||||
};
|
||||
})();
|
||||
47
app/Domain/Setting/Permissions/SettingPermissions.php
Normal file
47
app/Domain/Setting/Permissions/SettingPermissions.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
170
app/Domain/Setting/Repositories/Setting.php
Normal file
170
app/Domain/Setting/Repositories/Setting.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
441
app/Domain/Setting/Services/Setting.php
Normal file
441
app/Domain/Setting/Services/Setting.php
Normal 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;
|
||||
}
|
||||
}
|
||||
84
app/Domain/Setting/Services/SettingCache.php
Normal file
84
app/Domain/Setting/Services/SettingCache.php
Normal 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();
|
||||
}
|
||||
}
|
||||
22
app/Domain/Setting/Templates/editBoxDialog.blade.php
Normal file
22
app/Domain/Setting/Templates/editBoxDialog.blade.php
Normal 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
|
||||
264
app/Domain/Setting/Templates/editCompanySettings.blade.php
Normal file
264
app/Domain/Setting/Templates/editCompanySettings.blade.php
Normal 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
|
||||
21
app/Domain/Setting/routes.php
Normal file
21
app/Domain/Setting/routes.php
Normal 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');
|
||||
Reference in New Issue
Block a user