OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
57
app/Domain/Ai/Controllers/ChatApi.php
Normal file
57
app/Domain/Ai/Controllers/ChatApi.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Ai\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Leantime\Domain\Ai\Services\AiChat;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* 界面内 AI 聊天 JSON API(原生 Laravel 控制器)。
|
||||
* 前端右下角悬浮窗发 {messages},后端代理 DeepSeek/千问并执行工具调用。
|
||||
*/
|
||||
class ChatApi
|
||||
{
|
||||
public function __construct(
|
||||
private AiChat $aiChat,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* POST /ai/chat
|
||||
*/
|
||||
public function chat(Request $request): Response
|
||||
{
|
||||
$messages = $request->input('messages', []);
|
||||
|
||||
if (! is_array($messages) || empty($messages)) {
|
||||
return response()->json(['status' => 'error', 'message' => 'messages 不能为空'], 400);
|
||||
}
|
||||
|
||||
// 只保留 role/content 字段,防止注入多余字段
|
||||
$clean = [];
|
||||
foreach ($messages as $m) {
|
||||
if (is_array($m) && isset($m['role'])) {
|
||||
$clean[] = [
|
||||
'role' => (string) $m['role'],
|
||||
'content' => (string) ($m['content'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$result = $this->aiChat->chat($clean);
|
||||
|
||||
return $result['success']
|
||||
? response()->json(['status' => 'success', 'content' => $result['content']])
|
||||
: response()->json(['status' => 'error', 'message' => $result['error'] ?? 'AI 调用失败'], 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /ai/config —— 返回当前 AI 配置(脱敏,apiKey 只显示前 6 位 + 掩码)。
|
||||
*/
|
||||
public function config(): Response
|
||||
{
|
||||
$cfg = $this->aiChat->publicConfig();
|
||||
|
||||
return response()->json(['status' => 'success', 'data' => $cfg]);
|
||||
}
|
||||
}
|
||||
228
app/Domain/Ai/Services/AiChat.php
Normal file
228
app/Domain/Ai/Services/AiChat.php
Normal file
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Ai\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Leantime\Core\Domains\BaseService;
|
||||
use Leantime\Domain\Setting\Services\Setting as SettingService;
|
||||
|
||||
/**
|
||||
* OneBot 界面内 AI 聊天代理。
|
||||
*
|
||||
* 调用 OpenAI 兼容的 /chat/completions(DeepSeek 官方 API 或本地千问 Ollama),
|
||||
* 支持 function-calling:把 MCP Tools 类转成 OpenAI tools 定义,LLM 请求工具调用时
|
||||
* 执行对应 Tool::handle(),结果回填 messages 继续,直到 LLM 返回最终文本。
|
||||
*/
|
||||
class AiChat extends BaseService
|
||||
{
|
||||
/** 默认 LLM 配置(.env 或 settings 可覆盖) */
|
||||
private const DEFAULTS = [
|
||||
'provider' => 'deepseek', // deepseek | ollama
|
||||
'baseUrl' => 'https://api.deepseek.com/v1',
|
||||
'model' => 'deepseek-chat',
|
||||
'apiKey' => '',
|
||||
'temperature' => '0.7',
|
||||
'maxTokens' => '2048',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private SettingService $settingService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 发送一条用户消息,返回 AI 最终回复文本(已执行工具调用)。
|
||||
*
|
||||
* @param array<int,array{role:string,content:string}> $messages 历史消息(含本次 user 消息)
|
||||
* @return array{success:bool,content?:string,error?:string}
|
||||
*/
|
||||
public function chat(array $messages): array
|
||||
{
|
||||
$cfg = $this->config();
|
||||
|
||||
if (empty($cfg['apiKey'])) {
|
||||
return ['success' => false, 'error' => '尚未配置 AI 服务(apiKey 为空)。请在 .env 或「AI 设置」中填写 provider/baseUrl/model/apiKey。'];
|
||||
}
|
||||
|
||||
$tools = $this->buildTools();
|
||||
|
||||
// 最多循环 8 轮,防止工具调用死循环
|
||||
for ($round = 0; $round < 8; $round++) {
|
||||
$resp = Http::withToken($cfg['apiKey'])
|
||||
->withoutVerifying()
|
||||
->timeout(120)
|
||||
->post(rtrim($cfg['baseUrl'], '/').'/chat/completions', [
|
||||
'model' => $cfg['model'],
|
||||
'messages' => $messages,
|
||||
'temperature' => (float) $cfg['temperature'],
|
||||
'max_tokens' => (int) $cfg['maxTokens'],
|
||||
'tools' => $tools,
|
||||
'tool_choice' => 'auto',
|
||||
]);
|
||||
|
||||
if (! $resp->successful()) {
|
||||
return ['success' => false, 'error' => "LLM 调用失败 HTTP {$resp->status()}: ".substr($resp->body(), 0, 300)];
|
||||
}
|
||||
|
||||
$body = $resp->json();
|
||||
$choice = $body['choices'][0] ?? null;
|
||||
if ($choice === null) {
|
||||
return ['success' => false, 'error' => 'LLM 返回为空'];
|
||||
}
|
||||
|
||||
$message = $choice['message'] ?? [];
|
||||
$toolCalls = $message['tool_calls'] ?? [];
|
||||
|
||||
// 无工具调用 → 最终回复
|
||||
if (empty($toolCalls)) {
|
||||
return ['success' => true, 'content' => (string) ($message['content'] ?? '')];
|
||||
}
|
||||
|
||||
// 有工具调用 → 把 assistant 消息(含 tool_calls)追加,逐个执行工具,回填 tool 结果
|
||||
$messages[] = $message;
|
||||
|
||||
foreach ($toolCalls as $tc) {
|
||||
$toolName = $tc['function']['name'] ?? '';
|
||||
$toolArgs = json_decode($tc['function']['arguments'] ?? '{}', true) ?: [];
|
||||
|
||||
$toolResult = $this->callTool($toolName, $toolArgs);
|
||||
|
||||
$messages[] = [
|
||||
'role' => 'tool',
|
||||
'tool_call_id' => $tc['id'] ?? '',
|
||||
'content' => $toolResult,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return ['success' => false, 'error' => '工具调用轮次过多,已中止。'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前 AI 配置(settings 优先,其次 .env,最后默认值)。
|
||||
*/
|
||||
private function config(): array
|
||||
{
|
||||
$cfg = self::DEFAULTS;
|
||||
foreach (['provider', 'baseUrl', 'model', 'apiKey', 'temperature', 'maxTokens'] as $k) {
|
||||
$envKey = 'AI_'.strtoupper(preg_replace('/(?<!^)[A-Z]/', '_$0', $k));
|
||||
$env = env($envKey);
|
||||
$stored = $this->settingService->getSetting('ai.'.$k, false);
|
||||
|
||||
if ($stored !== false && $stored !== '' && $stored !== null) {
|
||||
$cfg[$k] = $stored;
|
||||
} elseif ($env !== null && $env !== '') {
|
||||
$cfg[$k] = $env;
|
||||
}
|
||||
}
|
||||
|
||||
return $cfg;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回当前 AI 配置(脱敏版,apiKey 只显示前 6 位)。
|
||||
* 供前端「AI 设置」面板展示。
|
||||
*/
|
||||
public function publicConfig(): array
|
||||
{
|
||||
$cfg = $this->config();
|
||||
$key = (string) $cfg['apiKey'];
|
||||
$cfg['apiKeyMasked'] = $key === '' ? '' : substr($key, 0, 6).'...';
|
||||
$cfg['apiKey'] = ''; // 不向前端回传完整 key
|
||||
|
||||
return $cfg;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 MCP Tools 类转成 OpenAI function-calling 的 tools 定义。
|
||||
*/
|
||||
private function buildTools(): array
|
||||
{
|
||||
$tools = [];
|
||||
$pattern = app()->basePath('app/Domain/*/Tools/*Tool.php');
|
||||
foreach (glob($pattern) ?: [] as $file) {
|
||||
$class = $this->classFromFile($file);
|
||||
if ($class === null || ! class_exists($class) || ! is_subclass_of($class, Tool::class)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
/** @var Tool $instance */
|
||||
$instance = app()->make($class);
|
||||
$arr = $instance->toArray();
|
||||
$tools[] = [
|
||||
'type' => 'function',
|
||||
'function' => [
|
||||
'name' => $arr['name'],
|
||||
'description' => $arr['description'],
|
||||
'parameters' => $arr['inputSchema'],
|
||||
],
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
// 单个工具失败不阻断
|
||||
}
|
||||
}
|
||||
|
||||
return $tools;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一个工具(按 name 匹配 Tools 类),返回文本结果。
|
||||
*/
|
||||
private function callTool(string $toolName, array $args): string
|
||||
{
|
||||
$pattern = app()->basePath('app/Domain/*/Tools/*Tool.php');
|
||||
foreach (glob($pattern) ?: [] as $file) {
|
||||
$class = $this->classFromFile($file);
|
||||
if ($class === null || ! class_exists($class) || ! is_subclass_of($class, Tool::class)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
/** @var Tool $instance */
|
||||
$instance = app()->make($class);
|
||||
if ($instance->name() !== $toolName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result = $instance->handle($args);
|
||||
$arr = $result->toArray();
|
||||
// ToolResult 的 content 是 [{type,text}] 结构
|
||||
$texts = [];
|
||||
foreach (($arr['content'] ?? []) as $c) {
|
||||
if (isset($c['text'])) {
|
||||
$texts[] = $c['text'];
|
||||
}
|
||||
}
|
||||
|
||||
return $texts !== [] ? implode("\n", $texts) : json_encode($arr, JSON_UNESCAPED_UNICODE);
|
||||
} catch (\Throwable $e) {
|
||||
return '工具执行出错: '.$e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return "未知工具: {$toolName}";
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件路径推导类名:app/Domain/Projects/Tools/GetAllProjectsTool.php
|
||||
* → Leantime\Domain\Projects\Tools\GetAllProjectsTool
|
||||
*/
|
||||
private function classFromFile(string $file): ?string
|
||||
{
|
||||
$appRoot = rtrim(app()->basePath('app'), '/\\');
|
||||
if (! str_starts_with($file, $appRoot)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$relative = substr($file, strlen($appRoot));
|
||||
$relative = ltrim(str_replace(['/', '\\'], '\\', $relative), '\\');
|
||||
$relative = preg_replace('/\.php$/', '', $relative);
|
||||
|
||||
if ($relative === '' || $relative === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app()->getNamespace().$relative;
|
||||
}
|
||||
}
|
||||
18
app/Domain/Ai/routes.php
Normal file
18
app/Domain/Ai/routes.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Leantime\Domain\Ai\Controllers\ChatApi;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| AI Domain Routes
|
||||
|--------------------------------------------------------------------------
|
||||
| 界面内 AI 聊天面板 JSON API。
|
||||
| POST /ai/chat —— 发送消息,后端代理 DeepSeek/千问 + 执行工具调用
|
||||
| GET /ai/config —— 读取当前 AI 配置(脱敏)
|
||||
*/
|
||||
|
||||
Route::prefix('ai')->group(function () {
|
||||
Route::post('/chat', [ChatApi::class, 'chat']);
|
||||
Route::get('/config', [ChatApi::class, 'config']);
|
||||
});
|
||||
103
app/Domain/Api/Contracts/StaticAssetType.php
Normal file
103
app/Domain/Api/Contracts/StaticAssetType.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Api\Contracts;
|
||||
|
||||
enum StaticAssetType: string
|
||||
{
|
||||
case AAC = 'audio/aac';
|
||||
case ABW = 'application/x-abiword';
|
||||
case ARC = 'application/x-freearc';
|
||||
case AVI = 'video/x-msvideo';
|
||||
case AZW = 'application/vnd.amazon.ebook';
|
||||
case BIN = 'application/octet-stream';
|
||||
case BMP = 'image/bmp';
|
||||
case BZ = 'application/x-bzip';
|
||||
case BZ2 = 'application/x-bzip2';
|
||||
case CSH = 'application/x-csh';
|
||||
case CSS = 'text/css';
|
||||
case CSV = 'text/csv';
|
||||
case DOC = 'application/msword';
|
||||
case DOCX = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
||||
case EOT = 'application/vnd.ms-fontobject';
|
||||
case EPUB = 'application/epub+zip';
|
||||
case GIF = 'image/gif';
|
||||
case GZ = 'application/gzip';
|
||||
case HTM = 'HTML';
|
||||
case HTML = 'text/html';
|
||||
case ICO = 'image/vnd.microsoft.icon';
|
||||
case ICS = 'text/calendar';
|
||||
case JAR = 'application/java-archive';
|
||||
case JPEG = 'JPG';
|
||||
case JPG = 'image/jpeg';
|
||||
case JS = 'text/javascript';
|
||||
case JSON = 'application/json';
|
||||
case JSONLD = 'application/ld+json';
|
||||
case MD = 'text/markdown';
|
||||
case MID = 'MIDI';
|
||||
case MIDI = 'audio/midi';
|
||||
case MJS = 'JS';
|
||||
case MP3 = 'audio/mpeg';
|
||||
case MPEG = 'video/mpeg';
|
||||
case MPKG = 'application/vnd.apple.installer+xml';
|
||||
case ODP = 'application/vnd.oasis.opendocument.presentation';
|
||||
case ODS = 'application/vnd.oasis.opendocument.spreadsheet';
|
||||
case ODT = 'application/vnd.oasis.opendocument.text';
|
||||
case OGA = 'audio/ogg';
|
||||
case OGV = 'video/ogg';
|
||||
case OGX = 'application/ogg';
|
||||
case OPUS = 'audio/opus';
|
||||
case OTF = 'font/otf';
|
||||
case PDF = 'application/pdf';
|
||||
case PNG = 'image/png';
|
||||
case PPT = 'application/vnd.ms-powerpoint';
|
||||
case PPTX = 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
|
||||
case RAR = 'application/vnd.rar';
|
||||
case RTF = 'application/rtf';
|
||||
case SVG = 'image/svg+xml';
|
||||
case TAR = 'application/x-tar';
|
||||
case TIF = 'TIFF';
|
||||
case TIFF = 'image/tiff';
|
||||
case TS = 'video/mp2t';
|
||||
case TTF = 'font/ttf';
|
||||
case TXT = 'text/plain';
|
||||
case VSD = 'application/vnd.visio';
|
||||
case WAV = 'audio/wav';
|
||||
case WEBA = 'audio/webm';
|
||||
case WEBM = 'video/webm';
|
||||
case WEBP = 'image/webp';
|
||||
case WOFF = 'font/woff';
|
||||
case WOFF2 = 'font/woff2';
|
||||
case XHTML = 'application/xhtml+xml';
|
||||
case XLS = 'application/vnd.ms-excel';
|
||||
case XLSX = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
case XML = 'application/xml';
|
||||
case XUL = 'application/vnd.mozilla.xul+xml';
|
||||
case YAML = 'YML';
|
||||
case YML = 'text/yaml';
|
||||
case ZIP = 'application/zip';
|
||||
|
||||
/**
|
||||
* Retrieves the MIME type by extension.
|
||||
*
|
||||
* @param StaticAssetType $extension The file extension to get the MIME type for.
|
||||
* @return string The MIME type associated with the given extension.
|
||||
*/
|
||||
public static function getMimeTypeByExtension(StaticAssetType $extension): string
|
||||
{
|
||||
if (in_array($value = $extension->value, self::getFileExtensions())) {
|
||||
$value = constant("self::$value")->value;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the file extensions.
|
||||
*
|
||||
* @return array Array of file extensions.
|
||||
*/
|
||||
public static function getFileExtensions(): array
|
||||
{
|
||||
return array_map(fn ($case) => $case->name, self::cases());
|
||||
}
|
||||
}
|
||||
108
app/Domain/Api/Controllers/ApiKey.php
Normal file
108
app/Domain/Api/Controllers/ApiKey.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Api\Controllers;
|
||||
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Domain\Api\Services\Api as ApiService;
|
||||
use Leantime\Domain\Auth\Models\Roles;
|
||||
use Leantime\Domain\Auth\Services\Auth;
|
||||
use Leantime\Domain\Clients\Services\Clients as ClientService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* API-key controller.
|
||||
*/
|
||||
class ApiKey extends Controller
|
||||
{
|
||||
private ApiService $apiService;
|
||||
|
||||
private ClientService $clientService;
|
||||
|
||||
/**
|
||||
* Initializes dependencies.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function init(ApiService $apiService, ClientService $clientService): void
|
||||
{
|
||||
self::dispatch_event('api_key_init', $this);
|
||||
|
||||
$this->apiService = $apiService;
|
||||
$this->clientService = $clientService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the API key edit form.
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function get(array $params): Response
|
||||
{
|
||||
Auth::authOrRedirect([Roles::$owner, Roles::$admin], true);
|
||||
|
||||
$id = (int) ($params['id'] ?? $_GET['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
return $this->tpl->display('errors.error403');
|
||||
}
|
||||
|
||||
$values = $this->apiService->getApiKeyFormValues($id);
|
||||
|
||||
$this->assignTemplateVars($id, $values);
|
||||
|
||||
return $this->tpl->displayPartial('api.apiKey');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles API key updates.
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function post(array $params): Response
|
||||
{
|
||||
Auth::authOrRedirect([Roles::$owner, Roles::$admin], true);
|
||||
|
||||
$id = (int) ($params['id'] ?? $_GET['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
return $this->tpl->display('errors.error403');
|
||||
}
|
||||
|
||||
$values = $this->apiService->getApiKeyFormValues($id);
|
||||
|
||||
if (isset($_POST['save'])) {
|
||||
if (isset($_POST[session('formTokenName')]) && $_POST[session('formTokenName')] == session('formTokenValue')) {
|
||||
$this->apiService->updateApiKey($id, $_POST, $_POST['projects'] ?? null);
|
||||
|
||||
$this->tpl->setNotification($this->language->__('notifications.key_updated'), 'success', 'apikey_updated');
|
||||
} else {
|
||||
$this->tpl->setNotification($this->language->__('notification.form_token_incorrect'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
$this->assignTemplateVars($id, $values);
|
||||
|
||||
return $this->tpl->displayPartial('api.apiKey');
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns common template variables.
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function assignTemplateVars(int $id, array $values): void
|
||||
{
|
||||
$this->apiService->generateFormToken();
|
||||
|
||||
$this->tpl->assign('allProjects', $this->apiService->getAllProjects());
|
||||
$this->tpl->assign('roles', Roles::getRoles());
|
||||
$this->tpl->assign('clients', $this->clientService->getAll());
|
||||
$this->tpl->assign('values', $values);
|
||||
$this->tpl->assign('relations', $this->apiService->getProjectRelationIds($id));
|
||||
$this->tpl->assign('status', $this->apiService->getUserStatusOptions());
|
||||
$this->tpl->assign('id', $id);
|
||||
}
|
||||
}
|
||||
79
app/Domain/Api/Controllers/Canvas.php
Normal file
79
app/Domain/Api/Controllers/Canvas.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* canvas class - Generic canvas API controller
|
||||
*/
|
||||
|
||||
namespace Leantime\Domain\Api\Controllers;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Domain\Blueprints\Permissions\BlueprintsPermissions;
|
||||
use Leantime\Domain\Blueprints\Services\Blueprints as BlueprintsService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* @TODO: Could this class be change to abstract? As it is a generic class that should never be initiated!
|
||||
*/
|
||||
class Canvas extends Controller
|
||||
{
|
||||
/**
|
||||
* Constant that must be redefined
|
||||
*/
|
||||
protected const CANVAS_NAME = '??';
|
||||
|
||||
private BlueprintsService $blueprintsService;
|
||||
|
||||
/**
|
||||
* init - initialize private variables
|
||||
*/
|
||||
public function init(): void
|
||||
{
|
||||
$this->blueprintsService = app()->make(BlueprintsService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* get - handle get requests
|
||||
*/
|
||||
public function get(array $params): Response
|
||||
{
|
||||
return $this->tpl->displayJson(['status' => 'Not implemented'], 501);
|
||||
}
|
||||
|
||||
/**
|
||||
* post - handle post requests
|
||||
*/
|
||||
public function post(array $params): Response
|
||||
{
|
||||
return $this->tpl->displayJson(['status' => 'Not implemented'], 501);
|
||||
}
|
||||
|
||||
/**
|
||||
* patch - handle patch requests with authorization check
|
||||
*/
|
||||
#[RequiresPermission(BlueprintsPermissions::EDIT, entityScoped: true)]
|
||||
public function patch(array $params): Response
|
||||
{
|
||||
if (! isset($params['id'])) {
|
||||
return $this->tpl->displayJson(['status' => 'failure'], 400);
|
||||
}
|
||||
|
||||
// The service resolves the item's REAL project and authorizes EDIT against it (throwing
|
||||
// 403 for a missing/foreign item or an insufficient role) before patching — replacing
|
||||
// the previous membership-only check with the permission framework. A false return means
|
||||
// no allowlisted columns were present (a client error, not a denial).
|
||||
if ($this->blueprintsService->patchCanvasItem((int) $params['id'], $params, static::CANVAS_NAME.'canvas') === false) {
|
||||
return $this->tpl->displayJson(['status' => 'no valid fields to update'], 400);
|
||||
}
|
||||
|
||||
return $this->tpl->displayJson(['status' => 'ok']);
|
||||
}
|
||||
|
||||
/**
|
||||
* delete - handle delete requests
|
||||
*/
|
||||
public function delete(array $params): Response
|
||||
{
|
||||
return $this->tpl->displayJson(['status' => 'Not implemented'], 501);
|
||||
}
|
||||
}
|
||||
91
app/Domain/Api/Controllers/DelAPIKey.php
Normal file
91
app/Domain/Api/Controllers/DelAPIKey.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Api\Controllers;
|
||||
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Core\Controller\Frontcontroller;
|
||||
use Leantime\Domain\Auth\Models\Roles;
|
||||
use Leantime\Domain\Auth\Services\Auth;
|
||||
use Leantime\Domain\Users\Services\Users as UserService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Handles API key deletion.
|
||||
*/
|
||||
class DelAPIKey extends Controller
|
||||
{
|
||||
private UserService $userService;
|
||||
|
||||
/**
|
||||
* Initializes dependencies.
|
||||
*/
|
||||
public function init(UserService $userService): void
|
||||
{
|
||||
$this->userService = $userService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the delete API key confirmation.
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function get(array $params): Response
|
||||
{
|
||||
Auth::authOrRedirect([Roles::$owner, Roles::$admin], true);
|
||||
|
||||
$id = (int) ($params['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
return $this->tpl->display('errors.error403');
|
||||
}
|
||||
|
||||
$this->tpl->assign('user', $this->userService->getUser($id));
|
||||
$this->generateFormTokens();
|
||||
|
||||
return $this->tpl->display('api.delKey');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles API key deletion.
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function post(array $params): Response
|
||||
{
|
||||
Auth::authOrRedirect([Roles::$owner, Roles::$admin], true);
|
||||
|
||||
$id = (int) ($params['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
return $this->tpl->display('errors.error403');
|
||||
}
|
||||
|
||||
if (isset($_POST['del'])) {
|
||||
if (isset($_POST[session('formTokenName')]) && $_POST[session('formTokenName')] == session('formTokenValue')) {
|
||||
$this->userService->deleteUser($id);
|
||||
$this->tpl->setNotification($this->language->__('notifications.key_deleted'), 'success', 'apikey_deleted');
|
||||
|
||||
return Frontcontroller::redirect(BASE_URL.'/setting/editCompanySettings/#apiKeys');
|
||||
}
|
||||
|
||||
$this->tpl->setNotification($this->language->__('notification.form_token_incorrect'), 'error');
|
||||
}
|
||||
|
||||
$this->tpl->assign('user', $this->userService->getUser($id));
|
||||
$this->generateFormTokens();
|
||||
|
||||
return $this->tpl->display('api.delKey');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates CSRF form tokens for the delete confirmation form.
|
||||
*/
|
||||
private function generateFormTokens(): void
|
||||
{
|
||||
$permittedChars = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
session(['formTokenName' => substr(str_shuffle($permittedChars), 0, 32)]);
|
||||
session(['formTokenValue' => substr(str_shuffle($permittedChars), 0, 32)]);
|
||||
}
|
||||
}
|
||||
39
app/Domain/Api/Controllers/Goalcanvas.php
Normal file
39
app/Domain/Api/Controllers/Goalcanvas.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Goalcanvas class - Controller API
|
||||
*/
|
||||
|
||||
namespace Leantime\Domain\Api\Controllers;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
use Leantime\Domain\Goalcanvas\Permissions\GoalcanvasPermissions;
|
||||
use Leantime\Domain\Goalcanvas\Services\Goalcanvas as GoalcanvaService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class Goalcanvas extends Canvas
|
||||
{
|
||||
protected const CANVAS_NAME = 'goal';
|
||||
|
||||
/**
|
||||
* patch - inline goal-item update.
|
||||
*
|
||||
* Overrides the generic Canvas base so goal items are authorized with goals.* (the Goals
|
||||
* vocabulary) rather than the generic blueprints.* — the Goalcanvas service resolves the
|
||||
* item's real project and authorizes goals.edit before patching (throws 403 for a
|
||||
* missing/foreign item or insufficient role; false = no allowlisted columns).
|
||||
*/
|
||||
#[RequiresPermission(GoalcanvasPermissions::EDIT, entityScoped: true)]
|
||||
public function patch(array $params): Response
|
||||
{
|
||||
if (! isset($params['id'])) {
|
||||
return $this->tpl->displayJson(['status' => 'failure'], 400);
|
||||
}
|
||||
|
||||
if (app()->make(GoalcanvaService::class)->patchGoalItem((int) $params['id'], $params) === false) {
|
||||
return $this->tpl->displayJson(['status' => 'no valid fields to update'], 400);
|
||||
}
|
||||
|
||||
return $this->tpl->displayJson(['status' => 'ok']);
|
||||
}
|
||||
}
|
||||
51
app/Domain/Api/Controllers/I18n.php
Normal file
51
app/Domain/Api/Controllers/I18n.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Api\Controllers;
|
||||
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Domain\Api\Services\I18n as I18nService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Class I18n
|
||||
*
|
||||
* This class handles attaching the language file to JavaScript.
|
||||
*/
|
||||
class I18n extends Controller
|
||||
{
|
||||
private I18nService $i18nService;
|
||||
|
||||
/**
|
||||
* init - initialize private variables
|
||||
*/
|
||||
public function init(I18nService $i18nService): void
|
||||
{
|
||||
$this->i18nService = $i18nService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the language file to javascript
|
||||
*
|
||||
* @todo refactor to remove user timezone and timeformat and move to user settings
|
||||
*
|
||||
* @param array $params or body of the request.
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function get(array $params): Response
|
||||
{
|
||||
$response = new Response(
|
||||
$this->i18nService->buildJsDictionary(),
|
||||
200
|
||||
);
|
||||
|
||||
$response->headers->set('Content-Type', 'application/javascript');
|
||||
$response->headers->set('Pragma', 'public');
|
||||
|
||||
// Disable cache for this file since datetime format settings is stored in here as well.
|
||||
// Need to find a better cache busting option for this.
|
||||
// $response->headers->set("Cache-Control", 'max-age=86400');
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
562
app/Domain/Api/Controllers/Jsonrpc.php
Normal file
562
app/Domain/Api/Controllers/Jsonrpc.php
Normal file
@@ -0,0 +1,562 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Generates an JSON-RPC 2.0 API
|
||||
*/
|
||||
|
||||
namespace Leantime\Domain\Api\Controllers;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Leantime\Core\Auth\Permissions\PermissionEnforcer;
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Core\Exceptions\Contracts\LeantimeExceptionInterface;
|
||||
use Leantime\Core\Exceptions\MissingParameterException;
|
||||
use Leantime\Core\Http\Responses\JsonRpcErrorResponse;
|
||||
use Leantime\Core\Http\Responses\JsonRpcResponse;
|
||||
use Leantime\Core\Plugins\Attributes\RequiresPlugin;
|
||||
use Leantime\Domain\Plugins\Services\Plugins as PluginsService;
|
||||
use ReflectionClass;
|
||||
use ReflectionMethod;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class Jsonrpc extends Controller
|
||||
{
|
||||
private PermissionEnforcer $permissionEnforcer;
|
||||
|
||||
/**
|
||||
* init - initialize private variables or events to happen before route execution
|
||||
*/
|
||||
public function init(PermissionEnforcer $permissionEnforcer): void
|
||||
{
|
||||
$this->permissionEnforcer = $permissionEnforcer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles post requests
|
||||
*
|
||||
* @param array $params - value of $_POST
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
* @throws \ReflectionException
|
||||
*/
|
||||
public function post(array $params): Response
|
||||
{
|
||||
|
||||
// Remove act from params array
|
||||
if (isset($params['act'])) {
|
||||
unset($params['act']);
|
||||
}
|
||||
|
||||
// If params is empty, maybe it was in the body, get body
|
||||
if (empty($params)) {
|
||||
|
||||
try {
|
||||
$params = $this->getJsonFromBody();
|
||||
} catch (MissingParameterException $e) {
|
||||
Log::error($e);
|
||||
|
||||
return $this->returnMethodNotFound('Could not get any parameters from body');
|
||||
} catch (\JsonException $e) {
|
||||
Log::error($e);
|
||||
|
||||
return $this->returnParseError('Could not parse JSON. Error '.$e->getMessage());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// params['params'] could be array (single value) or json object
|
||||
if (isset($params['params'])) {
|
||||
if (! is_array($params['params'])) {
|
||||
$params['params'] = json_decode($params['params'], true);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->executeApiRequest($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles get requests
|
||||
*
|
||||
* @param array $params - value of $_GET
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
* @throws \ReflectionException
|
||||
*/
|
||||
public function get(array $params): Response
|
||||
{
|
||||
if (! isset($params['method'])) {
|
||||
return $this->returnInvalidRequest('Method name required');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode params
|
||||
*
|
||||
* @see https://www.jsonrpc.org/historical/json-rpc-over-http.html#get
|
||||
*/
|
||||
if (isset($params['params'])) {
|
||||
$paramsDecoded = base64_decode(urldecode($params['params']));
|
||||
} else {
|
||||
$paramsDecoded = [];
|
||||
}
|
||||
|
||||
$params = [
|
||||
'method' => $params['method'],
|
||||
'params' => $paramsDecoded,
|
||||
'id' => $params['id'] ?? null,
|
||||
'jsonrpc' => $params['jsonrpc'] ?? '',
|
||||
];
|
||||
|
||||
$params['params'] = json_decode($params['params'], true);
|
||||
|
||||
// check if decode failed
|
||||
if ($params == null) {
|
||||
return $this->returnParseError('JSON is invalid and was not able to be parsed');
|
||||
}
|
||||
|
||||
return $this->executeApiRequest($params);
|
||||
}
|
||||
|
||||
private function getJsonFromBody(): array
|
||||
{
|
||||
|
||||
if ($this->incomingRequest->server('REQUEST_METHOD') === 'POST'
|
||||
&& empty($_POST)
|
||||
&& $this->incomingRequest->getContent() !== null
|
||||
&& $this->incomingRequest->getContent() !== false
|
||||
&& $this->incomingRequest->getContent() !== '') {
|
||||
|
||||
$bodyContent = json_decode(
|
||||
json: $this->incomingRequest->getContent(),
|
||||
associative: true,
|
||||
flags: JSON_THROW_ON_ERROR
|
||||
);
|
||||
|
||||
return $bodyContent;
|
||||
}
|
||||
|
||||
throw new MissingParameterException('Could not get JSON from body or form fields');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles patch requests
|
||||
*/
|
||||
public function patch(): Response
|
||||
{
|
||||
return $this->returnInvalidRequest('The JSON-RPC API only supports POST/GET requests');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles delete requests
|
||||
*/
|
||||
public function delete(): Response
|
||||
{
|
||||
return $this->returnInvalidRequest('The JSON-RPC API only supports POST/GET requests');
|
||||
}
|
||||
|
||||
/**
|
||||
* executes api call
|
||||
*
|
||||
* @param array $params - request body
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
* @throws \ReflectionException
|
||||
*/
|
||||
private function executeApiRequest(array $params): Response
|
||||
{
|
||||
/**
|
||||
* checks to see if array keys are incremented, if so, assume it's a batch request
|
||||
*
|
||||
* @see https://jsonrpc.org/specification#batch
|
||||
*/
|
||||
if (array_keys($params) == range(0, count($params) - 1)) {
|
||||
|
||||
return new JsonResponse(array_map(
|
||||
function ($requestParams) {
|
||||
return json_decode($this->executeApiRequest($requestParams)->getContent());
|
||||
},
|
||||
$params
|
||||
));
|
||||
}
|
||||
|
||||
$id = $params['id'] ?? null;
|
||||
|
||||
try {
|
||||
$methodparts = $this->parseMethodString($params['method'] ?? '');
|
||||
} catch (Exception $e) {
|
||||
return $this->returnInvalidParams($e, $id);
|
||||
}
|
||||
|
||||
$jsonRpcVer = $params['jsonrpc'] ?? null;
|
||||
|
||||
$moduleName = Str::studly($methodparts['module']);
|
||||
$serviceName = Str::studly($methodparts['service']);
|
||||
|
||||
$domainServiceNamespace = app()->getNamespace()."Domain\\$moduleName\\Services\\$serviceName";
|
||||
$pluginServiceNamespace = app()->getNamespace()."Plugins\\$moduleName\\Services\\$serviceName";
|
||||
// Plugins may expose JSON-RPC methods from a Tools/ directory too — the same
|
||||
// directory McpToolDiscovery scans for #[UnifiedTool]-tagged classes. This
|
||||
// lets one class serve both MCP discovery and JSON-RPC dispatch without
|
||||
// moving the file or duplicating it under Services/.
|
||||
$pluginToolNamespace = app()->getNamespace()."Plugins\\$moduleName\\Tools\\$serviceName";
|
||||
|
||||
$methodName = Str::camel($methodparts['method']);
|
||||
|
||||
$paramsFromRequest = $params['params'] ?? [];
|
||||
|
||||
if (class_exists($domainServiceNamespace)) {
|
||||
$serviceName = $domainServiceNamespace;
|
||||
} elseif (class_exists($pluginServiceNamespace)) {
|
||||
$serviceName = $pluginServiceNamespace;
|
||||
} elseif (class_exists($pluginToolNamespace)) {
|
||||
$serviceName = $pluginToolNamespace;
|
||||
} else {
|
||||
return $this->returnMethodNotFound("Service doesn't exist: $serviceName", $id);
|
||||
}
|
||||
|
||||
if (! method_exists($serviceName, $methodName)) {
|
||||
return $this->returnMethodNotFound("Method doesn't exist: $methodName", $id);
|
||||
}
|
||||
|
||||
// Only allow methods explicitly marked with @api annotation
|
||||
if (! $this->isApiMethod($serviceName, $methodName)) {
|
||||
return $this->returnMethodNotFound("Method is not available via API: $methodName", $id);
|
||||
}
|
||||
|
||||
// Enforce plugin-gated methods. Methods or classes carrying #[RequiresPlugin('Name')]
|
||||
// refuse to dispatch when the named plugin is disabled — return a JSON-RPC error
|
||||
// with HTTP 200 body, mirroring the returnMethodNotFound pattern above.
|
||||
//
|
||||
// Uses the Domain Plugins service (DB-backed user plugins) rather than
|
||||
// Core\Plugins\Plugins (env-driven system plugins only). This matches what
|
||||
// config.getSystemInfo reports, so the gate and the client-visible capability
|
||||
// list share one source of truth.
|
||||
$requiredPlugin = $this->getRequiredPlugin($serviceName, $methodName);
|
||||
if ($requiredPlugin !== null && ! app()->make(PluginsService::class)->isEnabled($requiredPlugin)) {
|
||||
return $this->returnError(
|
||||
"Plugin '$requiredPlugin' is required but not enabled.",
|
||||
-32004,
|
||||
null,
|
||||
$id
|
||||
);
|
||||
}
|
||||
|
||||
if ($jsonRpcVer == null) {
|
||||
return $this->returnInvalidRequest('You must include a "jsonrpc" parameter with a value of "2.0"', $id);
|
||||
}
|
||||
|
||||
if ($jsonRpcVer !== '2.0') {
|
||||
return $this->returnInvalidRequest('Leantime only supports JSON-RPC version 2.0', $id);
|
||||
}
|
||||
|
||||
try {
|
||||
$methodParams = $this->getMethodParameters($serviceName, $methodName);
|
||||
} catch (\ReflectionException $e) {
|
||||
return $this->returnServerError("Error getting parameters: $e", $id);
|
||||
}
|
||||
|
||||
try {
|
||||
$preparedParams = $this->prepareParameters($paramsFromRequest, $methodParams);
|
||||
} catch (Exception $e) {
|
||||
return $this->returnInvalidParams($e, $id);
|
||||
}
|
||||
|
||||
// can be null
|
||||
try {
|
||||
// RPC bypasses the controller gate, so per-method authorization is enforced here:
|
||||
// a #[RequiresPermission] on the resolved service method is checked before the call.
|
||||
// A denial throws AuthorizationException, mapped below to JSON-RPC -32001.
|
||||
$this->permissionEnforcer->enforce($serviceName, $methodName, is_array($paramsFromRequest) ? $paramsFromRequest : []);
|
||||
|
||||
$method_response = app()->make($serviceName)->$methodName(...$preparedParams);
|
||||
} catch (\Throwable $e) {
|
||||
// Leantime exceptions carry a client-safe code/message/data and map to a precise
|
||||
// JSON-RPC error. Anything else is an unexpected failure that must be logged and
|
||||
// collapsed to a generic server error so internal detail never reaches the caller.
|
||||
if (! $e instanceof LeantimeExceptionInterface) {
|
||||
Log::error($e);
|
||||
}
|
||||
|
||||
// A notification (no id) must not be responded to, even on failure, per the
|
||||
// JSON-RPC 2.0 spec — mirror the success path's empty 200.
|
||||
if ($id === null) {
|
||||
return new Response('', Response::HTTP_OK);
|
||||
}
|
||||
|
||||
return JsonRpcErrorResponse::fromException($e, $id)->toResponse($this->incomingRequest);
|
||||
}
|
||||
|
||||
// Convert objects to associative arrays for JSON serialization, but pass
|
||||
// scalars and arrays through as-is. The previous `settype($var, 'array')`
|
||||
// coerced scalars to `[$scalar]`, which broke RPC methods returning ints
|
||||
// (e.g., addTicket returning a new ticket ID).
|
||||
if ($method_response !== null && is_object($method_response)) {
|
||||
$method_response = (array) $method_response;
|
||||
}
|
||||
|
||||
return $this->returnResponse($method_response, $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the method string
|
||||
*
|
||||
* @param string $methodstring - leantime.rpc.service.method
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
private function parseMethodString(string $methodstring): array
|
||||
{
|
||||
if (empty($methodstring)) {
|
||||
throw new Exception('Must include method');
|
||||
}
|
||||
|
||||
if (! str_starts_with($methodstring, 'leantime.rpc.')) {
|
||||
throw new Exception("Method string doesn't start with \"leantime.rpc.\"");
|
||||
}
|
||||
|
||||
// method parameter breakdown
|
||||
// 00000000.111.22222222.3333333333333.444444444444
|
||||
// leantime.rpc.{module}.{servicename}.{methodname}
|
||||
$methodStringPieces = explode('.', $methodstring);
|
||||
|
||||
if (count($methodStringPieces) !== 4 && count($methodStringPieces) !== 5) {
|
||||
throw new Exception('Method is case sensitive and must follow the following naming convention: "leantime.rpc.{domain}.{servicename}.{methodname}"');
|
||||
}
|
||||
|
||||
if (count($methodStringPieces) === 4) {
|
||||
return [
|
||||
'module' => $methodStringPieces[2],
|
||||
'service' => $methodStringPieces[2],
|
||||
'method' => $methodStringPieces[3],
|
||||
];
|
||||
}
|
||||
|
||||
if (count($methodStringPieces) === 5) {
|
||||
return [
|
||||
'module' => $methodStringPieces[2],
|
||||
'service' => $methodStringPieces[3],
|
||||
'method' => $methodStringPieces[4],
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a service method is marked with the @api annotation.
|
||||
*
|
||||
* @param string $serviceName Fully qualified class name
|
||||
* @param string $methodName Method name
|
||||
* @return bool True if the method has an @api docblock tag
|
||||
*/
|
||||
private function isApiMethod(string $serviceName, string $methodName): bool
|
||||
{
|
||||
try {
|
||||
$reflection = new ReflectionMethod($serviceName, $methodName);
|
||||
$docComment = $reflection->getDocComment();
|
||||
|
||||
if ($docComment === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Match the @api tag only at the START of a docblock line (" * @api"), so an
|
||||
// explanatory prose mention (e.g. "@internal not exposed via JSON-RPC, unlike @api
|
||||
// methods") can never accidentally re-expose a deliberately-internal method.
|
||||
return (bool) preg_match('/^\s*\*\s*@api\b/m', $docComment);
|
||||
} catch (\ReflectionException $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the plugin name a method (or its declaring class) requires, if any.
|
||||
*
|
||||
* Looks for the RequiresPlugin attribute on the method first, then the class.
|
||||
* Method-level wins over class-level.
|
||||
*
|
||||
* @return string|null The required plugin folder name, or null if not gated
|
||||
*/
|
||||
private function getRequiredPlugin(string $serviceName, string $methodName): ?string
|
||||
{
|
||||
try {
|
||||
$method = new ReflectionMethod($serviceName, $methodName);
|
||||
$attrs = $method->getAttributes(RequiresPlugin::class);
|
||||
if (! empty($attrs)) {
|
||||
return $attrs[0]->newInstance()->pluginName;
|
||||
}
|
||||
|
||||
$class = new ReflectionClass($serviceName);
|
||||
$classAttrs = $class->getAttributes(RequiresPlugin::class);
|
||||
if (! empty($classAttrs)) {
|
||||
return $classAttrs[0]->newInstance()->pluginName;
|
||||
}
|
||||
} catch (\ReflectionException $e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the Method Parameters
|
||||
*
|
||||
*
|
||||
*
|
||||
* @throws \ReflectionException
|
||||
*/
|
||||
private function getMethodParameters(string $servicename, string $methodname): array
|
||||
{
|
||||
return (new ReflectionClass($servicename))
|
||||
->getMethod($methodname)
|
||||
->getParameters();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks request params
|
||||
*
|
||||
*
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
private function prepareParameters(array $params, array $methodParams): array
|
||||
{
|
||||
$filtered_parameters = [];
|
||||
|
||||
// matches params, params that don't match are ignored
|
||||
foreach ($methodParams as $methodParam) {
|
||||
$required = ! $methodParam->isDefaultValueAvailable();
|
||||
$position = $methodParam->getPosition();
|
||||
$name = $methodParam->name;
|
||||
$type = $methodParam->getType();
|
||||
|
||||
// check if param is there
|
||||
if (! in_array($name, array_keys($params))) {
|
||||
if ($required) {
|
||||
throw new Exception("Required Parameter Missing: $name");
|
||||
}
|
||||
|
||||
$filtered_parameters[$position] = $methodParam->getDefaultValue();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// check if type is correct or can be correct
|
||||
if ($methodParam->hasType()) {
|
||||
if (in_array($type, [gettype($params[$name]), 'mixed'])) {
|
||||
$filtered_parameters[$position] = $params[$name];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($params[$name] === null && ! $type->allowsNull()) {
|
||||
throw new Exception("Parameter $name can't be null");
|
||||
}
|
||||
|
||||
try {
|
||||
$filtered_parameters[$position] = cast($params[$name], $type->getName());
|
||||
} catch (\Throwable $e) {
|
||||
Log::error($e);
|
||||
throw new \Exception("Could not cast parameter: $name. See server logs for more details.");
|
||||
}
|
||||
}
|
||||
|
||||
if (! isset($filtered_parameters[$position])) {
|
||||
$filtered_parameters[$position] = $params[$name];
|
||||
}
|
||||
}
|
||||
|
||||
// make sure it is in the right order
|
||||
ksort($filtered_parameters);
|
||||
|
||||
return $filtered_parameters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Echos the return response.
|
||||
*
|
||||
* @param mixed $returnValue The return value from the RPC method. Widened from
|
||||
* `?array` because the upstream `settype` coercion that
|
||||
* wrapped scalars into single-element arrays was removed
|
||||
* (it broke methods returning ints — e.g. addTicket's
|
||||
* new ticket id was being delivered as [id]). Per the
|
||||
* JSON-RPC 2.0 spec §5, `result` MAY be any JSON value;
|
||||
* caller code in `executeRPC` already casts objects to
|
||||
* associative arrays before reaching here, so in practice
|
||||
* this is array|scalar|null.
|
||||
*
|
||||
* @see https://jsonrpc.org/specification#response_object
|
||||
*/
|
||||
private function returnResponse(mixed $returnValue, int|string|null $id = null): Response
|
||||
{
|
||||
return (new JsonRpcResponse($returnValue, $id))->toResponse($this->incomingRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return error response
|
||||
*
|
||||
* @see https://jsonrpc.org/specification#error_object
|
||||
*/
|
||||
private function returnError(string $errorMessage, int $errorcode, mixed $additional_info = null, int|string|null $id = 0): Response
|
||||
{
|
||||
// Protocol-level callers (parse / invalid-request / method-not-found / invalid-params)
|
||||
// may pass their own thrown exception for context; surface only its message. Service-
|
||||
// level exceptions never reach here — they go through JsonRpcErrorResponse::fromException().
|
||||
$data = $additional_info instanceof \Throwable ? $additional_info->getMessage() : $additional_info;
|
||||
|
||||
return (new JsonRpcErrorResponse($errorcode, $errorMessage, $data, $id))->toResponse($this->incomingRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a parse error
|
||||
*
|
||||
* @see https://jsonrpc.org/specification#error_object
|
||||
*/
|
||||
private function returnParseError(mixed $additional_info = null, int|string|null $id = 0): Response
|
||||
{
|
||||
return $this->returnError('Parse error', -32700, $additional_info, $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an invalid request error
|
||||
*
|
||||
* @see https://jsonrpc.org/specification#error_object
|
||||
*/
|
||||
private function returnInvalidRequest(mixed $additional_info = null, int|string|null $id = 0): Response
|
||||
{
|
||||
return $this->returnError('Invalid Request', -32600, $additional_info, $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a method not found error
|
||||
*
|
||||
* @see https://jsonrpc.org/specification#error_object
|
||||
*/
|
||||
private function returnMethodNotFound(mixed $additional_info = null, int|string|null $id = 0): Response
|
||||
{
|
||||
return $this->returnError('Method not found', -32601, $additional_info, $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an invalid parameters error
|
||||
*
|
||||
* @see https://jsonrpc.org/specification#error_object
|
||||
*/
|
||||
private function returnInvalidParams(mixed $additional_info = null, int|string|null $id = 0): Response
|
||||
{
|
||||
return $this->returnError('Invalid params', -32602, $additional_info, $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a server error
|
||||
*
|
||||
* @see https://jsonrpc.org/specification#error_object
|
||||
*
|
||||
* @param mixed|null $additional_info
|
||||
*/
|
||||
private function returnServerError(mixed $additional_info, int|string|null $id = 0): Response
|
||||
{
|
||||
return $this->returnError('Server error', -32000, $additional_info, $id);
|
||||
}
|
||||
}
|
||||
114
app/Domain/Api/Controllers/NewApiKey.php
Normal file
114
app/Domain/Api/Controllers/NewApiKey.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Api\Controllers;
|
||||
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Domain\Api\Services\Api as ApiService;
|
||||
use Leantime\Domain\Auth\Models\Roles;
|
||||
use Leantime\Domain\Auth\Services\Auth;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class NewApiKey extends Controller
|
||||
{
|
||||
private ApiService $APIService;
|
||||
|
||||
/**
|
||||
* Initializes dependencies.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function init(ApiService $APIService): void
|
||||
{
|
||||
self::dispatch_event('api_key_init', $this);
|
||||
|
||||
$this->APIService = $APIService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the new API key form.
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function get(array $params): Response
|
||||
{
|
||||
Auth::authOrRedirect([Roles::$owner, Roles::$admin], true);
|
||||
|
||||
if (! Auth::userIsAtLeast(Roles::$admin)) {
|
||||
return $this->tpl->displayPartial('errors.error403');
|
||||
}
|
||||
|
||||
$values = [
|
||||
'firstname' => '',
|
||||
'lastname' => '',
|
||||
'user' => '',
|
||||
'role' => '',
|
||||
'password' => '',
|
||||
'status' => 'a',
|
||||
'source' => 'api',
|
||||
];
|
||||
|
||||
$this->tpl->assign('values', $values);
|
||||
$this->tpl->assign('allProjects', $this->APIService->getAllProjects());
|
||||
$this->tpl->assign('roles', Roles::getRoles());
|
||||
$this->tpl->assign('relations', []);
|
||||
|
||||
return $this->tpl->displayPartial('api.newAPIKey');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles API key creation.
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function post(array $params): Response
|
||||
{
|
||||
Auth::authOrRedirect([Roles::$owner, Roles::$admin], true);
|
||||
|
||||
if (! Auth::userIsAtLeast(Roles::$admin)) {
|
||||
return $this->tpl->displayPartial('errors.error403');
|
||||
}
|
||||
|
||||
$values = [
|
||||
'firstname' => '',
|
||||
'lastname' => '',
|
||||
'user' => '',
|
||||
'role' => '',
|
||||
'password' => '',
|
||||
'status' => 'a',
|
||||
'source' => 'api',
|
||||
];
|
||||
|
||||
$projectRelation = [];
|
||||
|
||||
if (isset($_POST['save'])) {
|
||||
$values = [
|
||||
'firstname' => ($_POST['firstname']),
|
||||
'user' => '',
|
||||
'role' => ($_POST['role']),
|
||||
'password' => '',
|
||||
'pwReset' => '',
|
||||
'status' => '',
|
||||
'source' => 'api',
|
||||
];
|
||||
|
||||
$projectRelation = (isset($_POST['projects']) && is_array($_POST['projects'])) ? $_POST['projects'] : [];
|
||||
|
||||
$apiKeyValues = $this->APIService->createApiKeyWithProjects($values, $_POST['projects'] ?? null);
|
||||
|
||||
$this->tpl->setNotification('notifications.key_created', 'success', 'apikey_created');
|
||||
$this->tpl->assign('apiKeyValues', $apiKeyValues);
|
||||
}
|
||||
|
||||
$this->tpl->assign('values', $values);
|
||||
$this->tpl->assign('allProjects', $this->APIService->getAllProjects());
|
||||
$this->tpl->assign('roles', Roles::getRoles());
|
||||
$this->tpl->assign('relations', $projectRelation);
|
||||
|
||||
return $this->tpl->displayPartial('api.newAPIKey');
|
||||
}
|
||||
}
|
||||
69
app/Domain/Api/Controllers/StaticAsset.php
Normal file
69
app/Domain/Api/Controllers/StaticAsset.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Api\Controllers;
|
||||
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use Leantime\Domain\Api\Contracts\StaticAssetType;
|
||||
use Leantime\Domain\Api\Services\Api as ApiService;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class StaticAsset extends Controller
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
private Environment $config;
|
||||
|
||||
private ApiService $apiService;
|
||||
|
||||
/**
|
||||
* init - initialize private variables
|
||||
*/
|
||||
public function init(Environment $config, ApiService $apiService): void
|
||||
{
|
||||
$this->config = $config;
|
||||
$this->apiService = $apiService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the static asset by path.
|
||||
*
|
||||
*
|
||||
* @param array $params parameters or body of the request
|
||||
*/
|
||||
public function get(array $params): Response
|
||||
{
|
||||
$debug = (bool) $this->config->get('debug', false);
|
||||
|
||||
$asset = $this->apiService->resolveStaticAsset($this->incomingRequest->getPathInfo(), $debug);
|
||||
|
||||
if ($asset === false) {
|
||||
return new Response('', 404);
|
||||
}
|
||||
|
||||
/** @var StaticAssetType $type */
|
||||
$type = $asset['type'];
|
||||
|
||||
return tap(
|
||||
new BinaryFileResponse($asset['path']),
|
||||
function (BinaryFileResponse $response) use ($type, $debug) {
|
||||
$response->headers->set('Content-Type', StaticAssetType::getMimeTypeByExtension($type));
|
||||
// Only set Content-length when filesize() succeeds; on failure let
|
||||
// BinaryFileResponse compute it rather than advertising a bogus 0-length body.
|
||||
$size = filesize($response->getFile()->getPathname());
|
||||
if ($size !== false) {
|
||||
$response->headers->set('Content-length', (string) $size);
|
||||
}
|
||||
|
||||
if (in_array(true, [! $this->incomingRequest->query->has('id'), $debug])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$response->headers->set('Cache-Control', 'public, max-age=86500, immutable');
|
||||
$response->headers->set('Pragma', 'public');
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
71
app/Domain/Api/Js/jsonrpcClient.js
Normal file
71
app/Domain/Api/Js/jsonrpcClient.js
Normal file
@@ -0,0 +1,71 @@
|
||||
var leantime = leantime || {};
|
||||
|
||||
/**
|
||||
* Shared JSON-RPC 2.0 client.
|
||||
*
|
||||
* Calls a service method exposed via the '/api/jsonrpc' endpoint, addressed as
|
||||
* leantime.rpc.{Module}.{Service}.{method}. The endpoint is CSRF-exempt and
|
||||
* authenticates via the session cookie, so we only send X-Requested-With.
|
||||
*
|
||||
* Only service methods annotated with @api are callable (the endpoint enforces this).
|
||||
*
|
||||
* Usage:
|
||||
* const result = await leantime.rpc('Tickets.Tickets.patchTicket', { id: 5, values: { status: 3 } });
|
||||
*/
|
||||
leantime.jsonrpc = (function () {
|
||||
|
||||
/**
|
||||
* Invoke a single JSON-RPC method.
|
||||
*
|
||||
* @param {string} method - dotted path WITHOUT the leantime.rpc prefix, e.g. 'Tickets.Tickets.patchTicket'
|
||||
* @param {object} params - named parameters matched by name to the service method signature
|
||||
* @param {object} options - { id, signal } optional request id and AbortSignal
|
||||
* @returns {Promise<*>} resolves to the service return value, rejects with an Error {code, data} on RPC error
|
||||
*/
|
||||
async function call(method, params, options) {
|
||||
params = params || {};
|
||||
options = options || {};
|
||||
|
||||
const response = await fetch(leantime.appUrl + '/api/jsonrpc', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
signal: options.signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'leantime.rpc.' + method,
|
||||
params: params,
|
||||
id: typeof options.id !== 'undefined' ? options.id : 1,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const httpError = new Error('JSON-RPC request failed with HTTP ' + response.status);
|
||||
httpError.code = response.status;
|
||||
throw httpError;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data && data.error) {
|
||||
const rpcError = new Error(data.error.message || 'JSON-RPC error');
|
||||
rpcError.code = data.error.code;
|
||||
rpcError.data = data.error.data;
|
||||
throw rpcError;
|
||||
}
|
||||
|
||||
return data ? data.result : undefined;
|
||||
}
|
||||
|
||||
return { call: call };
|
||||
})();
|
||||
|
||||
/**
|
||||
* Convenience alias: leantime.rpc('Module.Service.method', params, options) -> Promise.
|
||||
*/
|
||||
leantime.rpc = function (method, params, options) {
|
||||
return leantime.jsonrpc.call(method, params, options);
|
||||
};
|
||||
17
app/Domain/Api/Models/StaticAsset.php
Normal file
17
app/Domain/Api/Models/StaticAsset.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Api\Models;
|
||||
|
||||
use Leantime\Domain\Api\Contracts\StaticAssetType;
|
||||
|
||||
/**
|
||||
* Represents a static asset file.
|
||||
*/
|
||||
class StaticAsset
|
||||
{
|
||||
public function __construct(
|
||||
public string $key,
|
||||
public string $absPath,
|
||||
public StaticAssetType $fileType,
|
||||
) {}
|
||||
}
|
||||
37
app/Domain/Api/Permissions/ApiPermissions.php
Normal file
37
app/Domain/Api/Permissions/ApiPermissions.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Api\Permissions;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\Permission;
|
||||
use Leantime\Core\Auth\Permissions\ProvidesPermissions;
|
||||
|
||||
/**
|
||||
* The API (key management) permission vocabulary — the verbs only.
|
||||
*
|
||||
* Leantime API keys act as service accounts (a key IS a user row with a role), so creating,
|
||||
* listing, and editing them is an installation-wide administrative capability — the management
|
||||
* UI (ApiKey / NewApiKey / DelAPIKey controllers) is already `authOrRedirect([owner, admin])`.
|
||||
* The single verb below is therefore COMPANY-WIDE (`projectScoped = false`); call sites gate with
|
||||
* `#[RequiresPermission(ApiPermissions::MANAGE, global: true)]`, which by the default role map
|
||||
* lands on admin/owner only.
|
||||
*
|
||||
* Note: authenticating WITH an existing key (getAPIKeyUser) is not gated by this — that is the
|
||||
* auth primitive itself, invoked by the AuthCheck middleware, not a management action.
|
||||
*/
|
||||
final class ApiPermissions implements ProvidesPermissions
|
||||
{
|
||||
/** Create, list, edit, or remove API keys / service-account credentials (company-wide). */
|
||||
public const MANAGE = 'api.manage';
|
||||
|
||||
public function domain(): string
|
||||
{
|
||||
return 'api';
|
||||
}
|
||||
|
||||
public function permissions(): array
|
||||
{
|
||||
return [
|
||||
new Permission(self::MANAGE, 'Manage API keys', false),
|
||||
];
|
||||
}
|
||||
}
|
||||
27
app/Domain/Api/Repositories/Api.php
Normal file
27
app/Domain/Api/Repositories/Api.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Api\Repositories;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Leantime\Core\Db\Db as DbCore;
|
||||
|
||||
class Api
|
||||
{
|
||||
private ConnectionInterface $db;
|
||||
|
||||
public function __construct(DbCore $db)
|
||||
{
|
||||
$this->db = $db->getConnection();
|
||||
}
|
||||
|
||||
public function getAPIKeyUser(string $apiKeyUser): mixed
|
||||
{
|
||||
$result = $this->db->table('zp_user')
|
||||
->where('username', $apiKeyUser)
|
||||
->where('source', 'api')
|
||||
->limit(1)
|
||||
->first();
|
||||
|
||||
return $result ? (array) $result : false;
|
||||
}
|
||||
}
|
||||
524
app/Domain/Api/Services/Api.php
Normal file
524
app/Domain/Api/Services/Api.php
Normal file
@@ -0,0 +1,524 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Api\Services;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use Leantime\Domain\Api\Contracts\StaticAssetType;
|
||||
use Leantime\Domain\Api\Permissions\ApiPermissions;
|
||||
use Leantime\Domain\Api\Repositories\Api as ApiRepository;
|
||||
use Leantime\Domain\Auth\Services\UserSessionBuilder;
|
||||
use Leantime\Domain\Menu\Repositories\Menu as MenuRepository;
|
||||
use Leantime\Domain\Projects\Repositories\Projects as ProjectRepository;
|
||||
use Leantime\Domain\Users\Repositories\Users as UserRepository;
|
||||
use RangeException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class Api
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
private ApiRepository $apiRepository;
|
||||
|
||||
private UserRepository $userRepo;
|
||||
|
||||
private ProjectRepository $projectRepo;
|
||||
|
||||
private MenuRepository $menuRepo;
|
||||
|
||||
private ?array $error = null;
|
||||
|
||||
/**
|
||||
* @api
|
||||
*/
|
||||
public function __construct(
|
||||
ApiRepository $apiRepository,
|
||||
UserRepository $userRepo,
|
||||
ProjectRepository $projectRepo,
|
||||
MenuRepository $menuRepo
|
||||
) {
|
||||
$this->apiRepository = $apiRepository;
|
||||
$this->userRepo = $userRepo;
|
||||
$this->projectRepo = $projectRepo;
|
||||
$this->menuRepo = $menuRepo;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BindingResolutionException
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function getAPIKeyUser(string $apiKey): bool|array
|
||||
{
|
||||
|
||||
// Split apiKey into parts
|
||||
$apiKeyParts = explode('_', $apiKey);
|
||||
|
||||
if (! is_array($apiKeyParts) || count($apiKeyParts) != 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$namespace = $apiKeyParts[0];
|
||||
$user = $apiKeyParts[1];
|
||||
$key = $apiKeyParts[2];
|
||||
|
||||
if ($namespace != 'lt') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$apiUser = $this->apiRepository->getAPIKeyUser($user);
|
||||
|
||||
if ($apiUser) {
|
||||
if (password_verify($key, $apiUser['password'])) {
|
||||
|
||||
$this->setApiUserSession($apiUser, true);
|
||||
|
||||
return $apiUser;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*
|
||||
* Note: This is deliberately a duplicate of the authService setSession method to not have to load the authService
|
||||
* which will run db connections when we are not ready yet.
|
||||
* TODO: Move session management into a dedicated service
|
||||
*/
|
||||
public function setApiUserSession(array $user, bool $isExternalAuth = false)
|
||||
{
|
||||
// x-api-key (and Bearer fallback) session. twoFAVerified: true — like the Sanctum-token
|
||||
// path, an API token is the strong credential and no interactive 2FA is possible (this
|
||||
// was previously false, diverging from the AuthUser/Bearer builder). Built via the shared
|
||||
// factory so role + every field stay identical across all auth paths.
|
||||
$currentUser = UserSessionBuilder::build($user, isExternalAuth: $isExternalAuth, twoFAVerified: true);
|
||||
|
||||
$currentUser = self::dispatch_filter('user_session_vars', $currentUser);
|
||||
|
||||
// Session handler for api is array
|
||||
session(['userdata' => $currentUser]);
|
||||
}
|
||||
|
||||
/**
|
||||
* createAPIKey - simple service wrapper to create a new user
|
||||
*
|
||||
* TODO: Should accept userModel
|
||||
*
|
||||
* @param array $values basic user values
|
||||
|
||||
* @return bool|array returns new user id on success, false on failure
|
||||
|
||||
*
|
||||
* @throws Exception
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
#[RequiresPermission(ApiPermissions::MANAGE, global: true)]
|
||||
public function createAPIKey(array $values): bool|array
|
||||
{
|
||||
$user = $this->randomStr(32);
|
||||
$password = $this->randomStr(32);
|
||||
|
||||
$values['user'] = $user;
|
||||
$values['lastname'] = '';
|
||||
$values['passwordClean'] = $password;
|
||||
$values['password'] = $password;
|
||||
$values['status'] = 'a';
|
||||
$values['clientId'] = '';
|
||||
$values['phone'] = '';
|
||||
$values['id'] = $this->userRepo->addUser($values);
|
||||
|
||||
return $values['id'] ? $values : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the stored values of an existing API key (user row) and maps them
|
||||
* into the value array used by the API key edit form.
|
||||
*
|
||||
* @param int $id API key (user) id
|
||||
* @return array Mapped value array
|
||||
*
|
||||
* @throws Exception When the id is not a positive integer
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
#[RequiresPermission(ApiPermissions::MANAGE, global: true)]
|
||||
public function getApiKeyFormValues(int $id): array
|
||||
{
|
||||
if ($id <= 0) {
|
||||
throw new Exception('Invalid API key id');
|
||||
}
|
||||
|
||||
$row = $this->userRepo->getUser($id);
|
||||
|
||||
return [
|
||||
'firstname' => $row['firstname'],
|
||||
'lastname' => $row['lastname'],
|
||||
'user' => $row['username'],
|
||||
'phone' => $row['phone'],
|
||||
'status' => $row['status'],
|
||||
'role' => $row['role'],
|
||||
'hours' => $row['hours'],
|
||||
'wage' => $row['wage'],
|
||||
'clientId' => $row['clientId'],
|
||||
'source' => $row['source'],
|
||||
'pwReset' => $row['pwReset'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing API key and reconciles its project relations.
|
||||
*
|
||||
* The save values are intentionally normalized the same way the legacy
|
||||
* controller did: only firstname/status/role are taken from the posted
|
||||
* values, everything else is blanked and the source stays 'api'.
|
||||
*
|
||||
* @param int $id API key (user) id
|
||||
* @param array $postValues Posted form values (firstname, status, role, ...)
|
||||
* @param array|null $projects Selected project ids, or null when none submitted
|
||||
*
|
||||
* @throws Exception When the id is not a positive integer
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
#[RequiresPermission(ApiPermissions::MANAGE, global: true)]
|
||||
public function updateApiKey(int $id, array $postValues, ?array $projects): bool
|
||||
{
|
||||
if ($id <= 0) {
|
||||
throw new Exception('Invalid API key id');
|
||||
}
|
||||
|
||||
$row = $this->userRepo->getUser($id);
|
||||
|
||||
$values = [
|
||||
'firstname' => ($postValues['firstname'] ?? $row['firstname']),
|
||||
'lastname' => '',
|
||||
'user' => $row['username'],
|
||||
'phone' => '',
|
||||
'status' => ($postValues['status'] ?? $row['status']),
|
||||
'role' => ($postValues['role'] ?? $row['role']),
|
||||
'hours' => '',
|
||||
'wage' => '',
|
||||
'clientId' => '',
|
||||
'password' => '',
|
||||
'source' => 'api',
|
||||
'pwReset' => '',
|
||||
];
|
||||
|
||||
$this->userRepo->editUser($values, $id);
|
||||
|
||||
$this->reconcileProjectRelations($id, $projects);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new API key and reconciles its project relations.
|
||||
*
|
||||
* @param array $values Basic user/key values (firstname, role, ...)
|
||||
* @param array|null $projects Selected project ids, or null when none submitted
|
||||
* @return array|false The created key values on success, false on failure
|
||||
*
|
||||
* @throws Exception
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
#[RequiresPermission(ApiPermissions::MANAGE, global: true)]
|
||||
public function createApiKeyWithProjects(array $values, ?array $projects): array|false
|
||||
{
|
||||
$apiKeyValues = $this->createAPIKey($values);
|
||||
|
||||
if ($apiKeyValues === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (is_array($projects) && count($projects) > 0) {
|
||||
$this->reconcileProjectRelations((int) $apiKeyValues['id'], $projects);
|
||||
}
|
||||
|
||||
return $apiKeyValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles the project relations for an API key (user).
|
||||
*
|
||||
* Mirrors the legacy controller behaviour: a leading "0" selection (or no
|
||||
* selection at all) clears all relations, otherwise the relations are set.
|
||||
*
|
||||
* @param int $id API key (user) id
|
||||
* @param array|null $projects Selected project ids
|
||||
*/
|
||||
private function reconcileProjectRelations(int $id, ?array $projects): void
|
||||
{
|
||||
if (is_array($projects) && isset($projects[0]) && $projects[0] !== '0') {
|
||||
$this->projectRepo->editUserProjectRelations($id, $projects);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->projectRepo->deleteAllProjectRelations($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of project ids an API key (user) is related to.
|
||||
*
|
||||
* @param int $id API key (user) id
|
||||
* @return array List of project ids
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
#[RequiresPermission(ApiPermissions::MANAGE, global: true)]
|
||||
public function getProjectRelationIds(int $id): array
|
||||
{
|
||||
$projects = $this->projectRepo->getUserProjectRelation($id);
|
||||
|
||||
$relations = [];
|
||||
foreach ($projects as $projectId) {
|
||||
$relations[] = $projectId['projectId'];
|
||||
}
|
||||
|
||||
return $relations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all projects (for populating the API key form selectors).
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function getAllProjects(): array
|
||||
{
|
||||
return $this->projectRepo->getAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of valid API key (user) status values.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function getUserStatusOptions(): array
|
||||
{
|
||||
return $this->userRepo->status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new form (CSRF) token and stores it in the session so the
|
||||
* API key form can validate the subsequent submission.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function generateFormToken(): void
|
||||
{
|
||||
$permitted_chars = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
session(['formTokenName' => substr(str_shuffle($permitted_chars), 0, 32)]);
|
||||
session(['formTokenValue' => substr(str_shuffle($permitted_chars), 0, 32)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* getAPIKeys - gets api keys (users) from user table
|
||||
*
|
||||
*
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
#[RequiresPermission(ApiPermissions::MANAGE, global: true)]
|
||||
public function getAPIKeys(): false|array
|
||||
{
|
||||
$keys = $this->userRepo->getAllBySource('api');
|
||||
|
||||
foreach ($keys as &$key) {
|
||||
$key['username'] = substr($key['username'], 0, 5);
|
||||
}
|
||||
|
||||
return $keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random string, using a cryptographically secure
|
||||
* pseudorandom number generator (random_int)
|
||||
*
|
||||
* This function uses type hints now (PHP 7+ only), but it was originally
|
||||
* written for PHP 5 as well.
|
||||
*
|
||||
* For PHP 7, random_int is a PHP core function
|
||||
* For PHP 5.x, depends on https://github.com/paragonie/random_compat
|
||||
*
|
||||
* @param int $length How many characters do we want?
|
||||
* @param string $keyspace A string of all possible characters to select from
|
||||
*
|
||||
* @throws Exception
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function randomStr(
|
||||
int $length = 64,
|
||||
string $keyspace = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||
): string {
|
||||
if ($length < 1) {
|
||||
throw new RangeException('Length must be a positive integer');
|
||||
}
|
||||
|
||||
$pieces = [];
|
||||
$max = mb_strlen($keyspace, '8bit') - 1;
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$pieces[] = $keyspace[random_int(0, $max)];
|
||||
}
|
||||
|
||||
return implode('', $pieces);
|
||||
}
|
||||
|
||||
/**
|
||||
* @todo Remove this.
|
||||
*
|
||||
* @see ../Controllers/Tickets.php
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function jsonResponse(int $id, ?array $result): void
|
||||
{
|
||||
$jsonRPCArray = [
|
||||
'jsonrpc' => '2.0',
|
||||
];
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
if ($this->error != null) {
|
||||
$jsonRPCArray['error'] = $this->error;
|
||||
} elseif ($result !== null) {
|
||||
$jsonRPCArray['result'] = $result;
|
||||
}
|
||||
|
||||
echo json_encode($jsonRPCArray);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the manifest for the asset and serve if found.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function getCaseCorrectPathFromManifest(string $filepath): string|false
|
||||
{
|
||||
$manifest = mix('')->getManifest();
|
||||
$clone = array_change_key_case(collect(Arr::dot($manifest))
|
||||
->mapWithKeys(fn ($value, $key) => [Str::of($key)->replaceFirst('./', '/')->lower()->toString() => $value])
|
||||
->all());
|
||||
|
||||
if (is_null($referenceValue = $clone[strtolower($filepath)] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$correctManifest = array_filter($manifest, fn ($arr) => in_array($referenceValue, $arr));
|
||||
$basePath = array_keys($correctManifest)[0];
|
||||
$correctManifest = array_values($correctManifest)[0];
|
||||
|
||||
return $basePath.array_search($referenceValue, $correctManifest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a static asset request path into an on-disk path and its
|
||||
* asset type.
|
||||
*
|
||||
* Maps the request URI to the filesystem app path, validates the extension
|
||||
* against the StaticAssetType enum, rewrites phar paths, and resolves the
|
||||
* case-correct path via the mix manifest.
|
||||
*
|
||||
* @param string $pathInfo The request path info (e.g. /api/static-asset/...)
|
||||
* @param bool $debug Whether debug mode is enabled (affects failure behaviour)
|
||||
* @return array{path: string, type: StaticAssetType}|false The resolved asset, or false on failure
|
||||
*
|
||||
* @throws BadRequestHttpException When the extension is not a known asset type and debug is on
|
||||
* @throws NotFoundHttpException When the asset is not found in the manifest and debug is on
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function resolveStaticAsset(string $pathInfo, bool $debug = false): array|false
|
||||
{
|
||||
$fullpath = Str::of($pathInfo)
|
||||
->replaceFirst('/api/static-asset/', APP_ROOT.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR)
|
||||
->replace('/', DIRECTORY_SEPARATOR)
|
||||
->lower();
|
||||
|
||||
// Check if it's a static asset
|
||||
if (! defined($constant = StaticAssetType::class.'::'.$fullpath->afterLast('.')->upper())) {
|
||||
if ($debug) {
|
||||
throw new BadRequestHttpException;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Str::contains($fullpath, '.phar') && ! Str::startsWith($fullpath, 'phar://')) {
|
||||
$fullpath = 'phar://'.$fullpath;
|
||||
}
|
||||
|
||||
/** @var StaticAssetType $type */
|
||||
$type = constant($constant);
|
||||
|
||||
if (! $correctPath = $this->getCaseCorrectPathFromManifest((string) $fullpath)) {
|
||||
if ($debug) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return [
|
||||
'path' => $correctPath,
|
||||
'type' => $type,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the collapsed/expanded state of a submenu.
|
||||
*
|
||||
* @param string $submenu Submenu identifier
|
||||
* @param string $state Submenu state
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function setSubmenuState(string $submenu, string $state): void
|
||||
{
|
||||
$this->menuRepo->setSubmenuState($submenu, $state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the main menu state both in the session and the menu store.
|
||||
*
|
||||
* @param string $state Raw main menu state from the request
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function setMainMenuState(string $state): void
|
||||
{
|
||||
session(['menuState' => htmlentities($state)]);
|
||||
$this->menuRepo->setSubmenuState('mainMenu', $state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists whether the product tour is active in the session.
|
||||
*
|
||||
* @param mixed $tourActive Raw tour flag from the request
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function setTourActive($tourActive): void
|
||||
{
|
||||
session(['tourActive' => filter_var($tourActive, FILTER_SANITIZE_NUMBER_INT)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true
|
||||
*/
|
||||
public function healthCheck()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
51
app/Domain/Api/Services/Config.php
Normal file
51
app/Domain/Api/Services/Config.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Api\Services;
|
||||
|
||||
use Leantime\Core\Configuration\AppSettings;
|
||||
use Leantime\Domain\Plugins\Services\Plugins as PluginsService;
|
||||
|
||||
/**
|
||||
* Core capability-discovery service for JSON-RPC clients (mobile, MCP, web).
|
||||
*
|
||||
* Always available regardless of which plugins are installed — paired with the
|
||||
* RequiresPlugin attribute so clients can gate UI client-side instead of discovering
|
||||
* disabled capabilities through failed RPC calls.
|
||||
*
|
||||
* Capability staleness: client-side cache should refresh on next login. Admin toggles
|
||||
* propagate on next session; up-to-session-length staleness is acceptable.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class Config
|
||||
{
|
||||
public function __construct(
|
||||
private AppSettings $appSettings,
|
||||
private PluginsService $pluginsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Return system version + enabled-plugin list for capability discovery.
|
||||
*
|
||||
* @return array{version: string, enabledPlugins: array<int, string>}
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function getSystemInfo(): array
|
||||
{
|
||||
$enabled = $this->pluginsService->getEnabledPlugins() ?: [];
|
||||
|
||||
$pluginFolders = [];
|
||||
foreach ($enabled as $plugin) {
|
||||
$folder = is_object($plugin) ? ($plugin->foldername ?? null) : ($plugin['foldername'] ?? null);
|
||||
if ($folder) {
|
||||
$pluginFolders[] = $folder;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'version' => $this->appSettings->appVersion,
|
||||
'enabledPlugins' => $pluginFolders,
|
||||
];
|
||||
}
|
||||
}
|
||||
63
app/Domain/Api/Services/I18n.php
Normal file
63
app/Domain/Api/Services/I18n.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Api\Services;
|
||||
|
||||
use Leantime\Core\Language;
|
||||
|
||||
/**
|
||||
* Class I18n
|
||||
*
|
||||
* Assembles the i18n dictionary payload that is exposed to JavaScript.
|
||||
*/
|
||||
class I18n
|
||||
{
|
||||
private Language $language;
|
||||
|
||||
/**
|
||||
* @api
|
||||
*/
|
||||
public function __construct(Language $language)
|
||||
{
|
||||
$this->language = $language;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the JavaScript snippet that defines the global leantime.i18n object,
|
||||
* including the language dictionary, the resolved date/time format strings and
|
||||
* the user timezone.
|
||||
*
|
||||
* @return string The JavaScript payload
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function buildJsDictionary(): string
|
||||
{
|
||||
$languageIni = $this->language->ini_array;
|
||||
|
||||
$dateTimeIniSettings = [
|
||||
'language.dateformat',
|
||||
'language.timeformat',
|
||||
];
|
||||
|
||||
foreach ($dateTimeIniSettings as $index) {
|
||||
$languageIni[$index] = $this->language->__($index);
|
||||
}
|
||||
|
||||
// Fullcalendar and other scripts can handle local to use the browser timezone
|
||||
$languageIni['usersettings.timezone'] = session('usersettings.timezone') ?? 'local';
|
||||
|
||||
$decodedString = json_encode($languageIni);
|
||||
|
||||
$result = $decodedString ? $decodedString : '{}';
|
||||
|
||||
return <<<JS
|
||||
var leantime = leantime || {};
|
||||
var leantime = {
|
||||
i18n: {
|
||||
dictionary: $result,
|
||||
__: function(index){ return leantime.i18n.dictionary[index]; }
|
||||
}
|
||||
};
|
||||
JS;
|
||||
}
|
||||
}
|
||||
50
app/Domain/Api/Services/Ideas.php
Normal file
50
app/Domain/Api/Services/Ideas.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Api\Services;
|
||||
|
||||
use Leantime\Domain\Ideas\Repositories\Ideas as IdeasRepository;
|
||||
|
||||
/**
|
||||
* Internal shim wrapping idea data access for the legacy Api Ideation controller.
|
||||
*
|
||||
* NOT exposed via JSON-RPC: these methods operate by item id with no project
|
||||
* scoping. Idea mutations from the frontend now go through the authorized
|
||||
* wrappers on Leantime\Domain\Ideas\Services\Ideas (reorderIdeas /
|
||||
* bulkUpdateStatus / patchIdeaItem), which enforce editor + project access.
|
||||
*
|
||||
* @deprecated Will be removed once the Api Ideation controller is gone.
|
||||
*/
|
||||
class Ideas
|
||||
{
|
||||
private IdeasRepository $ideasRepository;
|
||||
|
||||
public function __construct(IdeasRepository $ideasRepository)
|
||||
{
|
||||
$this->ideasRepository = $ideasRepository;
|
||||
}
|
||||
|
||||
public function updateIdeaSorting($payload): bool
|
||||
{
|
||||
return $this->ideasRepository->updateIdeaSorting($payload);
|
||||
}
|
||||
|
||||
public function bulkUpdateIdeaStatus($payload): bool
|
||||
{
|
||||
return $this->ideasRepository->bulkUpdateIdeaStatus($payload);
|
||||
}
|
||||
|
||||
public function updateIdeationSorting($payload): bool
|
||||
{
|
||||
return $this->ideasRepository->updateIdeaSorting($payload);
|
||||
}
|
||||
|
||||
public function bulkUpdateIdeationStatus($payload): bool
|
||||
{
|
||||
return $this->ideasRepository->bulkUpdateIdeaStatus($payload);
|
||||
}
|
||||
|
||||
public function patchCanvasItem(int $id, array $params): bool
|
||||
{
|
||||
return $this->ideasRepository->patchCanvasItem($id, $params);
|
||||
}
|
||||
}
|
||||
142
app/Domain/Api/Templates/apiKey.blade.php
Normal file
142
app/Domain/Api/Templates/apiKey.blade.php
Normal file
@@ -0,0 +1,142 @@
|
||||
@extends($layout)
|
||||
|
||||
@section('content')
|
||||
|
||||
<div style="min-width:700px;">
|
||||
|
||||
<h4 class="widgettitle title-light"><i class="fa fa-key"></i> {!! __('headlines.api_key') !!}</h4>
|
||||
|
||||
{!! $tpl->displayNotification() !!}
|
||||
|
||||
<form action="{{ BASE_URL }}/api/apiKey/{{ (int) $_GET['id'] }}" method="post" class="stdform formModal" >
|
||||
<input type="hidden" name="{{ session('formTokenName') }}" value="{{ session('formTokenValue') }}" />
|
||||
<input type="hidden" name="save" value="1" />
|
||||
|
||||
<div class="row" >
|
||||
<div class="col-md-6">
|
||||
|
||||
<h4 class="widgettitle title-light">{!! __('label.basic_information') !!}</h4>
|
||||
|
||||
<label>{!! __('label.key') !!}</label><div class="clearfix"></div>
|
||||
lt_{{ substr($values['user'], 0, 5) }}***<br /><br />
|
||||
|
||||
<label for="firstname">{!! __('label.key_name') !!}</label><div class="clearfix"></div>
|
||||
<x-global::forms.text-input
|
||||
name="firstname" id="firstname"
|
||||
value="{{ $values['firstname'] }}" /><br />
|
||||
|
||||
|
||||
<label for="role">{!! __('label.role') !!}</label><div class="clearfix"></div>
|
||||
<select name="role" id="role">
|
||||
|
||||
@foreach ($roles as $key => $role)
|
||||
<option value="{{ $key }}"
|
||||
@if ($key == $values['role'])
|
||||
selected="selected"
|
||||
@endif
|
||||
>
|
||||
{!! __('label.roles.' . $role) !!}
|
||||
</option>
|
||||
@endforeach
|
||||
|
||||
</select> <br />
|
||||
|
||||
<label for="status">{!! __('label.status') !!}</label><div class="clearfix"></div>
|
||||
<select name="status" id="status">
|
||||
<option value="a"
|
||||
@if (strtolower($values['status']) == 'a')
|
||||
selected="selected"
|
||||
@endif
|
||||
>
|
||||
{!! __('label.active') !!}
|
||||
</option>
|
||||
|
||||
<option value=""
|
||||
@if (strtolower($values['status']) == '')
|
||||
selected="selected"
|
||||
@endif
|
||||
>
|
||||
{!! __('label.deactivated') !!}
|
||||
</option>
|
||||
|
||||
</select>
|
||||
|
||||
<div class="clearfix"></div>
|
||||
|
||||
<p class="stdformbutton">
|
||||
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" name="save" id="save" />
|
||||
</p>
|
||||
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
|
||||
<h4 class="widgettitle title-light">{!! __('label.project_access') !!}</h4>
|
||||
|
||||
<div class="scrollableItemList">
|
||||
@php
|
||||
$currentClient = '';
|
||||
$i = 0;
|
||||
$containerOpen = false;
|
||||
@endphp
|
||||
@foreach ($allProjects as $row)
|
||||
@if ($currentClient != $row['clientName'])
|
||||
@if ($i > 0 && $containerOpen)
|
||||
</div>
|
||||
@php $containerOpen = false; @endphp
|
||||
@endif
|
||||
<h3 id="accordion_link_{{ $i }}">
|
||||
<a href="#" onclick="accordionToggle({{ $i }});" id="accordion_toggle_{{ $i }}"><i class="fa fa-angle-down"></i> {{ $tpl->escape($row['clientName']) }}</a>
|
||||
</h3>
|
||||
<div id="accordion_{{ $i }}" class="simpleAccordionContainer">
|
||||
@php
|
||||
$currentClient = $row['clientName'];
|
||||
$containerOpen = true;
|
||||
@endphp
|
||||
@endif
|
||||
|
||||
<div class="item">
|
||||
<input type="checkbox" name="projects[]" id="project_{{ $row['id'] }}" value="{{ $row['id'] }}"
|
||||
@if (is_array($relations) === true && in_array($row['id'], $relations) === true)
|
||||
checked="checked"
|
||||
@endif
|
||||
/><label for="project_{{ $row['id'] }}">{{ $tpl->escape($row['name']) }}</label>
|
||||
<div class="clearall"></div>
|
||||
</div>
|
||||
@php $i++; @endphp
|
||||
@endforeach
|
||||
@if ($containerOpen)
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@once
|
||||
@push('scripts')
|
||||
<script>
|
||||
jQuery(".noClickProp.dropdown-menu").on("click", function(e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
function accordionToggle(id) {
|
||||
let currentLink = jQuery("#accordion_toggle_"+id).find("i.fa");
|
||||
if (currentLink.hasClass("fa-angle-right")){
|
||||
currentLink.removeClass("fa-angle-right");
|
||||
currentLink.addClass("fa-angle-down");
|
||||
jQuery('#accordion_'+id).slideDown("fast");
|
||||
} else {
|
||||
currentLink.removeClass("fa-angle-down");
|
||||
currentLink.addClass("fa-angle-right");
|
||||
jQuery('#accordion_'+id).slideUp("fast");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
@endonce
|
||||
|
||||
@endsection
|
||||
30
app/Domain/Api/Templates/delKey.blade.php
Normal file
30
app/Domain/Api/Templates/delKey.blade.php
Normal file
@@ -0,0 +1,30 @@
|
||||
@extends($layout)
|
||||
|
||||
@section('content')
|
||||
|
||||
<div class="pageheader">
|
||||
<div class="pageicon"><i class="fa-solid fa-key"></i></div>
|
||||
<div class="pagetitle">
|
||||
<h5>{!! __('label.administration') !!}</h5>
|
||||
<h1>{!! __('headlines.delete_key') !!}</h1>
|
||||
</div>
|
||||
</div><!--pageheader-->
|
||||
|
||||
<div class="maincontent">
|
||||
<div class="maincontentinner">
|
||||
|
||||
{!! $tpl->displayNotification() !!}
|
||||
|
||||
<h5 class="subtitle">{!! __('subtitles.delete_key') !!}</h5>
|
||||
|
||||
<form method="post">
|
||||
<input type="hidden" name="{{ session('formTokenName') }}" value="{{ session('formTokenValue') }}" />
|
||||
<p>{!! __('text.confirm_key_deletion') !!}</p><br />
|
||||
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.yes_delete')" name="del" />
|
||||
<x-global::forms.button tag="a" link="{{ BASE_URL }}/setting/editCompanySettings/#apiKeys" contentRole="tertiary">{!! __('buttons.back') !!}</x-global::forms.button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
147
app/Domain/Api/Templates/newAPIKey.blade.php
Normal file
147
app/Domain/Api/Templates/newAPIKey.blade.php
Normal file
@@ -0,0 +1,147 @@
|
||||
@extends($layout)
|
||||
|
||||
@section('content')
|
||||
|
||||
@php
|
||||
$apiKeyValues = $apiKeyValues ?? false;
|
||||
@endphp
|
||||
|
||||
<div style="min-width:700px;">
|
||||
|
||||
<h4 class="widgettitle title-light"><i class="fa fa-key"></i> {!! __('headlines.new_api_key') !!}</h4>
|
||||
|
||||
{!! $tpl->displayNotification() !!}
|
||||
@if ($apiKeyValues !== false && isset($apiKeyValues['id']))
|
||||
<p>Your API Key was successfully created. Please copy the key below. This is your only chance to copy it.</p>
|
||||
<x-global::forms.text-input id="apiKey" value="lt_{{ $apiKeyValues['user'] }}_{{ $apiKeyValues['passwordClean'] }}" style="width:100%;" />
|
||||
<x-global::forms.button contentRole="primary" onclick="leantime.snippets.copyUrl('apiKey');">{!! __('links.copy_key') !!}</x-global::forms.button>
|
||||
@else
|
||||
<form action="{{ BASE_URL }}/api/newApiKey" method="post" class="stdform formModal" >
|
||||
|
||||
<input type="hidden" name="save" value="1" />
|
||||
|
||||
<div class="row" >
|
||||
<div class="col-md-6">
|
||||
|
||||
<h4 class="widgettitle title-light">{!! __('label.basic_information') !!}</h4>
|
||||
|
||||
<label for="firstname">{!! __('label.key_name') !!}</label><div class="clearfix"></div>
|
||||
<x-global::forms.text-input
|
||||
name="firstname" id="firstname"
|
||||
value="" /><br />
|
||||
|
||||
|
||||
<label for="role">{!! __('label.role') !!}</label><div class="clearfix"></div>
|
||||
<select name="role" id="role">
|
||||
|
||||
@foreach ($roles as $key => $role)
|
||||
<option value="{{ $key }}"
|
||||
@if ($key == $values['role'])
|
||||
selected="selected"
|
||||
@endif
|
||||
>
|
||||
{!! __('label.roles.' . $role) !!}
|
||||
</option>
|
||||
@endforeach
|
||||
|
||||
</select> <br />
|
||||
|
||||
<label for="status">{!! __('label.status') !!}</label><div class="clearfix"></div>
|
||||
<select name="status" id="status">
|
||||
<option value="a"
|
||||
@if (strtolower($values['status']) == 'a')
|
||||
selected="selected"
|
||||
@endif
|
||||
>
|
||||
{!! __('label.active') !!}
|
||||
</option>
|
||||
|
||||
<option value=""
|
||||
@if (strtolower($values['status']) == '')
|
||||
selected="selected"
|
||||
@endif
|
||||
>
|
||||
{!! __('label.deactivated') !!}
|
||||
</option>
|
||||
|
||||
</select>
|
||||
|
||||
<div class="clearfix"></div>
|
||||
|
||||
<p class="stdformbutton">
|
||||
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" name="save" id="save" />
|
||||
</p>
|
||||
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
|
||||
<h4 class="widgettitle title-light">{!! __('label.project_access') !!}</h4>
|
||||
|
||||
<div class="scrollableItemList">
|
||||
@php
|
||||
$currentClient = '';
|
||||
$i = 0;
|
||||
$containerOpen = false;
|
||||
@endphp
|
||||
@foreach ($allProjects as $row)
|
||||
@if ($currentClient != $row['clientName'])
|
||||
@if ($i > 0 && $containerOpen)
|
||||
</div>
|
||||
@php $containerOpen = false; @endphp
|
||||
@endif
|
||||
<h3 id="accordion_link_{{ $i }}">
|
||||
<a href="#" onclick="accordionToggle({{ $i }});" id="accordion_toggle_{{ $i }}"><i class="fa fa-angle-down"></i> {{ $tpl->escape($row['clientName']) }}</a>
|
||||
</h3>
|
||||
<div id="accordion_{{ $i }}" class="simpleAccordionContainer">
|
||||
@php
|
||||
$currentClient = $row['clientName'];
|
||||
$containerOpen = true;
|
||||
@endphp
|
||||
@endif
|
||||
|
||||
<div class="item">
|
||||
<input type="checkbox" name="projects[]" id="project_{{ $row['id'] }}" value="{{ $row['id'] }}"
|
||||
@if (is_array($relations) === true && in_array($row['id'], $relations) === true)
|
||||
checked="checked"
|
||||
@endif
|
||||
/><label for="project_{{ $row['id'] }}">{{ $tpl->escape($row['name']) }}</label>
|
||||
<div class="clearall"></div>
|
||||
</div>
|
||||
@php $i++; @endphp
|
||||
@endforeach
|
||||
@if ($containerOpen)
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@once
|
||||
@push('scripts')
|
||||
<script>
|
||||
jQuery(".noClickProp.dropdown-menu").on("click", function(e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
function accordionToggle(id) {
|
||||
let currentLink = jQuery("#accordion_toggle_"+id).find("i.fa");
|
||||
if (currentLink.hasClass("fa-angle-right")){
|
||||
currentLink.removeClass("fa-angle-right");
|
||||
currentLink.addClass("fa-angle-down");
|
||||
jQuery('#accordion_'+id).slideDown("fast");
|
||||
} else {
|
||||
currentLink.removeClass("fa-angle-down");
|
||||
currentLink.addClass("fa-angle-right");
|
||||
jQuery('#accordion_'+id).slideUp("fast");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
@endonce
|
||||
|
||||
@endsection
|
||||
106
app/Domain/Audit/Repositories/Audit.php
Normal file
106
app/Domain/Audit/Repositories/Audit.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Audit\Repositories;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Leantime\Core\Db\Db as DbCore;
|
||||
|
||||
class Audit
|
||||
{
|
||||
private ConnectionInterface $db;
|
||||
|
||||
public function __construct(DbCore $db)
|
||||
{
|
||||
$this->db = $db->getConnection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Store an audit event in the database.
|
||||
*
|
||||
* @param string $action The action that occurred (e.g. 'article.create', 'article.edit')
|
||||
* @param string $values JSON-encoded values associated with the event
|
||||
* @param string $entity The entity type (e.g. 'article')
|
||||
* @param int $entityId The ID of the entity
|
||||
* @param int $userId The ID of the user who performed the action
|
||||
* @param int $projectId The project context
|
||||
* @param string $thedate Optional date override; defaults to now
|
||||
*/
|
||||
public function storeEvent(string $action = 'ping', string $values = '', string $entity = '', int $entityId = 0, int $userId = 0, int $projectId = 0, string $thedate = ''): void
|
||||
{
|
||||
$eventDate = $thedate === '' ? now() : $thedate;
|
||||
|
||||
$this->db->table('zp_audit')->insert([
|
||||
'userId' => $userId,
|
||||
'projectId' => $projectId,
|
||||
'action' => $action,
|
||||
'entity' => $entity,
|
||||
'entityId' => $entityId,
|
||||
'values' => $values,
|
||||
'date' => $eventDate,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return mixed|null
|
||||
*/
|
||||
public function getLastEvent(string $action = ''): mixed
|
||||
{
|
||||
$query = $this->db->table('zp_audit');
|
||||
|
||||
if ($action !== '') {
|
||||
$query->where('action', $action);
|
||||
}
|
||||
|
||||
$result = $query->orderBy('date', 'desc')
|
||||
->limit(1)
|
||||
->first();
|
||||
|
||||
return $result ? (array) $result : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit events for a specific entity, joined with user info.
|
||||
*
|
||||
* Uses explicit column list to avoid id collision between zp_audit and zp_user.
|
||||
*
|
||||
* @param string $entity The entity type to filter by
|
||||
* @param int $entityId The entity ID to filter by
|
||||
* @param int $limit Maximum number of events to return
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getEventsForEntity(string $entity, int $entityId, int $limit = 20): array
|
||||
{
|
||||
return $this->db->table('zp_audit')
|
||||
->select(
|
||||
'zp_audit.id',
|
||||
'zp_audit.action',
|
||||
'zp_audit.date',
|
||||
'zp_audit.entity',
|
||||
'zp_audit.entityId',
|
||||
'zp_audit.projectId',
|
||||
'zp_audit.userId',
|
||||
'zp_audit.values',
|
||||
'zp_user.firstname',
|
||||
'zp_user.lastname',
|
||||
'zp_user.profileId'
|
||||
)
|
||||
->leftJoin('zp_user', 'zp_audit.userId', '=', 'zp_user.id')
|
||||
->where('entity', $entity)
|
||||
->where('entityId', $entityId)
|
||||
->orderBy('date', 'desc')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn ($item) => (array) $item)
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function pruneEvents(int $ageDays = 30): void
|
||||
{
|
||||
$cutoffDate = CarbonImmutable::now()->subDays($ageDays)->startOfDay();
|
||||
|
||||
$this->db->table('zp_audit')
|
||||
->whereDate('date', '<', $cutoffDate)
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
47
app/Domain/Auth/Controllers/KeepAlive.php
Normal file
47
app/Domain/Auth/Controllers/KeepAlive.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Auth\Controllers;
|
||||
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Domain\Auth\Services\Auth as AuthService;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Keeping the session alive when not active
|
||||
*
|
||||
* @Deprecated With laravels new session management we should not need this anymore
|
||||
*/
|
||||
class KeepAlive extends Controller
|
||||
{
|
||||
private AuthService $authService;
|
||||
|
||||
/**
|
||||
* init - initialize private variables
|
||||
*/
|
||||
public function init(AuthService $authService): void
|
||||
{
|
||||
$this->authService = $authService;
|
||||
}
|
||||
|
||||
/**
|
||||
* get - handle get requests
|
||||
*/
|
||||
public function get(array $params): Response
|
||||
{
|
||||
|
||||
$userId = session('userdata.id');
|
||||
$sessionId = session()->getId();
|
||||
|
||||
// @TODO: Once we have a session table, check the session is valid in there as well as
|
||||
// added security layer. If not we can log the user out.
|
||||
$return = $this->authService->updateUserSessionDB($userId, $sessionId);
|
||||
|
||||
$response = ['status' => 'ok'];
|
||||
if (! $return) {
|
||||
$response['status'] = 'logout';
|
||||
}
|
||||
|
||||
return new JsonResponse($response);
|
||||
}
|
||||
}
|
||||
110
app/Domain/Auth/Controllers/Login.php
Normal file
110
app/Domain/Auth/Controllers/Login.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Auth\Controllers;
|
||||
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Core\Controller\Frontcontroller as FrontcontrollerCore;
|
||||
use Leantime\Domain\Auth\Services\Auth as AuthService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class Login extends Controller
|
||||
{
|
||||
private AuthService $authService;
|
||||
|
||||
private Environment $config;
|
||||
|
||||
/**
|
||||
* init - initialize private variables
|
||||
*/
|
||||
public function init(
|
||||
AuthService $authService,
|
||||
Environment $config
|
||||
): void {
|
||||
$this->authService = $authService;
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* get - handle get requests
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function get(array $params): Response
|
||||
{
|
||||
self::dispatchEvent('beforeAuth', $params);
|
||||
|
||||
$return = self::dispatchFilter('beforeAuthHandling', $params);
|
||||
if ($return instanceof Response) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
// Guard the type: redirect[]=x arrives as an array, which would TypeError against
|
||||
// resolveSafeRedirect(?string) and 500 the login page on malformed input.
|
||||
$rawRedirect = $_GET['redirect'] ?? null;
|
||||
$redirectUrl = $this->authService->resolveSafeRedirect(is_string($rawRedirect) ? $rawRedirect : null);
|
||||
|
||||
$this->tpl->assign('inputPlaceholder', $this->authService->getLoginInputPlaceholder());
|
||||
$this->tpl->assign('redirectUrl', urlencode($redirectUrl));
|
||||
$this->tpl->assign('oidcEnabled', $this->config->oidcEnable);
|
||||
$this->tpl->assign('noLoginForm', $this->authService->shouldHideLoginForm());
|
||||
|
||||
return $this->tpl->display('auth.login', 'entry');
|
||||
}
|
||||
|
||||
/**
|
||||
* post - handle post requests
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function post(array $params): Response
|
||||
{
|
||||
if (isset($_POST['username']) === true && isset($_POST['password']) === true) {
|
||||
|
||||
// Same array guard as the GET path above — redirectUrl[]=x must not 500 the login POST.
|
||||
$rawRedirect = $_POST['redirectUrl'] ?? null;
|
||||
$redirectUrl = $this->authService->resolveSafeRedirect(is_string($rawRedirect) ? $rawRedirect : null);
|
||||
|
||||
$username = trim($_POST['username']);
|
||||
$password = $_POST['password'];
|
||||
|
||||
try {
|
||||
// Allow login interruptions through events
|
||||
self::dispatch_event('beforeAuthServiceCall', ['post' => $_POST]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
|
||||
$this->tpl->setNotification($e->getMessage(), 'error');
|
||||
|
||||
return FrontcontrollerCore::redirect(BASE_URL.'/auth/login');
|
||||
}
|
||||
|
||||
// If login successful redirect to the correct url to avoid post on reload
|
||||
if ($this->authService->login($username, $password) === true) {
|
||||
|
||||
self::dispatch_event('successfulLogin', ['post' => $_POST]);
|
||||
|
||||
if ($this->authService->use2FA()) {
|
||||
return FrontcontrollerCore::redirect(BASE_URL.'/auth/twoFA');
|
||||
}
|
||||
|
||||
return FrontcontrollerCore::redirect($redirectUrl);
|
||||
} else {
|
||||
$this->tpl->setNotification('notifications.username_or_password_incorrect', 'error');
|
||||
|
||||
return FrontcontrollerCore::redirect(BASE_URL.'/auth/login');
|
||||
}
|
||||
} else {
|
||||
$this->tpl->setNotification('notifications.username_or_password_missing', 'error');
|
||||
|
||||
return FrontcontrollerCore::redirect(BASE_URL.'/auth/login');
|
||||
}
|
||||
}
|
||||
}
|
||||
31
app/Domain/Auth/Controllers/Logout.php
Normal file
31
app/Domain/Auth/Controllers/Logout.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Auth\Controllers;
|
||||
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Core\Controller\Frontcontroller as FrontcontrollerCore;
|
||||
use Leantime\Domain\Auth\Services\Auth as AuthService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class Logout extends Controller
|
||||
{
|
||||
private AuthService $authService;
|
||||
|
||||
/**
|
||||
* init - initialize private variables
|
||||
*/
|
||||
public function init(AuthService $authService): void
|
||||
{
|
||||
$this->authService = $authService;
|
||||
}
|
||||
|
||||
/**
|
||||
* get - handle get requests
|
||||
*/
|
||||
public function get(array $params): Response
|
||||
{
|
||||
$this->authService->logout();
|
||||
|
||||
return FrontcontrollerCore::redirect(BASE_URL.'/');
|
||||
}
|
||||
}
|
||||
23
app/Domain/Auth/Controllers/Redirect.php
Normal file
23
app/Domain/Auth/Controllers/Redirect.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Auth\Controllers;
|
||||
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Redirects to the OAuth provider for authentication.
|
||||
*/
|
||||
class Redirect extends Controller
|
||||
{
|
||||
/**
|
||||
* Redirects to the GitHub OAuth login page.
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*/
|
||||
public function get(array $params): Response
|
||||
{
|
||||
return Socialite::driver('github')->setScopes(['user:email'])->redirect();
|
||||
}
|
||||
}
|
||||
106
app/Domain/Auth/Controllers/ResetPw.php
Normal file
106
app/Domain/Auth/Controllers/ResetPw.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Auth\Controllers;
|
||||
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Core\Controller\Frontcontroller as FrontcontrollerCore;
|
||||
use Leantime\Domain\Auth\Services\Auth as AuthService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ResetPw extends Controller
|
||||
{
|
||||
private AuthService $authService;
|
||||
|
||||
/**
|
||||
* init - initialize private variables
|
||||
*/
|
||||
public function init(
|
||||
AuthService $authService
|
||||
): void {
|
||||
$this->authService = $authService;
|
||||
}
|
||||
|
||||
/**
|
||||
* get - handle get requests
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function get(array $params): Response
|
||||
{
|
||||
if ((isset($params['id']) === true && $this->authService->validateResetLink($params['id']))) {
|
||||
return $this->tpl->display('auth.resetPw', 'entry');
|
||||
} else {
|
||||
return $this->tpl->display('auth.requestPwLink', 'entry');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* post - handle post requests
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function post(array $params): Response
|
||||
{
|
||||
if (! isset($_POST['resetPassword'])) {
|
||||
return FrontcontrollerCore::redirect(BASE_URL.'/auth/resetPw/');
|
||||
}
|
||||
|
||||
if (isset($_POST['username']) === true) {
|
||||
// Always return success to prevent db attacks checking which email address are in there
|
||||
$this->authService->generateLinkAndSendEmail($_POST['username']);
|
||||
$this->tpl->setNotification($this->language->__('notifications.email_was_sent_to_reset'), 'success');
|
||||
|
||||
return FrontcontrollerCore::redirect(BASE_URL.'/auth/resetPw/');
|
||||
}
|
||||
|
||||
if (isset($_POST['password']) === true && isset($_POST['password2']) === true) {
|
||||
$result = $this->authService->resetPassword($_POST['password'], $_POST['password2'], $params['id']);
|
||||
|
||||
if ($result === 'success') {
|
||||
$this->tpl->setNotification(
|
||||
$this->language->__('notifications.passwords_changed_successfully'),
|
||||
'success',
|
||||
'password_changed'
|
||||
);
|
||||
|
||||
return FrontcontrollerCore::redirect(BASE_URL.'/auth/login');
|
||||
}
|
||||
|
||||
if ($result === 'mismatch') {
|
||||
$this->tpl->setNotification($this->language->__('notification.passwords_dont_match'), 'error');
|
||||
|
||||
return FrontcontrollerCore::redirect(BASE_URL.'/auth/resetPw/'.$params['id']);
|
||||
}
|
||||
|
||||
if ($result === 'weak') {
|
||||
$this->tpl->setNotification(
|
||||
$this->language->__('notification.password_not_strong_enough'),
|
||||
'error'
|
||||
);
|
||||
|
||||
return FrontcontrollerCore::redirect(BASE_URL.'/auth/resetPw/'.$params['id']);
|
||||
}
|
||||
|
||||
$this->tpl->setNotification(
|
||||
$this->language->__('notifications.problem_resetting_password'),
|
||||
'error'
|
||||
);
|
||||
|
||||
return FrontcontrollerCore::redirect(BASE_URL.'/auth/resetPw/'.$params['id']);
|
||||
}
|
||||
|
||||
$this->tpl->setNotification(
|
||||
$this->language->__('notifications.problem_resetting_password'),
|
||||
'error'
|
||||
);
|
||||
|
||||
return FrontcontrollerCore::redirect(BASE_URL.'/auth/resetPw/'.$params['id']);
|
||||
}
|
||||
}
|
||||
22
app/Domain/Auth/Controllers/TokenNew.php
Normal file
22
app/Domain/Auth/Controllers/TokenNew.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Auth\Controllers;
|
||||
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Renders the "create personal access token" modal.
|
||||
*/
|
||||
class TokenNew extends Controller
|
||||
{
|
||||
/**
|
||||
* Displays the new token form (loaded into a modal via #/auth/tokenNew).
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*/
|
||||
public function get(array $params): Response
|
||||
{
|
||||
return $this->tpl->displayPartial('auth.tokenNew');
|
||||
}
|
||||
}
|
||||
174
app/Domain/Auth/Controllers/UserInvite.php
Normal file
174
app/Domain/Auth/Controllers/UserInvite.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Auth\Controllers;
|
||||
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Core\Controller\Frontcontroller as FrontcontrollerCore;
|
||||
use Leantime\Core\UI\Theme;
|
||||
use Leantime\Domain\Auth\Services\Auth as AuthService;
|
||||
use Leantime\Domain\Auth\Services\Onboarding as OnboardingService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class UserInvite extends Controller
|
||||
{
|
||||
private AuthService $authService;
|
||||
|
||||
private OnboardingService $onboardingService;
|
||||
|
||||
private Theme $themeCore;
|
||||
|
||||
/**
|
||||
* init - initializes the objects for the class
|
||||
*
|
||||
*
|
||||
* @param AuthService $authService The AuthService object
|
||||
* @param OnboardingService $onboardingService The Onboarding service object
|
||||
* @param Theme $theme The Theme object
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function init(
|
||||
AuthService $authService,
|
||||
OnboardingService $onboardingService,
|
||||
Theme $theme
|
||||
): void {
|
||||
$this->authService = $authService;
|
||||
$this->onboardingService = $onboardingService;
|
||||
$this->themeCore = $theme;
|
||||
}
|
||||
|
||||
/**
|
||||
* get - handle get requests
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function get(array $params): Response
|
||||
{
|
||||
if (isset($params['id']) === true) {
|
||||
|
||||
$inviteId = htmlspecialchars($params['id']);
|
||||
$user = $this->authService->getUserByInviteLink($params['id']);
|
||||
|
||||
if (! $user) {
|
||||
return FrontcontrollerCore::redirect(BASE_URL.'/auth/login');
|
||||
}
|
||||
|
||||
$inviteSettings = $this->onboardingService->getInviteSettings($user);
|
||||
|
||||
array_map([$this->tpl, 'assign'], array_keys($inviteSettings), array_values($inviteSettings));
|
||||
|
||||
$this->tpl->assign('user', $user);
|
||||
$this->tpl->assign('themeCore', $this->themeCore);
|
||||
$this->tpl->assign('inviteId', $inviteId);
|
||||
|
||||
if (isset($_GET['step']) && is_numeric($_GET['step'])) {
|
||||
return $this->tpl->display('auth.userInvite'.$_GET['step'], 'entry');
|
||||
}
|
||||
|
||||
return $this->tpl->display('auth.userInvite', 'entry');
|
||||
}
|
||||
|
||||
return FrontcontrollerCore::redirect(BASE_URL.'/errors/error404');
|
||||
}
|
||||
|
||||
/**
|
||||
* post - handle post requests
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function post(array $params): Response
|
||||
{
|
||||
|
||||
$invitationId = $params['id'] ?? '';
|
||||
|
||||
$userInvite = $this->authService->getUserByInviteLink($invitationId);
|
||||
if (! $userInvite) {
|
||||
return FrontcontrollerCore::redirect(BASE_URL.'/auth/login');
|
||||
}
|
||||
|
||||
// Step 1
|
||||
if (isset($_POST['saveAccount']) && isset($_POST['step'])) {
|
||||
|
||||
$result = $this->onboardingService->saveAccount(
|
||||
$userInvite,
|
||||
$_POST['name'] ?? '',
|
||||
$_POST['jobTitle'] ?? '',
|
||||
$_POST['password'] ?? ''
|
||||
);
|
||||
|
||||
if ($result === 'weak') {
|
||||
$this->tpl->setNotification(
|
||||
$this->language->__('notification.password_not_strong_enough'),
|
||||
'error'
|
||||
);
|
||||
|
||||
return FrontcontrollerCore::redirect(BASE_URL.'/auth/userInvite/'.$invitationId);
|
||||
}
|
||||
|
||||
if ($result === 'saved') {
|
||||
return FrontcontrollerCore::redirect(BASE_URL.'/auth/userInvite/'.$invitationId.'?step=2');
|
||||
} else {
|
||||
$this->tpl->setNotification(
|
||||
$this->language->__('notifications.problem_updating_user'),
|
||||
'error'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($_POST['step']) && $_POST['step'] == 2) {
|
||||
|
||||
$this->onboardingService->saveThemeChoice($userInvite, $_POST['theme'], $_POST['themeFont']);
|
||||
|
||||
return FrontcontrollerCore::redirect(BASE_URL.'/auth/userInvite/'.$invitationId.'?step=3');
|
||||
}
|
||||
|
||||
if (isset($_POST['step']) && $_POST['step'] == 3) {
|
||||
|
||||
$this->onboardingService->saveColorChoice(
|
||||
$userInvite,
|
||||
$_POST['colormode'],
|
||||
$_POST['colorscheme'] ?? 'themeDefault'
|
||||
);
|
||||
|
||||
return FrontcontrollerCore::redirect(BASE_URL.'/auth/userInvite/'.$invitationId.'?step=4');
|
||||
}
|
||||
|
||||
if (isset($_POST['step']) && $_POST['step'] == 4) {
|
||||
|
||||
$this->onboardingService->saveSchedule(
|
||||
$userInvite,
|
||||
$_POST['daySchedule-workStart'] ?? '',
|
||||
$_POST['daySchedule-lunch'] ?? '',
|
||||
$_POST['daySchedule-workEnd'] ?? ''
|
||||
);
|
||||
|
||||
return FrontcontrollerCore::redirect(BASE_URL.'/auth/userInvite/'.$invitationId.'?step=5');
|
||||
}
|
||||
|
||||
if (isset($_POST['step']) && $_POST['step'] == 5) {
|
||||
|
||||
$this->tpl->setNotification(
|
||||
$this->language->__('notifications.you_are_active'),
|
||||
'success',
|
||||
'user_activated'
|
||||
);
|
||||
|
||||
$loggedIn = $this->onboardingService->completeOnboarding($userInvite);
|
||||
|
||||
if ($loggedIn) {
|
||||
return FrontcontrollerCore::redirect(BASE_URL.'/dashboard/home');
|
||||
} else {
|
||||
return FrontcontrollerCore::redirect(BASE_URL.'/auth/login');
|
||||
}
|
||||
}
|
||||
|
||||
return FrontcontrollerCore::redirect(BASE_URL.'/auth/userInvite/'.$invitationId);
|
||||
}
|
||||
}
|
||||
106
app/Domain/Auth/Guards/ApiGuard.php
Normal file
106
app/Domain/Auth/Guards/ApiGuard.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Auth\Guards;
|
||||
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Contracts\Auth\Guard;
|
||||
use Illuminate\Contracts\Auth\UserProvider;
|
||||
use Leantime\Core\Http\ApiRequest;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Leantime\Domain\Api\Services\Api;
|
||||
use Leantime\Domain\Auth\Models\AuthenticatableUser;
|
||||
|
||||
class ApiGuard implements Guard
|
||||
{
|
||||
protected $user;
|
||||
|
||||
private string $apiKey = '';
|
||||
|
||||
public function __construct(
|
||||
protected UserProvider $provider,
|
||||
protected Api $apiService,
|
||||
protected IncomingRequest $request)
|
||||
{
|
||||
if ($this->request instanceof ApiRequest && $this->request->isApiRequest()) {
|
||||
$this->apiKey = $this->request->getAPIKey();
|
||||
}
|
||||
}
|
||||
|
||||
public function check()
|
||||
{
|
||||
|
||||
if (empty($this->apiKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$apiUser = $this->apiService->getAPIKeyUser($this->apiKey);
|
||||
|
||||
if (! $apiUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function guest()
|
||||
{
|
||||
return ! $this->check();
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
if ($this->user !== null) {
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
if (empty($this->apiKey)) {
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
$apiUser = $this->apiService->getAPIKeyUser($this->apiKey);
|
||||
|
||||
if (! $apiUser) {
|
||||
$this->user = null;
|
||||
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
$this->user = new AuthenticatableUser((array) $apiUser);
|
||||
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function id()
|
||||
{
|
||||
return $this->user()?->getAuthIdentifier();
|
||||
}
|
||||
|
||||
public function validate(array $credentials = [])
|
||||
{
|
||||
|
||||
if (empty($this->apiKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$apiUser = $this->apiService->getAPIKeyUser($this->apiKey);
|
||||
|
||||
if (! $apiUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
public function hasUser()
|
||||
{
|
||||
return $this->user ? true : false;
|
||||
}
|
||||
|
||||
public function setUser(Authenticatable $user)
|
||||
{
|
||||
$this->user = $user;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
71
app/Domain/Auth/Guards/WebGuard.php
Normal file
71
app/Domain/Auth/Guards/WebGuard.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Auth\Guards;
|
||||
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Contracts\Auth\Guard;
|
||||
use Illuminate\Contracts\Auth\UserProvider;
|
||||
use Leantime\Domain\Auth\Services\Auth as AuthService;
|
||||
|
||||
class WebGuard implements Guard
|
||||
{
|
||||
protected $provider;
|
||||
|
||||
protected $user;
|
||||
|
||||
protected AuthService $authService;
|
||||
|
||||
public function __construct(UserProvider $provider, AuthService $authService)
|
||||
{
|
||||
$this->provider = $provider;
|
||||
$this->authService = $authService;
|
||||
}
|
||||
|
||||
public function check()
|
||||
{
|
||||
return $this->authService->loggedIn();
|
||||
}
|
||||
|
||||
public function guest()
|
||||
{
|
||||
return ! $this->check();
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
if ($this->user !== null) {
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
if ($this->authService->loggedIn()) {
|
||||
$this->user = $this->provider->retrieveById($this->authService::getUserId());
|
||||
}
|
||||
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function hasUser()
|
||||
{
|
||||
return $this->user ? true : false;
|
||||
}
|
||||
|
||||
public function id()
|
||||
{
|
||||
return $this->user()?->getAuthIdentifier();
|
||||
}
|
||||
|
||||
public function validate(array $credentials = [])
|
||||
{
|
||||
return $this->authService->login(
|
||||
$credentials['username'],
|
||||
$credentials['password']
|
||||
);
|
||||
}
|
||||
|
||||
public function setUser(Authenticatable $user)
|
||||
{
|
||||
$this->user = $user;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
82
app/Domain/Auth/Hxcontrollers/PersonalTokens.php
Normal file
82
app/Domain/Auth/Hxcontrollers/PersonalTokens.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Auth\Hxcontrollers;
|
||||
|
||||
use Leantime\Core\Controller\HtmxController;
|
||||
use Leantime\Domain\Auth\Services\AccessToken;
|
||||
|
||||
/**
|
||||
* HxController for Personal Access Token management.
|
||||
*
|
||||
* Provides HTMX endpoints for creating, listing, and revoking
|
||||
* personal access tokens from the user settings page.
|
||||
*/
|
||||
class PersonalTokens extends HtmxController
|
||||
{
|
||||
protected static string $view = 'auth::partials.tokens';
|
||||
|
||||
private AccessToken $tokenService;
|
||||
|
||||
/**
|
||||
* Initialize the controller with dependencies.
|
||||
*/
|
||||
public function init(AccessToken $tokenService): void
|
||||
{
|
||||
$this->tokenService = $tokenService;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all tokens for the current user.
|
||||
*/
|
||||
public function get(): void
|
||||
{
|
||||
$tokens = $this->tokenService->getUserTokens(session('userdata.id'));
|
||||
$this->tpl->assign('tokens', $tokens);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new personal access token.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
$name = $this->incomingRequest->request->get('name');
|
||||
|
||||
if (empty($name)) {
|
||||
$this->tpl->setNotification(__('notifications.token_name_required'), 'error');
|
||||
|
||||
return $this->tpl->emptyResponse(400);
|
||||
}
|
||||
|
||||
$token = $this->tokenService->createToken(
|
||||
session('userdata.id'),
|
||||
$name
|
||||
);
|
||||
|
||||
$this->tpl->setNotification(__('notifications.token_created'), 'success');
|
||||
|
||||
// Return the token value in a modal for one-time display
|
||||
$this->tpl->assign('newToken', $token->token);
|
||||
|
||||
return $this->tpl->displayPartial('auth.token-created');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a personal access token.
|
||||
*/
|
||||
public function delete(): void
|
||||
{
|
||||
$id = $this->incomingRequest->get('id');
|
||||
|
||||
if (! $this->tokenService->deleteToken((int) $id)) {
|
||||
$this->tpl->setNotification(__('notifications.token_not_found'), 'error');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->tpl->setNotification(__('notifications.token_deleted'), 'success');
|
||||
|
||||
$this->get();
|
||||
}
|
||||
}
|
||||
47
app/Domain/Auth/Js/authController.js
Normal file
47
app/Domain/Auth/Js/authController.js
Normal file
@@ -0,0 +1,47 @@
|
||||
leantime.authController = (function () {
|
||||
|
||||
var makeInputReadonly = function (container) {
|
||||
if (typeof container === undefined) {
|
||||
container = "body";
|
||||
}
|
||||
|
||||
jQuery(container).find("input").not(".filterBar input").prop("readonly", true);
|
||||
jQuery(container).find("input").not(".filterBar input").prop("disabled", true);
|
||||
|
||||
jQuery(container).find("select").not(".filterBar select, .mainSprintSelector").prop("readonly", true);
|
||||
jQuery(container).find("select").not(".filterBar select, .mainSprintSelector").prop("disabled", true);
|
||||
|
||||
jQuery(container).find("textarea").not(".filterBar textarea").prop("disabled", true);
|
||||
|
||||
jQuery(container).find("a.delete").remove();
|
||||
|
||||
jQuery(container).find(".quickAddLink").hide();
|
||||
|
||||
// Make Tiptap editors readonly
|
||||
if (jQuery(container).find(".tiptap-editor").length && window.leantime && window.leantime.tiptapController) {
|
||||
jQuery(container).find(".tiptap-editor").each(function () {
|
||||
var editor = leantime.tiptapController.registry.get(this);
|
||||
if (editor) {
|
||||
editor.setEditable(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Hide Tiptap toolbar
|
||||
jQuery(container).find(".tiptap-toolbar").hide();
|
||||
|
||||
jQuery(container).find(".ticketDropdown a").removeAttr("data-toggle");
|
||||
|
||||
jQuery("#mainToggler").hide();
|
||||
jQuery(".commentBox").hide();
|
||||
jQuery(".deleteComment, .replyButton").hide();
|
||||
|
||||
jQuery(container).find(".dropdown i").removeClass('fa-caret-down');
|
||||
};
|
||||
|
||||
// Make public what you want to have public, everything else is private
|
||||
return {
|
||||
makeInputReadonly:makeInputReadonly,
|
||||
};
|
||||
|
||||
})();
|
||||
24
app/Domain/Auth/Listeners/ShowPersonalTokenContent.php
Normal file
24
app/Domain/Auth/Listeners/ShowPersonalTokenContent.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Auth\Listeners;
|
||||
|
||||
/**
|
||||
* Renders the Personal Access Tokens tab content in the user account settings page.
|
||||
*
|
||||
* Loads via HTMX: the tab panel contains an hx-get that fetches the token list
|
||||
* from the Auth HxController on first reveal.
|
||||
*/
|
||||
class ShowPersonalTokenContent
|
||||
{
|
||||
/**
|
||||
* Render the tab content panel with HTMX lazy-loading.
|
||||
*/
|
||||
public function handle(mixed $payload): void
|
||||
{
|
||||
echo '<div id="personalTokens"
|
||||
hx-get="'.BASE_URL.'/hx/auth/personalTokens"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
</div>';
|
||||
}
|
||||
}
|
||||
17
app/Domain/Auth/Listeners/ShowPersonalTokenTab.php
Normal file
17
app/Domain/Auth/Listeners/ShowPersonalTokenTab.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Auth\Listeners;
|
||||
|
||||
/**
|
||||
* Injects the Personal Access Tokens tab into the user account settings page.
|
||||
*/
|
||||
class ShowPersonalTokenTab
|
||||
{
|
||||
/**
|
||||
* Render the tab navigation item.
|
||||
*/
|
||||
public function handle(mixed $payload): void
|
||||
{
|
||||
echo '<li><a href="#personalTokens"><i class="fa-solid fa-key"></i> '.__('tabs.personal_access_tokens').'</a></li>';
|
||||
}
|
||||
}
|
||||
63
app/Domain/Auth/Models/AuthenticatableUser.php
Normal file
63
app/Domain/Auth/Models/AuthenticatableUser.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Auth\Models;
|
||||
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
|
||||
/**
|
||||
* Lightweight Authenticatable wrapper around a user-data row.
|
||||
*
|
||||
* Replaces the `(object) $userRow` stdClass casts in AuthUser/ApiGuard so the provider/guard
|
||||
* methods satisfy their `?Authenticatable` contracts. It uses dynamic properties on purpose so it
|
||||
* stays a behavioural drop-in for the old stdClass cast — same property reads, same json/array
|
||||
* serialization, truthy even when empty — and merely ADDS the Authenticatable accessor methods.
|
||||
*/
|
||||
#[\AllowDynamicProperties]
|
||||
class AuthenticatableUser implements Authenticatable
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $attributes A user row (column => value).
|
||||
*/
|
||||
public function __construct(array $attributes = [])
|
||||
{
|
||||
foreach ($attributes as $key => $value) {
|
||||
$this->{$key} = $value;
|
||||
}
|
||||
}
|
||||
|
||||
public function getAuthIdentifierName(): string
|
||||
{
|
||||
return 'id';
|
||||
}
|
||||
|
||||
public function getAuthIdentifier(): mixed
|
||||
{
|
||||
return $this->id ?? null;
|
||||
}
|
||||
|
||||
public function getAuthPasswordName(): string
|
||||
{
|
||||
return 'password';
|
||||
}
|
||||
|
||||
public function getAuthPassword(): string
|
||||
{
|
||||
return $this->password ?? '';
|
||||
}
|
||||
|
||||
public function getRememberToken(): string
|
||||
{
|
||||
return $this->remember_token ?? '';
|
||||
}
|
||||
|
||||
public function setRememberToken($value): void
|
||||
{
|
||||
// No-op: Leantime does not persist remember tokens (mirrors Auth::setRememberToken and
|
||||
// AuthUser::updateRememberToken, which are likewise not implemented).
|
||||
}
|
||||
|
||||
public function getRememberTokenName(): string
|
||||
{
|
||||
return 'remember_token';
|
||||
}
|
||||
}
|
||||
24
app/Domain/Auth/Models/CurrentUser.php
Normal file
24
app/Domain/Auth/Models/CurrentUser.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Auth\Models;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
class CurrentUser
|
||||
{
|
||||
public function __construct(
|
||||
public int $id,
|
||||
public string $name,
|
||||
public string $profileId,
|
||||
public string $mail,
|
||||
public int $clientId,
|
||||
public string $role,
|
||||
public mixed $settings,
|
||||
public bool $twoFAEnabled,
|
||||
public bool $twoFAVerified,
|
||||
public string $twoFASecret,
|
||||
public bool $isExternalAuth,
|
||||
public CarbonImmutable $createdOn,
|
||||
public CarbonImmutable $modified,
|
||||
) {}
|
||||
}
|
||||
61
app/Domain/Auth/Models/Roles.php
Normal file
61
app/Domain/Auth/Models/Roles.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Auth\Models;
|
||||
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
|
||||
/**
|
||||
* @TODO: Role names should be converted into an enum.
|
||||
*/
|
||||
class Roles
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
public static string $readonly = 'readonly';
|
||||
|
||||
public static string $commenter = 'commenter';
|
||||
|
||||
public static string $editor = 'editor';
|
||||
|
||||
public static string $manager = 'manager';
|
||||
|
||||
public static string $admin = 'admin';
|
||||
|
||||
public static string $owner = 'owner';
|
||||
|
||||
private static array $roleKeys = [
|
||||
5 => 'readonly', // prev: none
|
||||
10 => 'commenter', // prev: client
|
||||
20 => 'editor', // prev: developer
|
||||
30 => 'manager', // prev: clientmanager
|
||||
40 => 'admin', // prev: manager
|
||||
50 => 'owner', // prev: admin
|
||||
];
|
||||
|
||||
/**
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
private static function getFilteredRoles(): mixed
|
||||
{
|
||||
return self::dispatch_filter('available_roles', self::$roleKeys);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return false|mixed
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public static function getRoleString(mixed $key): mixed
|
||||
{
|
||||
return self::getFilteredRoles()[$key] ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public static function getRoles(): mixed
|
||||
{
|
||||
return self::getFilteredRoles();
|
||||
}
|
||||
}
|
||||
114
app/Domain/Auth/Repositories/AccessTokenRepository.php
Normal file
114
app/Domain/Auth/Repositories/AccessTokenRepository.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Auth\Repositories;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Support\Str;
|
||||
use Leantime\Core\Db\Db;
|
||||
|
||||
class AccessTokenRepository
|
||||
{
|
||||
private ConnectionInterface $db;
|
||||
|
||||
public function __construct(Db $db)
|
||||
{
|
||||
$this->db = $db->getConnection();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \DateTimeInterface|null $expiresAt Optional absolute expiry. Null
|
||||
* keeps the historical non-expiring
|
||||
* behavior; getTokenByUserId() already
|
||||
* honors expires_at when set.
|
||||
*/
|
||||
public function createToken(int $userId, string $name, array $abilities = ['*'], ?\DateTimeInterface $expiresAt = null): array
|
||||
{
|
||||
$token = Str::random(40);
|
||||
$hashedToken = hash('sha256', $token);
|
||||
|
||||
$id = $this->db->table('zp_access_tokens')->insertGetId([
|
||||
'tokenable_type' => 'Leantime\\Domain\\Auth\\Services\\Auth',
|
||||
'tokenable_id' => $userId,
|
||||
'name' => $name,
|
||||
'token' => $hashedToken,
|
||||
'abilities' => json_encode($abilities),
|
||||
'expires_at' => $expiresAt,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'id' => $id,
|
||||
'token' => $token,
|
||||
];
|
||||
}
|
||||
|
||||
public function findToken(string $token): ?array
|
||||
{
|
||||
$hashedToken = hash('sha256', $token);
|
||||
|
||||
$result = $this->db->table('zp_access_tokens')
|
||||
->where('token', $hashedToken)
|
||||
->first();
|
||||
|
||||
return $result ? (array) $result : null;
|
||||
}
|
||||
|
||||
public function findTokenById(int $tokenId): ?array
|
||||
{
|
||||
$result = $this->db->table('zp_access_tokens')
|
||||
->where('id', $tokenId)
|
||||
->first();
|
||||
|
||||
return $result ? (array) $result : null;
|
||||
}
|
||||
|
||||
public function deleteToken(int $id): bool
|
||||
{
|
||||
return $this->db->table('zp_access_tokens')
|
||||
->where('id', $id)
|
||||
->delete() > 0;
|
||||
}
|
||||
|
||||
public function updateLastUsedAt(int $id): bool
|
||||
{
|
||||
return $this->db->table('zp_access_tokens')
|
||||
->where('id', $id)
|
||||
->update(['last_used_at' => now()]) > 0;
|
||||
}
|
||||
|
||||
public function getTokenByUserId(int|string $userId, ?string $name = null): ?array
|
||||
{
|
||||
$query = $this->db->table('zp_access_tokens')
|
||||
->where('tokenable_id', $userId)
|
||||
->where(function ($q) {
|
||||
$q->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
});
|
||||
|
||||
if ($name !== null) {
|
||||
$query->where('name', $name);
|
||||
}
|
||||
|
||||
$result = $query->first();
|
||||
|
||||
return $result ? (array) $result : null;
|
||||
}
|
||||
|
||||
public function getAllTokensByUserId(int|string $userId, ?string $name = null): ?array
|
||||
{
|
||||
$query = $this->db->table('zp_access_tokens')
|
||||
->where('tokenable_id', $userId);
|
||||
|
||||
if ($name !== null) {
|
||||
$query->where('name', $name);
|
||||
}
|
||||
|
||||
$results = $query->get();
|
||||
|
||||
if ($results->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array_map(fn ($item) => (array) $item, $results->toArray());
|
||||
}
|
||||
}
|
||||
167
app/Domain/Auth/Repositories/Auth.php
Normal file
167
app/Domain/Auth/Repositories/Auth.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Auth\Repositories;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Leantime\Core\Db\DatabaseHelper;
|
||||
use Leantime\Core\Db\Db as DbCore;
|
||||
use Leantime\Domain\Users\Repositories\Users as UserRepository;
|
||||
|
||||
class Auth
|
||||
{
|
||||
private ConnectionInterface $db;
|
||||
|
||||
/**
|
||||
* @var string userrole (admin, client, employee)
|
||||
*/
|
||||
public string $role = '';
|
||||
|
||||
public string $settings = '';
|
||||
|
||||
/**
|
||||
* @var int time for cookie
|
||||
*/
|
||||
public int $cookieTime;
|
||||
|
||||
public string $error = '';
|
||||
|
||||
public string $success = '';
|
||||
|
||||
public string|bool $resetInProgress = false;
|
||||
|
||||
public object $hasher;
|
||||
|
||||
/**
|
||||
* How often can a user reset a password before it has to be changed
|
||||
*/
|
||||
public int $pwResetLimit = 5;
|
||||
|
||||
private UserRepository $userRepo;
|
||||
|
||||
private DatabaseHelper $dbHelper;
|
||||
|
||||
public function __construct(
|
||||
DbCore $db,
|
||||
UserRepository $userRepo,
|
||||
DatabaseHelper $dbHelper
|
||||
) {
|
||||
$this->db = $db->getConnection();
|
||||
$this->userRepo = $userRepo;
|
||||
$this->dbHelper = $dbHelper;
|
||||
}
|
||||
|
||||
/**
|
||||
* logout - destroy sessions and cookies
|
||||
*/
|
||||
public function invalidateSession(string $sessionId): bool
|
||||
{
|
||||
return $this->db->table('zp_user')
|
||||
->where('session', $sessionId)
|
||||
->update(['session' => '']) >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* getUserByLogin - Check login data and returns user if correct
|
||||
*/
|
||||
public function getUserByLogin(string $username, string $password): array|false
|
||||
{
|
||||
$user = $this->userRepo->getUserByEmail($username);
|
||||
|
||||
if ($user !== false && password_verify($password, $user['password'])) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getUserByEmail(string $username): array|false
|
||||
{
|
||||
return $this->userRepo->getUserByEmail($username);
|
||||
}
|
||||
|
||||
/**
|
||||
* updateSession - Update the session time by sessionId
|
||||
*/
|
||||
public function updateUserSession(int $userId, string $sessionid, string $time): bool
|
||||
{
|
||||
return $this->db->table('zp_user')
|
||||
->where('id', $userId)
|
||||
->update([
|
||||
'lastlogin' => now(),
|
||||
'session' => $sessionid,
|
||||
'sessiontime' => $time,
|
||||
'pwReset' => null,
|
||||
'pwResetExpiration' => null,
|
||||
]) >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* validateResetLink - validates that the password reset link belongs to a user account in the database
|
||||
*/
|
||||
public function validateResetLink(string $hash): bool
|
||||
{
|
||||
return $this->db->table('zp_user')
|
||||
->where('pwReset', $hash)
|
||||
->where('status', 'like', 'a')
|
||||
->where('pwResetExpiration', '>=', now())
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* getUserByInviteLink - gets an invited user by invite code
|
||||
*/
|
||||
public function getUserByInviteLink(string $hash): bool|array
|
||||
{
|
||||
$result = $this->db->table('zp_user')
|
||||
->where('pwReset', $hash)
|
||||
->whereRaw('LOWER(status) = ?', ['i'])
|
||||
->limit(1)
|
||||
->first();
|
||||
|
||||
return $result ? (array) $result : false;
|
||||
}
|
||||
|
||||
public function setPWResetLink(string $username, string $resetLink): bool
|
||||
{
|
||||
return $this->db->table('zp_user')
|
||||
->where('username', $username)
|
||||
->update([
|
||||
'pwReset' => $resetLink,
|
||||
// Store the EXPIRY moment (not creation): the reset link is valid for 1 hour.
|
||||
'pwResetExpiration' => now()->addHours(1),
|
||||
'pwResetCount' => $this->db->raw('COALESCE('.$this->dbHelper->wrapColumn('pwResetCount').', 0) + 1'),
|
||||
]) >= 0;
|
||||
}
|
||||
|
||||
public function changePW(string $password, string $hash): bool
|
||||
{
|
||||
// Never match on an empty reset token: many accounts carry an empty
|
||||
// pwReset (it's cleared after every successful change), so an empty hash
|
||||
// would match a pile of users. Resolve to a single user id first, then
|
||||
// update by primary key. This also avoids the MySQL-only DELETE/UPDATE
|
||||
// ... LIMIT 1 that breaks on Postgres (#3384) — we can't drop limit(1)
|
||||
// here because pwReset isn't unique.
|
||||
if ($hash === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$userId = $this->db->table('zp_user')
|
||||
->where('pwReset', $hash)
|
||||
->where('pwResetExpiration', '>=', now())
|
||||
->value('id');
|
||||
|
||||
if (empty($userId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->db->table('zp_user')
|
||||
->where('id', $userId)
|
||||
->update([
|
||||
'password' => password_hash($password, PASSWORD_DEFAULT),
|
||||
'pwReset' => '',
|
||||
'pwResetExpiration' => '',
|
||||
'lastpwd_change' => now(),
|
||||
'pwResetCount' => 0,
|
||||
]) >= 0;
|
||||
}
|
||||
}
|
||||
184
app/Domain/Auth/Services/AccessToken.php
Normal file
184
app/Domain/Auth/Services/AccessToken.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Auth\Services;
|
||||
|
||||
use DateTimeInterface;
|
||||
use Illuminate\Validation\UnauthorizedException;
|
||||
use Laravel\Sanctum\Contracts\HasAbilities;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Leantime\Domain\Auth\Models\Roles;
|
||||
use Leantime\Domain\Auth\Repositories\AccessTokenRepository;
|
||||
|
||||
class AccessToken implements HasAbilities
|
||||
{
|
||||
use HasApiTokens, \Illuminate\Auth\Authenticatable;
|
||||
|
||||
public ?int $id = null;
|
||||
|
||||
public string $tokenableType;
|
||||
|
||||
public int $tokenableId;
|
||||
|
||||
public string $name;
|
||||
|
||||
public string $token;
|
||||
|
||||
public array $abilities;
|
||||
|
||||
public ?DateTimeInterface $lastUsedAt;
|
||||
|
||||
public ?DateTimeInterface $expires_at;
|
||||
|
||||
public ?DateTimeInterface $created_at;
|
||||
|
||||
public ?DateTimeInterface $updatedAt;
|
||||
|
||||
public AuthUser $tokenable;
|
||||
|
||||
protected AccessTokenRepository $tokenRepo;
|
||||
|
||||
public function __construct(
|
||||
array $attributes = [],
|
||||
) {
|
||||
foreach ($attributes as $key => $value) {
|
||||
if (property_exists($this, $key)) {
|
||||
$this->$key = $value;
|
||||
}
|
||||
}
|
||||
$this->tokenRepo = app()->make(AccessTokenRepository::class);
|
||||
$this->tokenable = app()->make(AuthUser::class);
|
||||
|
||||
$this->abilities = $this->abilities ?? ['*'];
|
||||
}
|
||||
|
||||
public function can($ability): bool
|
||||
{
|
||||
return in_array('*', $this->abilities) ||
|
||||
array_key_exists($ability, array_flip($this->abilities));
|
||||
}
|
||||
|
||||
public function cant($ability): bool
|
||||
{
|
||||
return ! $this->can($ability);
|
||||
}
|
||||
|
||||
public function createToken($userId, $name = null)
|
||||
{
|
||||
|
||||
if ($userId == session('userdata.id') || Auth::userIsAtLeast(Roles::$admin)) {
|
||||
|
||||
$token = $this->tokenRepo->createToken($userId, $name ?? 'personal-token');
|
||||
|
||||
return (object) $token;
|
||||
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static function findToken($token)
|
||||
{
|
||||
$tokenObject = new self;
|
||||
$tokenData = $tokenObject->tokenRepo->findToken($token);
|
||||
|
||||
if (empty($tokenData)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tokenObject->id = $tokenData['id'];
|
||||
$tokenObject->expires_at = ! empty($tokenData['expires_at']) ? dtHelper()->parseDbDateTime($tokenData['expires_at']) : null;
|
||||
$tokenObject->created_at = ! empty($tokenData['created_at']) ? dtHelper()->parseDbDateTime($tokenData['created_at']) : null;
|
||||
|
||||
$tokenObject->tokenable->setUser($tokenData['tokenable_id']);
|
||||
|
||||
return $tokenObject;
|
||||
}
|
||||
|
||||
public function getConnection()
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function forceFill()
|
||||
{
|
||||
$this->tokenRepo->updateLastUsedAt($this->id);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getUserTokens(int $userId)
|
||||
{
|
||||
|
||||
if (Auth::userIsAtLeast(Roles::$admin) || $userId == session('userdata.id')) {
|
||||
|
||||
return $this->tokenRepo->getAllTokensByUserId($userId) ?? [];
|
||||
|
||||
} else {
|
||||
|
||||
throw new UnauthorizedException('You are not authorized to access this resource.');
|
||||
}
|
||||
}
|
||||
|
||||
public function getTokenById($tokenId)
|
||||
{
|
||||
return $this->tokenRepo->findTokenById($tokenId);
|
||||
}
|
||||
|
||||
public function deleteToken(int $tokenId)
|
||||
{
|
||||
|
||||
$token = $this->getTokenById($tokenId);
|
||||
|
||||
if (Auth::userIsAtLeast(Roles::$admin) || $token['tokenable_id'] == session('userdata.id')) {
|
||||
|
||||
return $this->tokenRepo->deleteToken($tokenId);
|
||||
|
||||
} else {
|
||||
|
||||
throw new UnauthorizedException('You are not authorized to access this resource.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke the bearer token used to authenticate the current request.
|
||||
* Designed for client-side sign-out flows (mobile, third-party
|
||||
* integrations) that need a server-side invalidation rather than
|
||||
* just clearing local credentials.
|
||||
*
|
||||
* Uses the request's Authorization header to identify the token —
|
||||
* caller doesn't need to track its own token id. Defense-in-depth
|
||||
* check confirms the matched token belongs to the session user
|
||||
* (the middleware should already guarantee this since the bearer
|
||||
* is what populated the session, but the explicit check guards
|
||||
* against any odd state).
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function revokeCurrentToken(): bool
|
||||
{
|
||||
$request = app(\Leantime\Core\Http\ApiRequest::class);
|
||||
$bearer = $request->getBearerToken();
|
||||
if (! $bearer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$token = $this->tokenRepo->findToken($bearer);
|
||||
if (! $token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$sessionUserId = (int) session('userdata.id');
|
||||
if ($sessionUserId === 0 || (int) $token['tokenable_id'] !== $sessionUserId) {
|
||||
// Bearer matched a row that isn't this session's user —
|
||||
// refuse rather than risk deleting someone else's token.
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->tokenRepo->deleteToken((int) $token['id']);
|
||||
}
|
||||
}
|
||||
772
app/Domain/Auth/Services/Auth.php
Normal file
772
app/Domain/Auth/Services/Auth.php
Normal file
@@ -0,0 +1,772 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Auth\Services;
|
||||
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Illuminate\Session\SessionManager;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Leantime\Core\Configuration\Environment as EnvironmentCore;
|
||||
use Leantime\Core\Controller\Frontcontroller as FrontcontrollerCore;
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use Leantime\Core\Language as LanguageCore;
|
||||
use Leantime\Core\Mailer as MailerCore;
|
||||
use Leantime\Core\UI\Theme;
|
||||
use Leantime\Domain\Auth\Models\Roles;
|
||||
use Leantime\Domain\Auth\Repositories\AccessTokenRepository;
|
||||
use Leantime\Domain\Auth\Repositories\Auth as AuthRepository;
|
||||
use Leantime\Domain\Ldap\Services\Ldap;
|
||||
use Leantime\Domain\Setting\Repositories\Setting as SettingRepository;
|
||||
use Leantime\Domain\Users\Repositories\Users as UserRepository;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use RobThree\Auth\TwoFactorAuth;
|
||||
|
||||
class Auth implements Authenticatable
|
||||
{
|
||||
use DispatchesEvents, HasApiTokens, \Illuminate\Auth\Authenticatable;
|
||||
|
||||
/**
|
||||
* @var int|null user id from DB
|
||||
*/
|
||||
private ?int $userId = null;
|
||||
|
||||
private ?string $password = null;
|
||||
|
||||
private ?SessionManager $session = null;
|
||||
|
||||
/**
|
||||
* @var string userrole (admin, client, employee)
|
||||
*/
|
||||
public string $role = '';
|
||||
|
||||
public array $settings = [];
|
||||
|
||||
/**
|
||||
* @var int time for cookie
|
||||
*/
|
||||
public mixed $cookieTime;
|
||||
|
||||
public string $error = '';
|
||||
|
||||
public string $success = '';
|
||||
|
||||
public string|bool $resetInProgress = false;
|
||||
|
||||
/**
|
||||
* How often can a user reset a password before it has to be changed
|
||||
*/
|
||||
public int $pwResetLimit = 5;
|
||||
|
||||
private EnvironmentCore $config;
|
||||
|
||||
public LanguageCore $language;
|
||||
|
||||
public SettingRepository $settingsRepo;
|
||||
|
||||
public AuthRepository $authRepo;
|
||||
|
||||
public UserRepository $userRepo;
|
||||
|
||||
private AccessTokenRepository $tokenRepo;
|
||||
|
||||
/**
|
||||
* __construct - getInstance of session and get sessionId and refers to login if post is set
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function __construct(
|
||||
EnvironmentCore $config,
|
||||
?SessionManager $session,
|
||||
LanguageCore $language,
|
||||
SettingRepository $settingsRepo,
|
||||
AuthRepository $authRepo,
|
||||
UserRepository $userRepo,
|
||||
AccessTokenRepository $tokenRepo
|
||||
) {
|
||||
$this->config = $config;
|
||||
$this->session = $session;
|
||||
$this->language = $language;
|
||||
$this->settingsRepo = $settingsRepo;
|
||||
$this->authRepo = $authRepo;
|
||||
$this->userRepo = $userRepo;
|
||||
$this->tokenRepo = $tokenRepo;
|
||||
|
||||
$this->cookieTime = $this->config->sessionExpiration;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string|bool returns role as string or false on failure
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public static function getRoleToCheck(bool $forceGlobalRoleCheck): string|bool
|
||||
{
|
||||
if (session()->exists('userdata') === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($forceGlobalRoleCheck) {
|
||||
$roleToCheck = session('userdata.role');
|
||||
// If projectRole is not defined or if it is set to inherited
|
||||
} elseif (! session()->exists('userdata.projectRole') || session('userdata.projectRole') == 'inherited' || session('userdata.projectRole') == '') {
|
||||
$roleToCheck = session('userdata.role');
|
||||
// Do not overwrite admin or owner roles
|
||||
} elseif (session('userdata.role') == Roles::$owner || session('userdata.role') == Roles::$admin || session('userdata.role') == Roles::$manager) {
|
||||
$roleToCheck = session('userdata.role');
|
||||
// In all other cases check the project role
|
||||
} else {
|
||||
$roleToCheck = session('userdata.projectRole');
|
||||
}
|
||||
|
||||
// Ensure the role is a valid role. An unresolvable role here makes the permission engine
|
||||
// deny EVERYTHING (every #[RequiresPermission] check fails) — so log it loudly with
|
||||
// context. This exact breadcrumb ("invalid role detected: 50") is what surfaced the 3.9.x
|
||||
// Bearer regression where a session stored the raw role int instead of its name string.
|
||||
if (in_array($roleToCheck, Roles::getRoles()) === false) {
|
||||
|
||||
Log::warning('Invalid role in session — authorization will deny everything. Resolved role: '.var_export($roleToCheck, true).' (user '.(session('userdata.id') ?? 'guest').'). Expected one of: '.implode(', ', Roles::getRoles()));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return $roleToCheck;
|
||||
}
|
||||
|
||||
/**
|
||||
* login - Validate POST-data with DB
|
||||
*
|
||||
*
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function login(string $username, string $password): bool
|
||||
{
|
||||
self::dispatch_event('beforeLoginCheck', ['username' => $username, 'password' => $password]);
|
||||
|
||||
// different identity providers can live here
|
||||
// they all need to
|
||||
// // A: ensure the user is in leantime (with a valid role) and if not create the user
|
||||
// // B: set the session variables
|
||||
// // C: update users from the identity provider,
|
||||
// Try Ldap
|
||||
if ($this->config->useLdap === true && extension_loaded('ldap')) {
|
||||
$ldap = app()->make(Ldap::class);
|
||||
|
||||
if ($ldap->connect() && $ldap->bind($username, $password)) {
|
||||
// Update username to include domain
|
||||
$usernameWDomain = $ldap->getEmail($username);
|
||||
// Get user
|
||||
$user = $this->userRepo->getUserByEmail($usernameWDomain);
|
||||
|
||||
$ldapUser = $ldap->getSingleUser($username);
|
||||
|
||||
if ($ldapUser === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If user does not exist create user
|
||||
if (! $user) {
|
||||
$userArray = [
|
||||
'firstname' => $ldapUser['firstname'],
|
||||
'lastname' => $ldapUser['lastname'],
|
||||
'phone' => $ldapUser['phone'],
|
||||
'user' => $ldapUser['user'],
|
||||
'role' => $ldapUser['role'],
|
||||
'department' => $ldapUser['department'],
|
||||
'jobTitle' => $ldapUser['jobTitle'],
|
||||
'jobLevel' => $ldapUser['jobLevel'],
|
||||
'password' => '',
|
||||
'clientId' => '',
|
||||
'source' => 'ldap',
|
||||
'status' => 'a',
|
||||
];
|
||||
|
||||
$userId = $this->userRepo->addUser($userArray);
|
||||
|
||||
if ($userId !== false) {
|
||||
$user = $this->userRepo->getUserByEmail($usernameWDomain);
|
||||
} else {
|
||||
|
||||
Log::error('Ldap user creation failed.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// @TODO: create a better login response. This will return that the username or password was not correct
|
||||
} else {
|
||||
$user['firstname'] = $ldapUser['firstname'];
|
||||
$user['lastname'] = $ldapUser['lastname'];
|
||||
$user['phone'] = $ldapUser['phone'];
|
||||
$user['user'] = $user['username'];
|
||||
$user['department'] = $ldapUser['department'];
|
||||
$user['jobTitle'] = $ldapUser['jobTitle'];
|
||||
$user['jobLevel'] = $ldapUser['jobLevel'];
|
||||
|
||||
$this->userRepo->editUser($user, $user['id']);
|
||||
}
|
||||
|
||||
if ($user !== false && is_array($user)) {
|
||||
$this->setUserSession($user, true);
|
||||
|
||||
return true;
|
||||
} else {
|
||||
|
||||
Log::info('Could not retrieve user by email');
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't return false, to allow the standard login provider to check the db for contractors or clients not
|
||||
// in ldap
|
||||
} elseif ($this->config->useLdap === true && ! extension_loaded('ldap')) {
|
||||
Log::error("Can't use ldap. Extension not installed");
|
||||
}
|
||||
|
||||
// TODO: Single Sign On?
|
||||
// Standard login
|
||||
// Check if the user is in our db
|
||||
// Check even if ldap is turned on to allow contractors and clients to have an account
|
||||
$user = $this->authRepo->getUserByLogin($username, $password);
|
||||
|
||||
if ($user !== false && is_array($user)) {
|
||||
$this->setUserSession($user);
|
||||
|
||||
self::dispatch_event('afterLoginCheck', ['username' => $username, 'password' => $password, 'authService' => app()->make(self::class)]);
|
||||
|
||||
return true;
|
||||
} else {
|
||||
$this->logFailedLogin($username);
|
||||
self::dispatch_event('afterLoginCheck', ['username' => $username, 'password' => $password, 'authService' => app()->make(self::class)]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new personal access token
|
||||
*/
|
||||
public function createToken(string $name, array $abilities = ['*']): array
|
||||
{
|
||||
if (! $this->loggedIn()) {
|
||||
throw new \Exception('User must be authenticated to create token');
|
||||
}
|
||||
|
||||
return $this->tokenRepo->createToken($this->getUserId(), $name, $abilities);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return false|void
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function setUserSession(mixed $user, bool $isExternalAuth = false)
|
||||
{
|
||||
if (! $user || ! is_array($user)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Web-login session. twoFAVerified: false — the web flow enforces interactive 2FA via the
|
||||
// AuthCheck gate. Built via the shared factory (role NAME string + consistent fields), with
|
||||
// the web-only globalUserId added on top.
|
||||
$currentUser = UserSessionBuilder::build($user, isExternalAuth: $isExternalAuth, twoFAVerified: false);
|
||||
$currentUser['globalUserId'] = Uuid::uuid5(Uuid::NAMESPACE_DNS, strtolower($user['username']));
|
||||
|
||||
$currentUser = self::dispatch_filter('user_session_vars', $currentUser);
|
||||
|
||||
session(['userdata' => $currentUser]);
|
||||
session(['usersettings' => $currentUser['settings']]);
|
||||
|
||||
$this->updateUserSessionDB($currentUser['id'], session()->getId());
|
||||
|
||||
// Clear user theme cache on login
|
||||
Theme::clearCache();
|
||||
}
|
||||
|
||||
public function updateUserSessionDB(int $userId, string $sessionID): bool
|
||||
{
|
||||
return $this->authRepo->updateUserSession($userId, $sessionID, (string) time());
|
||||
}
|
||||
|
||||
/**
|
||||
* logged_in - Check if logged in and Update sessions
|
||||
*/
|
||||
public function loggedIn(): bool
|
||||
{
|
||||
// Check if we actually have a php session available
|
||||
if (session()->exists('userdata')) {
|
||||
return true;
|
||||
// If the session doesn't have any session data we are out of sync. Start again
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a user is logged in.
|
||||
*
|
||||
* @return bool Returns true if the user is logged in, false otherwise.
|
||||
*/
|
||||
public static function isLoggedIn(): bool
|
||||
{
|
||||
|
||||
// Check if we actually have a php session available
|
||||
if (session()->exists('userdata')) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* logout - destroy sessions and cookies
|
||||
*
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function logout(): void
|
||||
{
|
||||
|
||||
$this->authRepo->invalidateSession($this->session->getId());
|
||||
|
||||
$sessionsToDestroy = self::dispatch_filter('sessions_vars_to_destroy', [
|
||||
'userdata',
|
||||
'template',
|
||||
'subdomainData',
|
||||
'currentProject',
|
||||
'currentSprint',
|
||||
'projectsettings',
|
||||
'currentSubscriptions',
|
||||
'lastTicketView',
|
||||
'lastFilteredTicketTableView',
|
||||
]);
|
||||
|
||||
foreach ($sessionsToDestroy as $key) {
|
||||
session()->forget($key);
|
||||
}
|
||||
|
||||
self::dispatch_event('afterSessionDestroy', ['authService' => app()->make(self::class)]);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* validateResetLink - validates that the password reset link belongs to a user account in the database
|
||||
*
|
||||
* @param string $hash invite link hash
|
||||
*/
|
||||
public function validateResetLink(string $hash): bool
|
||||
{
|
||||
|
||||
return $this->authRepo->validateResetLink($hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* getUserByInviteLink - gets the user by invite link
|
||||
*
|
||||
* @param string $hash invite link hash
|
||||
*/
|
||||
public function getUserByInviteLink(string $hash): bool|array
|
||||
{
|
||||
return $this->authRepo->getUserByInviteLink($hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* generateLinkAndSendEmail - generates an invitation link (hash) and sends email to user
|
||||
*
|
||||
* @param string $username new user to be invited (email)
|
||||
* @return bool returns true on success, false on failure
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function generateLinkAndSendEmail(string $username): bool
|
||||
{
|
||||
|
||||
$userFromDB = $this->userRepo->getUserByEmail($username);
|
||||
|
||||
if ($userFromDB !== false && count($userFromDB) > 0) {
|
||||
if ($userFromDB['pwResetCount'] < $this->pwResetLimit) {
|
||||
$permitted_chars = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
$resetLink = substr(str_shuffle($permitted_chars), 0, 32);
|
||||
|
||||
$result = $this->authRepo->setPWResetLink($username, $resetLink);
|
||||
|
||||
if ($result) {
|
||||
// Don't queue, send right away
|
||||
$mailer = app()->make(MailerCore::class);
|
||||
$mailer->setContext('password_reset');
|
||||
$mailer->setSubject($this->language->__('email_notifications.password_reset_subject'));
|
||||
$actual_link = ''.BASE_URL.'/auth/resetPw/'.$resetLink;
|
||||
$mailer->setHtml(sprintf($this->language->__('email_notifications.password_reset_message'), $actual_link));
|
||||
$to = [$username];
|
||||
$mailer->sendMail($to, 'Leantime System');
|
||||
|
||||
return true;
|
||||
}
|
||||
} elseif ($this->config->debug) {
|
||||
|
||||
Log::warning('PW reset failed: maximum request count has been reached for user '.$userFromDB['id']);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function changePw(string $password, string $hash): bool
|
||||
{
|
||||
return $this->authRepo->changePW($password, $hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* checkPasswordStrength - validates that a password meets the minimum strength requirements.
|
||||
*
|
||||
* Password must be at least 8 characters and include an upper case letter,
|
||||
* a lower case letter, a number and a special character.
|
||||
*
|
||||
* @param string $password the password to validate
|
||||
* @return bool returns true if the password is strong enough, false otherwise
|
||||
*/
|
||||
public function checkPasswordStrength(string $password): bool
|
||||
{
|
||||
$uppercase = preg_match('@[A-Z]@', $password);
|
||||
$lowercase = preg_match('@[a-z]@', $password);
|
||||
$number = preg_match('@[0-9]@', $password);
|
||||
$specialChars = preg_match('@[^\w]@', $password);
|
||||
|
||||
if (! $uppercase || ! $lowercase || ! $number || ! $specialChars || strlen($password) < 8) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* resetPassword - validates and applies a password reset for a given reset link.
|
||||
*
|
||||
* Performs the password match check, strength check and persists the new
|
||||
* password. Returns a status string the caller can map to a notification:
|
||||
* 'success', 'mismatch', 'weak' or 'error'.
|
||||
*
|
||||
* @param string $password the new password
|
||||
* @param string $passwordConfirm the password confirmation
|
||||
* @param string $hash the password reset link hash
|
||||
* @return string one of 'success', 'mismatch', 'weak', 'error'
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function resetPassword(string $password, string $passwordConfirm, string $hash): string
|
||||
{
|
||||
if (strlen($password) === 0 || $password !== $passwordConfirm) {
|
||||
return 'mismatch';
|
||||
}
|
||||
|
||||
if (! $this->checkPasswordStrength($password)) {
|
||||
return 'weak';
|
||||
}
|
||||
|
||||
if ($this->changePw($password, $hash)) {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
return 'error';
|
||||
}
|
||||
|
||||
/**
|
||||
* resolveSafeRedirect - resolves a user supplied redirect target into a safe,
|
||||
* application-internal absolute URL, guarding against open redirects.
|
||||
*
|
||||
* @param string|null $redirect the raw redirect target (typically from the request)
|
||||
* @return string an absolute URL that is safe to redirect to
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function resolveSafeRedirect(?string $redirect): string
|
||||
{
|
||||
$redirectUrl = BASE_URL.'/dashboard/home';
|
||||
|
||||
if ($redirect !== null && trim($redirect) !== '' && trim($redirect) !== '/') {
|
||||
// Normalize backslash-based protocol tricks (e.g. \/\/attacker.com)
|
||||
// to forward slashes before any checks.
|
||||
$url = str_replace('\\', '/', rawurldecode($redirect));
|
||||
|
||||
// Drop control characters and surrounding whitespace before any guard, so a
|
||||
// padded variant (" //evil.com", "%09//evil.com") can't slip past the checks
|
||||
// below and can't reach the Location header.
|
||||
$url = trim(preg_replace('/[\x00-\x1F\x7F]/', '', $url));
|
||||
|
||||
// Strip the application base URL when present so that same-origin
|
||||
// absolute URLs (e.g. https://my-leantime.com/dashboard/home) are
|
||||
// treated the same as their relative counterparts.
|
||||
//
|
||||
// Match only on a real boundary: a bare str_starts_with() would also fire on
|
||||
// https://hostile.com/pwn when BASE_URL is https://host, rewriting an external
|
||||
// URL into the bogus internal path /ile.com/pwn instead of rejecting it. The
|
||||
// same applies to subdirectory installs (BASE_URL /app vs a /application path).
|
||||
$base = rtrim(BASE_URL, '/');
|
||||
|
||||
if ($base !== '' && (
|
||||
$url === $base
|
||||
|| str_starts_with($url, $base.'/')
|
||||
|| str_starts_with($url, $base.'?')
|
||||
|| str_starts_with($url, $base.'#')
|
||||
)) {
|
||||
$url = substr($url, strlen($base));
|
||||
}
|
||||
|
||||
// Guard: protocol-relative URL (//attacker.com) — explicitly reject.
|
||||
// FILTER_VALIDATE_URL treats these as valid without a scheme, but
|
||||
// browsers resolve them to the current scheme, making them an open
|
||||
// redirect vector.
|
||||
if (str_starts_with($url, '//')) {
|
||||
return $redirectUrl;
|
||||
}
|
||||
|
||||
// Guard: external absolute URL — reject.
|
||||
// filter_var returns the URL (truthy) for well-formed absolute URLs
|
||||
// with a scheme; relative paths return false.
|
||||
if (filter_var($url, FILTER_VALIDATE_URL) !== false) {
|
||||
return $redirectUrl;
|
||||
}
|
||||
|
||||
// At this point $url is a relative path. Guard against an empty
|
||||
// path that could result from stripping a BASE_URL-only input.
|
||||
$url = ltrim($url, '/');
|
||||
|
||||
// Block redirect to logout — allowing a POST-login redirect to
|
||||
// /auth/logout would create a forced-logout loop. Compare the normalized
|
||||
// path so the query string, a trailing slash and casing can't be used to
|
||||
// walk around the block (/auth/logout/, /auth/logout?next=/x, /AUTH/logout).
|
||||
$path = rtrim(strtolower(strtok($url, '?#')), '/');
|
||||
|
||||
if ($url !== '' && $path !== 'auth/logout') {
|
||||
$redirectUrl = BASE_URL.'/'.$url;
|
||||
}
|
||||
}
|
||||
|
||||
return $redirectUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* shouldHideLoginForm - determines whether the default login form should be hidden,
|
||||
* combining the admin setting with the configured disableLoginForm flag.
|
||||
*
|
||||
* @return bool returns true if the default login form should be hidden
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function shouldHideLoginForm(): bool
|
||||
{
|
||||
$hideLogin = $this->settingsRepo->getSetting('auth.hideDefaultLogin');
|
||||
|
||||
if (! empty($hideLogin) && $hideLogin == 'on') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (bool) $this->config->disableLoginForm;
|
||||
}
|
||||
|
||||
/**
|
||||
* getLoginInputPlaceholder - returns the translation key for the login input placeholder
|
||||
* depending on whether LDAP authentication is enabled.
|
||||
*
|
||||
* @return string the placeholder translation key
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function getLoginInputPlaceholder(): string
|
||||
{
|
||||
if ($this->config->useLdap) {
|
||||
return 'input.placeholders.enter_email_or_username';
|
||||
}
|
||||
|
||||
return 'input.placeholders.enter_email';
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public static function userIsAtLeast(string $role, bool $forceGlobalRoleCheck = false): bool
|
||||
{
|
||||
|
||||
// Force Global Role check to circumvent projectRole checks for global controllers (users, projects, clients etc)
|
||||
$roleToCheck = self::getRoleToCheck($forceGlobalRoleCheck);
|
||||
|
||||
if ($roleToCheck === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$testKey = array_search($role, Roles::getRoles());
|
||||
|
||||
if ($role == '' || $testKey === false) {
|
||||
Log::warning('Check for invalid role detected: '.$role);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$currentUserKey = array_search($roleToCheck, Roles::getRoles());
|
||||
|
||||
if ($testKey <= $currentUserKey) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws HttpResponseException
|
||||
*/
|
||||
public static function authOrRedirect(array|string $role, bool $forceGlobalRoleCheck = false): bool
|
||||
{
|
||||
if (self::userHasRole($role, $forceGlobalRoleCheck)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new HttpResponseException(FrontcontrollerCore::redirect(BASE_URL.'/errors/error403'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public static function userHasRole(string|array $role, bool $forceGlobalRoleCheck = false): bool
|
||||
{
|
||||
|
||||
// Force Global Role check to circumvent projectRole checks for global controllers (users, projects, clients etc)
|
||||
$roleToCheck = self::getRoleToCheck($forceGlobalRoleCheck);
|
||||
|
||||
if (is_array($role) && in_array($roleToCheck, $role)) {
|
||||
return true;
|
||||
} elseif ($role == $roleToCheck) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function getRole(): void {}
|
||||
|
||||
public static function getUserClientId(): mixed
|
||||
{
|
||||
return session('userdata.clientId');
|
||||
}
|
||||
|
||||
public static function getUserId(): mixed
|
||||
{
|
||||
return session('userdata.id');
|
||||
}
|
||||
|
||||
public function use2FA(): mixed
|
||||
{
|
||||
return session('userdata.twoFAEnabled');
|
||||
}
|
||||
|
||||
public function verify2FA(string $code): bool
|
||||
{
|
||||
$twoFactorAuthentication = new TwoFactorAuth('Leantime');
|
||||
|
||||
return $twoFactorAuthentication->verifyCode(session('userdata.twoFASecret'), $code);
|
||||
}
|
||||
|
||||
public function get2FAVerified(): mixed
|
||||
{
|
||||
return session('userdata.twoFAVerified');
|
||||
}
|
||||
|
||||
public function set2FAVerified(): void
|
||||
{
|
||||
session(['userdata.twoFAVerified' => true]);
|
||||
}
|
||||
|
||||
private function logFailedLogin(string $user): void
|
||||
{
|
||||
$user = $user == '' ? 'unknown' : $user;
|
||||
$date = new \DateTime;
|
||||
$date = $date->format('y:m:d h:i:s');
|
||||
|
||||
$ip = $_SERVER['REMOTE_ADDR'];
|
||||
$msg = '['.$date.']['.$ip.'] Login failed for user: '.$user;
|
||||
|
||||
Log::info($msg);
|
||||
}
|
||||
|
||||
public function getAuthIdentifierName()
|
||||
{
|
||||
return 'id';
|
||||
}
|
||||
|
||||
public function getAuthIdentifier()
|
||||
{
|
||||
return $this->userId;
|
||||
}
|
||||
|
||||
public function getAuthPassword()
|
||||
{
|
||||
return $this->password;
|
||||
}
|
||||
|
||||
public function getAuthPasswordName()
|
||||
{
|
||||
return 'password';
|
||||
}
|
||||
|
||||
public function getRememberToken()
|
||||
{
|
||||
return ''; // Not implemented yet (Authenticatable::getRememberToken is contractually a string)
|
||||
}
|
||||
|
||||
public function setRememberToken($value)
|
||||
{
|
||||
// Not implemented yet
|
||||
}
|
||||
|
||||
public function getRememberTokenName()
|
||||
{
|
||||
return 'remember_token';
|
||||
}
|
||||
|
||||
public function getUserById($id)
|
||||
{
|
||||
return (object) $this->userRepo->getUser($id);
|
||||
}
|
||||
|
||||
public function validateToken(string $token): bool
|
||||
{
|
||||
$user = $this->getUserByToken($token);
|
||||
|
||||
if ($user) {
|
||||
$this->setUserSession($user);
|
||||
|
||||
// Turn off 2FA for token verification
|
||||
$this->set2FAVerified();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
public function getUserByToken(string $token): array|bool
|
||||
{
|
||||
$tokenModel = $this->tokenRepo->findToken($token);
|
||||
|
||||
if (! $tokenModel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($tokenModel['expires_at'] && strtotime($tokenModel['expires_at']) < time()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Load the user associated with this token
|
||||
$user = $this->userRepo->getUser($tokenModel['tokenable_id']);
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->tokenRepo->updateLastUsedAt($tokenModel['id']);
|
||||
|
||||
return $user;
|
||||
|
||||
}
|
||||
}
|
||||
124
app/Domain/Auth/Services/AuthUser.php
Normal file
124
app/Domain/Auth/Services/AuthUser.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Auth\Services;
|
||||
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Contracts\Auth\UserProvider;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Leantime\Domain\Auth\Models\AuthenticatableUser;
|
||||
use Leantime\Domain\Auth\Services\Auth as AuthService;
|
||||
|
||||
class AuthUser implements UserProvider
|
||||
{
|
||||
use HasApiTokens;
|
||||
|
||||
protected $authRepo;
|
||||
|
||||
protected $userRepo;
|
||||
|
||||
protected $userdata;
|
||||
|
||||
public function __construct(
|
||||
protected AuthService $authService)
|
||||
{
|
||||
$this->authRepo = $this->authService->authRepo;
|
||||
$this->userRepo = $this->authService->userRepo;
|
||||
}
|
||||
|
||||
public function retrieveById($identifier)
|
||||
{
|
||||
$userData = $this->userRepo->getUser($identifier);
|
||||
|
||||
// Not found → null, per the UserProvider contract. Returning a (non-null) empty user
|
||||
// object would let the guard treat the request as authenticated.
|
||||
if (empty($userData)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AuthenticatableUser((array) $userData);
|
||||
}
|
||||
|
||||
public function retrieveByToken($identifier, $token)
|
||||
{
|
||||
$userData = $this->authService->getUserByToken($token);
|
||||
|
||||
if (empty($userData)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AuthenticatableUser((array) $userData);
|
||||
}
|
||||
|
||||
public function updateRememberToken(Authenticatable $user, $token)
|
||||
{
|
||||
// Not implemented for now
|
||||
}
|
||||
|
||||
public function retrieveByCredentials(array $credentials)
|
||||
{
|
||||
if (! isset($credentials['username'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->authRepo->getUserByLogin(
|
||||
$credentials['username'],
|
||||
$credentials['password'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
public function validateCredentials(Authenticatable $user, array $credentials)
|
||||
{
|
||||
return $this->authService->login(
|
||||
$credentials['username'],
|
||||
$credentials['password']
|
||||
);
|
||||
}
|
||||
|
||||
public function rehashPasswordIfRequired(Authenticatable $user, array $credentials, bool $force = false) {}
|
||||
|
||||
public function getOrCreateUser($user, $source)
|
||||
{
|
||||
// Look up the existing account in a separate variable — the $user param holds the
|
||||
// external/OAuth profile data we need to create the account from, so it must not be
|
||||
// overwritten by the lookup result (doing so previously built new users with empty fields).
|
||||
$existingUser = $this->authRepo->getUserByEmail($user['email']);
|
||||
|
||||
if (empty($existingUser) && config()->get('auth.create_user')) {
|
||||
|
||||
$userArray = [
|
||||
'firstname' => $user['firstname'],
|
||||
'lastname' => $user['lastname'],
|
||||
'phone' => $user['phone'] ?? '',
|
||||
'user' => $user['email'] ?? '',
|
||||
'role' => $user['role'] ?? '30',
|
||||
'department' => $user['department'] ?? '',
|
||||
'jobTitle' => $user['jobTitle'] ?? '',
|
||||
'jobLevel' => $user['jobLevel'] ?? '',
|
||||
'password' => '',
|
||||
'clientId' => '',
|
||||
'source' => $source,
|
||||
'status' => 'a',
|
||||
];
|
||||
|
||||
$this->userRepo->addUser($userArray);
|
||||
$existingUser = $this->authRepo->getUserByEmail($user['email']);
|
||||
}
|
||||
|
||||
return $existingUser;
|
||||
}
|
||||
|
||||
public function setUser($userId)
|
||||
{
|
||||
$this->userdata = $this->userRepo->getUser($userId);
|
||||
|
||||
$this->setUserSession($this->userdata);
|
||||
}
|
||||
|
||||
protected function setUserSession($user)
|
||||
{
|
||||
// Sanctum/Bearer-token session. twoFAVerified: true — the token is the strong credential
|
||||
// and no interactive 2FA is possible. Built via the shared factory so role (NAME string,
|
||||
// not raw int) and every other field stay identical to the web + x-api-key paths.
|
||||
session(['userdata' => UserSessionBuilder::build($user, isExternalAuth: false, twoFAVerified: true)]);
|
||||
}
|
||||
}
|
||||
368
app/Domain/Auth/Services/Onboarding.php
Normal file
368
app/Domain/Auth/Services/Onboarding.php
Normal file
@@ -0,0 +1,368 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Auth\Services;
|
||||
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use Leantime\Core\Language as LanguageCore;
|
||||
use Leantime\Core\UI\Theme;
|
||||
use Leantime\Domain\Auth\Services\Auth as AuthService;
|
||||
use Leantime\Domain\Setting\Services\Setting as SettingService;
|
||||
use Leantime\Domain\Users\Services\Users as UserService;
|
||||
|
||||
/**
|
||||
* Onboarding service - encapsulates the multi-step user invite / onboarding
|
||||
* flow that was previously orchestrated inside the UserInvite controller.
|
||||
*/
|
||||
class Onboarding
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
/**
|
||||
* The event context the onboarding controller used to dispatch events under.
|
||||
*
|
||||
* Onboarding events are dispatched from this service, but the original
|
||||
* (controller-based) event names must be preserved so registered listeners
|
||||
* (including plugins) keep matching. Passing this fully-qualified context to
|
||||
* the dispatch helpers keeps the emitted event names byte-identical.
|
||||
*/
|
||||
private const EVENT_CONTEXT = 'leantime.domain.auth.controllers.userinvite.post';
|
||||
|
||||
/**
|
||||
* init - initializes the service dependencies.
|
||||
*/
|
||||
public function __construct(
|
||||
private AuthService $authService,
|
||||
private UserService $userService,
|
||||
private SettingService $settingService,
|
||||
private Theme $themeCore,
|
||||
private LanguageCore $language
|
||||
) {}
|
||||
|
||||
/**
|
||||
* getInviteSettings - builds the defaulted settings payload used to render the
|
||||
* onboarding screens for a given invited user.
|
||||
*
|
||||
* @param array $user the invited user record
|
||||
* @return array the resolved settings (theme, colorMode, colorScheme, themeFont,
|
||||
* date/time formats, timezone, workdays and daySchedule plus the
|
||||
* available option catalogs)
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function getInviteSettings(array $user): array
|
||||
{
|
||||
$userId = $user['id'];
|
||||
|
||||
$userTheme = $this->settingService->getSetting('usersettings.'.$userId.'.theme');
|
||||
if (! $userTheme) {
|
||||
$userTheme = 'default';
|
||||
}
|
||||
|
||||
$userColorMode = $this->settingService->getSetting('usersettings.'.$userId.'.colorMode');
|
||||
if (! $userColorMode) {
|
||||
$userColorMode = 'light';
|
||||
}
|
||||
|
||||
$userColorScheme = $this->settingService->getSetting('usersettings.'.$userId.'.colorScheme');
|
||||
if (! $userColorScheme) {
|
||||
$userColorScheme = 'companyColors';
|
||||
}
|
||||
|
||||
$themeFont = $this->settingService->getSetting('usersettings.'.$userId.'.themeFont');
|
||||
if (! $themeFont) {
|
||||
$themeFont = 'Roboto';
|
||||
}
|
||||
|
||||
$userDateFormat = $this->settingService->getSetting('usersettings.'.$userId.'.date_format');
|
||||
$userTimeFormat = $this->settingService->getSetting('usersettings.'.$userId.'.time_format');
|
||||
|
||||
$timezone = $this->settingService->getSetting('usersettings.'.$userId.'.timezone');
|
||||
if (! $timezone) {
|
||||
$timezone = date_default_timezone_get();
|
||||
}
|
||||
|
||||
$workdays = $this->settingService->getSetting('usersettings.'.$userId.'.workdays');
|
||||
if (! $workdays) {
|
||||
$workdays = $this->getDefaultWorkdays();
|
||||
} else {
|
||||
$workdays = safe_unserialize($workdays, []);
|
||||
}
|
||||
|
||||
$daySchedule = $this->settingService->getSetting('usersettings.'.$userId.'.daySchedule');
|
||||
if ($daySchedule) {
|
||||
$daySchedule = safe_unserialize($daySchedule, []);
|
||||
} else {
|
||||
$daySchedule = $this->getDefaultDaySchedule();
|
||||
}
|
||||
|
||||
return [
|
||||
'userTheme' => $userTheme,
|
||||
'userColorMode' => $userColorMode,
|
||||
'userColorScheme' => $userColorScheme,
|
||||
'themeFont' => $themeFont,
|
||||
'dateFormat' => $userDateFormat,
|
||||
'timeFormat' => $userTimeFormat,
|
||||
'dateTimeValues' => $this->getSupportedDateTimeFormats(),
|
||||
'timezone' => $timezone,
|
||||
'timezoneOptions' => timezone_identifiers_list(),
|
||||
'availableColorSchemes' => $this->themeCore->getAvailableColorSchemes(),
|
||||
'availableFonts' => $this->themeCore->getAvailableFonts(),
|
||||
'fontTooltips' => $this->themeCore->fontTooltips,
|
||||
'availableThemes' => $this->themeCore->getAll(),
|
||||
'languageList' => $this->language->getLanguageList(),
|
||||
'workdays' => $workdays,
|
||||
'daySchedule' => $daySchedule,
|
||||
'dayHourOptions' => $this->getDayHourOptions(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* getDefaultWorkdays - returns the default weekly working hours used when a
|
||||
* user has not yet configured their schedule.
|
||||
*
|
||||
* @return array<int, array{start: string, end: string}>
|
||||
*/
|
||||
public function getDefaultWorkdays(): array
|
||||
{
|
||||
return [
|
||||
1 => ['start' => '09:00', 'end' => '17:00'],
|
||||
2 => ['start' => '09:00', 'end' => '17:00'],
|
||||
3 => ['start' => '09:00', 'end' => '17:00'],
|
||||
4 => ['start' => '09:00', 'end' => '17:00'],
|
||||
5 => ['start' => '09:00', 'end' => '17:00'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* getDefaultDaySchedule - returns the default daily schedule used when a user
|
||||
* has not yet configured one.
|
||||
*
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public function getDefaultDaySchedule(): array
|
||||
{
|
||||
return [
|
||||
'wakeup' => 6,
|
||||
'workStart' => 8,
|
||||
'lunch' => 12,
|
||||
'workEnd' => 16,
|
||||
'bed' => 22,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* getDayHourOptions - returns the catalog of selectable two-hour blocks used
|
||||
* when configuring the daily schedule.
|
||||
*
|
||||
* @return array<int, array{start: string, end: string}>
|
||||
*/
|
||||
public function getDayHourOptions(): array
|
||||
{
|
||||
return [
|
||||
0 => ['start' => '0:00', 'end' => '2:00'],
|
||||
2 => ['start' => '2:00', 'end' => '4:00'],
|
||||
4 => ['start' => '4:00', 'end' => '6:00'],
|
||||
6 => ['start' => '6:00', 'end' => '8:00'],
|
||||
8 => ['start' => '8:00', 'end' => '10:00'],
|
||||
10 => ['start' => '10:00', 'end' => '12:00'],
|
||||
12 => ['start' => '12:00', 'end' => '14:00'],
|
||||
14 => ['start' => '14:00', 'end' => '16:00'],
|
||||
16 => ['start' => '16:00', 'end' => '18:00'],
|
||||
18 => ['start' => '18:00', 'end' => '20:00'],
|
||||
20 => ['start' => '20:00', 'end' => '22:00'],
|
||||
22 => ['start' => '22:00', 'end' => '0:00'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* getSupportedDateTimeFormats - returns the catalog of supported date and time
|
||||
* format options shown during onboarding.
|
||||
*
|
||||
* @return array{dates: array<int, string>, times: array<int, string>}
|
||||
*/
|
||||
public function getSupportedDateTimeFormats(): array
|
||||
{
|
||||
return [
|
||||
'dates' => [
|
||||
$this->language->__('language.dateformat'),
|
||||
'Y-m-d',
|
||||
'D, d M y',
|
||||
'l, d-M-y',
|
||||
'd.m.Y',
|
||||
'd/m/Y',
|
||||
'd. F Y',
|
||||
'm-d-Y',
|
||||
'dmY',
|
||||
'F d, Y',
|
||||
'd F Y',
|
||||
],
|
||||
'times' => [
|
||||
$this->language->__('language.timeformat'),
|
||||
'H:i P',
|
||||
'H:i O',
|
||||
'H:i T',
|
||||
'H:i:s',
|
||||
'H:i',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* saveAccount - first onboarding step: validates the chosen password, assembles
|
||||
* the user record from the submitted profile fields and persists it.
|
||||
*
|
||||
* The plaintext password is stored in the session (tempPassword) so the user can
|
||||
* be logged in automatically once onboarding completes, mirroring the original flow.
|
||||
*
|
||||
* @param array $userInvite the invited user record (resolved from the invite link)
|
||||
* @param string $name the full name as submitted (split into first/last name)
|
||||
* @param string $jobTitle the submitted job title
|
||||
* @param string $password the chosen password
|
||||
* @return string 'weak' if the password is not strong enough, 'saved' if the user was
|
||||
* persisted, 'error' if persistence failed
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function saveAccount(array $userInvite, string $name, string $jobTitle, string $password): string
|
||||
{
|
||||
if (! $this->userService->checkPasswordStrength($password)) {
|
||||
return 'weak';
|
||||
}
|
||||
|
||||
$nameParts = explode(' ', $name);
|
||||
$userInvite['firstname'] = $nameParts[0];
|
||||
$userInvite['lastname'] = $nameParts[1] ?? '';
|
||||
$userInvite['jobTitle'] = $jobTitle;
|
||||
$userInvite['status'] = 'i';
|
||||
$userInvite['user'] = $userInvite['username'];
|
||||
$userInvite['password'] = $password;
|
||||
|
||||
session(['tempPassword' => $password]);
|
||||
|
||||
if ($this->userService->editUser($userInvite, $userInvite['id'])) {
|
||||
return 'saved';
|
||||
}
|
||||
|
||||
return 'error';
|
||||
}
|
||||
|
||||
/**
|
||||
* saveThemeChoice - second onboarding step: persists the chosen theme and font,
|
||||
* activates them and dispatches the related onboarding events.
|
||||
*
|
||||
* @param array $userInvite the invited user record
|
||||
* @param string $theme the chosen theme
|
||||
* @param string $themeFont the chosen font
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function saveThemeChoice(array $userInvite, string $theme, string $themeFont): void
|
||||
{
|
||||
$postTheme = htmlentities($theme);
|
||||
$font = htmlentities($themeFont);
|
||||
|
||||
$this->settingService->saveSetting('usersettings.'.$userInvite['id'].'.theme', $postTheme);
|
||||
$this->settingService->saveSetting('usersettings.'.$userInvite['id'].'.themeFont', $font);
|
||||
|
||||
$this->themeCore->clearCache();
|
||||
$this->themeCore->setActive($postTheme);
|
||||
$this->themeCore->setFont($font);
|
||||
$this->themeCore->clearCache();
|
||||
|
||||
self::dispatchEvent('onboarding_themechoice_'.$postTheme, [], self::EVENT_CONTEXT);
|
||||
self::dispatchEvent('onboarding_themechoice_'.$font, [], self::EVENT_CONTEXT);
|
||||
}
|
||||
|
||||
/**
|
||||
* saveColorChoice - third onboarding step: persists the chosen color mode and
|
||||
* scheme, activates them and dispatches the related onboarding events.
|
||||
*
|
||||
* @param array $userInvite the invited user record
|
||||
* @param string $colorMode the chosen color mode
|
||||
* @param string $colorScheme the chosen color scheme
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function saveColorChoice(array $userInvite, string $colorMode, string $colorScheme): void
|
||||
{
|
||||
$postColorMode = htmlentities($colorMode);
|
||||
$postColorScheme = htmlentities($colorScheme);
|
||||
|
||||
$this->settingService->saveSetting('usersettings.'.$userInvite['id'].'.colorMode', $postColorMode);
|
||||
$this->settingService->saveSetting('usersettings.'.$userInvite['id'].'.colorScheme', $postColorScheme);
|
||||
|
||||
self::dispatchEvent('onboarding_colorchoice_'.$postColorMode, [], self::EVENT_CONTEXT);
|
||||
self::dispatchEvent('onboarding_colorchoice_'.$postColorScheme, [], self::EVENT_CONTEXT);
|
||||
|
||||
$this->themeCore->clearCache();
|
||||
$this->themeCore->setColorMode($postColorMode);
|
||||
$this->themeCore->setColorScheme($postColorScheme);
|
||||
$this->themeCore->clearCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* saveSchedule - fourth onboarding step: assembles the day schedule from the
|
||||
* submitted values, dispatches the related onboarding events and persists it.
|
||||
*
|
||||
* @param array $userInvite the invited user record
|
||||
* @param string $workStart the submitted work start block
|
||||
* @param string $lunch the submitted lunch block
|
||||
* @param string $workEnd the submitted work end block
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function saveSchedule(array $userInvite, string $workStart, string $lunch, string $workEnd): void
|
||||
{
|
||||
$daySchedule = [
|
||||
'wakeup' => '',
|
||||
'workStart' => $workStart,
|
||||
'lunch' => $lunch,
|
||||
'workEnd' => $workEnd,
|
||||
'bed' => '',
|
||||
];
|
||||
|
||||
self::dispatchEvent('onboarding_schedule_start_'.$daySchedule['workStart'], [], self::EVENT_CONTEXT);
|
||||
self::dispatchEvent('onboarding_schedule_lunch_'.$daySchedule['lunch'], [], self::EVENT_CONTEXT);
|
||||
self::dispatchEvent('onboarding_schedule_end_'.$daySchedule['workEnd'], [], self::EVENT_CONTEXT);
|
||||
|
||||
$this->settingService->saveSetting('usersettings.'.$userInvite['id'].'.daySchedule', serialize($daySchedule));
|
||||
}
|
||||
|
||||
/**
|
||||
* completeOnboarding - final onboarding step: activates the user, dispatches the
|
||||
* onboarding-finished and signup-success events, then logs the user in using the
|
||||
* temporary password captured during account setup.
|
||||
*
|
||||
* @param array $userInvite the invited user record
|
||||
* @return bool true if the user was successfully logged in, false otherwise
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function completeOnboarding(array $userInvite): bool
|
||||
{
|
||||
$userInvite['status'] = 'A';
|
||||
$userInvite['password'] = '';
|
||||
$userInvite['user'] = $userInvite['username'];
|
||||
|
||||
$this->userService->editUser($userInvite, $userInvite['id']);
|
||||
|
||||
self::dispatchEvent('onboarding_finished', [], self::EVENT_CONTEXT);
|
||||
|
||||
$loggedIn = $this->authService->login($userInvite['username'], session('tempPassword'));
|
||||
|
||||
session()->forget('tempPassword');
|
||||
|
||||
self::dispatch_event('userSignUpSuccess', ['user' => $userInvite], self::EVENT_CONTEXT);
|
||||
|
||||
return $loggedIn;
|
||||
}
|
||||
}
|
||||
56
app/Domain/Auth/Services/UserSessionBuilder.php
Normal file
56
app/Domain/Auth/Services/UserSessionBuilder.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Auth\Services;
|
||||
|
||||
use Leantime\Core\Support\NameSanitizer;
|
||||
use Leantime\Domain\Auth\Models\Roles;
|
||||
|
||||
/**
|
||||
* Single source of truth for the `session('userdata')` array.
|
||||
*
|
||||
* Every authentication path — web login ({@see Auth::setUserSession}), x-api-key
|
||||
* ({@see \Leantime\Domain\Api\Services\Api::setApiUserSession}) and Sanctum/Bearer tokens
|
||||
* ({@see AuthUser::setUserSession}) — builds this same structure. They used to each build it
|
||||
* inline, which let fields drift silently between paths:
|
||||
* - `role` was stored as the raw DB int on the Bearer path but as the role-NAME string on the
|
||||
* others; the permission engine validates against the name list, so Bearer auth denied every
|
||||
* gated method with -32001 (the 3.9.x regression).
|
||||
* - `twoFAVerified` likewise diverged between the two token paths.
|
||||
*
|
||||
* Routing all three through this factory makes those bugs structurally impossible: `role` is
|
||||
* ALWAYS converted via {@see Roles::getRoleString()}, and every field is produced identically.
|
||||
* The two genuinely per-path values — whether the session is external-auth and whether 2FA is
|
||||
* already satisfied — are explicit parameters.
|
||||
*/
|
||||
class UserSessionBuilder
|
||||
{
|
||||
/**
|
||||
* Build the canonical userdata array from a `zp_user` row.
|
||||
*
|
||||
* @param array $user A zp_user row (id, firstname, username, profileId, clientId, role, …).
|
||||
* @param bool $isExternalAuth True when the user authenticated via an external provider.
|
||||
* @param bool $twoFAVerified True when 2FA is considered satisfied (token auth — the token
|
||||
* is the strong credential and no interactive 2FA is possible).
|
||||
* @return array<string, mixed> The userdata array to store in `session('userdata')`.
|
||||
*/
|
||||
public static function build(array $user, bool $isExternalAuth = false, bool $twoFAVerified = false): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $user['id'],
|
||||
'name' => NameSanitizer::clean($user['firstname'] ?? ''),
|
||||
'profileId' => $user['profileId'] ?? '',
|
||||
'mail' => filter_var($user['username'] ?? '', FILTER_SANITIZE_EMAIL),
|
||||
'clientId' => $user['clientId'] ?? '',
|
||||
// ALWAYS the role-NAME string the permission engine validates against — never the raw
|
||||
// DB int. This is the field whose drift caused the Bearer -32001 regression.
|
||||
'role' => Roles::getRoleString($user['role']),
|
||||
'settings' => ! empty($user['settings']) ? safe_unserialize($user['settings'], []) : [],
|
||||
'twoFAEnabled' => $user['twoFAEnabled'] ?? false,
|
||||
'twoFAVerified' => $twoFAVerified,
|
||||
'twoFASecret' => $user['twoFASecret'] ?? '',
|
||||
'isExternalAuth' => $isExternalAuth,
|
||||
'createdOn' => ! empty($user['createdOn']) ? dtHelper()->parseDbDateTime($user['createdOn']) : dtHelper()->userNow(),
|
||||
'modified' => ! empty($user['modified']) ? dtHelper()->parseDbDateTime($user['modified']) : dtHelper()->userNow(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
@props([
|
||||
'percentComplete' => 0,
|
||||
'current' => '',
|
||||
'completed' => [],
|
||||
])
|
||||
|
||||
<div class="projectSteps">
|
||||
<div class="progressWrapper">
|
||||
<div class="progress">
|
||||
<div
|
||||
id="progressChecklistBar"
|
||||
class="progress-bar progress-bar-success tx-transition"
|
||||
role="progressbar"
|
||||
aria-valuenow="0"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
style="width: {{ $percentComplete }}%"
|
||||
><span class="sr-only">{{ $percentComplete }}%</span></div>
|
||||
</div>
|
||||
<div class="step @if($current=='account') current @endif @if(in_array("account", $completed)) complete @endif" style="left: 12%;">
|
||||
<a href="javascript:void(0)" data-toggle="dropdown" class="dropdown-toggle">
|
||||
<span class="innerCircle">
|
||||
@if(in_array("account", $completed))
|
||||
<i class="fa-solid fa-check" style="color:var(--main-action-color); padding-left:3px;"></i>
|
||||
@endif
|
||||
</span>
|
||||
<span class="title">
|
||||
Account
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="step @if($current=='theme') current @endif @if(in_array("theme", $completed)) complete @endif" style="left: 37%;">
|
||||
<a href="javascript:void(0)" data-toggle="dropdown" class="dropdown-toggle">
|
||||
<span class="innerCircle">
|
||||
@if(in_array("theme", $completed))
|
||||
<i class="fa-solid fa-check" style="color:var(--main-action-color); padding-left:3px;"></i>
|
||||
@endif
|
||||
</span>
|
||||
<span class="title">
|
||||
Theme
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="step @if($current=='personalization') current @endif @if(in_array("personalization", $completed)) complete @endif" style="left: 62%;">
|
||||
<a href="javascript:void(0)" data-toggle="dropdown" class="dropdown-toggle">
|
||||
<span class="innerCircle">
|
||||
@if(in_array("personalization", $completed))
|
||||
<i class="fa-solid fa-check" style="color:var(--main-action-color); padding-left:3px;"></i>
|
||||
@endif
|
||||
</span>
|
||||
<span class="title">
|
||||
Personalization
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="step @if($current=='time') current @endif @if(in_array("time", $completed)) complete @endif" style="left: 88%;">
|
||||
<a href="javascript:void(0)" data-toggle="dropdown" class="dropdown-toggle">
|
||||
<span class="innerCircle">
|
||||
@if(in_array("time", $completed))
|
||||
<i class="fa-solid fa-check" style="color:var(--main-action-color); padding-left:3px;"></i>
|
||||
@endif
|
||||
</span>
|
||||
<span class="title">
|
||||
Routine
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<br /><br /><br />
|
||||
63
app/Domain/Auth/Templates/login.blade.php
Normal file
63
app/Domain/Auth/Templates/login.blade.php
Normal file
@@ -0,0 +1,63 @@
|
||||
@extends($layout)
|
||||
@section('content')
|
||||
|
||||
@dispatchEvent('beforePageHeaderOpen')
|
||||
<div class="pageheader">
|
||||
@dispatchEvent('afterPageHeaderOpen')
|
||||
<div class="pagetitle">
|
||||
<h1>{!! __('headlines.login') !!}</h1>
|
||||
</div>
|
||||
@dispatchEvent('beforePageHeaderClose')
|
||||
</div>
|
||||
@dispatchEvent('afterPageHeaderClose')
|
||||
|
||||
<div class="regcontent">
|
||||
@dispatchEvent('afterRegcontentOpen')
|
||||
{!! $tpl->displayInlineNotification() !!}
|
||||
|
||||
@if ($noLoginForm === false)
|
||||
<form id="login" action="{{ BASE_URL }}/auth/login" method="post">
|
||||
@csrf
|
||||
@dispatchEvent('afterFormOpen')
|
||||
<input type="hidden" name="redirectUrl" value="{{ $redirectUrl }}" />
|
||||
|
||||
<div class="">
|
||||
<label for="username">Email</label>
|
||||
<x-global::forms.text-input name="username" id="username" placeholder="{{ __($inputPlaceholder) }}" value="" />
|
||||
</div>
|
||||
<div class="">
|
||||
<label for="password">Password</label>
|
||||
<x-global::forms.text-input type="password" name="password" id="password" autocomplete="off" placeholder="{{ __('input.placeholders.enter_password') }}" value="" />
|
||||
<div class="forgotPwContainer">
|
||||
<a href="{{ BASE_URL }}/auth/resetPw" class="forgotPw">{!! __('links.forgot_password') !!}</a>
|
||||
</div>
|
||||
</div>
|
||||
@dispatchEvent('beforeSubmitButton')
|
||||
<div class="">
|
||||
<x-global::forms.button tag="input" inputType="submit" name="login" contentRole="primary" :labelText="__('buttons.login')" />
|
||||
</div>
|
||||
<div>
|
||||
</div>
|
||||
@dispatchEvent('beforeFormClose')
|
||||
|
||||
</form>
|
||||
@else
|
||||
{!! __('text.no_login_form') !!}<br /><br />
|
||||
@endif
|
||||
|
||||
@if ($oidcEnabled)
|
||||
|
||||
@dispatchEvent('beforeOidcButton')
|
||||
|
||||
<div class="">
|
||||
<div style="margin-top:20px; border-bottom:1px solid #ccc; with:100%; height:10px; overflow:show; text-align:center; margin-bottom:40px;">
|
||||
<p style="text-align:center; display:inline-block; background:var(--secondary-background); padding:0px 5px;">{!! __('label.or_login_with') !!}</p>
|
||||
</div>
|
||||
<x-global::forms.button tag="a" :link="BASE_URL . '/oidc/login'" contentRole="primary" style="width:100%;">{!! __('buttons.oidclogin') !!}</x-global::forms.button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@dispatchEvent('beforeRegcontentClose')
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
44
app/Domain/Auth/Templates/partials/loginInfo.blade.php
Normal file
44
app/Domain/Auth/Templates/partials/loginInfo.blade.php
Normal file
@@ -0,0 +1,44 @@
|
||||
@dispatchEvent('beforeUserinfoMenuOpen')
|
||||
|
||||
<div class="userinfo">
|
||||
@dispatchEvent('afterUserinfoMenuOpen')
|
||||
@if(session()->exists("companysettings.logoPath") && session("companysettings.logoPath") !== false && session("companysettings.logoPath") !== '')
|
||||
<a href='{{ BASE_URL }}/users/editOwn/' preload="mouseover" class="dropdown-toggle profileHandler includeLogo" data-toggle="dropdown">
|
||||
<img src="{{ BASE_URL }}/api/users?profileImage={{ $user['id'] ?? -1 }}&v={{ format($user['modified'] ?? -1)->timestamp() }}" class="profilePicture"/>
|
||||
<img src="{{ session("companysettings.logoPath") }}" class="logo tw-pl-1" />
|
||||
</a>
|
||||
@else
|
||||
<a href='{{ BASE_URL }}/users/editOwn/' preload="mouseover" class="dropdown-toggle profileHandler" data-toggle="dropdown">
|
||||
<img src="{{ BASE_URL }}/api/users?profileImage={{ $user['id'] ?? -1 }}&v={{ format($user['modified'] ?? -1)->timestamp() }}" class="profilePicture"/>
|
||||
</a>
|
||||
@endif
|
||||
<ul class="dropdown-menu">
|
||||
@dispatchEvent('afterUserinfoDropdownMenuOpen')
|
||||
<li>
|
||||
<a href='{{ BASE_URL }}/users/editOwn/' preload="mouseover">
|
||||
{!! __("menu.my_profile") !!}
|
||||
</a>
|
||||
</li>
|
||||
@dispatchEvent('afterMyProfile')
|
||||
<li>
|
||||
<a href='{{ BASE_URL }}/users/editOwn#theme' preload="mouseover">
|
||||
{!! __("menu.theme") !!}
|
||||
</a>
|
||||
</li>
|
||||
@dispatchEvent('afterTheme')
|
||||
<li>
|
||||
<a href='{{ BASE_URL }}/users/editOwn#settings' preload="mouseover">
|
||||
{!! __("menu.settings") !!}
|
||||
</a>
|
||||
</li>
|
||||
@dispatchEvent('afterSettings')
|
||||
<li class="border">
|
||||
<a href='{{ BASE_URL }}/auth/logout'>
|
||||
{!! __("menu.sign_out") !!}
|
||||
</a>
|
||||
</li>
|
||||
@dispatchEvent('beforeUserinfoDropdownMenuClose')
|
||||
</ul>
|
||||
@dispatchEvent('beforeUserinfoMenuClose')
|
||||
</div>
|
||||
@dispatchEvent('afterUserinfoMenuClose')
|
||||
38
app/Domain/Auth/Templates/partials/tokens.blade.php
Normal file
38
app/Domain/Auth/Templates/partials/tokens.blade.php
Normal file
@@ -0,0 +1,38 @@
|
||||
@fragment('tokens-table')
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div>
|
||||
<h5 class="subtitle">{{ __('headlines.personal_access_tokens') }}</h5>
|
||||
<p>{{ __('text.create_tokens_to_authenticate') }}</p>
|
||||
<br />
|
||||
|
||||
<x-global::forms.button tag="a" contentRole="primary" link="#/auth/tokenNew">{{ __('buttons.create_token') }}</x-global::forms.button> <br />
|
||||
|
||||
<div class="clearfix"></div>
|
||||
|
||||
<table class="table table-bordered" id="tokens-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ __('label.name') }}</th>
|
||||
<th>{{ __('label.last_used') }}</th>
|
||||
<th>{{ __('label.created_on') }}</th>
|
||||
<th>{{ __('label.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($tokens as $token)
|
||||
<tr>
|
||||
<td>{{ $token['name'] }}</td>
|
||||
<td>{{ $token['last_used_at'] ? format($token['last_used_at'])->date() . ' ' . format($token['last_used_at'])->time(): 'Never' }}</td>
|
||||
<td>{{ format($token['created_at'])->date(). ' ' . format($token['created_at'])->time() }}</td>
|
||||
<td>
|
||||
<x-global::forms.button state="danger" class="btn-sm" hx-delete="{{ BASE_URL }}/hx/auth/personalTokens/delete/{{ $token['id'] }}" hx-confirm="{{ __('notifications.confirm_token_delete') }}" hx-target="#personalTokens"><i class="fa fa-trash"></i></x-global::forms.button>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endfragment
|
||||
32
app/Domain/Auth/Templates/requestPwLink.blade.php
Normal file
32
app/Domain/Auth/Templates/requestPwLink.blade.php
Normal file
@@ -0,0 +1,32 @@
|
||||
@extends($layout)
|
||||
@section('content')
|
||||
|
||||
@dispatchEvent('beforePageHeaderOpen')
|
||||
<div class="pageheader">
|
||||
<div class="pagetitle">
|
||||
<h1>{!! __('headlines.reset_password') !!}</h1>
|
||||
</div>
|
||||
</div>
|
||||
@dispatchEvent('afterPageHeaderClose')
|
||||
<div class="regcontent">
|
||||
@dispatchEvent('afterRegcontentOpen')
|
||||
<form id="resetPassword" action="" method="post">
|
||||
@dispatchEvent('afterFormOpen')
|
||||
{!! $tpl->displayInlineNotification() !!}
|
||||
<p>{!! __('text.enter_email_address_to_reset') !!}<br /><br /></p>
|
||||
<div class="">
|
||||
<x-global::forms.text-input name="username" id="username" placeholder="{{ __('input.placeholders.enter_email') }}" />
|
||||
</div>
|
||||
<div class="">
|
||||
<div class="forgotPwContainer">
|
||||
<a href="{{ BASE_URL }}/" class="forgotPw">{!! __('links.back_to_login') !!}</a>
|
||||
</div>
|
||||
@dispatchEvent('beforeSubmitButton')
|
||||
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.reset_password')" name="resetPassword" />
|
||||
</div>
|
||||
@dispatchEvent('beforeFormClose')
|
||||
</form>
|
||||
@dispatchEvent('beforeRegcontentClose')
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
51
app/Domain/Auth/Templates/resetPw.blade.php
Normal file
51
app/Domain/Auth/Templates/resetPw.blade.php
Normal file
@@ -0,0 +1,51 @@
|
||||
@extends($layout)
|
||||
@section('content')
|
||||
|
||||
@dispatchEvent('beforePageHeaderOpen')
|
||||
<div class="pageheader">
|
||||
@dispatchEvent('afterPageHeaderOpen')
|
||||
<div class="pagetitle">
|
||||
<h1>{!! __('headlines.reset_password') !!}</h1>
|
||||
</div>
|
||||
@dispatchEvent('beforePageHeaderClose')
|
||||
</div>
|
||||
@dispatchEvent('afterPageHeaderClose')
|
||||
<div class="regcontent">
|
||||
@dispatchEvent('afterRegcontentOpen')
|
||||
<form id="resetPassword" action="" method="post">
|
||||
@dispatchEvent('afterFormOpen')
|
||||
|
||||
{!! $tpl->displayInlineNotification() !!}
|
||||
|
||||
<p>{!! __('text.enter_new_password') !!}<br /><br /></p>
|
||||
|
||||
<div class="">
|
||||
<x-global::forms.text-input type="password" autocomplete="off" name="password" id="password" placeholder="{{ __('input.placeholders.enter_new_password') }}" />
|
||||
<span id="pwStrength" style="width:100%;"></span>
|
||||
</div>
|
||||
<div class=" ">
|
||||
<x-global::forms.text-input type="password" autocomplete="off" name="password2" id="password2" placeholder="{{ __('input.placeholders.confirm_password') }}" />
|
||||
</div>
|
||||
<small>{!! __('label.passwordRequirements') !!}</small><br /><br />
|
||||
<div class="">
|
||||
|
||||
@dispatchEvent('beforeSubmitButton')
|
||||
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.reset_password')" name="resetPassword" />
|
||||
<div class="forgotPwContainer">
|
||||
<a href="{{ BASE_URL }}/" class="forgotPw">{!! __('links.back_to_login') !!}</a>
|
||||
</div>
|
||||
</div>
|
||||
@dispatchEvent('beforeFormClose')
|
||||
</form>
|
||||
@dispatchEvent('beforeRegcontentClose')
|
||||
</div>
|
||||
|
||||
@once
|
||||
@push('scripts')
|
||||
<script>
|
||||
leantime.usersController.checkPWStrength('password');
|
||||
</script>
|
||||
@endpush
|
||||
@endonce
|
||||
|
||||
@endsection
|
||||
10
app/Domain/Auth/Templates/token-created.blade.php
Normal file
10
app/Domain/Auth/Templates/token-created.blade.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<h4 class="widgettitle title-light">{{ __('headlines.token_created') }}</h4>
|
||||
<p>{{ __('text.copy_token_now') }}</p>
|
||||
<div class="form-group">
|
||||
<x-global::forms.text-input value="{{ $newToken }}" onclick="this.select();" />
|
||||
</div>
|
||||
|
||||
<div class="align-right">
|
||||
<x-global::forms.button inputType="button" contentRole="default" onclick="leantime.modals.closeModal();">{{ __('buttons.close') }}</x-global::forms.button>
|
||||
<x-global::forms.button inputType="button" contentRole="primary" onclick="leantime.snippets.copyToClipboard('{{ $newToken }}')">{{ __('labels.copy_to_clipboard') }}</x-global::forms.button>
|
||||
</div>
|
||||
20
app/Domain/Auth/Templates/tokenNew.blade.php
Normal file
20
app/Domain/Auth/Templates/tokenNew.blade.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<div id="tokenModal">
|
||||
<h4 class="widgettitle title-light">{{ __('headlines.create_access_token') }}</h4>
|
||||
|
||||
<form hx-post="{{ BASE_URL }}/hx/auth/personalTokens/create"
|
||||
hx-target="#tokenModal" id="newToken">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="tokenName">{{ __('label.token_name') }}</label>
|
||||
<x-global::forms.text-input id="tokenName" name="name" required />
|
||||
<small class="form-text text-muted">
|
||||
<br/>{{ __('text.token_name_description') }}
|
||||
</small>
|
||||
</div>
|
||||
<br />
|
||||
<div class="align-right">
|
||||
<x-global::forms.button inputType="button" contentRole="default" onclick="jQuery('#modal').modal('hide');">{{ __('buttons.close') }}</x-global::forms.button>
|
||||
<x-global::forms.button inputType="submit" contentRole="primary">{{ __('buttons.create_token') }}</x-global::forms.button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
52
app/Domain/Auth/Templates/userInvite.blade.php
Normal file
52
app/Domain/Auth/Templates/userInvite.blade.php
Normal file
@@ -0,0 +1,52 @@
|
||||
@extends($layout)
|
||||
|
||||
@section('content')
|
||||
|
||||
<x-auth::onboardingProgress :percentComplete="12" current="account" :completed="[]" />
|
||||
|
||||
<h2>{{ __('titles.account_details') }}</h2>
|
||||
|
||||
<?php $tpl->dispatchTplEvent('afterPageHeaderClose'); ?>
|
||||
<div class="regcontent">
|
||||
<?php $tpl->dispatchTplEvent('afterRegcontentOpen'); ?>
|
||||
|
||||
<form id="resetPassword" action="" method="post">
|
||||
<?php $tpl->dispatchTplEvent('afterFormOpen'); ?>
|
||||
|
||||
<?php echo $tpl->displayInlineNotification(); ?>
|
||||
|
||||
<input type="hidden" name="step" value="1"/>
|
||||
|
||||
<div class="">
|
||||
<label for="name"><?php echo $tpl->language->__("label.name"); ?></label>
|
||||
<input type="text" name="name" style="margin-bottom:15px" id="name" placeholder="<?php echo $tpl->language->__("input.placeholders.name"); ?>" value="<?=$tpl->escape($user['firstname']); ?>" />
|
||||
</div>
|
||||
<div class="">
|
||||
<label for="jobTitle"><?php echo $tpl->language->__("label.role_or_title"); ?></label>
|
||||
<input type="text" name="jobTitle" id="jobTitle" style="margin-bottom:15px" placeholder="<?php echo $tpl->language->__("input.placeholders.jobtitle"); ?>" value="<?=$tpl->escape($user['jobTitle']); ?>" />
|
||||
|
||||
</div>
|
||||
<div class="">
|
||||
<label for="password"><?php echo $tpl->language->__("label.password"); ?></label>
|
||||
<input type="password" name="password" autocomplete="off" id="password" style="margin-bottom:15px" placeholder="<?php echo $tpl->language->__("input.placeholders.enter_new_password"); ?>" />
|
||||
<span id="pwStrength" style="width:100%;"></span>
|
||||
</div>
|
||||
<small><?=$tpl->__('label.passwordRequirements') ?></small><br /><br />
|
||||
<div class="">
|
||||
<input type="hidden" name="saveAccount" value="1" />
|
||||
<?php $tpl->dispatchTplEvent('beforeSubmitButton'); ?>
|
||||
<div class="tw-text-right">
|
||||
<input type="submit" name="createAccount" class="tw-w-auto" style="width:auto" value="<?php echo $tpl->language->__("buttons.next"); ?>" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<?php $tpl->dispatchTplEvent('beforeFormClose'); ?>
|
||||
</form>
|
||||
<?php $tpl->dispatchTplEvent('beforeRegcontentClose'); ?>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
leantime.usersController.checkPWStrength('password');
|
||||
</script>
|
||||
|
||||
@endsection
|
||||
63
app/Domain/Auth/Templates/userInvite2.blade.php
Normal file
63
app/Domain/Auth/Templates/userInvite2.blade.php
Normal file
@@ -0,0 +1,63 @@
|
||||
@extends($layout)
|
||||
|
||||
@section('content')
|
||||
|
||||
<x-auth::onboardingProgress :percentComplete="37" current="theme" :completed="['account']" />
|
||||
|
||||
<h2>{{ __('titles.determine_visual_experience') }}</h2>
|
||||
<p>{{ __('text.choose_a_theme_and_font_easy_to_read') }}</p>
|
||||
|
||||
<div class="regcontent">
|
||||
|
||||
<form id="resetPassword" action="" method="post">
|
||||
<input type="hidden" name="step" value="2" />
|
||||
|
||||
{{ $tpl->displayInlineNotification() }}
|
||||
|
||||
<div class="row-fluid">
|
||||
<div class="form-group">
|
||||
<label for="themeSelect">Optimal Stimulation</label>
|
||||
<span class='field tw-flex'>
|
||||
|
||||
<?php
|
||||
$themeAll = $themeCore->getAll();
|
||||
foreach ($themeAll as $key => $theme) { ?>
|
||||
<x-global::selectable selected="{{ ($userTheme == $key ? 'true' : 'false') }}" :id="''" :name="'theme'" :value="$key" :label="''" class="tw-w-1/2" onclick="leantime.snippets.toggleBg('{{ $key }}')">
|
||||
<img src="{{ BASE_URL }}/dist/images/background-{{$key}}.png" style="margin:0; border-radius:10px;" />
|
||||
<br /><?= $tpl->__($theme['name']) ?>
|
||||
</x-global::selectable>
|
||||
|
||||
<?php } ?>
|
||||
</span>
|
||||
</div>
|
||||
<br />
|
||||
<div class="form-group">
|
||||
<label>Readability</label>
|
||||
<div class="tw-flex">
|
||||
@foreach($availableFonts as $key => $font)
|
||||
|
||||
<x-global::selectable data-tippy-content="{{ $fontTooltips[$key] }}" :selected="($themeFont == $font) ? 'true' : ''" :id="$key" :name="'themeFont'" :value="$font" :label="$font" onclick="leantime.snippets.toggleFont('{{ $font }}')">
|
||||
<label for="selectable-{{ $key }}" class="font tw-w-[150px]"
|
||||
style="font-family:'{{ $font }}'; font-size:16px;">
|
||||
The quick brown fox jumps over the lazy dog
|
||||
</label>
|
||||
</x-global::selectable>
|
||||
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<br />
|
||||
<div class="tw-text-right">
|
||||
<x-global::forms.button tag="a" link="{{BASE_URL}}/auth/userInvite/{{$inviteId}}" contentRole="tertiary" style="width:auto; margin-right:10px">Back</x-global::forms.button>
|
||||
<input type="submit" name="createAccount" class="tw-w-auto" style="width:auto" value="<?php echo $tpl->language->__("buttons.next"); ?>" />
|
||||
</div>
|
||||
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
61
app/Domain/Auth/Templates/userInvite3.blade.php
Normal file
61
app/Domain/Auth/Templates/userInvite3.blade.php
Normal file
@@ -0,0 +1,61 @@
|
||||
@extends($layout)
|
||||
|
||||
@section('content')
|
||||
|
||||
<x-auth::onboardingProgress :percentComplete="64" current="personalization" :completed="['account', 'theme']" />
|
||||
|
||||
<h2>🎨 Creating A Comfortable View</h2>
|
||||
<p>Your favorite color mode and scheme.<br /></p>
|
||||
|
||||
<div class="regcontent">
|
||||
|
||||
<form id="resetPassword" action="" method="post">
|
||||
<input type="hidden" name="step" value="3" />
|
||||
|
||||
{{ $tpl->displayInlineNotification() }}
|
||||
|
||||
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<label for="colormode" >{{ __('label.colormode') }}</label>
|
||||
|
||||
<x-global::selectable :selected="($userColorMode == 'light') ? 'true' : ''" :id="'light'" :name="'colormode'" :value="'light'" :label="'Light'" onclick="leantime.snippets.toggleTheme('light')">
|
||||
<label for="colormode-light" class="tw-w-[200px]">
|
||||
<i class="fa-solid fa-sun tw-font-xxl"></i>
|
||||
</label>
|
||||
</x-global::selectable>
|
||||
|
||||
<x-global::selectable :selected="($userColorMode == 'dark') ? 'true' : ''" :id="'dark'" :name="'colormode'" :value="'dark'" :label="'Dark'" onclick="leantime.snippets.toggleTheme('dark')">
|
||||
<label for="colormode-light" class="tw-w-[200px]">
|
||||
<i class="fa-solid fa-moon tw-font-xxl"></i>
|
||||
</label>
|
||||
</x-global::selectable>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<label>Color Scheme</label>
|
||||
@foreach($availableColorSchemes as $key => $scheme )
|
||||
<x-global::selectable class="circle" :selected="($userColorScheme == $key) ? 'true' : ''" :id="$key" :name="'colorscheme'" :value="$key" :label="__($scheme['name'])" onclick="leantime.snippets.toggleColors('{{ $scheme['primaryColor'] }}','{{ $scheme['secondaryColor'] }}');">
|
||||
<label for="color-{{ $key }}" class="colorCircle"
|
||||
style="background:linear-gradient(135deg, {{ $scheme["primaryColor"] }} 20%, {{ $scheme["secondaryColor"] }} 100%);">
|
||||
</label>
|
||||
</x-global::selectable>
|
||||
@endforeach
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<br /> <br />
|
||||
<div class="tw-text-right">
|
||||
<x-global::forms.button tag="a" link="{{BASE_URL}}/auth/userInvite/{{$inviteId}}?step=2" contentRole="tertiary" style="width:auto; margin-right:10px">Back</x-global::forms.button>
|
||||
<input type="submit" name="createAccount" class="tw-w-auto" style="width:auto" value="<?php echo $tpl->language->__("buttons.next"); ?>" />
|
||||
</div>
|
||||
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
200
app/Domain/Auth/Templates/userInvite4.blade.php
Normal file
200
app/Domain/Auth/Templates/userInvite4.blade.php
Normal file
@@ -0,0 +1,200 @@
|
||||
@extends($layout)
|
||||
|
||||
@section('content')
|
||||
|
||||
|
||||
<x-auth::onboardingProgress :percentComplete="88" current="time" :completed="['account', 'theme', 'personalization']" />
|
||||
|
||||
|
||||
<h2>🗓️ Shaping A Daily Flow</h2>
|
||||
<p>We'll use these times to help prioritize your tasks</p>
|
||||
|
||||
<div class="regcontent">
|
||||
|
||||
<form id="resetPassword" action="" method="post">
|
||||
|
||||
<input type="hidden" name="step" value="4"/>
|
||||
|
||||
{{ $tpl->displayInlineNotification() }}
|
||||
|
||||
<label>What time do you usually start working?</label>
|
||||
<div class="">
|
||||
<x-global::selectable selected="{{ $daySchedule['workStart'] == '8' ? 'true' : 'false' }}" :id="'daySchedule-workStart-1'" :name="'daySchedule-workStart-button'" :value="'8'" :label="''" onclick="jQuery('#daySchedule-workStart').val('8').hide(); jQuery('#daySchedule-workStart-3').show();" class="compact">
|
||||
<label for="" class="">
|
||||
{{ format($dayHourOptions[8]['start'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time() }} - {{ format($dayHourOptions[8]['end'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time()}}
|
||||
</label>
|
||||
</x-global::selectable>
|
||||
<x-global::selectable selected="{{ $daySchedule['workStart'] == '10' ? 'true' : 'false' }}" :id="'daySchedule-workStart-2'" :name="'daySchedule-workStart-button'" :value="'10'" :label="''" onclick="jQuery('#daySchedule-workStart').val('10').hide(); jQuery('#daySchedule-workStart-3').show(); " class="compact">
|
||||
<label for="" class="">
|
||||
{{ format($dayHourOptions[10]['start'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time() }} - {{ format($dayHourOptions[10]['end'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time()}}
|
||||
</label>
|
||||
</x-global::selectable>
|
||||
<x-global::selectable selected="" :id="'daySchedule-workStart-3'" :name="'daySchedule-workStart-button'" :value="''" :label="''" class="compact" onclick="jQuery(this).hide(); jQuery('#daySchedule-workStart').show()">
|
||||
<label for="" class="">
|
||||
<i class="fa fa-clock"></i> Select my own
|
||||
</label>
|
||||
</x-global::selectable>
|
||||
<select name="daySchedule-workStart" id="daySchedule-workStart" style="display:none; vertical-align: top;">
|
||||
@foreach($dayHourOptions as $key => $value)
|
||||
<option value="{{ $key }}">
|
||||
{{ format($value['start'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time() }} - {{ format($value['end'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time()}}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<br />
|
||||
<label>When do you normally take a lunch break from work?</label>
|
||||
<div class="">
|
||||
<x-global::selectable selected="{{ $daySchedule['lunch'] == '12' ? 'true' : 'false' }}" :id="'daySchedule-lunch-1'" :name="'daySchedule-lunch-button'" :value="'12'" :label="''" onclick="jQuery('#daySchedule-lunch').val('12').hide(); jQuery('#daySchedule-lunch-3').show();" class="compact">
|
||||
<label for="" class="">
|
||||
{{ format($dayHourOptions[12]['start'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time() }} - {{ format($dayHourOptions[12]['end'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time()}}
|
||||
</label>
|
||||
</x-global::selectable>
|
||||
<x-global::selectable selected="{{ $daySchedule['lunch'] == '14' ? 'true' : 'false' }}" :id="'daySchedule-lunch-2'" :name="'daySchedule-lunch-button'" :value="'14'" :label="''" onclick="jQuery('#daySchedule-lunch').val('14').hide(); jQuery('#daySchedule-lunch-3').show();" class="compact">
|
||||
<label for="" class="">
|
||||
{{ format($dayHourOptions[14]['start'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time() }} - {{ format($dayHourOptions[14]['end'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time()}}
|
||||
</label>
|
||||
</x-global::selectable>
|
||||
<x-global::selectable selected="" :id="'daySchedule-lunch-3'" :name="'daySchedule-lunch-button'" :value="''" :label="''" class="compact" onclick="jQuery(this).hide(); jQuery('#daySchedule-lunch').show()">
|
||||
<label for="" class="">
|
||||
<i class="fa fa-clock"></i> Select my own
|
||||
</label>
|
||||
</x-global::selectable>
|
||||
<select name="daySchedule-lunch" id="daySchedule-lunch" style="display:none; vertical-align: top;">
|
||||
@foreach($dayHourOptions as $key => $value)
|
||||
<option value="{{ $key }}">
|
||||
{{ format($value['start'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time() }} - {{ format($value['end'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time()}}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<br />
|
||||
<label>When do you normally end your work day? 🥳</label>
|
||||
|
||||
<div class="">
|
||||
<x-global::selectable selected="{{ $daySchedule['workEnd'] == '16' ? 'true' : 'false' }}" :id="'daySchedule-workEnd-1'" :name="'daySchedule-workEnd-button'" :value="'16'" :label="''" onclick="jQuery('#daySchedule-workEnd').val('16').hide(); jQuery('#daySchedule-workEnd-3').show();" class="compact">
|
||||
<label for="" class="">
|
||||
{{ format($dayHourOptions[16]['start'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time() }} - {{ format($dayHourOptions[16]['end'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time()}}
|
||||
</label>
|
||||
</x-global::selectable>
|
||||
<x-global::selectable selected="{{ $daySchedule['workEnd'] == '18' ? 'true' : 'false' }}" :id="'daySchedule-workEnd-2'" :name="'daySchedule-workEnd-button'" :value="'18'" :label="''" onclick="jQuery('#daySchedule-workEnd').val('18').hide(); jQuery('#daySchedule-workEnd-3').show();" class="compact">
|
||||
<label for="" class="">
|
||||
{{ format($dayHourOptions[18]['start'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time() }} - {{ format($dayHourOptions[18]['end'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time()}}
|
||||
</label>
|
||||
</x-global::selectable>
|
||||
<x-global::selectable selected="" :id="'daySchedule-workEnd-3'" :name="'daySchedule-workEnd-button'" :value="''" :label="''" class="compact" onclick="jQuery(this).hide(); jQuery('#daySchedule-workEnd').show()">
|
||||
<label for="" class="">
|
||||
<i class="fa fa-clock"></i> Select my own
|
||||
</label>
|
||||
</x-global::selectable>
|
||||
<select name="daySchedule-workEnd" id="daySchedule-workEnd" style="display:none; vertical-align: top;">
|
||||
@foreach($dayHourOptions as $key => $value)
|
||||
<option value="{{ $key }}">
|
||||
{{ format($value['start'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time() }} - {{ format($value['end'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time()}}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{-- <div class="tw-flex">--}}
|
||||
{{-- @foreach([1,2,3,4,5,6,7] as $dayOfWeekIso)--}}
|
||||
{{-- <x-global::selectable type="checkbox" class="circle" selected="{{ isset($workdays[$dayOfWeekIso]) ? 'true' : '' }}" :id="'dayOfWeek-'.$dayOfWeekIso" :name="'dayOfWeek-'.$dayOfWeekIso" :value="$dayOfWeekIso" :label="''" onclick="showTimeForm({{$dayOfWeekIso}})">--}}
|
||||
{{-- <label for="dayOfWeek-{{ $dayOfWeekIso }}" class="">--}}
|
||||
{{-- {{ substr(__('dates.day_of_week_iso-'.$dayOfWeekIso), 0, 2) }}--}}
|
||||
{{-- </label>--}}
|
||||
{{-- </x-global::selectable>--}}
|
||||
{{-- @endforeach--}}
|
||||
{{-- </div>--}}
|
||||
{{-- <div>--}}
|
||||
{{-- @foreach([1,2,3,4,5,6,7] as $dayOfWeekIso)--}}
|
||||
{{-- <div class="dayOfWeekInputs dayOfWeekInput-{{$dayOfWeekIso}} {{ isset($workdays[$dayOfWeekIso]) ? 'tw-flex' : 'tw-hidden' }}">--}}
|
||||
{{-- <div class="tw-w-1/4 tw-leading-[32px]">--}}
|
||||
{{-- {{ __('dates.day_of_week_iso-'.$dayOfWeekIso) }}--}}
|
||||
{{-- </div>--}}
|
||||
{{-- <div class="tw-w-1/4">--}}
|
||||
{{-- <input type="time" class="dayStart" name="dayOfWeek-{{$dayOfWeekIso}}-start" value='{{ isset($workdays[$dayOfWeekIso]) ? $workdays[$dayOfWeekIso]['start'] : '09:00'}}' step="1800"/>--}}
|
||||
{{-- </div>--}}
|
||||
{{-- <div class="tw-px-2 tw-leading-[32px]">to</div>--}}
|
||||
{{-- <div class="tw-w-1/4">--}}
|
||||
{{-- <input type="time" class="dayEnd" name="dayOfWeek-{{$dayOfWeekIso}}-end" value='{{ isset($workdays[$dayOfWeekIso]) ? $workdays[$dayOfWeekIso]['end'] : '17:00'}}' step="1800"/>--}}
|
||||
{{-- </div>--}}
|
||||
{{-- <div class="tw-w tw-leading-[32px] tw-pl-2 applyBox">--}}
|
||||
{{-- @if($loop->index == 0)--}}
|
||||
{{-- <a href="javascript:void(0)">Apply to all</a>--}}
|
||||
{{-- @endif--}}
|
||||
{{-- </div>--}}
|
||||
{{-- </div>--}}
|
||||
{{-- @endforeach--}}
|
||||
|
||||
|
||||
{{-- </div>--}}
|
||||
<br /> <br />
|
||||
<div class="tw-text-right">
|
||||
<x-global::forms.button tag="a" link="{{BASE_URL}}/auth/userInvite/{{$inviteId}}?step=3" contentRole="tertiary" style="width:auto; margin-right:10px">Back</x-global::forms.button>
|
||||
<input type="submit" name="createAccount" class="tw-w-auto" style="width:auto" value="<?php echo $tpl->language->__("buttons.next"); ?>" />
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function applyToAllClick() {
|
||||
jQuery('.dayOfWeekInputs').each(function() {
|
||||
|
||||
let linkParentContainer = jQuery(this);
|
||||
|
||||
jQuery(this).find('.applyBox a').click(function() {
|
||||
let startInput = jQuery(linkParentContainer).find("input.dayStart").val();
|
||||
let endInput = jQuery(linkParentContainer).find("input.dayEnd").val();
|
||||
|
||||
jQuery('.dayOfWeekInputs input.dayStart').val(startInput);
|
||||
jQuery('.dayOfWeekInputs input.dayEnd').val(endInput);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
jQuery(document).ready(function() {
|
||||
applyToAllClick();
|
||||
|
||||
var timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
jQuery("#timezone").val(timezone);
|
||||
|
||||
var now=new Date(2010,11,31);
|
||||
var str=now.toLocaleDateString();
|
||||
|
||||
|
||||
|
||||
str=str.replace("31","dd");
|
||||
str=str.replace("12","mm");
|
||||
str=str.replace("2010","yyyy");
|
||||
|
||||
})
|
||||
|
||||
function showTimeForm($id) {
|
||||
let isVisible = jQuery('.dayOfWeekInput-'+$id).hasClass("tw-flex");
|
||||
if(isVisible) {
|
||||
jQuery('.dayOfWeekInput-'+$id).removeClass("tw-flex");
|
||||
jQuery('.dayOfWeekInput-'+$id).addClass("tw-hidden");
|
||||
}else{
|
||||
jQuery('.dayOfWeekInput-'+$id).addClass("tw-flex");
|
||||
jQuery('.dayOfWeekInput-'+$id).removeClass("tw-hidden");
|
||||
}
|
||||
|
||||
jQuery('.dayOfWeekInputs').find('.applyBox').html("");
|
||||
jQuery('.dayOfWeekInputs.tw-flex').each(function(index){
|
||||
|
||||
if(index == 0) {
|
||||
jQuery(this).find('.applyBox').html("<a href='javascript:void(0);'>Apply to all")
|
||||
}else{
|
||||
jQuery(this).find('.applyBox').html();
|
||||
}
|
||||
});
|
||||
|
||||
applyToAllClick();
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
@endsection
|
||||
42
app/Domain/Auth/Templates/userInvite5.blade.php
Normal file
42
app/Domain/Auth/Templates/userInvite5.blade.php
Normal file
@@ -0,0 +1,42 @@
|
||||
@extends($layout)
|
||||
|
||||
@section('content')
|
||||
|
||||
<x-auth::onboardingProgress :percentComplete="100" current="" :completed="['account', 'theme', 'personalization', 'time']" />
|
||||
|
||||
<h2>🎉 Your Leantime journey is about to begin</h2>
|
||||
|
||||
<div class="regcontent">
|
||||
|
||||
<form id="resetPassword" action="" method="post">
|
||||
|
||||
<input type="hidden" name="step" value="5"/>
|
||||
<input type="hidden" name="complete" value="1"/>
|
||||
|
||||
{{ $tpl->displayInlineNotification() }}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="ticketBox tw-p-[20px]">
|
||||
<span class="fancyLink">Did you know?</span><br />
|
||||
<span style="font-size:16px;">Setting Intentions has been shown to <strong>more than double the success rate</strong> of completing a task.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<x-global::undrawSvg image="undraw_adventure_map_hnin.svg" maxWidth="60%" maxHeight="300px"></x-global::undrawSvg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p><br />From here, we'll help you turn your task list into a project and goals.
|
||||
Then we'll work<br /> together to identify your most important tasks so you can create some
|
||||
intentions<br />to get the work done.</p> <br />
|
||||
|
||||
<br />
|
||||
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" labelText="Complete Sign up" name="createAccount" />
|
||||
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
62
app/Domain/Auth/register.php
Normal file
62
app/Domain/Auth/register.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
use Leantime\Core\Events\EventDispatcher;
|
||||
use Leantime\Domain\Auth\Listeners\ShowPersonalTokenContent;
|
||||
use Leantime\Domain\Auth\Listeners\ShowPersonalTokenTab;
|
||||
|
||||
// Register Personal Access Tokens tab in user account settings
|
||||
EventDispatcher::add_event_listener(
|
||||
'leantime.domain.users.templates.editOwn.tabs',
|
||||
ShowPersonalTokenTab::class
|
||||
);
|
||||
|
||||
EventDispatcher::add_event_listener(
|
||||
'leantime.domain.users.templates.editOwn.tabsContent',
|
||||
ShowPersonalTokenContent::class
|
||||
);
|
||||
|
||||
EventDispatcher::add_filter_listener('leantime.domain.auth.template.userInvite.welcomeText', function ($content, $params) {
|
||||
$language = app()->make(\Leantime\Core\Language::class);
|
||||
|
||||
return $language->__('text.welcome_to_leantime_content');
|
||||
});
|
||||
|
||||
EventDispatcher::add_filter_listener('leantime.domain.auth.template.userInvite2.welcomeText', function ($content, $params) {
|
||||
$language = app()->make(\Leantime\Core\Language::class);
|
||||
|
||||
return $language->__('text.welcome_to_leantime_content');
|
||||
});
|
||||
|
||||
EventDispatcher::add_filter_listener('leantime.domain.auth.template.userInvite3.welcomeText', function ($content, $params) {
|
||||
$language = app()->make(\Leantime\Core\Language::class);
|
||||
|
||||
return $language->__('text.welcome_to_leantime_content');
|
||||
});
|
||||
|
||||
EventDispatcher::add_filter_listener('leantime.domain.auth.template.userInvite4.welcomeText', function ($content, $params) {
|
||||
$language = app()->make(\Leantime\Core\Language::class);
|
||||
|
||||
return $language->__('text.welcome_to_leantime_content');
|
||||
});
|
||||
|
||||
EventDispatcher::add_filter_listener('leantime.domain.auth.template.userInvite5.welcomeText', function ($content, $params) {
|
||||
$language = app()->make(\Leantime\Core\Language::class);
|
||||
|
||||
return $language->__('text.welcome_to_leantime_content');
|
||||
});
|
||||
|
||||
EventDispatcher::add_filter_listener('leantime.domain.auth.*.belowWelcomeText', function ($content, $params) {
|
||||
|
||||
$quotes = [];
|
||||
$quotes[] = "\"It's the first project management app I've used for more than a week, and it makes sense too.\"<br /><br />- Interior Designer";
|
||||
$quotes[] = '"For me, Leantime is very cool, because it is lean. Not 3 million options to think about. The more you put in, the more it could be overloaded."<br /><br />- Open Source User';
|
||||
$quotes[] = '"We are a small digital marketing agency and have been using Leantime for a couple of months after switching from ClickUp. Getting great feedback from our clients."<br /><br />- CEO';
|
||||
|
||||
$random = rand(0, 2);
|
||||
|
||||
return '
|
||||
<div class="socialProofContent">
|
||||
<i>'.$quotes[$random].'</i>
|
||||
</div>
|
||||
';
|
||||
});
|
||||
75
app/Domain/Blueprints/Controllers/ApiCanvas.php
Normal file
75
app/Domain/Blueprints/Controllers/ApiCanvas.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Leantime\Domain\Blueprints\Controllers;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Leantime\Core\UI\Template;
|
||||
use Leantime\Domain\Blueprints\Models\CanvasTemplate;
|
||||
use Leantime\Domain\Blueprints\Permissions\BlueprintsPermissions;
|
||||
use Leantime\Domain\Blueprints\Services\Blueprints as BlueprintsService;
|
||||
use Leantime\Domain\Blueprints\Services\TemplateRegistry;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* ApiCanvas controller - handles PATCH requests for inline canvas item updates.
|
||||
*
|
||||
* Provides the API endpoint used by the blueprintsController.js for inline
|
||||
* status, relates, and user dropdown updates on the canvas board.
|
||||
*/
|
||||
class ApiCanvas
|
||||
{
|
||||
private string $canvasSlug;
|
||||
|
||||
private ?CanvasTemplate $template;
|
||||
|
||||
/**
|
||||
* __construct - resolve dependencies and determine the canvas slug from request.
|
||||
*
|
||||
* @param IncomingRequest $request Incoming request
|
||||
* @param Template $tpl Template handler
|
||||
* @param BlueprintsService $blueprintsService Blueprints service (project-authorized item CRUD)
|
||||
* @param TemplateRegistry $templateRegistry Template registry
|
||||
*/
|
||||
public function __construct(
|
||||
private IncomingRequest $request,
|
||||
private Template $tpl,
|
||||
private BlueprintsService $blueprintsService,
|
||||
TemplateRegistry $templateRegistry,
|
||||
) {
|
||||
$this->canvasSlug = strip_tags((string) ($request->route('canvasSlug') ?? ''));
|
||||
$this->template = $templateRegistry->get($this->canvasSlug);
|
||||
}
|
||||
|
||||
/**
|
||||
* patch - handle PATCH requests for inline canvas item updates.
|
||||
*
|
||||
* Supports updating status, relates, and author fields on individual
|
||||
* canvas items via AJAX calls from the board view dropdowns.
|
||||
*/
|
||||
#[RequiresPermission(BlueprintsPermissions::EDIT, entityScoped: true)]
|
||||
public function patch(): Response
|
||||
{
|
||||
$data = $this->request->getRequestParams();
|
||||
|
||||
if ($this->template === null) {
|
||||
return $this->tpl->displayJson(['status' => 'Unknown canvas type'], 404);
|
||||
}
|
||||
|
||||
if (! isset($data['id'])) {
|
||||
return $this->tpl->displayJson(['status' => 'failure'], 400);
|
||||
}
|
||||
|
||||
// The service resolves the item's REAL project and authorizes EDIT against it before
|
||||
// patching — closing the by-id cross-project mutation IDOR (a missing/foreign item or
|
||||
// an insufficient role throws AuthorizationException -> 403). A false return means no
|
||||
// allowlisted columns were present, which is a client error, not a denial.
|
||||
if (! $this->blueprintsService->patchCanvasItem((int) $data['id'], $data, $this->template->getDatabaseType())) {
|
||||
return $this->tpl->displayJson(['status' => 'no valid fields to update'], 400);
|
||||
}
|
||||
|
||||
return $this->tpl->displayJson(['status' => 'ok']);
|
||||
}
|
||||
}
|
||||
202
app/Domain/Blueprints/Controllers/BoardDialog.php
Normal file
202
app/Domain/Blueprints/Controllers/BoardDialog.php
Normal file
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Leantime\Domain\Blueprints\Controllers;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
use Leantime\Core\Controller\Frontcontroller;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Core\Mailer as MailerCore;
|
||||
use Leantime\Core\UI\Template;
|
||||
use Leantime\Domain\Blueprints\Models\CanvasTemplate;
|
||||
use Leantime\Domain\Blueprints\Permissions\BlueprintsPermissions;
|
||||
use Leantime\Domain\Blueprints\Repositories\Blueprints as BlueprintsRepository;
|
||||
use Leantime\Domain\Blueprints\Services\Blueprints as BlueprintsService;
|
||||
use Leantime\Domain\Blueprints\Services\TemplateRegistry;
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
use Leantime\Domain\Queue\Repositories\Queue as QueueRepository;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* BoardDialog controller - handles the create/edit board dialog for blueprints.
|
||||
*
|
||||
* Replaces the old per-variant Canvas\Controllers\BoardDialog subclasses.
|
||||
* The canvas type slug comes from a GET parameter instead of a class constant.
|
||||
*/
|
||||
class BoardDialog
|
||||
{
|
||||
private string $canvasSlug;
|
||||
|
||||
private ?CanvasTemplate $template;
|
||||
|
||||
/**
|
||||
* __construct - resolve dependencies and determine the canvas slug from request.
|
||||
*
|
||||
* @param IncomingRequest $request Incoming request
|
||||
* @param Template $tpl Template engine
|
||||
* @param Language $language Language service
|
||||
* @param ProjectService $projectService Project service
|
||||
* @param BlueprintsService $blueprintsService Blueprints service (project-authorized board CRUD)
|
||||
* @param BlueprintsRepository $blueprintsRepo Blueprints repository (currentProject-scoped existence check)
|
||||
* @param TemplateRegistry $templateRegistry Template registry
|
||||
*/
|
||||
public function __construct(
|
||||
private IncomingRequest $request,
|
||||
private Template $tpl,
|
||||
private Language $language,
|
||||
private ProjectService $projectService,
|
||||
private BlueprintsService $blueprintsService,
|
||||
private BlueprintsRepository $blueprintsRepo,
|
||||
TemplateRegistry $templateRegistry,
|
||||
) {
|
||||
$this->canvasSlug = strip_tags((string) ($request->route('canvasSlug') ?? ''));
|
||||
$this->template = $templateRegistry->get($this->canvasSlug);
|
||||
}
|
||||
|
||||
/**
|
||||
* get - display the create/edit board dialog.
|
||||
*
|
||||
* @param string|null $canvasSlug Canvas type slug from the route (resolved in the constructor)
|
||||
* @param string|null $id Current board id
|
||||
*/
|
||||
#[RequiresPermission(BlueprintsPermissions::VIEW, entityScoped: true)]
|
||||
public function get(?string $canvasSlug = null, ?string $id = null): Response
|
||||
{
|
||||
if ($this->template === null) {
|
||||
return $this->tpl->displayPartial('errors.error404');
|
||||
}
|
||||
|
||||
$currentCanvasId = '';
|
||||
$canvasTitle = '';
|
||||
|
||||
if ($id !== null) {
|
||||
// getBoard authorizes VIEW against the board's real project; false = missing /
|
||||
// foreign / unauthorized, in which case we neither expose the title nor switch
|
||||
// the active board (no session poisoning with a foreign id).
|
||||
$singleCanvas = $this->blueprintsService->getBoard((int) $id, $this->template->getDatabaseType());
|
||||
if ($singleCanvas !== false) {
|
||||
$currentCanvasId = (int) $id;
|
||||
$canvasTitle = $singleCanvas[0]['title'] ?? '';
|
||||
session([$this->template->getSessionKey() => $currentCanvasId]);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->renderDialog($currentCanvasId, $canvasTitle);
|
||||
}
|
||||
|
||||
/**
|
||||
* post - handle create/edit board submissions.
|
||||
*
|
||||
* @param string|null $canvasSlug Canvas type slug from the route (resolved in the constructor)
|
||||
* @param string|null $id Current board id
|
||||
*/
|
||||
#[RequiresPermission(BlueprintsPermissions::EDIT, entityScoped: true)]
|
||||
public function post(?string $canvasSlug = null, ?string $id = null): Response
|
||||
{
|
||||
if ($this->template === null) {
|
||||
return $this->tpl->displayPartial('errors.error404');
|
||||
}
|
||||
|
||||
$canvasType = $this->template->getDatabaseType();
|
||||
$sessionKey = $this->template->getSessionKey();
|
||||
$basePath = '/blueprints/'.$this->canvasSlug;
|
||||
|
||||
$currentCanvasId = ($id !== null && $id !== '') ? (int) $id : '';
|
||||
$canvasTitle = '';
|
||||
if (is_int($currentCanvasId) && $currentCanvasId > 0) {
|
||||
$singleCanvas = $this->blueprintsService->getBoard($currentCanvasId, $canvasType);
|
||||
if ($singleCanvas !== false) {
|
||||
$canvasTitle = $singleCanvas[0]['title'] ?? '';
|
||||
session([$sessionKey => $currentCanvasId]);
|
||||
}
|
||||
}
|
||||
|
||||
// Add Canvas
|
||||
if ($this->request->has('newCanvas')) {
|
||||
if ($this->request->has('canvastitle') && ! empty($this->request->input('canvastitle'))) {
|
||||
if (! $this->blueprintsRepo->existCanvas(session('currentProject'), $this->request->input('canvastitle'), $canvasType)) {
|
||||
$values = [
|
||||
'title' => $this->request->input('canvastitle'),
|
||||
'author' => session('userdata.id'),
|
||||
'projectId' => session('currentProject'),
|
||||
];
|
||||
// createBoard authorizes CREATE against the target (current) project.
|
||||
$currentCanvasId = $this->blueprintsService->createBoard($values, $canvasType);
|
||||
|
||||
$mailer = app()->make(MailerCore::class);
|
||||
$users = $this->projectService->getUsersToNotify(session('currentProject'));
|
||||
|
||||
$mailer->setSubject($this->language->__('notification.board_created'));
|
||||
|
||||
$message = sprintf(
|
||||
$this->language->__('email_notifications.canvas_created_message'),
|
||||
session('userdata.name'),
|
||||
"<a href='".CURRENT_URL."'>".strip_tags($values['title']).'</a>'
|
||||
);
|
||||
$mailer->setHtml($message);
|
||||
|
||||
$queue = app()->make(QueueRepository::class);
|
||||
$queue->queueMessageToUsers(
|
||||
$users,
|
||||
$message,
|
||||
$this->language->__('notification.board_created'),
|
||||
session('currentProject')
|
||||
);
|
||||
|
||||
$this->tpl->setNotification(
|
||||
$this->language->__('notification.board_created'),
|
||||
'success',
|
||||
$this->canvasSlug.'board_created'
|
||||
);
|
||||
|
||||
session([$sessionKey => $currentCanvasId]);
|
||||
|
||||
return Frontcontroller::redirect(BASE_URL.$basePath.'/boardDialog/'.$currentCanvasId);
|
||||
}
|
||||
|
||||
$this->tpl->setNotification($this->language->__('notification.board_exists'), 'error');
|
||||
} else {
|
||||
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Edit Canvas
|
||||
if ($this->request->has('editCanvas') && is_int($currentCanvasId) && $currentCanvasId > 0) {
|
||||
if ($this->request->has('canvastitle') && ! empty($this->request->input('canvastitle'))) {
|
||||
if (! $this->blueprintsRepo->existCanvas(session('currentProject'), $this->request->input('canvastitle'), $canvasType)) {
|
||||
// renameBoard authorizes EDIT against the board's real project.
|
||||
$this->blueprintsService->renameBoard($currentCanvasId, $this->request->input('canvastitle'), $canvasType);
|
||||
|
||||
$this->tpl->setNotification($this->language->__('notification.board_edited'), 'success');
|
||||
|
||||
return Frontcontroller::redirect(BASE_URL.$basePath.'/boardDialog/'.$currentCanvasId);
|
||||
}
|
||||
|
||||
$this->tpl->setNotification($this->language->__('notification.board_exists'), 'error');
|
||||
} else {
|
||||
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
return $this->renderDialog($currentCanvasId, $canvasTitle);
|
||||
}
|
||||
|
||||
/**
|
||||
* renderDialog - assign shared template variables and render the board dialog.
|
||||
*
|
||||
* @param int|string $currentCanvasId Current board id (empty string when creating)
|
||||
* @param string $canvasTitle Current board title
|
||||
*/
|
||||
private function renderDialog(int|string $currentCanvasId, string $canvasTitle): Response
|
||||
{
|
||||
$this->tpl->assign('currentCanvas', $currentCanvasId);
|
||||
$this->tpl->assign('canvasName', $this->canvasSlug);
|
||||
$this->tpl->assign('canvasSlug', $this->canvasSlug);
|
||||
$this->tpl->assign('canvasTitle', $canvasTitle);
|
||||
$this->tpl->assign('users', $this->projectService->getUsersAssignedToProject(session('currentProject')));
|
||||
|
||||
return $this->tpl->displayPartial('blueprints.boardDialog');
|
||||
}
|
||||
}
|
||||
127
app/Domain/Blueprints/Controllers/DelCanvas.php
Normal file
127
app/Domain/Blueprints/Controllers/DelCanvas.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Leantime\Domain\Blueprints\Controllers;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
use Leantime\Core\Controller\Frontcontroller;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Core\UI\Template;
|
||||
use Leantime\Domain\Blueprints\Models\CanvasTemplate;
|
||||
use Leantime\Domain\Blueprints\Permissions\BlueprintsPermissions;
|
||||
use Leantime\Domain\Blueprints\Repositories\Blueprints as BlueprintsRepository;
|
||||
use Leantime\Domain\Blueprints\Services\Blueprints as BlueprintsService;
|
||||
use Leantime\Domain\Blueprints\Services\TemplateRegistry;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* DelCanvas controller - handles canvas board deletion.
|
||||
*
|
||||
* Native Laravel controller: route-bound actions, the {canvasSlug}/{id} path segments
|
||||
* arrive via the route (canvasSlug resolved in the constructor, id as a typed action arg),
|
||||
* and request input is read from the injected IncomingRequest instead of the legacy
|
||||
* merged-$params argument and superglobals.
|
||||
*/
|
||||
class DelCanvas
|
||||
{
|
||||
private string $canvasSlug;
|
||||
|
||||
private ?CanvasTemplate $template;
|
||||
|
||||
public function __construct(
|
||||
private IncomingRequest $request,
|
||||
private Template $tpl,
|
||||
private Language $language,
|
||||
private BlueprintsService $blueprintsService,
|
||||
private BlueprintsRepository $blueprintsRepo,
|
||||
TemplateRegistry $templateRegistry,
|
||||
) {
|
||||
$this->canvasSlug = strip_tags((string) ($request->route('canvasSlug') ?? ''));
|
||||
$this->template = $templateRegistry->get($this->canvasSlug);
|
||||
}
|
||||
|
||||
/**
|
||||
* get - display the delete confirmation dialog.
|
||||
*
|
||||
* @param string|null $canvasSlug Canvas type slug from the route (resolved in the constructor)
|
||||
* @param string|null $id Board id from the route
|
||||
*/
|
||||
#[RequiresPermission(BlueprintsPermissions::DELETE)]
|
||||
public function get(?string $canvasSlug = null, ?string $id = null): Response
|
||||
{
|
||||
if ($this->template === null) {
|
||||
return $this->tpl->displayPartial('errors.error404');
|
||||
}
|
||||
|
||||
// The route id is optional in the pattern but mandatory in practice: every caller
|
||||
// links with a concrete id. Validate strictly rather than sanitising — FILTER_SANITIZE_NUMBER_INT
|
||||
// lets "1-2" through, which a later (int) cast would silently read as 1 and act on the
|
||||
// wrong record. Fail closed instead.
|
||||
$canvasId = filter_var($id, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
|
||||
|
||||
if ($canvasId === false) {
|
||||
return $this->tpl->displayPartial('errors.error404');
|
||||
}
|
||||
|
||||
$this->tpl->assign('canvasSlug', $this->canvasSlug);
|
||||
$this->tpl->assign('id', $canvasId);
|
||||
|
||||
return $this->tpl->displayPartial('blueprints.delCanvas');
|
||||
}
|
||||
|
||||
/**
|
||||
* post - process the board deletion.
|
||||
*
|
||||
* @param string|null $canvasSlug Canvas type slug from the route (resolved in the constructor)
|
||||
* @param string|null $id Board id from the route
|
||||
*/
|
||||
#[RequiresPermission(BlueprintsPermissions::DELETE, entityScoped: true)]
|
||||
public function post(?string $canvasSlug = null, ?string $id = null): Response
|
||||
{
|
||||
if ($this->template === null) {
|
||||
return $this->tpl->displayPartial('errors.error404');
|
||||
}
|
||||
|
||||
// The route id is optional in the pattern but mandatory in practice: every caller
|
||||
// links with a concrete id. Validate strictly rather than sanitising — FILTER_SANITIZE_NUMBER_INT
|
||||
// lets "1-2" through, which a later (int) cast would silently read as 1 and act on the
|
||||
// wrong record. Fail closed instead.
|
||||
$canvasId = filter_var($id, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
|
||||
|
||||
if ($canvasId === false) {
|
||||
return $this->tpl->displayPartial('errors.error404');
|
||||
}
|
||||
|
||||
$canvasType = $this->template->getDatabaseType();
|
||||
$sessionKey = $this->template->getSessionKey();
|
||||
|
||||
if ($this->request->has('del')) {
|
||||
// The service resolves the board's REAL project and authorizes DELETE against it
|
||||
// (throwing 403 for a missing/foreign board) — closing the by-id board-delete IDOR
|
||||
// that the previous role-only Auth::authOrRedirect left open.
|
||||
$this->blueprintsService->deleteBoard($canvasId, $canvasType);
|
||||
|
||||
$allCanvas = $this->blueprintsRepo->getAllCanvas(session('currentProject'), $canvasType);
|
||||
session([$sessionKey => $allCanvas[0]['id'] ?? -1]);
|
||||
|
||||
$this->tpl->setNotification(
|
||||
$this->language->__('notification.board_deleted'),
|
||||
'success',
|
||||
strtoupper($this->canvasSlug).'canvas_deleted'
|
||||
);
|
||||
|
||||
if (! $allCanvas) {
|
||||
return Frontcontroller::redirect(BASE_URL.'/blueprints/showBoards');
|
||||
}
|
||||
|
||||
return Frontcontroller::redirect(BASE_URL.'/blueprints/'.$this->canvasSlug.'/showCanvas');
|
||||
}
|
||||
|
||||
$this->tpl->assign('canvasSlug', $this->canvasSlug);
|
||||
$this->tpl->assign('id', $canvasId);
|
||||
|
||||
return $this->tpl->displayPartial('blueprints.delCanvas');
|
||||
}
|
||||
}
|
||||
115
app/Domain/Blueprints/Controllers/DelCanvasItem.php
Normal file
115
app/Domain/Blueprints/Controllers/DelCanvasItem.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Leantime\Domain\Blueprints\Controllers;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
use Leantime\Core\Controller\Frontcontroller;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Core\UI\Template;
|
||||
use Leantime\Domain\Blueprints\Models\CanvasTemplate;
|
||||
use Leantime\Domain\Blueprints\Permissions\BlueprintsPermissions;
|
||||
use Leantime\Domain\Blueprints\Services\Blueprints as BlueprintsService;
|
||||
use Leantime\Domain\Blueprints\Services\TemplateRegistry;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* DelCanvasItem controller - handles canvas item deletion.
|
||||
*
|
||||
* Native Laravel controller: route-bound actions, the {canvasSlug}/{id} path segments
|
||||
* arrive via the route (canvasSlug resolved in the constructor, id as a typed action arg),
|
||||
* and request input is read from the injected IncomingRequest instead of the legacy
|
||||
* merged-$params argument and superglobals.
|
||||
*/
|
||||
class DelCanvasItem
|
||||
{
|
||||
private string $canvasSlug;
|
||||
|
||||
private ?CanvasTemplate $template;
|
||||
|
||||
public function __construct(
|
||||
private IncomingRequest $request,
|
||||
private Template $tpl,
|
||||
private Language $language,
|
||||
private BlueprintsService $blueprintsService,
|
||||
TemplateRegistry $templateRegistry,
|
||||
) {
|
||||
$this->canvasSlug = strip_tags((string) ($request->route('canvasSlug') ?? ''));
|
||||
$this->template = $templateRegistry->get($this->canvasSlug);
|
||||
}
|
||||
|
||||
/**
|
||||
* get - display the delete confirmation dialog for a canvas item.
|
||||
*
|
||||
* @param string|null $canvasSlug Canvas type slug from the route (resolved in the constructor)
|
||||
* @param string|null $id Canvas item id from the route
|
||||
*/
|
||||
#[RequiresPermission(BlueprintsPermissions::DELETE)]
|
||||
public function get(?string $canvasSlug = null, ?string $id = null): Response
|
||||
{
|
||||
if ($this->template === null) {
|
||||
return $this->tpl->displayPartial('errors.error404');
|
||||
}
|
||||
|
||||
// The route id is optional in the pattern but mandatory in practice: every caller
|
||||
// links with a concrete id. Validate strictly rather than sanitising — FILTER_SANITIZE_NUMBER_INT
|
||||
// lets "1-2" through, which a later (int) cast would silently read as 1 and act on the
|
||||
// wrong record. Fail closed instead.
|
||||
$canvasId = filter_var($id, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
|
||||
|
||||
if ($canvasId === false) {
|
||||
return $this->tpl->displayPartial('errors.error404');
|
||||
}
|
||||
|
||||
$this->tpl->assign('canvasSlug', $this->canvasSlug);
|
||||
$this->tpl->assign('id', $canvasId);
|
||||
|
||||
return $this->tpl->displayPartial('blueprints.delCanvasItem');
|
||||
}
|
||||
|
||||
/**
|
||||
* post - delete the canvas item identified by the route id.
|
||||
*
|
||||
* @param string|null $canvasSlug Canvas type slug from the route (resolved in the constructor)
|
||||
* @param string|null $id Canvas item id from the route
|
||||
*/
|
||||
#[RequiresPermission(BlueprintsPermissions::DELETE, entityScoped: true)]
|
||||
public function post(?string $canvasSlug = null, ?string $id = null): Response
|
||||
{
|
||||
if ($this->template === null) {
|
||||
return $this->tpl->displayPartial('errors.error404');
|
||||
}
|
||||
|
||||
// The route id is optional in the pattern but mandatory in practice: every caller
|
||||
// links with a concrete id. Validate strictly rather than sanitising — FILTER_SANITIZE_NUMBER_INT
|
||||
// lets "1-2" through, which a later (int) cast would silently read as 1 and act on the
|
||||
// wrong record. Fail closed instead.
|
||||
$canvasId = filter_var($id, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
|
||||
|
||||
if ($canvasId === false) {
|
||||
return $this->tpl->displayPartial('errors.error404');
|
||||
}
|
||||
|
||||
if ($this->request->has('del')) {
|
||||
// The service resolves the item's REAL project and authorizes DELETE against it
|
||||
// (throwing 403 for a missing/foreign item) — closing the by-id delete IDOR that
|
||||
// the previous role-only Auth::authOrRedirect left open.
|
||||
$this->blueprintsService->deleteCanvasItem($canvasId, $this->template->getDatabaseType());
|
||||
|
||||
$this->tpl->setNotification(
|
||||
$this->language->__('notification.element_deleted'),
|
||||
'success',
|
||||
strtoupper($this->canvasSlug).'canvasitem_deleted'
|
||||
);
|
||||
|
||||
return Frontcontroller::redirect(BASE_URL.'/blueprints/'.$this->canvasSlug.'/showCanvas');
|
||||
}
|
||||
|
||||
$this->tpl->assign('canvasSlug', $this->canvasSlug);
|
||||
$this->tpl->assign('id', $canvasId);
|
||||
|
||||
return $this->tpl->displayPartial('blueprints.delCanvasItem');
|
||||
}
|
||||
}
|
||||
341
app/Domain/Blueprints/Controllers/EditCanvasComment.php
Normal file
341
app/Domain/Blueprints/Controllers/EditCanvasComment.php
Normal file
@@ -0,0 +1,341 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Leantime\Domain\Blueprints\Controllers;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
use Leantime\Core\Controller\Frontcontroller;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Core\UI\Template;
|
||||
use Leantime\Domain\Blueprints\Models\CanvasTemplate;
|
||||
use Leantime\Domain\Blueprints\Permissions\BlueprintsPermissions;
|
||||
use Leantime\Domain\Blueprints\Services\Blueprints as BlueprintsService;
|
||||
use Leantime\Domain\Blueprints\Services\TemplateRegistry;
|
||||
use Leantime\Domain\Comments\Repositories\Comments as CommentRepository;
|
||||
use Leantime\Domain\Notifications\Models\Notification as NotificationModel;
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* EditCanvasComment controller - handles the comment-focused editing view for canvas items.
|
||||
*
|
||||
* Replaces the old per-variant Canvas\Controllers\EditCanvasComment subclasses.
|
||||
* The canvas type slug comes from the route instead of a class constant.
|
||||
*
|
||||
* All by-id item access goes through the Blueprints service, which authorizes against the
|
||||
* item's real project — the controller never reads/writes canvas items via the repository.
|
||||
*/
|
||||
class EditCanvasComment
|
||||
{
|
||||
private string $canvasSlug;
|
||||
|
||||
private ?CanvasTemplate $template;
|
||||
|
||||
/**
|
||||
* __construct - resolve dependencies and determine the canvas slug from request.
|
||||
*
|
||||
* @param IncomingRequest $request Incoming request
|
||||
* @param Template $tpl Template engine
|
||||
* @param Language $language Language service
|
||||
* @param CommentRepository $commentsRepo Comment repository
|
||||
* @param ProjectService $projectService Project service
|
||||
* @param BlueprintsService $blueprintsService Blueprints service (project-authorized item CRUD)
|
||||
* @param TemplateRegistry $templateRegistry Template registry
|
||||
*/
|
||||
public function __construct(
|
||||
private IncomingRequest $request,
|
||||
private Template $tpl,
|
||||
private Language $language,
|
||||
private CommentRepository $commentsRepo,
|
||||
private ProjectService $projectService,
|
||||
private BlueprintsService $blueprintsService,
|
||||
TemplateRegistry $templateRegistry,
|
||||
) {
|
||||
$this->canvasSlug = strip_tags((string) ($request->route('canvasSlug') ?? ''));
|
||||
$this->template = $templateRegistry->get($this->canvasSlug);
|
||||
}
|
||||
|
||||
/**
|
||||
* get - handle GET requests for the comment editing view.
|
||||
*
|
||||
* @param string|null $canvasSlug Canvas type slug from the route (resolved in the constructor)
|
||||
* @param string|null $id Canvas item id from the route
|
||||
*/
|
||||
#[RequiresPermission(BlueprintsPermissions::VIEW, entityScoped: true)]
|
||||
public function get(?string $canvasSlug = null, ?string $id = null): Response
|
||||
{
|
||||
$data = $this->request->getRequestParams();
|
||||
if ($id !== null) {
|
||||
$data['id'] = $id;
|
||||
}
|
||||
|
||||
if ($this->template === null) {
|
||||
return $this->tpl->displayPartial('errors.error404');
|
||||
}
|
||||
|
||||
$canvasType = $this->template->getDatabaseType();
|
||||
$commentModule = $this->template->getCommentModule();
|
||||
$canvasTypes = $this->blueprintsService->getTranslatedBoxes($this->template);
|
||||
$statusLabels = $this->blueprintsService->getTranslatedStatusLabels($this->template);
|
||||
$relatesLabels = $this->blueprintsService->getTranslatedRelatesLabels($this->template);
|
||||
|
||||
if (isset($data['id'])) {
|
||||
// Resolve + VIEW-authorize the item against its real project before anything else.
|
||||
$canvasItem = $this->blueprintsService->getCanvasItem((int) $data['id'], $canvasType);
|
||||
if (! $canvasItem) {
|
||||
return $this->tpl->displayPartial('errors.error404');
|
||||
}
|
||||
|
||||
// Delete comment — ONLY when it belongs to THIS gated item (module + moduleId).
|
||||
// deleteComment() filters on the comment id alone, so without this bind a viewable
|
||||
// item would let any global comment id be deleted (cross-item / cross-project).
|
||||
if (isset($data['delComment']) === true) {
|
||||
$commentId = (int) ($data['delComment']);
|
||||
$comment = $this->commentsRepo->getComment($commentId);
|
||||
if ($comment !== false
|
||||
&& (string) $comment['module'] === $commentModule
|
||||
&& (int) $comment['moduleId'] === (int) $canvasItem['id']) {
|
||||
$this->commentsRepo->deleteComment($commentId);
|
||||
$this->tpl->setNotification(
|
||||
$this->language->__('notifications.comment_deleted'),
|
||||
'success',
|
||||
strtoupper($this->canvasSlug).'canvascomment_deleted'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$comments = $this->commentsRepo->getComments($commentModule, $canvasItem['id']);
|
||||
$this->tpl->assign(
|
||||
'numComments',
|
||||
$this->commentsRepo->countComments($commentModule, $canvasItem['id'])
|
||||
);
|
||||
} else {
|
||||
if (isset($data['type'])) {
|
||||
$type = strip_tags($data['type']);
|
||||
} else {
|
||||
$type = array_key_first($canvasTypes);
|
||||
}
|
||||
|
||||
$canvasItem = [
|
||||
'id' => '',
|
||||
'box' => $type,
|
||||
'description' => '',
|
||||
'status' => array_key_first($statusLabels),
|
||||
'relates' => array_key_first($relatesLabels),
|
||||
'assumptions' => '',
|
||||
'data' => '',
|
||||
'conclusion' => '',
|
||||
'milestoneHeadline' => '',
|
||||
'milestoneId' => '',
|
||||
];
|
||||
|
||||
$comments = [];
|
||||
}
|
||||
|
||||
$this->tpl->assign('comments', $comments);
|
||||
$this->tpl->assign('canvasTypes', $canvasTypes);
|
||||
$this->tpl->assign('canvasItem', $canvasItem);
|
||||
$this->tpl->assign('canvasSlug', $this->canvasSlug);
|
||||
|
||||
return $this->tpl->displayPartial('blueprints.canvasComment');
|
||||
}
|
||||
|
||||
/**
|
||||
* post - handle POST requests for updating canvas items and adding comments.
|
||||
*
|
||||
* @param string|null $canvasSlug Canvas type slug from the route (resolved in the constructor)
|
||||
* @param string|null $id Canvas item id from the route
|
||||
*/
|
||||
#[RequiresPermission(BlueprintsPermissions::EDIT, entityScoped: true)]
|
||||
public function post(?string $canvasSlug = null, ?string $id = null): Response
|
||||
{
|
||||
$data = $this->request->getRequestParams();
|
||||
if ($id !== null) {
|
||||
$data['id'] = $id;
|
||||
}
|
||||
|
||||
if ($this->template === null) {
|
||||
return $this->tpl->displayPartial('errors.error404');
|
||||
}
|
||||
|
||||
$canvasType = $this->template->getDatabaseType();
|
||||
$commentModule = $this->template->getCommentModule();
|
||||
$sessionKey = $this->template->getSessionKey();
|
||||
$basePath = '/blueprints/'.$this->canvasSlug;
|
||||
|
||||
if (isset($data['changeItem'])) {
|
||||
if (isset($data['itemId']) && $data['itemId'] != '') {
|
||||
if (isset($data['description']) && ! empty($data['description'])) {
|
||||
$currentCanvasId = (int) session($sessionKey);
|
||||
|
||||
$canvasItem = [
|
||||
'box' => $data['box'],
|
||||
'author' => session('userdata.id'),
|
||||
'description' => $data['description'],
|
||||
'status' => $data['status'],
|
||||
'relates' => $data['relates'],
|
||||
'assumptions' => $data['assumptions'],
|
||||
'data' => $data['data'],
|
||||
'conclusion' => $data['conclusion'],
|
||||
'itemId' => $data['itemId'],
|
||||
'id' => $data['itemId'],
|
||||
'canvasId' => $currentCanvasId,
|
||||
'milestoneId' => $data['milestoneId'],
|
||||
'dependentMilstone' => '',
|
||||
];
|
||||
|
||||
// Resolves the item's real project from itemId and authorizes EDIT there.
|
||||
$this->blueprintsService->updateCanvasItem($canvasItem, $canvasType);
|
||||
|
||||
$comments = $this->commentsRepo->getComments($commentModule, $data['itemId']);
|
||||
$this->tpl->assign('numComments', $this->commentsRepo->countComments(
|
||||
$commentModule,
|
||||
$data['itemId']
|
||||
));
|
||||
$this->tpl->assign('comments', $comments);
|
||||
|
||||
$this->tpl->setNotification(
|
||||
$this->language->__('notifications.canvas_item_updates'),
|
||||
'success',
|
||||
strtoupper($this->canvasSlug).'canvasitem_updated'
|
||||
);
|
||||
|
||||
$notification = app()->make(NotificationModel::class);
|
||||
$notification->url = [
|
||||
'url' => BASE_URL.$basePath.'/editCanvasComment/'.(int) $data['itemId'],
|
||||
'text' => $this->language->__('email_notifications.canvas_item_update_cta'),
|
||||
];
|
||||
$notification->entity = $canvasItem;
|
||||
$notification->module = $this->canvasSlug.'canvas';
|
||||
$notification->action = 'updated';
|
||||
$notification->projectId = session('currentProject');
|
||||
$notification->subject = $this->language->__('email_notifications.canvas_board_edited');
|
||||
$notification->authorId = session('userdata.id');
|
||||
$notification->message = sprintf(
|
||||
$this->language->__('email_notifications.canvas_item_update_message'),
|
||||
session('userdata.name'),
|
||||
$canvasItem['description']
|
||||
);
|
||||
|
||||
$this->projectService->notifyProjectUsers($notification);
|
||||
|
||||
return Frontcontroller::redirect(BASE_URL.$basePath.'/editCanvasComment/'.$data['itemId']);
|
||||
} else {
|
||||
$this->tpl->setNotification($this->language->__('notification.please_enter_element_title'), 'error');
|
||||
}
|
||||
} else {
|
||||
if (isset($data['description']) && ! empty($data['description'])) {
|
||||
$currentCanvasId = (int) session($sessionKey);
|
||||
|
||||
$canvasItem = [
|
||||
'box' => $data['box'],
|
||||
'author' => session('userdata.id'),
|
||||
'description' => $data['description'],
|
||||
'status' => $data['status'],
|
||||
'relates' => $data['relates'],
|
||||
'assumptions' => $data['assumptions'],
|
||||
'data' => $data['data'],
|
||||
'conclusion' => $data['conclusion'],
|
||||
'canvasId' => $currentCanvasId,
|
||||
];
|
||||
|
||||
// Resolves the target board's real project from canvasId and authorizes CREATE.
|
||||
$id = $this->blueprintsService->createCanvasItem($canvasItem, $canvasType);
|
||||
|
||||
$canvasItem['id'] = $id;
|
||||
|
||||
$canvasTypes = $this->blueprintsService->getTranslatedBoxes($this->template);
|
||||
|
||||
$this->tpl->setNotification(
|
||||
($canvasTypes[$data['box']]['title'] ?? $data['box']).' successfully created',
|
||||
'success',
|
||||
strtoupper($this->canvasSlug).'canvasitem_created'
|
||||
);
|
||||
|
||||
$notification = app()->make(NotificationModel::class);
|
||||
$notification->url = [
|
||||
'url' => BASE_URL.$basePath.'/editCanvasComment/'.(int) ($data['itemId'] ?? $id),
|
||||
'text' => $this->language->__('email_notifications.canvas_item_update_cta'),
|
||||
];
|
||||
$notification->entity = $canvasItem;
|
||||
$notification->module = $this->canvasSlug.'canvas';
|
||||
$notification->action = 'created';
|
||||
$notification->projectId = session('currentProject');
|
||||
$notification->subject = $this->language->__('email_notifications.canvas_board_item_created');
|
||||
$notification->authorId = session('userdata.id');
|
||||
$notification->message = sprintf(
|
||||
$this->language->__('email_notifications.canvas_item_created_message'),
|
||||
session('userdata.name'),
|
||||
$canvasItem['description']
|
||||
);
|
||||
|
||||
$this->projectService->notifyProjectUsers($notification);
|
||||
|
||||
$this->tpl->setNotification(
|
||||
$this->language->__('notification.element_created'),
|
||||
'success',
|
||||
strtoupper($this->canvasSlug).'canvasitem_created'
|
||||
);
|
||||
|
||||
return Frontcontroller::redirect(BASE_URL.$basePath.'/editCanvasComment/'.$id);
|
||||
} else {
|
||||
$this->tpl->setNotification($this->language->__('notification.please_enter_element_title'), 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($data['comment']) === true) {
|
||||
$itemId = (int) ($data['id'] ?? 0);
|
||||
|
||||
// Only allow commenting on an item the user can view in their project.
|
||||
if (! $this->blueprintsService->getCanvasItem($itemId, $canvasType)) {
|
||||
return $this->tpl->displayPartial('errors.error404');
|
||||
}
|
||||
|
||||
$values = [
|
||||
'text' => $data['text'],
|
||||
'date' => date('Y-m-d H:i:s'),
|
||||
'userId' => (session('userdata.id')),
|
||||
'moduleId' => $itemId,
|
||||
'commentParent' => ($data['father']),
|
||||
];
|
||||
|
||||
$this->commentsRepo->addComment($values, $commentModule);
|
||||
$this->tpl->setNotification(
|
||||
$this->language->__('notifications.comment_create_success'),
|
||||
'success',
|
||||
strtoupper($this->canvasSlug).'canvasitemcomment_created'
|
||||
);
|
||||
|
||||
$notification = app()->make(NotificationModel::class);
|
||||
$notification->url = [
|
||||
'url' => BASE_URL.$basePath.'/editCanvasComment/'.$itemId,
|
||||
'text' => $this->language->__('email_notifications.canvas_item_update_cta'),
|
||||
];
|
||||
$notification->entity = $values;
|
||||
$notification->module = $this->canvasSlug.'canvas';
|
||||
$notification->action = 'commented';
|
||||
$notification->projectId = session('currentProject');
|
||||
$notification->subject = $this->language->__('email_notifications.canvas_board_comment_created');
|
||||
$notification->authorId = session('userdata.id');
|
||||
$notification->message = sprintf(
|
||||
$this->language->__('email_notifications.canvas_item__comment_created_message'),
|
||||
session('userdata.name')
|
||||
);
|
||||
|
||||
$this->projectService->notifyProjectUsers($notification);
|
||||
|
||||
return Frontcontroller::redirect(BASE_URL.$basePath.'/editCanvasComment/'.$itemId);
|
||||
}
|
||||
|
||||
$itemId = (int) ($data['id'] ?? 0);
|
||||
$this->tpl->assign('id', $itemId);
|
||||
$this->tpl->assign('canvasTypes', $this->blueprintsService->getTranslatedBoxes($this->template));
|
||||
$this->tpl->assign('canvasItem', $this->blueprintsService->getCanvasItem($itemId, $canvasType));
|
||||
$this->tpl->assign('canvasSlug', $this->canvasSlug);
|
||||
|
||||
return $this->tpl->displayPartial('blueprints.canvasComment');
|
||||
}
|
||||
}
|
||||
427
app/Domain/Blueprints/Controllers/EditCanvasItem.php
Normal file
427
app/Domain/Blueprints/Controllers/EditCanvasItem.php
Normal file
@@ -0,0 +1,427 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Leantime\Domain\Blueprints\Controllers;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
use Leantime\Core\Controller\Frontcontroller;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Core\UI\Template;
|
||||
use Leantime\Domain\Blueprints\Models\CanvasTemplate;
|
||||
use Leantime\Domain\Blueprints\Permissions\BlueprintsPermissions;
|
||||
use Leantime\Domain\Blueprints\Services\Blueprints as BlueprintsService;
|
||||
use Leantime\Domain\Blueprints\Services\TemplateRegistry;
|
||||
use Leantime\Domain\Comments\Repositories\Comments as CommentRepository;
|
||||
use Leantime\Domain\Notifications\Models\Notification as NotificationModel;
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
use Leantime\Domain\Tickets\Services\Tickets as TicketService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* EditCanvasItem controller - handles viewing and editing a single canvas item.
|
||||
*
|
||||
* Native Laravel controller: route-bound actions, the {canvasSlug}/{id} path segments
|
||||
* arrive via the route (canvasSlug resolved in the constructor, id as a typed action arg),
|
||||
* and request input is read from the injected IncomingRequest instead of the legacy
|
||||
* merged-$params argument and superglobals.
|
||||
*
|
||||
* All by-id item access goes through the Blueprints service, which authorizes against the
|
||||
* item's real project — the controller never reads/writes canvas items via the repository.
|
||||
*/
|
||||
class EditCanvasItem
|
||||
{
|
||||
private string $canvasSlug;
|
||||
|
||||
private ?CanvasTemplate $template;
|
||||
|
||||
/**
|
||||
* __construct - resolve dependencies and the canvas template for the requested slug.
|
||||
*
|
||||
* @param IncomingRequest $request Incoming HTTP request
|
||||
* @param Template $tpl Template engine
|
||||
* @param Language $language Language service
|
||||
* @param TicketService $ticketService Ticket service
|
||||
* @param ProjectService $projectService Project service
|
||||
* @param CommentRepository $commentsRepo Comments repository
|
||||
* @param BlueprintsService $blueprintsService Blueprints service (project-authorized item CRUD)
|
||||
* @param TemplateRegistry $templateRegistry Canvas template registry
|
||||
*/
|
||||
public function __construct(
|
||||
private IncomingRequest $request,
|
||||
private Template $tpl,
|
||||
private Language $language,
|
||||
private TicketService $ticketService,
|
||||
private ProjectService $projectService,
|
||||
private CommentRepository $commentsRepo,
|
||||
private BlueprintsService $blueprintsService,
|
||||
TemplateRegistry $templateRegistry,
|
||||
) {
|
||||
$this->canvasSlug = strip_tags((string) ($request->route('canvasSlug') ?? ''));
|
||||
$this->template = $templateRegistry->get($this->canvasSlug);
|
||||
}
|
||||
|
||||
/**
|
||||
* get - handle GET requests for viewing/editing a canvas item.
|
||||
*
|
||||
* @param string|null $canvasSlug Canvas type slug from the route (resolved in the constructor)
|
||||
* @param string|null $id Canvas item id
|
||||
*/
|
||||
#[RequiresPermission(BlueprintsPermissions::VIEW, entityScoped: true)]
|
||||
public function get(?string $canvasSlug = null, ?string $id = null): Response
|
||||
{
|
||||
$data = $this->request->getRequestParams();
|
||||
if ($id !== null) {
|
||||
$data['id'] = $id;
|
||||
}
|
||||
|
||||
if ($this->template === null) {
|
||||
return $this->tpl->displayPartial('errors.error404');
|
||||
}
|
||||
|
||||
$canvasType = $this->template->getDatabaseType();
|
||||
$commentModule = $this->template->getCommentModule();
|
||||
$canvasTypes = $this->blueprintsService->getTranslatedBoxes($this->template);
|
||||
$statusLabels = $this->blueprintsService->getTranslatedStatusLabels($this->template);
|
||||
$relatesLabels = $this->blueprintsService->getTranslatedRelatesLabels($this->template);
|
||||
|
||||
if (isset($data['id'])) {
|
||||
// Resolve + VIEW-authorize the item against its real project BEFORE any mutation.
|
||||
// false = missing / foreign project / unauthorized (indistinguishable -> no oracle).
|
||||
$canvasItem = $this->blueprintsService->getCanvasItem((int) $data['id'], $canvasType);
|
||||
if (! $canvasItem) {
|
||||
return $this->tpl->displayPartial('errors.error404');
|
||||
}
|
||||
|
||||
// Delete comment — ONLY when it actually belongs to THIS gated item (same module +
|
||||
// moduleId). The item being viewable is not enough: deleteComment() filters on the
|
||||
// comment id alone, so without this bind any global comment id (a comment on another
|
||||
// item, canvas type, or project — one shared id sequence) could be deleted.
|
||||
if (isset($data['delComment'])) {
|
||||
$commentId = (int) ($data['delComment']);
|
||||
$comment = $this->commentsRepo->getComment($commentId);
|
||||
if ($comment !== false
|
||||
&& (string) $comment['module'] === $commentModule
|
||||
&& (int) $comment['moduleId'] === (int) $canvasItem['id']) {
|
||||
$this->commentsRepo->deleteComment($commentId);
|
||||
$this->tpl->setNotification($this->language->__('notifications.comment_deleted'), 'success');
|
||||
}
|
||||
}
|
||||
|
||||
// Delete milestone relationship — an EDIT, authorized by the service against the
|
||||
// item's project (a view-only user is denied here).
|
||||
if (isset($data['removeMilestone'])) {
|
||||
$this->blueprintsService->patchCanvasItem((int) $data['id'], ['milestoneId' => ''], $canvasType);
|
||||
$canvasItem = $this->blueprintsService->getCanvasItem((int) $data['id'], $canvasType);
|
||||
$this->tpl->setNotification($this->language->__('notifications.milestone_detached'), 'success');
|
||||
}
|
||||
|
||||
$comments = $this->commentsRepo->getComments($commentModule, $canvasItem['id']);
|
||||
$this->tpl->assign(
|
||||
'numComments',
|
||||
$this->commentsRepo->countComments($commentModule, $canvasItem['id'])
|
||||
);
|
||||
} else {
|
||||
if (isset($data['type'])) {
|
||||
$type = strip_tags($data['type']);
|
||||
} else {
|
||||
$type = array_key_first($canvasTypes);
|
||||
}
|
||||
|
||||
// Fall back to a known box when the requested type isn't part of this
|
||||
// canvas, otherwise the dialog renders $canvasTypes[$type] on null (500).
|
||||
if (! isset($canvasTypes[$type])) {
|
||||
$type = array_key_first($canvasTypes);
|
||||
}
|
||||
|
||||
$canvasItem = [
|
||||
'id' => '',
|
||||
'box' => $type,
|
||||
'description' => '',
|
||||
'status' => array_key_first($statusLabels),
|
||||
'relates' => array_key_first($relatesLabels),
|
||||
'assumptions' => '',
|
||||
'data' => '',
|
||||
'conclusion' => '',
|
||||
'milestoneHeadline' => '',
|
||||
'milestoneId' => '',
|
||||
];
|
||||
|
||||
$comments = [];
|
||||
}
|
||||
|
||||
$this->tpl->assign('comments', $comments);
|
||||
|
||||
$allProjectMilestones = $this->ticketService->getAllMilestones([
|
||||
'sprint' => '',
|
||||
'type' => 'milestone',
|
||||
'currentProject' => session('currentProject'),
|
||||
]);
|
||||
$this->tpl->assign('milestones', $allProjectMilestones);
|
||||
$this->tpl->assign('canvasItem', $canvasItem);
|
||||
$this->tpl->assign('canvasSlug', $this->canvasSlug);
|
||||
$this->tpl->assign('canvasIcon', $this->template->icon);
|
||||
$this->tpl->assign('relatesLabels', $relatesLabels);
|
||||
$this->tpl->assign('canvasTypes', $canvasTypes);
|
||||
$this->tpl->assign('statusLabels', $statusLabels);
|
||||
$this->tpl->assign('dataLabels', $this->blueprintsService->getTranslatedDataLabels($this->template));
|
||||
|
||||
return $this->tpl->displayPartial('blueprints.canvasDialog');
|
||||
}
|
||||
|
||||
/**
|
||||
* post - handle POST requests for creating/updating canvas items and comments.
|
||||
*
|
||||
* @param string|null $canvasSlug Canvas type slug from the route (resolved in the constructor)
|
||||
* @param string|null $id Canvas item id
|
||||
*/
|
||||
#[RequiresPermission(BlueprintsPermissions::EDIT, entityScoped: true)]
|
||||
public function post(?string $canvasSlug = null, ?string $id = null): Response
|
||||
{
|
||||
$data = $this->request->getRequestParams();
|
||||
if ($id !== null) {
|
||||
$data['id'] = $id;
|
||||
}
|
||||
|
||||
if ($this->template === null) {
|
||||
return $this->tpl->displayPartial('errors.error404');
|
||||
}
|
||||
|
||||
$canvasType = $this->template->getDatabaseType();
|
||||
$commentModule = $this->template->getCommentModule();
|
||||
$sessionKey = $this->template->getSessionKey();
|
||||
$basePath = '/blueprints/'.$this->canvasSlug;
|
||||
|
||||
if (isset($data['changeItem'])) {
|
||||
if (isset($data['itemId']) && ! empty($data['itemId'])) {
|
||||
if (isset($data['description']) && ! empty($data['description'])) {
|
||||
$currentCanvasId = (int) session($sessionKey);
|
||||
|
||||
$canvasItem = [
|
||||
'box' => $data['box'],
|
||||
'author' => session('userdata.id'),
|
||||
'description' => $data['description'],
|
||||
'status' => $data['status'],
|
||||
'relates' => $data['relates'],
|
||||
'assumptions' => $data['assumptions'],
|
||||
'data' => $data['data'],
|
||||
'conclusion' => $data['conclusion'],
|
||||
'itemId' => $data['itemId'],
|
||||
'canvasId' => $currentCanvasId,
|
||||
'milestoneId' => $data['milestoneId'],
|
||||
'dependentMilstone' => '',
|
||||
'id' => $data['itemId'],
|
||||
];
|
||||
|
||||
if (isset($data['newMilestone']) && $data['newMilestone'] != '') {
|
||||
$data['headline'] = $data['newMilestone'];
|
||||
$data['tags'] = '#ccc';
|
||||
$data['editFrom'] = dtHelper()->userNow()->formatDateForUser();
|
||||
$data['editTo'] = dtHelper()->userNow()->addDays(7)->formatDateForUser();
|
||||
$data['dependentMilestone'] = '';
|
||||
$id = $this->ticketService->quickAddMilestone($data);
|
||||
|
||||
if ($id !== false) {
|
||||
$canvasItem['milestoneId'] = $id;
|
||||
}
|
||||
}
|
||||
if (isset($data['existingMilestone']) && $data['existingMilestone'] != '') {
|
||||
$canvasItem['milestoneId'] = $data['existingMilestone'];
|
||||
}
|
||||
|
||||
// Resolves the item's real project from itemId and authorizes EDIT there;
|
||||
// the payload's canvasId can't relocate the item across projects.
|
||||
$this->blueprintsService->updateCanvasItem($canvasItem, $canvasType);
|
||||
|
||||
$comments = $this->commentsRepo->getComments($commentModule, $data['itemId']);
|
||||
$this->tpl->assign('numComments', $this->commentsRepo->countComments(
|
||||
$commentModule,
|
||||
$data['itemId']
|
||||
));
|
||||
$this->tpl->assign('comments', $comments);
|
||||
|
||||
$this->tpl->setNotification($this->language->__('notifications.canvas_item_updates'), 'success');
|
||||
|
||||
$subject = $this->language->__('email_notifications.canvas_board_edited');
|
||||
$actualLink = BASE_URL.$basePath.'#/editCanvasItem/'.(int) $data['itemId'];
|
||||
$message = sprintf(
|
||||
$this->language->__('email_notifications.canvas_item_update_message'),
|
||||
session('userdata.name'),
|
||||
strip_tags($canvasItem['description'])
|
||||
);
|
||||
|
||||
$notification = app()->make(NotificationModel::class);
|
||||
$notification->url = [
|
||||
'url' => $actualLink,
|
||||
'text' => $this->language->__('email_notifications.canvas_item_update_cta'),
|
||||
];
|
||||
$notification->entity = $canvasItem;
|
||||
$notification->module = $this->canvasSlug.'canvas';
|
||||
$notification->action = 'updated';
|
||||
$notification->projectId = session('currentProject');
|
||||
$notification->subject = $subject;
|
||||
$notification->authorId = session('userdata.id');
|
||||
$notification->message = $message;
|
||||
|
||||
$this->projectService->notifyProjectUsers($notification);
|
||||
|
||||
$closeModal = '';
|
||||
if (isset($data['submitAction']) && $data['submitAction'] == 'closeModal') {
|
||||
$closeModal = '?closeModal=true';
|
||||
}
|
||||
|
||||
return Frontcontroller::redirect(BASE_URL.$basePath.'/editCanvasItem/'.$data['itemId'].$closeModal);
|
||||
} else {
|
||||
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
|
||||
}
|
||||
} else {
|
||||
if (isset($data['description']) && ! empty($data['description'])) {
|
||||
$currentCanvasId = (int) session($sessionKey);
|
||||
|
||||
$canvasItem = [
|
||||
'box' => $data['box'],
|
||||
'author' => session('userdata.id'),
|
||||
'description' => $data['description'],
|
||||
'status' => $data['status'],
|
||||
'relates' => $data['relates'],
|
||||
'assumptions' => $data['assumptions'],
|
||||
'data' => $data['data'],
|
||||
'conclusion' => $data['conclusion'],
|
||||
'canvasId' => $currentCanvasId,
|
||||
];
|
||||
|
||||
// Resolves the TARGET board's real project from canvasId and authorizes
|
||||
// CREATE there (the board must exist and belong to a project the user can
|
||||
// create in) before inserting.
|
||||
$id = $this->blueprintsService->createCanvasItem($canvasItem, $canvasType);
|
||||
$canvasTypes = $this->blueprintsService->getTranslatedBoxes($this->template);
|
||||
|
||||
$this->tpl->setNotification(
|
||||
$canvasTypes[$data['box']]['title'].' successfully created',
|
||||
'success',
|
||||
''.$data['box'].'_item_created'
|
||||
);
|
||||
|
||||
$subject = $this->language->__('email_notifications.canvas_board_item_created');
|
||||
$actualLink = BASE_URL.$basePath.'#/editCanvasItem/'.(int) ($data['itemId'] ?? $id);
|
||||
$message = sprintf(
|
||||
$this->language->__('email_notifications.canvas_item_created_message'),
|
||||
session('userdata.name'),
|
||||
strip_tags($canvasItem['description'])
|
||||
);
|
||||
|
||||
$notification = app()->make(NotificationModel::class);
|
||||
$notification->url = [
|
||||
'url' => $actualLink,
|
||||
'text' => $this->language->__('email_notifications.canvas_item_update_cta'),
|
||||
];
|
||||
$notification->entity = $canvasItem;
|
||||
$notification->module = $this->canvasSlug.'canvas';
|
||||
$notification->action = 'created';
|
||||
$notification->projectId = session('currentProject');
|
||||
$notification->subject = $subject;
|
||||
$notification->authorId = session('userdata.id');
|
||||
$notification->message = $message;
|
||||
|
||||
$this->projectService->notifyProjectUsers($notification);
|
||||
|
||||
$this->tpl->setNotification($this->language->__('notification.element_created'), 'success');
|
||||
|
||||
$closeModal = '';
|
||||
if (isset($data['submitAction']) && $data['submitAction'] == 'closeModal') {
|
||||
$closeModal = '?closeModal=true';
|
||||
}
|
||||
|
||||
return Frontcontroller::redirect(BASE_URL.$basePath.'/editCanvasItem/'.$id.$closeModal);
|
||||
} else {
|
||||
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($data['comment']) && isset($data['id'])) {
|
||||
$itemId = (int) $data['id'];
|
||||
|
||||
// Only allow commenting on an item the user can view in their project.
|
||||
if (! $this->blueprintsService->getCanvasItem($itemId, $canvasType)) {
|
||||
return $this->tpl->displayPartial('errors.error404');
|
||||
}
|
||||
|
||||
$values = [
|
||||
'text' => $data['text'],
|
||||
'date' => date('Y-m-d H:i:s'),
|
||||
'userId' => (session('userdata.id')),
|
||||
'moduleId' => $itemId,
|
||||
'commentParent' => ($data['father']),
|
||||
];
|
||||
|
||||
$commentId = $this->commentsRepo->addComment($values, $commentModule);
|
||||
$this->tpl->setNotification($this->language->__('notifications.comment_create_success'), 'success');
|
||||
$values['id'] = $commentId;
|
||||
|
||||
$subject = $this->language->__('email_notifications.canvas_board_comment_created');
|
||||
$actualLink = BASE_URL.$basePath.'#/editCanvasItem/'.$itemId;
|
||||
$message = sprintf(
|
||||
$this->language->__('email_notifications.canvas_item__comment_created_message'),
|
||||
session('userdata.name')
|
||||
);
|
||||
|
||||
$notification = app()->make(NotificationModel::class);
|
||||
$notification->url = [
|
||||
'url' => $actualLink,
|
||||
'text' => $this->language->__('email_notifications.canvas_item_update_cta'),
|
||||
];
|
||||
$notification->entity = $values;
|
||||
$notification->module = $this->canvasSlug.'canvas';
|
||||
$notification->action = 'commented';
|
||||
$notification->projectId = session('currentProject');
|
||||
$notification->subject = $subject;
|
||||
$notification->authorId = session('userdata.id');
|
||||
$notification->message = $message;
|
||||
|
||||
$this->projectService->notifyProjectUsers($notification);
|
||||
|
||||
return Frontcontroller::redirect(BASE_URL.$basePath.'/editCanvasItem/'.$itemId);
|
||||
}
|
||||
|
||||
$statusLabels = $this->blueprintsService->getTranslatedStatusLabels($this->template);
|
||||
$relatesLabels = $this->blueprintsService->getTranslatedRelatesLabels($this->template);
|
||||
|
||||
$allProjectMilestones = $this->ticketService->getAllMilestones([
|
||||
'sprint' => '',
|
||||
'type' => 'milestone',
|
||||
'currentProject' => session('currentProject'),
|
||||
]);
|
||||
$this->tpl->assign('milestones', $allProjectMilestones);
|
||||
$this->tpl->assign('canvasTypes', $this->blueprintsService->getTranslatedBoxes($this->template));
|
||||
$this->tpl->assign('statusLabels', $statusLabels);
|
||||
$this->tpl->assign('relatesLabels', $relatesLabels);
|
||||
$this->tpl->assign('dataLabels', $this->blueprintsService->getTranslatedDataLabels($this->template));
|
||||
if (isset($data['id'])) {
|
||||
$canvasItemId = (int) $data['id'];
|
||||
$comments = $this->commentsRepo->getComments($commentModule, $canvasItemId);
|
||||
$this->tpl->assign('canvasItem', $this->blueprintsService->getCanvasItem($canvasItemId, $canvasType));
|
||||
} else {
|
||||
$value = [
|
||||
'id' => '',
|
||||
'box' => $data['box'],
|
||||
'author' => session('userdata.id'),
|
||||
'description' => '',
|
||||
'status' => array_key_first($statusLabels),
|
||||
'relates' => array_key_first($relatesLabels),
|
||||
'assumptions' => '',
|
||||
'data' => '',
|
||||
'conclusion' => '',
|
||||
'milestoneHeadline' => '',
|
||||
'milestoneId' => '',
|
||||
];
|
||||
$comments = [];
|
||||
$this->tpl->assign('canvasItem', $value);
|
||||
}
|
||||
$this->tpl->assign('comments', $comments);
|
||||
$this->tpl->assign('canvasSlug', $this->canvasSlug);
|
||||
|
||||
return $this->tpl->displayPartial('blueprints.canvasDialog');
|
||||
}
|
||||
}
|
||||
82
app/Domain/Blueprints/Controllers/Export.php
Normal file
82
app/Domain/Blueprints/Controllers/Export.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Leantime\Domain\Blueprints\Controllers;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Leantime\Domain\Blueprints\Models\CanvasTemplate;
|
||||
use Leantime\Domain\Blueprints\Permissions\BlueprintsPermissions;
|
||||
use Leantime\Domain\Blueprints\Services\BlueprintsExport;
|
||||
use Leantime\Domain\Blueprints\Services\TemplateRegistry;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Export controller - exports a blueprint canvas board as an XML file.
|
||||
*
|
||||
* Thin controller: resolves the board id and delegates XML generation to the
|
||||
* BlueprintsExport service. The canvas type slug comes from the route.
|
||||
*/
|
||||
class Export
|
||||
{
|
||||
private string $canvasSlug;
|
||||
|
||||
private ?CanvasTemplate $template;
|
||||
|
||||
/**
|
||||
* __construct - resolve dependencies and determine the canvas slug from the request.
|
||||
*
|
||||
* @param IncomingRequest $request Incoming request
|
||||
* @param BlueprintsExport $exportService Blueprints export service
|
||||
* @param TemplateRegistry $templateRegistry Template registry
|
||||
*/
|
||||
public function __construct(
|
||||
IncomingRequest $request,
|
||||
private BlueprintsExport $exportService,
|
||||
TemplateRegistry $templateRegistry,
|
||||
) {
|
||||
$this->canvasSlug = strip_tags((string) ($request->route('canvasSlug') ?? ''));
|
||||
$this->template = $templateRegistry->get($this->canvasSlug);
|
||||
}
|
||||
|
||||
/**
|
||||
* get - generate and return the XML export file.
|
||||
*
|
||||
* @param string|null $canvasSlug Canvas type slug from the route (resolved in the constructor)
|
||||
* @param string|null $id Board id from the route
|
||||
*/
|
||||
#[RequiresPermission(BlueprintsPermissions::VIEW, entityScoped: true)]
|
||||
public function get(?string $canvasSlug = null, ?string $id = null): Response
|
||||
{
|
||||
if ($this->template === null) {
|
||||
return new Response('Unknown canvas type', 404);
|
||||
}
|
||||
|
||||
// Resolve the board id from the route/query, falling back to the session.
|
||||
$sessionKey = $this->template->getSessionKey();
|
||||
if ($id !== null && $id !== '') {
|
||||
$canvasId = (int) $id;
|
||||
} elseif (session()->exists($sessionKey)) {
|
||||
$canvasId = (int) session($sessionKey);
|
||||
} else {
|
||||
return new Response('', 204);
|
||||
}
|
||||
|
||||
$exportData = $this->exportService->exportToXml($canvasId, $this->canvasSlug);
|
||||
if ($exportData === null) {
|
||||
return new Response('Canvas not found', 404);
|
||||
}
|
||||
|
||||
clearstatcache();
|
||||
$response = new Response($exportData);
|
||||
$response->headers->set('Content-type', 'application/xml');
|
||||
$response->headers->set(
|
||||
'Content-Disposition',
|
||||
'attachment; filename="'.$this->template->getDatabaseType().'-'.$canvasId.'.xml"'
|
||||
);
|
||||
$response->headers->set('Cache-Control', 'no-cache');
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
49
app/Domain/Blueprints/Controllers/ShowBoards.php
Normal file
49
app/Domain/Blueprints/Controllers/ShowBoards.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Leantime\Domain\Blueprints\Controllers;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
use Leantime\Core\UI\Template;
|
||||
use Leantime\Domain\Blueprints\Permissions\BlueprintsPermissions;
|
||||
use Leantime\Domain\Blueprints\Services\Blueprints as BlueprintsService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* ShowBoards controller - displays the blueprints boards overview.
|
||||
*
|
||||
* Absorbed from the former Strategy domain (there is no longer a separate
|
||||
* "strategy" module). Native Laravel controller: a single route-bound get()
|
||||
* action that reads the active project from the session and renders the
|
||||
* recent + available boards overview.
|
||||
*/
|
||||
class ShowBoards
|
||||
{
|
||||
/**
|
||||
* @param Template $tpl Template engine
|
||||
* @param BlueprintsService $blueprintsService Blueprints service providing the boards overview
|
||||
*/
|
||||
public function __construct(
|
||||
private Template $tpl,
|
||||
private BlueprintsService $blueprintsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* get - display the blueprints boards overview for the active project.
|
||||
*
|
||||
* @throws \Illuminate\Contracts\Container\BindingResolutionException
|
||||
*/
|
||||
#[RequiresPermission(BlueprintsPermissions::VIEW)]
|
||||
public function get(): Response
|
||||
{
|
||||
$overview = $this->blueprintsService->getBoardsOverview((int) session('currentProject'));
|
||||
|
||||
$this->tpl->assign('recentProgressCanvas', $overview['recentProgressCanvas']);
|
||||
$this->tpl->assign('recentlyUpdatedCanvas', $overview['recentlyUpdatedCanvas']);
|
||||
$this->tpl->assign('canvasProgress', $overview['canvasProgress']);
|
||||
$this->tpl->assign('otherBoards', $overview['otherBoards']);
|
||||
|
||||
return $this->tpl->display('blueprints.showBoards');
|
||||
}
|
||||
}
|
||||
380
app/Domain/Blueprints/Controllers/ShowCanvas.php
Normal file
380
app/Domain/Blueprints/Controllers/ShowCanvas.php
Normal file
@@ -0,0 +1,380 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Leantime\Domain\Blueprints\Controllers;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
use Leantime\Core\Controller\Frontcontroller;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Core\Mailer as MailerCore;
|
||||
use Leantime\Core\UI\Template;
|
||||
use Leantime\Domain\Blueprints\Models\CanvasTemplate;
|
||||
use Leantime\Domain\Blueprints\Permissions\BlueprintsPermissions;
|
||||
use Leantime\Domain\Blueprints\Repositories\Blueprints as BlueprintsRepository;
|
||||
use Leantime\Domain\Blueprints\Services\Blueprints as BlueprintsService;
|
||||
use Leantime\Domain\Blueprints\Services\TemplateRegistry;
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
use Leantime\Domain\Queue\Repositories\Queue as QueueRepository;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* ShowCanvas controller - displays and manages a blueprint canvas board.
|
||||
*
|
||||
* Replaces the old per-variant Canvas\Controllers\ShowCanvas subclasses.
|
||||
* The canvas type slug comes from the route instead of a class constant.
|
||||
*
|
||||
* Native Laravel controller: route-bound actions, the {canvasSlug}/{id} path segments
|
||||
* arrive via the route (canvasSlug resolved in the constructor, id as a typed action arg),
|
||||
* and request input is read from the injected IncomingRequest instead of the legacy
|
||||
* merged-$params argument and superglobals.
|
||||
*/
|
||||
class ShowCanvas
|
||||
{
|
||||
private string $canvasSlug;
|
||||
|
||||
private ?CanvasTemplate $template;
|
||||
|
||||
/**
|
||||
* __construct - resolve dependencies and determine the canvas slug from request.
|
||||
*
|
||||
* @param IncomingRequest $request Incoming request
|
||||
* @param Template $tpl Template engine
|
||||
* @param Language $language Language service
|
||||
* @param ProjectService $projectService Project service
|
||||
* @param BlueprintsRepository $blueprintsRepo Blueprints repository
|
||||
* @param BlueprintsService $blueprintsService Blueprints service
|
||||
* @param TemplateRegistry $templateRegistry Template registry
|
||||
*/
|
||||
public function __construct(
|
||||
private IncomingRequest $request,
|
||||
private Template $tpl,
|
||||
private Language $language,
|
||||
private ProjectService $projectService,
|
||||
private BlueprintsRepository $blueprintsRepo,
|
||||
private BlueprintsService $blueprintsService,
|
||||
TemplateRegistry $templateRegistry,
|
||||
) {
|
||||
$this->canvasSlug = strip_tags((string) ($request->route('canvasSlug') ?? ''));
|
||||
$this->template = $templateRegistry->get($this->canvasSlug);
|
||||
}
|
||||
|
||||
/**
|
||||
* get - display the canvas board (and handle the board switcher).
|
||||
*
|
||||
* @param string|null $canvasSlug Canvas type slug from the route (resolved in the constructor)
|
||||
* @param string|null $id Active board id from the route
|
||||
*/
|
||||
#[RequiresPermission(BlueprintsPermissions::VIEW, entityScoped: true)]
|
||||
public function get(?string $canvasSlug = null, ?string $id = null): Response
|
||||
{
|
||||
$data = $this->request->getRequestParams();
|
||||
if ($id !== null) {
|
||||
$data['id'] = $id;
|
||||
}
|
||||
|
||||
if ($this->template === null) {
|
||||
return $this->tpl->displayPartial('errors.error404');
|
||||
}
|
||||
|
||||
$canvasType = $this->template->getDatabaseType();
|
||||
$sessionKey = $this->template->getSessionKey();
|
||||
|
||||
[$allCanvas, $currentCanvasId] = $this->resolveCurrentBoard($data, $canvasType, $sessionKey);
|
||||
|
||||
// Board switcher
|
||||
if (isset($data['searchCanvas'])) {
|
||||
session([$sessionKey => (int) $data['searchCanvas']]);
|
||||
|
||||
return Frontcontroller::redirect(BASE_URL.'/blueprints/'.$this->canvasSlug.'/showCanvas/');
|
||||
}
|
||||
|
||||
return $this->renderCanvas($data, $allCanvas, $currentCanvasId);
|
||||
}
|
||||
|
||||
/**
|
||||
* post - handle create / edit / clone / merge / import board actions.
|
||||
*
|
||||
* @param string|null $canvasSlug Canvas type slug from the route (resolved in the constructor)
|
||||
* @param string|null $id Active board id from the route
|
||||
*/
|
||||
#[RequiresPermission(BlueprintsPermissions::EDIT, entityScoped: true)]
|
||||
public function post(?string $canvasSlug = null, ?string $id = null): Response
|
||||
{
|
||||
$data = $this->request->getRequestParams();
|
||||
if ($id !== null) {
|
||||
$data['id'] = $id;
|
||||
}
|
||||
|
||||
if ($this->template === null) {
|
||||
return $this->tpl->displayPartial('errors.error404');
|
||||
}
|
||||
|
||||
$canvasType = $this->template->getDatabaseType();
|
||||
$sessionKey = $this->template->getSessionKey();
|
||||
|
||||
[$allCanvas, $currentCanvasId] = $this->resolveCurrentBoard($data, $canvasType, $sessionKey);
|
||||
|
||||
// Add board
|
||||
if (isset($data['newCanvas'])) {
|
||||
if (isset($data['canvastitle']) && ! empty($data['canvastitle'])) {
|
||||
if (! $this->blueprintsRepo->existCanvas(session('currentProject'), $data['canvastitle'], $canvasType)) {
|
||||
$values = [
|
||||
'title' => $data['canvastitle'],
|
||||
'author' => session('userdata.id'),
|
||||
'projectId' => session('currentProject'),
|
||||
];
|
||||
// createBoard authorizes CREATE against the target (current) project.
|
||||
$currentCanvasId = $this->blueprintsService->createBoard($values, $canvasType);
|
||||
|
||||
$this->notifyBoardChange(
|
||||
'email_notifications.canvas_created_message',
|
||||
'notification.board_created',
|
||||
$values['title']
|
||||
);
|
||||
|
||||
$this->tpl->setNotification(
|
||||
$this->language->__('notification.board_created'),
|
||||
'success',
|
||||
$this->canvasSlug.'board_created'
|
||||
);
|
||||
|
||||
session([$sessionKey => $currentCanvasId]);
|
||||
|
||||
return Frontcontroller::redirect(BASE_URL.'/blueprints/'.$this->canvasSlug.'/showCanvas/');
|
||||
}
|
||||
|
||||
$this->tpl->setNotification($this->language->__('notification.board_exists'), 'error');
|
||||
} else {
|
||||
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Edit board
|
||||
if (isset($data['editCanvas']) && is_int($currentCanvasId) && $currentCanvasId > 0) {
|
||||
if (isset($data['canvastitle']) && ! empty($data['canvastitle'])) {
|
||||
if (! $this->blueprintsRepo->existCanvas(session('currentProject'), $data['canvastitle'], $canvasType)) {
|
||||
// renameBoard authorizes EDIT against the board's real project.
|
||||
$this->blueprintsService->renameBoard($currentCanvasId, $data['canvastitle'], $canvasType);
|
||||
|
||||
$this->tpl->setNotification($this->language->__('notification.board_edited'), 'success');
|
||||
|
||||
return $this->tpl->displayPartial('blueprints.boardDialog');
|
||||
}
|
||||
|
||||
$this->tpl->setNotification($this->language->__('notification.board_exists'), 'error');
|
||||
} else {
|
||||
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Clone board
|
||||
if (isset($data['cloneCanvas']) && is_int($currentCanvasId) && $currentCanvasId > 0) {
|
||||
if (isset($data['canvastitle']) && ! empty($data['canvastitle'])) {
|
||||
if (! $this->blueprintsRepo->existCanvas(session('currentProject'), $data['canvastitle'], $canvasType)) {
|
||||
// copyBoard authorizes VIEW on the source board's real project and CREATE
|
||||
// on the target (current) project.
|
||||
$currentCanvasId = $this->blueprintsService->copyBoard(
|
||||
$currentCanvasId,
|
||||
(int) session('currentProject'),
|
||||
(int) session('userdata.id'),
|
||||
$data['canvastitle'],
|
||||
$canvasType
|
||||
);
|
||||
|
||||
$this->tpl->setNotification($this->language->__('notification.board_copied'), 'success');
|
||||
|
||||
session([$sessionKey => $currentCanvasId]);
|
||||
|
||||
return Frontcontroller::redirect(BASE_URL.'/blueprints/'.$this->canvasSlug.'/showCanvas/');
|
||||
}
|
||||
|
||||
$this->tpl->setNotification($this->language->__('notification.board_exists'), 'error');
|
||||
} else {
|
||||
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Merge board
|
||||
if (isset($data['mergeCanvas']) && is_int($currentCanvasId) && $currentCanvasId > 0) {
|
||||
if (isset($data['canvasid']) && $data['canvasid'] > 0) {
|
||||
// mergeBoard authorizes EDIT on the target board's project and VIEW on the
|
||||
// source board's project — both resolved by id, so neither can cross projects.
|
||||
if ($this->blueprintsService->mergeBoard($currentCanvasId, (int) $data['canvasid'], $canvasType)) {
|
||||
$this->tpl->setNotification($this->language->__('notification.board_merged'), 'success');
|
||||
|
||||
return Frontcontroller::redirect(BASE_URL.'/blueprints/'.$this->canvasSlug.'/showCanvas/');
|
||||
}
|
||||
|
||||
$this->tpl->setNotification($this->language->__('notification.merge_error'), 'error');
|
||||
} else {
|
||||
$this->tpl->setNotification($this->language->__('notification.internal_error'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Import board
|
||||
if (isset($data['importCanvas']) && isset($_FILES['canvasfile']) && $_FILES['canvasfile']['error'] === 0) {
|
||||
$uploadfile = tempnam(sys_get_temp_dir(), 'leantime.').'.xml';
|
||||
|
||||
if (move_uploaded_file($_FILES['canvasfile']['tmp_name'], $uploadfile)) {
|
||||
$importCanvasId = $this->blueprintsService->import(
|
||||
$uploadfile,
|
||||
$this->canvasSlug,
|
||||
projectId: session('currentProject'),
|
||||
authorId: session('userdata.id')
|
||||
);
|
||||
unlink($uploadfile);
|
||||
|
||||
if ($importCanvasId !== false) {
|
||||
session([$sessionKey => $importCanvasId]);
|
||||
$canvas = $this->blueprintsService->getBoard((int) $importCanvasId, $canvasType);
|
||||
|
||||
$this->notifyBoardChange(
|
||||
'email_notifications.canvas_imported_message',
|
||||
'notification.board_imported',
|
||||
$canvas !== false ? ($canvas[0]['title'] ?? '') : ''
|
||||
);
|
||||
|
||||
$this->tpl->setNotification($this->language->__('notification.board_imported'), 'success');
|
||||
|
||||
return Frontcontroller::redirect(BASE_URL.'/blueprints/'.$this->canvasSlug.'/showCanvas/');
|
||||
}
|
||||
}
|
||||
|
||||
$this->tpl->setNotification($this->language->__('notification.board_import_failed'), 'error');
|
||||
}
|
||||
|
||||
return $this->renderCanvas($data, $allCanvas, $currentCanvasId);
|
||||
}
|
||||
|
||||
/**
|
||||
* resolveCurrentBoard - load the project's boards and determine the active board id.
|
||||
*
|
||||
* Creates a default board if none exist, validates the session board against the
|
||||
* available boards, and honours an explicit id from the request.
|
||||
*
|
||||
* @param array<string, mixed> $params Request parameters
|
||||
* @param string $canvasType Database canvas type
|
||||
* @param string $sessionKey Session key for the active board
|
||||
* @return array{0: array<int, array<string, mixed>>, 1: int} [allCanvas, currentCanvasId]
|
||||
*/
|
||||
private function resolveCurrentBoard(array $params, string $canvasType, string $sessionKey): array
|
||||
{
|
||||
$allCanvas = $this->blueprintsRepo->getAllCanvas(session('currentProject'), $canvasType);
|
||||
|
||||
// Create a default board when the project has none.
|
||||
if (! $allCanvas) {
|
||||
$this->blueprintsRepo->addCanvas([
|
||||
'title' => $this->language->__('label.board'),
|
||||
'author' => session('userdata.id'),
|
||||
'projectId' => session('currentProject'),
|
||||
], $canvasType);
|
||||
$allCanvas = $this->blueprintsRepo->getAllCanvas(session('currentProject'), $canvasType);
|
||||
}
|
||||
|
||||
$currentCanvasId = -1;
|
||||
|
||||
if (session()->exists($sessionKey)) {
|
||||
// Cast: DB drivers (MySQL emulated prepares) return ids as strings.
|
||||
$currentCanvasId = (int) session($sessionKey);
|
||||
|
||||
$found = false;
|
||||
foreach ($allCanvas as $row) {
|
||||
if ($currentCanvasId == $row['id']) {
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $found) {
|
||||
$currentCanvasId = -1;
|
||||
session([$sessionKey => '']);
|
||||
}
|
||||
} else {
|
||||
session([$sessionKey => '']);
|
||||
}
|
||||
|
||||
if (count($allCanvas) > 0 && session($sessionKey) == '') {
|
||||
$currentCanvasId = (int) $allCanvas[0]['id'];
|
||||
session([$sessionKey => $currentCanvasId]);
|
||||
}
|
||||
|
||||
if (isset($params['id'])) {
|
||||
// Only honor an explicit board id that belongs to the CURRENT project's boards
|
||||
// ($allCanvas is project-scoped). A foreign/unknown id must not become the active
|
||||
// board — otherwise renderCanvas would read another project's items (IDOR).
|
||||
$requestedId = (int) $params['id'];
|
||||
$projectBoardIds = array_map(static fn ($row) => (int) $row['id'], $allCanvas);
|
||||
if (in_array($requestedId, $projectBoardIds, true)) {
|
||||
$currentCanvasId = $requestedId;
|
||||
session([$sessionKey => $currentCanvasId]);
|
||||
}
|
||||
}
|
||||
|
||||
return [$allCanvas, $currentCanvasId];
|
||||
}
|
||||
|
||||
/**
|
||||
* renderCanvas - assign template data and render the canvas board page.
|
||||
*
|
||||
* @param array<string, mixed> $params Request parameters
|
||||
* @param array<int, array<string, mixed>> $allCanvas All boards for the project
|
||||
* @param int $currentCanvasId Active board id
|
||||
*/
|
||||
private function renderCanvas(array $params, array $allCanvas, int $currentCanvasId): Response
|
||||
{
|
||||
$filter['status'] = $params['filter_status'] ?? (session('filter_status') ?? 'all');
|
||||
session(['filter_status' => $filter['status']]);
|
||||
$filter['relates'] = $params['filter_relates'] ?? (session('filter_relates') ?? 'all');
|
||||
session(['filter_relates' => $filter['relates']]);
|
||||
|
||||
$this->tpl->assign('filter', $filter);
|
||||
$this->tpl->assign('currentCanvas', $currentCanvasId);
|
||||
$this->tpl->assign('canvasSlug', $this->canvasSlug);
|
||||
$this->tpl->assign('template', $this->template);
|
||||
$this->tpl->assign('canvasIcon', $this->template->icon);
|
||||
$this->tpl->assign('canvasTypes', $this->blueprintsService->getTranslatedBoxes($this->template));
|
||||
$this->tpl->assign('statusLabels', $this->blueprintsService->getTranslatedStatusLabels($this->template));
|
||||
$this->tpl->assign('relatesLabels', $this->blueprintsService->getTranslatedRelatesLabels($this->template));
|
||||
$this->tpl->assign('dataLabels', $this->blueprintsService->getTranslatedDataLabels($this->template));
|
||||
$this->tpl->assign('disclaimer', $this->blueprintsService->getTranslatedDisclaimer($this->template));
|
||||
$this->tpl->assign('allCanvas', $allCanvas);
|
||||
// getBoardItems authorizes VIEW against the board's real project and returns [] for a
|
||||
// foreign/unknown board, so a board id from another project can't leak its items here.
|
||||
$this->tpl->assign('canvasItems', $this->blueprintsService->getBoardItems($currentCanvasId, $this->template->getDatabaseType(), $this->template->getCommentModule()));
|
||||
$this->tpl->assign('users', $this->projectService->getUsersAssignedToProject(session('currentProject')));
|
||||
|
||||
return $this->tpl->display('blueprints.showCanvas');
|
||||
}
|
||||
|
||||
/**
|
||||
* notifyBoardChange - email + queue notify project users about a board change.
|
||||
*
|
||||
* @param string $messageKey i18n key for the email body (sprintf: user name, board link/title)
|
||||
* @param string $subjectKey i18n key for the subject/queue title
|
||||
* @param string $boardTitle Board title
|
||||
*/
|
||||
private function notifyBoardChange(string $messageKey, string $subjectKey, string $boardTitle): void
|
||||
{
|
||||
$mailer = app()->make(MailerCore::class);
|
||||
$users = $this->projectService->getUsersToNotify(session('currentProject'));
|
||||
|
||||
$mailer->setSubject($this->language->__($subjectKey));
|
||||
|
||||
$message = sprintf(
|
||||
$this->language->__($messageKey),
|
||||
session('userdata.name'),
|
||||
"<a href='".CURRENT_URL."'>".strip_tags($boardTitle).'</a>'
|
||||
);
|
||||
$mailer->setHtml($message);
|
||||
|
||||
$queue = app()->make(QueueRepository::class);
|
||||
$queue->queueMessageToUsers(
|
||||
$users,
|
||||
$message,
|
||||
$this->language->__($subjectKey),
|
||||
session('currentProject')
|
||||
);
|
||||
}
|
||||
}
|
||||
54
app/Domain/Blueprints/Events/CanvasItemUpdated.php
Normal file
54
app/Domain/Blueprints/Events/CanvasItemUpdated.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Blueprints\Events;
|
||||
|
||||
use Leantime\Core\Events\Concerns\InteractsWithEvents;
|
||||
use Leantime\Core\Events\Contracts\LeantimeEvent;
|
||||
|
||||
/**
|
||||
* Fired after a canvas item was updated — for ANY canvas type (goal, idea,
|
||||
* wiki, logic model, …), since all canvas items share the zp_canvas_items
|
||||
* table and the same update chokepoints.
|
||||
*
|
||||
* The event is deliberately generic: consumers that only care about a specific
|
||||
* canvas (e.g. the strategy Logic Model, which propagates edits down to the
|
||||
* work it generated) resolve the item's canvas and filter themselves.
|
||||
* `changedFields` is best-effort and never authoritative: for patches it is
|
||||
* the set of allow-listed keys that were written (which can include a key set
|
||||
* to the value it already held), and for full updates it is over-inclusive
|
||||
* (every mirrored column). Either way it says "possibly touched", not
|
||||
* "definitely changed", so listeners must still no-op when the field they
|
||||
* mirror is actually unchanged.
|
||||
*/
|
||||
final class CanvasItemUpdated implements LeantimeEvent
|
||||
{
|
||||
use InteractsWithEvents;
|
||||
|
||||
/**
|
||||
* @param int $canvasItemId The updated canvas item id.
|
||||
* @param array<int, string> $changedFields Best-effort names of the fields possibly
|
||||
* written (see class doc); not authoritative.
|
||||
* @param string|null $legacyHook TEMPORARY (migration window): the emitting method name —
|
||||
* pass __FUNCTION__ — used to rebuild the historical string
|
||||
* name for legacy string-based listeners.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $canvasItemId,
|
||||
public readonly array $changedFields = [],
|
||||
private readonly ?string $legacyHook = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The exact historical string name of the emitting site. Remove with the migration window.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function legacyHooks(): array
|
||||
{
|
||||
if ($this->legacyHook === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ['leantime.domain.blueprints.services.blueprints.'.$this->legacyHook.'.canvas_item_updated'];
|
||||
}
|
||||
}
|
||||
262
app/Domain/Blueprints/Js/blueprintsController.js
Normal file
262
app/Domain/Blueprints/Js/blueprintsController.js
Normal file
@@ -0,0 +1,262 @@
|
||||
leantime.blueprintsController = (function () {
|
||||
|
||||
var canvasName = '';
|
||||
|
||||
var setCanvasName = function (name) {
|
||||
canvasName = name;
|
||||
};
|
||||
|
||||
var setRowHeights = function () {
|
||||
// Collect all unique row IDs from .canvas-row elements
|
||||
var rowIds = [];
|
||||
jQuery(".canvas-row[id]").each(function () {
|
||||
var id = jQuery(this).attr("id");
|
||||
if (id && rowIds.indexOf(id) === -1) {
|
||||
rowIds.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
if (rowIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
var nbRows = rowIds.length;
|
||||
var rowHeight = jQuery("html").height() - 320 - 20 * nbRows - 25;
|
||||
var perRowHeight = rowHeight / nbRows;
|
||||
|
||||
// For each row, find the tallest content and set all columns to that height
|
||||
for (var i = 0; i < rowIds.length; i++) {
|
||||
var rowSelector = "#" + rowIds[i];
|
||||
var maxHeight = perRowHeight;
|
||||
|
||||
jQuery(rowSelector + " div.contentInner").each(function () {
|
||||
if (jQuery(this).height() > maxHeight) {
|
||||
maxHeight = jQuery(this).height() + 50;
|
||||
}
|
||||
});
|
||||
|
||||
jQuery(rowSelector + " .column .contentInner").css("height", maxHeight);
|
||||
}
|
||||
};
|
||||
|
||||
var initFilterBar = function () {
|
||||
|
||||
jQuery(window).bind("load", function () {
|
||||
jQuery(".loading").fadeOut();
|
||||
jQuery(".filterBar .row-fluid").css("opacity", "1");
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
var initCanvasLinks = function () {
|
||||
|
||||
jQuery(".addCanvasLink").nyroModal();
|
||||
|
||||
jQuery(".editCanvasLink").click(function () {
|
||||
jQuery('#editCanvas').modal('show');
|
||||
});
|
||||
|
||||
jQuery(".cloneCanvasLink").click(function () {
|
||||
jQuery('#cloneCanvas').modal('show');
|
||||
});
|
||||
|
||||
jQuery(".mergeCanvasLink").click(function () {
|
||||
jQuery('#mergeCanvas').modal('show');
|
||||
});
|
||||
|
||||
jQuery(".importCanvasLink").click(function () {
|
||||
jQuery('#importCanvas').modal('show');
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
var closeModal = false;
|
||||
|
||||
//Variables
|
||||
var canvasoptions = function () {
|
||||
return {
|
||||
sizes: {
|
||||
minW: 700,
|
||||
minH: 1000,
|
||||
},
|
||||
resizable: true,
|
||||
autoSizable: true,
|
||||
callbacks: {
|
||||
beforeShowCont: function () {
|
||||
jQuery(".showDialogOnLoad").show();
|
||||
if (closeModal == true) {
|
||||
closeModal = false;
|
||||
location.reload();
|
||||
}
|
||||
},
|
||||
afterShowCont: function () {
|
||||
window.htmx.process('.nyroModalCont');
|
||||
jQuery(".blueprintsCanvasModal, #commentForm, #commentForm .deleteComment, .blueprintsCanvasMilestone .deleteMilestone").nyroModal(canvasoptions());
|
||||
},
|
||||
beforeClose: function () {
|
||||
location.reload();
|
||||
}
|
||||
},
|
||||
titleFromIframe: true
|
||||
};
|
||||
};
|
||||
|
||||
//Functions
|
||||
|
||||
var _initModals = function () {
|
||||
jQuery(".blueprintsCanvasModal, #commentForm, #commentForm .deleteComment, .blueprintsCanvasMilestone .deleteMilestone").nyroModal(canvasoptions());
|
||||
};
|
||||
|
||||
var openModalManually = function (url) {
|
||||
jQuery.nmManual(url, canvasoptions());
|
||||
};
|
||||
|
||||
var toggleMilestoneSelectors = function (trigger) {
|
||||
if (trigger == 'existing') {
|
||||
jQuery('#newMilestone, #milestoneSelectors').hide('fast');
|
||||
jQuery('#existingMilestone').show();
|
||||
_initModals();
|
||||
}
|
||||
if (trigger == 'new') {
|
||||
jQuery('#newMilestone').show();
|
||||
jQuery('#existingMilestone, #milestoneSelectors').hide('fast');
|
||||
_initModals();
|
||||
}
|
||||
|
||||
if (trigger == 'hide') {
|
||||
jQuery('#newMilestone, #existingMilestone').hide('fast');
|
||||
jQuery('#milestoneSelectors').show('fast');
|
||||
}
|
||||
};
|
||||
|
||||
var setCloseModal = function () {
|
||||
closeModal = true;
|
||||
};
|
||||
|
||||
var initUserDropdown = function () {
|
||||
|
||||
jQuery("body").on(
|
||||
"click",
|
||||
".userDropdown .dropdown-menu a",
|
||||
function () {
|
||||
|
||||
var dataValue = jQuery(this).attr("data-value").split("_");
|
||||
var dataLabel = jQuery(this).attr('data-label');
|
||||
|
||||
if (dataValue.length == 3) {
|
||||
var canvasId = dataValue[0];
|
||||
var userId = dataValue[1];
|
||||
var profileImageId = dataValue[2];
|
||||
|
||||
jQuery.ajax(
|
||||
{
|
||||
type: 'PATCH',
|
||||
url: leantime.appUrl + '/api/blueprints/' + canvasName,
|
||||
data:
|
||||
{
|
||||
id : canvasId,
|
||||
author: userId
|
||||
}
|
||||
}
|
||||
).done(
|
||||
function () {
|
||||
jQuery("#userDropdownMenuLink" + canvasId + " span.text span#userImage" + canvasId + " img").attr("src", leantime.appUrl + "/users/profileImage/" + encodeURIComponent(userId));
|
||||
jQuery.growl({message: leantime.i18n.__("short_notifications.user_updated"), style: "success"});
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
var initStatusDropdown = function () {
|
||||
|
||||
jQuery("body").on(
|
||||
"click",
|
||||
".statusDropdown .dropdown-menu a",
|
||||
function () {
|
||||
|
||||
var dataValue = jQuery(this).attr("data-value").split("/");
|
||||
var dataLabel = jQuery(this).attr('data-label');
|
||||
|
||||
if (dataValue.length == 2) {
|
||||
var canvasItemId = dataValue[0];
|
||||
var status = dataValue[1];
|
||||
var statusClass = jQuery(this).attr('class');
|
||||
|
||||
jQuery.ajax(
|
||||
{
|
||||
type: 'PATCH',
|
||||
url: leantime.appUrl + '/api/blueprints/' + canvasName,
|
||||
data:
|
||||
{
|
||||
id : canvasItemId,
|
||||
status: status
|
||||
}
|
||||
}
|
||||
).done(
|
||||
function () {
|
||||
jQuery("#statusDropdownMenuLink" + canvasItemId + " span.text").text(dataLabel);
|
||||
jQuery("#statusDropdownMenuLink" + canvasItemId).removeClass().addClass(statusClass + " dropdown-toggle f-left status ");
|
||||
jQuery.growl({message: leantime.i18n.__("short_notifications.status_updated")});
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
var initRelatesDropdown = function () {
|
||||
|
||||
jQuery("body").on(
|
||||
"click",
|
||||
".relatesDropdown .dropdown-menu a",
|
||||
function () {
|
||||
|
||||
var dataValue = jQuery(this).attr("data-value").split("/");
|
||||
var dataLabel = jQuery(this).attr('data-label');
|
||||
|
||||
if (dataValue.length == 2) {
|
||||
var canvasItemId = dataValue[0];
|
||||
var relates = dataValue[1];
|
||||
var relatesClass = jQuery(this).attr('class');
|
||||
|
||||
jQuery.ajax(
|
||||
{
|
||||
type: 'PATCH',
|
||||
url: leantime.appUrl + '/api/blueprints/' + canvasName,
|
||||
data:
|
||||
{
|
||||
id : canvasItemId,
|
||||
relates: relates
|
||||
}
|
||||
}
|
||||
).done(
|
||||
function () {
|
||||
jQuery("#relatesDropdownMenuLink" + canvasItemId + " span.text").text(dataLabel);
|
||||
jQuery("#relatesDropdownMenuLink" + canvasItemId).removeClass().addClass(relatesClass + " dropdown-toggle f-left relates ");
|
||||
jQuery.growl({message: leantime.i18n.__("short_notifications.relates_updated")});
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
// Make public what you want to have public, everything else is private
|
||||
return {
|
||||
setCanvasName: setCanvasName,
|
||||
setRowHeights: setRowHeights,
|
||||
initFilterBar: initFilterBar,
|
||||
initCanvasLinks: initCanvasLinks,
|
||||
initUserDropdown: initUserDropdown,
|
||||
initStatusDropdown: initStatusDropdown,
|
||||
initRelatesDropdown: initRelatesDropdown,
|
||||
setCloseModal: setCloseModal,
|
||||
toggleMilestoneSelectors: toggleMilestoneSelectors,
|
||||
openModalManually: openModalManually
|
||||
};
|
||||
|
||||
})();
|
||||
144
app/Domain/Blueprints/Models/CanvasTemplate.php
Normal file
144
app/Domain/Blueprints/Models/CanvasTemplate.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Blueprints\Models;
|
||||
|
||||
class CanvasTemplate
|
||||
{
|
||||
public string $slug;
|
||||
|
||||
public string $icon;
|
||||
|
||||
public string $disclaimer;
|
||||
|
||||
public int $minColumns;
|
||||
|
||||
public int $minWidthOffset;
|
||||
|
||||
public array $boxes;
|
||||
|
||||
public array $statusLabels;
|
||||
|
||||
public array $relatesLabels;
|
||||
|
||||
public array $dataLabels;
|
||||
|
||||
public array $layout;
|
||||
|
||||
/**
|
||||
* Optional ContentTemplates key (see app/Domain/ContentTemplates).
|
||||
*
|
||||
* When set, freshly-created boards of this canvas type auto-apply the
|
||||
* referenced content template's items, giving the user a non-empty
|
||||
* starting point. Looked up against the registry as
|
||||
* forAppliesTo($this->slug)[$startContent].
|
||||
*
|
||||
* Null when the blueprint ships no starter content (the default).
|
||||
*/
|
||||
public ?string $startContent;
|
||||
|
||||
private const DEFAULT_STATUS_LABELS = [
|
||||
'status_draft' => ['icon' => 'fa-circle-question', 'color' => 'blue', 'title' => 'status.draft', 'dropdown' => 'info', 'active' => true],
|
||||
'status_review' => ['icon' => 'fa-circle-exclamation', 'color' => 'orange', 'title' => 'status.review', 'dropdown' => 'warning', 'active' => true],
|
||||
'status_valid' => ['icon' => 'fa-circle-check', 'color' => 'green', 'title' => 'status.valid', 'dropdown' => 'success', 'active' => true],
|
||||
'status_hold' => ['icon' => 'fa-circle-h', 'color' => 'red', 'title' => 'status.hold', 'dropdown' => 'danger', 'active' => true],
|
||||
'status_invalid' => ['icon' => 'fa-circle-xmark', 'color' => 'red', 'title' => 'status.invalid', 'dropdown' => 'danger', 'active' => true],
|
||||
];
|
||||
|
||||
private const DEFAULT_RELATES_LABELS = [
|
||||
'relates_none' => ['icon' => 'fa-border-none', 'color' => 'grey', 'title' => 'relates.none', 'dropdown' => 'default', 'active' => true],
|
||||
'relates_customers' => ['icon' => 'fa-users', 'color' => 'green', 'title' => 'relates.customers', 'dropdown' => 'success', 'active' => true],
|
||||
'relates_offerings' => ['icon' => 'fa-barcode', 'color' => 'red', 'title' => 'relates.offerings', 'dropdown' => 'danger', 'active' => true],
|
||||
'relates_capabilities' => ['icon' => 'fa-pen-ruler', 'color' => 'blue', 'title' => 'relates.capabilities', 'dropdown' => 'info', 'active' => true],
|
||||
'relates_financials' => ['icon' => 'fa-money-bill', 'color' => 'yellow', 'title' => 'relates.financials', 'dropdown' => 'warning', 'active' => true],
|
||||
'relates_markets' => ['icon' => 'fa-shop', 'color' => 'brown', 'title' => 'relates.markets', 'dropdown' => 'default', 'active' => true],
|
||||
'relates_environment' => ['icon' => 'fa-tree', 'color' => 'darkgreen', 'title' => 'relates.environment', 'dropdown' => 'default', 'active' => true],
|
||||
'relates_firm' => ['icon' => 'fa-building', 'color' => 'darkblue', 'title' => 'relates.firm', 'dropdown' => 'info', 'active' => true],
|
||||
];
|
||||
|
||||
private const DEFAULT_DATA_LABELS = [
|
||||
1 => ['title' => 'label.assumptions', 'field' => 'assumptions', 'active' => true],
|
||||
2 => ['title' => 'label.data', 'field' => 'data', 'active' => true],
|
||||
3 => ['title' => 'label.conclusion', 'field' => 'conclusion', 'active' => true],
|
||||
];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data Parsed YAML data
|
||||
*/
|
||||
public function __construct(array $data)
|
||||
{
|
||||
$this->slug = $data['slug'];
|
||||
$this->icon = $data['icon'] ?? 'fa-x';
|
||||
$this->disclaimer = $data['disclaimer'] ?? '';
|
||||
$this->minColumns = $data['minColumns'] ?? 2;
|
||||
$this->minWidthOffset = $data['minWidthOffset'] ?? 0;
|
||||
$this->boxes = $data['boxes'] ?? [];
|
||||
$this->layout = $data['layout'] ?? [];
|
||||
$this->startContent = isset($data['startContent']) && $data['startContent'] !== ''
|
||||
? (string) $data['startContent']
|
||||
: null;
|
||||
|
||||
$this->statusLabels = $this->resolveLabels($data, 'statusLabels', self::DEFAULT_STATUS_LABELS);
|
||||
$this->relatesLabels = $this->resolveLabels($data, 'relatesLabels', self::DEFAULT_RELATES_LABELS);
|
||||
$this->dataLabels = $this->resolveDataLabels($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string Database type value (e.g., "swotcanvas")
|
||||
*/
|
||||
public function getDatabaseType(): string
|
||||
{
|
||||
return $this->slug.'canvas';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string Comment module identifier (e.g., "swotcanvasitem")
|
||||
*/
|
||||
public function getCommentModule(): string
|
||||
{
|
||||
return $this->slug.'canvasitem';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string Session key for tracking current board
|
||||
*/
|
||||
public function getSessionKey(): string
|
||||
{
|
||||
return 'current'.strtoupper($this->slug).'Canvas';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data Parsed YAML data
|
||||
* @param string $key Label key
|
||||
* @param array<string, mixed> $defaults Default labels
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function resolveLabels(array $data, string $key, array $defaults): array
|
||||
{
|
||||
if (! array_key_exists($key, $data)) {
|
||||
return $defaults;
|
||||
}
|
||||
|
||||
if ($data[$key] === null || $data[$key] === 'default') {
|
||||
return $defaults;
|
||||
}
|
||||
|
||||
return $data[$key];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data Parsed YAML data
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function resolveDataLabels(array $data): array
|
||||
{
|
||||
if (! array_key_exists('dataLabels', $data)) {
|
||||
return self::DEFAULT_DATA_LABELS;
|
||||
}
|
||||
|
||||
if ($data['dataLabels'] === null || $data['dataLabels'] === 'default') {
|
||||
return self::DEFAULT_DATA_LABELS;
|
||||
}
|
||||
|
||||
return $data['dataLabels'];
|
||||
}
|
||||
}
|
||||
48
app/Domain/Blueprints/Permissions/BlueprintsPermissions.php
Normal file
48
app/Domain/Blueprints/Permissions/BlueprintsPermissions.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Blueprints\Permissions;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\Permission;
|
||||
use Leantime\Core\Auth\Permissions\ProvidesPermissions;
|
||||
|
||||
/**
|
||||
* The Blueprints (canvas) permission vocabulary — the verbs only.
|
||||
*
|
||||
* Blueprints is the consolidated canvas system: every canvas variant (SWOT, Lean, Value, …)
|
||||
* is a row in the shared `zp_canvas`/`zp_canvas_items` tables, distinguished by a `type`
|
||||
* column, and each board belongs to exactly one project. Capabilities are therefore
|
||||
* PROJECT-scoped (projectScoped = true, the default) — evaluated against the user's role IN
|
||||
* the board's project.
|
||||
*
|
||||
* One vocabulary covers the whole canvas family (Blueprints + the deprecated Canvas shim, and
|
||||
* later Goalcanvas/Logicmodelcanvas): a "canvas" capability is the same regardless of variant.
|
||||
*
|
||||
* The standard verbs auto-grant via the central matrix (readonly = view; editor =
|
||||
* create/edit/delete; manager+ = all), so no {@see \Leantime\Core\Auth\Permissions\DefaultRolePermissions}
|
||||
* change is required.
|
||||
*/
|
||||
final class BlueprintsPermissions implements ProvidesPermissions
|
||||
{
|
||||
public const VIEW = 'blueprints.view';
|
||||
|
||||
public const CREATE = 'blueprints.create';
|
||||
|
||||
public const EDIT = 'blueprints.edit';
|
||||
|
||||
public const DELETE = 'blueprints.delete';
|
||||
|
||||
public function domain(): string
|
||||
{
|
||||
return 'blueprints';
|
||||
}
|
||||
|
||||
public function permissions(): array
|
||||
{
|
||||
return [
|
||||
new Permission(self::VIEW, 'View canvas boards'),
|
||||
new Permission(self::CREATE, 'Create canvas boards and items'),
|
||||
new Permission(self::EDIT, 'Edit canvas boards and items'),
|
||||
new Permission(self::DELETE, 'Delete canvas boards and items'),
|
||||
];
|
||||
}
|
||||
}
|
||||
703
app/Domain/Blueprints/Repositories/Blueprints.php
Normal file
703
app/Domain/Blueprints/Repositories/Blueprints.php
Normal file
@@ -0,0 +1,703 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Blueprints\Repositories;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Leantime\Core\Db\DatabaseHelper;
|
||||
use Leantime\Core\Db\Db as DbCore;
|
||||
use Leantime\Core\Db\Repository;
|
||||
use Leantime\Domain\Tickets\Repositories\Tickets;
|
||||
|
||||
/**
|
||||
* @api
|
||||
*/
|
||||
class Blueprints extends Repository
|
||||
{
|
||||
/**
|
||||
* Columns on zp_canvas_items that may be written via patchCanvasItem().
|
||||
* Acts as a mass-assignment allowlist for the inline-update API.
|
||||
*/
|
||||
private const PATCHABLE_COLUMNS = [
|
||||
'title', 'description', 'assumptions', 'data', 'conclusion',
|
||||
'box', 'status', 'relates', 'milestoneId', 'kpi', 'data1',
|
||||
'startDate', 'endDate', 'setting', 'metricType', 'startValue',
|
||||
'currentValue', 'endValue', 'impact', 'effort', 'probability',
|
||||
'action', 'assignedTo', 'parent', 'tags', 'sortindex',
|
||||
'why_this_matters', 'starting_picture',
|
||||
];
|
||||
|
||||
protected ConnectionInterface $connection;
|
||||
|
||||
protected DatabaseHelper $dbHelper;
|
||||
|
||||
private Tickets $ticketRepo;
|
||||
|
||||
/**
|
||||
* @param DbCore $db Database connection
|
||||
* @param Tickets $ticketRepo Ticket repository
|
||||
* @param DatabaseHelper $dbHelper Database helper
|
||||
*/
|
||||
public function __construct(
|
||||
DbCore $db,
|
||||
Tickets $ticketRepo,
|
||||
DatabaseHelper $dbHelper
|
||||
) {
|
||||
$this->connection = $db->getConnection();
|
||||
$this->ticketRepo = $ticketRepo;
|
||||
$this->dbHelper = $dbHelper;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $projectId Project ID
|
||||
* @param string $canvasType Database type (e.g., "swotcanvas")
|
||||
* @return false|array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getAllCanvas(int $projectId, string $canvasType): false|array
|
||||
{
|
||||
$results = $this->connection->table('zp_canvas')
|
||||
->select([
|
||||
'zp_canvas.id',
|
||||
'zp_canvas.title',
|
||||
'zp_canvas.author',
|
||||
'zp_canvas.created',
|
||||
'zp_canvas.description',
|
||||
't1.firstname as authorFirstname',
|
||||
't1.lastname as authorLastname',
|
||||
])
|
||||
->selectRaw('COUNT(zp_canvas_items.id) AS '.$this->dbHelper->wrapColumn('boxItems'))
|
||||
->leftJoin('zp_user as t1', 'zp_canvas.author', '=', 't1.id')
|
||||
->leftJoin('zp_canvas_items', 'zp_canvas.id', '=', 'zp_canvas_items.canvasId')
|
||||
->where('type', $canvasType)
|
||||
->where('projectId', $projectId)
|
||||
->groupBy(['zp_canvas.id', 'zp_canvas.title', 'zp_canvas.created', 'zp_canvas.author', 'zp_canvas.description', 't1.firstname', 't1.lastname'])
|
||||
->orderBy('zp_canvas.title')
|
||||
->orderBy('zp_canvas.created')
|
||||
->get();
|
||||
|
||||
return array_map(fn ($item) => (array) $item, $results->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $canvasId Canvas board ID
|
||||
* @param string $canvasType Database type (e.g., "swotcanvas")
|
||||
* @return false|array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getSingleCanvas(int $canvasId, string $canvasType): false|array
|
||||
{
|
||||
$results = $this->connection->table('zp_canvas')
|
||||
->select([
|
||||
'zp_canvas.id',
|
||||
'zp_canvas.title',
|
||||
'zp_canvas.author',
|
||||
'zp_canvas.created',
|
||||
'zp_canvas.projectId',
|
||||
't1.firstname as authorFirstname',
|
||||
't1.lastname as authorLastname',
|
||||
])
|
||||
->leftJoin('zp_user as t1', 'zp_canvas.author', '=', 't1.id')
|
||||
->where('type', $canvasType)
|
||||
->where('zp_canvas.id', $canvasId)
|
||||
->orderBy('zp_canvas.title')
|
||||
->orderBy('zp_canvas.created')
|
||||
->get();
|
||||
|
||||
return array_map(fn ($item) => (array) $item, $results->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the project id a canvas ITEM ultimately belongs to (item → board → project),
|
||||
* optionally constrained to a canvas $canvasType. Returns null when the item does not exist
|
||||
* OR its board is of a different type.
|
||||
*
|
||||
* This is a fail-CLOSED primitive: the service layer uses it to authorize by-id item
|
||||
* operations against the item's REAL project, never the caller's session project. A
|
||||
* `null` return must be treated as "deny" — never as "fall back to currentProject".
|
||||
*
|
||||
* @param int $itemId Canvas item id
|
||||
* @param string|null $canvasType Constrain to this board type (e.g. "swotcanvas"); null = any type
|
||||
*/
|
||||
public function getCanvasItemProjectId(int $itemId, ?string $canvasType = null): ?int
|
||||
{
|
||||
$query = $this->connection->table('zp_canvas_items')
|
||||
->leftJoin('zp_canvas', 'zp_canvas.id', '=', 'zp_canvas_items.canvasId')
|
||||
->where('zp_canvas_items.id', $itemId);
|
||||
|
||||
if ($canvasType !== null) {
|
||||
$query->where('zp_canvas.type', $canvasType);
|
||||
}
|
||||
|
||||
$projectId = $query->value('zp_canvas.projectId');
|
||||
|
||||
return $projectId !== null ? (int) $projectId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the project id a canvas BOARD belongs to, optionally constrained to a canvas
|
||||
* $canvasType. Returns null when the board does not exist OR is of a different type.
|
||||
*
|
||||
* Fail-CLOSED companion to {@see getCanvasItemProjectId()} for by-id board operations.
|
||||
*
|
||||
* @param int $canvasId Canvas board id
|
||||
* @param string|null $canvasType Constrain to this board type; null = any type
|
||||
*/
|
||||
public function getCanvasProjectId(int $canvasId, ?string $canvasType = null): ?int
|
||||
{
|
||||
$query = $this->connection->table('zp_canvas')
|
||||
->where('id', $canvasId);
|
||||
|
||||
if ($canvasType !== null) {
|
||||
$query->where('type', $canvasType);
|
||||
}
|
||||
|
||||
$projectId = $query->value('projectId');
|
||||
|
||||
return $projectId !== null ? (int) $projectId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $id Canvas board ID
|
||||
*/
|
||||
public function deleteCanvas(int $id): void
|
||||
{
|
||||
$this->connection->table('zp_canvas_items')
|
||||
->where('canvasId', $id)
|
||||
->delete();
|
||||
|
||||
$this->connection->table('zp_canvas')
|
||||
->where('id', $id)
|
||||
->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $values Canvas values
|
||||
* @param string $canvasType Database type (e.g., "swotcanvas")
|
||||
*/
|
||||
public function addCanvas(array $values, string $canvasType): false|string
|
||||
{
|
||||
$insertId = $this->connection->table('zp_canvas')->insertGetId([
|
||||
'title' => $values['title'],
|
||||
'description' => $values['description'] ?? '',
|
||||
'author' => $values['author'],
|
||||
'created' => now(),
|
||||
'type' => $canvasType,
|
||||
'projectId' => $values['projectId'],
|
||||
]);
|
||||
|
||||
return (string) $insertId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $values Canvas values
|
||||
*/
|
||||
public function updateCanvas(array $values): mixed
|
||||
{
|
||||
return $this->connection->table('zp_canvas')
|
||||
->where('id', $values['id'])
|
||||
->update([
|
||||
'title' => $values['title'],
|
||||
'description' => $values['description'] ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $values Item values
|
||||
*/
|
||||
public function editCanvasItem(array $values): void
|
||||
{
|
||||
$this->connection->table('zp_canvas_items')
|
||||
->where('id', $values['itemId'] ?? $values['id'])
|
||||
->update([
|
||||
'title' => $values['title'] ?? '',
|
||||
'description' => $values['description'],
|
||||
'assumptions' => $values['assumptions'] ?? '',
|
||||
'data' => $values['data'] ?? '',
|
||||
'conclusion' => $values['conclusion'] ?? '',
|
||||
'modified' => now(),
|
||||
'status' => $values['status'] ?? '',
|
||||
'relates' => $values['relates'] ?? '',
|
||||
'milestoneId' => $values['milestoneId'] ?? '',
|
||||
'kpi' => $values['kpi'] ?? '',
|
||||
'data1' => $values['data1'] ?? '',
|
||||
'startDate' => $values['startDate'] ?? '',
|
||||
'endDate' => $values['endDate'] ?? '',
|
||||
'setting' => $values['setting'] ?? '',
|
||||
'metricType' => $values['metricType'] ?? '',
|
||||
'startValue' => $values['startValue'] ?? '',
|
||||
'currentValue' => $values['currentValue'] ?? '',
|
||||
'endValue' => $values['endValue'] ?? '',
|
||||
'impact' => $values['impact'] ?? '',
|
||||
'effort' => $values['effort'] ?? '',
|
||||
'probability' => $values['probability'] ?? '',
|
||||
'action' => $values['action'] ?? '',
|
||||
'assignedTo' => $values['assignedTo'] ?? '',
|
||||
'parent' => $values['parent'] ?? '',
|
||||
'tags' => $values['tags'] ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $id Item ID
|
||||
* @param array<string, mixed> $params Fields to patch
|
||||
*/
|
||||
public function patchCanvasItem(int $id, array $params): bool
|
||||
{
|
||||
$updates = [];
|
||||
foreach ($params as $key => $value) {
|
||||
if (in_array($key, self::PATCHABLE_COLUMNS, true)) {
|
||||
$updates[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($updates)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) $this->connection->table('zp_canvas_items')
|
||||
->where('id', $id)
|
||||
->update($updates);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $id Canvas board ID
|
||||
* @param string $commentModule Comment module name (e.g., "swotcanvasitem")
|
||||
* @return false|array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getCanvasItemsById(int $id, string $commentModule): false|array
|
||||
{
|
||||
$statusGroups = $this->ticketRepo->getStatusListGroupedByType(session('currentProject'));
|
||||
|
||||
$results = $this->connection->table('zp_canvas_items')
|
||||
->select([
|
||||
'zp_canvas_items.id',
|
||||
'zp_canvas_items.description',
|
||||
'zp_canvas_items.assumptions',
|
||||
'zp_canvas_items.data',
|
||||
'zp_canvas_items.conclusion',
|
||||
'zp_canvas_items.box',
|
||||
'zp_canvas_items.author',
|
||||
'zp_canvas_items.created',
|
||||
'zp_canvas_items.modified',
|
||||
'zp_canvas_items.canvasId',
|
||||
'zp_canvas_items.sortindex',
|
||||
'zp_canvas_items.status',
|
||||
'zp_canvas_items.relates',
|
||||
'zp_canvas_items.milestoneId',
|
||||
'zp_canvas_items.parent',
|
||||
'zp_canvas_items.title',
|
||||
'zp_canvas_items.tags',
|
||||
'zp_canvas_items.kpi',
|
||||
'zp_canvas_items.data1',
|
||||
'zp_canvas_items.data2',
|
||||
'zp_canvas_items.data3',
|
||||
'zp_canvas_items.data4',
|
||||
'zp_canvas_items.data5',
|
||||
'zp_canvas_items.startDate',
|
||||
'zp_canvas_items.endDate',
|
||||
'zp_canvas_items.setting',
|
||||
'zp_canvas_items.metricType',
|
||||
'zp_canvas_items.startValue',
|
||||
'zp_canvas_items.currentValue',
|
||||
'zp_canvas_items.endValue',
|
||||
'zp_canvas_items.impact',
|
||||
'zp_canvas_items.effort',
|
||||
'zp_canvas_items.probability',
|
||||
'zp_canvas_items.action',
|
||||
'zp_canvas_items.assignedTo',
|
||||
'zp_canvas_items.why_this_matters',
|
||||
'zp_canvas_items.starting_picture',
|
||||
't1.firstname as authorFirstname',
|
||||
't1.lastname as authorLastname',
|
||||
't1.profileId as authorProfileId',
|
||||
'milestone.headline as milestoneHeadline',
|
||||
'milestone.editTo as milestoneEditTo',
|
||||
])
|
||||
->selectRaw('COUNT(DISTINCT zp_comment.id) AS '.$this->dbHelper->wrapColumn('commentCount'))
|
||||
->selectRaw('0 AS '.$this->dbHelper->wrapColumn('percentDone'))
|
||||
->leftJoin('zp_user as t1', 'zp_canvas_items.author', '=', 't1.id')
|
||||
->leftJoin('zp_tickets as milestone', function ($join) {
|
||||
$join->on('zp_canvas_items.milestoneId', '=', $this->connection->raw($this->dbHelper->castAs($this->dbHelper->wrapColumn('milestone.id'), 'text')));
|
||||
})
|
||||
->leftJoin('zp_comment', function ($join) use ($commentModule) {
|
||||
$join->on('zp_canvas_items.id', '=', 'zp_comment.moduleId')
|
||||
->where('zp_comment.module', '=', $commentModule);
|
||||
})
|
||||
->where('zp_canvas_items.canvasId', $id)
|
||||
->groupBy(['zp_canvas_items.id', 'zp_canvas_items.box', 'zp_canvas_items.sortindex', 't1.firstname', 't1.lastname', 't1.profileId', 'milestone.headline', 'milestone.editTo'])
|
||||
->orderBy('zp_canvas_items.box')
|
||||
->orderBy('zp_canvas_items.sortindex')
|
||||
->get();
|
||||
|
||||
return array_map(fn ($item) => (array) $item, $results->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $id Canvas item ID
|
||||
*/
|
||||
public function getSingleCanvasItem(int $id): mixed
|
||||
{
|
||||
$statusGroups = $this->ticketRepo->getStatusListGroupedByType(session('currentProject'));
|
||||
|
||||
$result = $this->connection->table('zp_canvas_items')
|
||||
->select([
|
||||
'zp_canvas_items.id',
|
||||
'zp_canvas_items.title',
|
||||
'zp_canvas_items.description',
|
||||
'zp_canvas_items.assumptions',
|
||||
'zp_canvas_items.data',
|
||||
'zp_canvas_items.conclusion',
|
||||
'zp_canvas_items.box',
|
||||
'zp_canvas_items.author',
|
||||
'zp_canvas_items.created',
|
||||
'zp_canvas_items.modified',
|
||||
'zp_canvas_items.canvasId',
|
||||
'zp_canvas_items.sortindex',
|
||||
'zp_canvas_items.status',
|
||||
'zp_canvas_items.relates',
|
||||
'zp_canvas_items.milestoneId',
|
||||
'zp_canvas_items.kpi',
|
||||
'zp_canvas_items.data1',
|
||||
'zp_canvas_items.data2',
|
||||
'zp_canvas_items.data3',
|
||||
'zp_canvas_items.data4',
|
||||
'zp_canvas_items.data5',
|
||||
'zp_canvas_items.startDate',
|
||||
'zp_canvas_items.endDate',
|
||||
'zp_canvas_items.setting',
|
||||
'zp_canvas_items.metricType',
|
||||
'zp_canvas_items.startValue',
|
||||
'zp_canvas_items.currentValue',
|
||||
'zp_canvas_items.endValue',
|
||||
'zp_canvas_items.impact',
|
||||
'zp_canvas_items.effort',
|
||||
'zp_canvas_items.probability',
|
||||
'zp_canvas_items.action',
|
||||
'zp_canvas_items.assignedTo',
|
||||
'zp_canvas_items.parent',
|
||||
'zp_canvas_items.tags',
|
||||
'board.title as boardTitle',
|
||||
'parentKPI.description as parentKPIDescription',
|
||||
'parentGoal.title as parentGoalDescription',
|
||||
't1.firstname as authorFirstname',
|
||||
't1.lastname as authorLastname',
|
||||
'milestone.headline as milestoneHeadline',
|
||||
'milestone.editTo as milestoneEditTo',
|
||||
])
|
||||
->selectRaw('COUNT('.$this->dbHelper->wrapColumn('progressTickets.id').') AS '.$this->dbHelper->wrapColumn('allTickets'))
|
||||
->selectSub(function ($query) use ($statusGroups) {
|
||||
$progressSubId = $this->dbHelper->wrapColumn('progressSub.id');
|
||||
$progressSubStatus = $this->dbHelper->wrapColumn('progressSub.status');
|
||||
$progressSubStorypoints = $this->dbHelper->wrapColumn('progressSub.storypoints');
|
||||
$query->from('zp_tickets as progressSub')
|
||||
->selectRaw('(
|
||||
CASE WHEN
|
||||
COUNT(DISTINCT '.$progressSubId.') > 0
|
||||
THEN
|
||||
ROUND(
|
||||
(
|
||||
SUM(CASE WHEN '.$progressSubStatus.' '.$statusGroups['DONE'].' THEN CASE WHEN '.$progressSubStorypoints.' = 0 THEN 3 ELSE '.$progressSubStorypoints.' END ELSE 0 END) /
|
||||
SUM(CASE WHEN '.$progressSubStorypoints.' = 0 THEN 3 ELSE '.$progressSubStorypoints.' END)
|
||||
) *100)
|
||||
ELSE
|
||||
0
|
||||
END)
|
||||
')
|
||||
->whereColumn(
|
||||
$this->connection->raw($this->dbHelper->castAs($this->dbHelper->wrapColumn('progressSub.milestoneid'), 'text')),
|
||||
'=',
|
||||
'zp_canvas_items.milestoneId'
|
||||
)
|
||||
->where('progressSub.type', '<>', 'milestone');
|
||||
}, 'percentDone')
|
||||
->leftJoin('zp_canvas_items as parentKPI', 'zp_canvas_items.kpi', '=', 'parentKPI.id')
|
||||
->leftJoin('zp_canvas as board', 'board.id', '=', 'zp_canvas_items.canvasId')
|
||||
->leftJoin('zp_canvas_items as parentGoal', 'zp_canvas_items.parent', '=', 'parentGoal.id')
|
||||
->leftJoin('zp_tickets as progressTickets', function ($join) {
|
||||
$join->on(
|
||||
$this->connection->raw($this->dbHelper->castAs($this->dbHelper->wrapColumn('progressTickets.milestoneid'), 'text')),
|
||||
'=',
|
||||
'zp_canvas_items.milestoneId'
|
||||
)
|
||||
->where('progressTickets.type', '<>', 'milestone')
|
||||
->where('progressTickets.type', '<>', 'subtask');
|
||||
})
|
||||
->leftJoin('zp_tickets as milestone', function ($join) {
|
||||
$join->on('zp_canvas_items.milestoneId', '=', $this->connection->raw($this->dbHelper->castAs($this->dbHelper->wrapColumn('milestone.id'), 'text')));
|
||||
})
|
||||
->leftJoin('zp_user as t1', 'zp_canvas_items.author', '=', 't1.id')
|
||||
->where('zp_canvas_items.id', $id)
|
||||
->groupBy([
|
||||
'zp_canvas_items.id',
|
||||
'board.title',
|
||||
'parentKPI.description',
|
||||
'parentGoal.title',
|
||||
't1.firstname',
|
||||
't1.lastname',
|
||||
'milestone.headline',
|
||||
'milestone.editTo',
|
||||
])
|
||||
->first();
|
||||
|
||||
if ($result !== null && $result->id != null) {
|
||||
return (array) $result;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $values Item values
|
||||
*/
|
||||
public function addCanvasItem(array $values): false|string
|
||||
{
|
||||
$id = $this->connection->table('zp_canvas_items')->insertGetId([
|
||||
'description' => $values['description'] ?? '',
|
||||
'title' => $values['title'] ?? '',
|
||||
'assumptions' => $values['assumptions'] ?? '',
|
||||
'data' => $values['data'] ?? '',
|
||||
'conclusion' => $values['conclusion'] ?? '',
|
||||
'box' => $values['box'],
|
||||
'author' => $values['author'],
|
||||
'created' => now(),
|
||||
'modified' => now(),
|
||||
'canvasId' => $values['canvasId'],
|
||||
'status' => $values['status'] ?? '',
|
||||
'relates' => $values['relates'] ?? '',
|
||||
'milestoneId' => $values['milestoneId'] ?? '',
|
||||
'kpi' => $values['kpi'] ?? '',
|
||||
'data1' => $values['data1'] ?? '',
|
||||
'startDate' => $values['startDate'] ?? '',
|
||||
'endDate' => $values['endDate'] ?? '',
|
||||
'setting' => $values['setting'] ?? '',
|
||||
'metricType' => $values['metricType'] ?? '',
|
||||
'impact' => $values['impact'] ?? '',
|
||||
'effort' => $values['effort'] ?? '',
|
||||
'probability' => $values['probability'] ?? '',
|
||||
'action' => $values['action'] ?? '',
|
||||
'assignedTo' => $values['assignedTo'] ?? '',
|
||||
'startValue' => $values['startValue'] ?? '',
|
||||
'currentValue' => $values['currentValue'] ?? '',
|
||||
'endValue' => $values['endValue'] ?? '',
|
||||
'parent' => $values['parent'] ?? '',
|
||||
'tags' => $values['tags'] ?? '',
|
||||
]);
|
||||
|
||||
return (string) $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $id Item ID
|
||||
*/
|
||||
public function delCanvasItem(int $id): void
|
||||
{
|
||||
$this->connection->table('zp_canvas_items')
|
||||
->where('id', $id)
|
||||
->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int|null $projectId Project ID
|
||||
* @param string $canvasType Database type (e.g., "swotcanvas")
|
||||
*/
|
||||
public function getNumberOfCanvasItems(?int $projectId, string $canvasType): mixed
|
||||
{
|
||||
$query = $this->connection->table('zp_canvas_items')
|
||||
->selectRaw('COUNT(zp_canvas_items.id) AS '.$this->dbHelper->wrapColumn('canvasCount'))
|
||||
->leftJoin('zp_canvas as canvasBoard', 'zp_canvas_items.canvasId', '=', 'canvasBoard.id')
|
||||
->where('canvasBoard.type', $canvasType);
|
||||
|
||||
if (! is_null($projectId)) {
|
||||
$query->where('canvasBoard.projectId', $projectId);
|
||||
}
|
||||
|
||||
$result = $query->first();
|
||||
|
||||
return $result->canvasCount ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int|null $projectId Project ID
|
||||
* @param string $canvasType Database type (e.g., "swotcanvas")
|
||||
*/
|
||||
public function getNumberOfBoards(?int $projectId, string $canvasType): mixed
|
||||
{
|
||||
$query = $this->connection->table('zp_canvas')
|
||||
->selectRaw('COUNT(zp_canvas.id) AS '.$this->dbHelper->wrapColumn('boardCount'))
|
||||
->where('zp_canvas.type', $canvasType);
|
||||
|
||||
if (! is_null($projectId)) {
|
||||
$query->where('zp_canvas.projectId', $projectId);
|
||||
}
|
||||
|
||||
$result = $query->first();
|
||||
|
||||
return $result->boardCount ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $projectId Project ID
|
||||
* @param string $canvasTitle Canvas title
|
||||
* @param string $canvasType Database type (e.g., "swotcanvas")
|
||||
*/
|
||||
public function existCanvas(int $projectId, string $canvasTitle, string $canvasType): bool
|
||||
{
|
||||
$result = $this->connection->table('zp_canvas')
|
||||
->selectRaw('COUNT(id) as '.$this->dbHelper->wrapColumn('nbCanvas'))
|
||||
->where('projectId', $projectId)
|
||||
->where('title', $canvasTitle)
|
||||
->where('type', $canvasType)
|
||||
->first();
|
||||
|
||||
return isset($result->nbCanvas) && $result->nbCanvas > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $projectId Project ID
|
||||
* @param int $canvasId Source canvas ID
|
||||
* @param int $authorId Author ID
|
||||
* @param string $canvasTitle New canvas title
|
||||
* @param string $canvasType Database type (e.g., "swotcanvas")
|
||||
* @return int New canvas ID
|
||||
*/
|
||||
public function copyCanvas(int $projectId, int $canvasId, int $authorId, string $canvasTitle, string $canvasType): int
|
||||
{
|
||||
$values = ['title' => $canvasTitle, 'author' => $authorId, 'projectId' => $projectId];
|
||||
$newCanvasId = $this->addCanvas($values, $canvasType);
|
||||
|
||||
$columns = [
|
||||
'title', 'description', 'assumptions', 'data', 'conclusion', 'box', 'author',
|
||||
'created', 'modified', 'canvasId', 'status', 'relates', 'milestoneId', 'kpi',
|
||||
'data1', 'startDate', 'endDate', 'setting', 'metricType', 'impact', 'effort',
|
||||
'probability', 'action', 'assignedTo', 'startValue', 'currentValue', 'endValue',
|
||||
];
|
||||
|
||||
$selectQuery = $this->connection->table('zp_canvas_items')
|
||||
->select([
|
||||
'title', 'description', 'assumptions', 'data', 'conclusion', 'box', 'author',
|
||||
])
|
||||
->selectRaw($this->dbHelper->currentTimestamp().' as created')
|
||||
->selectRaw($this->dbHelper->currentTimestamp().' as modified')
|
||||
->selectRaw('? as '.$this->dbHelper->wrapColumn('canvasId'), [$newCanvasId])
|
||||
->select(['status', 'relates'])
|
||||
->selectRaw("'' as ".$this->dbHelper->wrapColumn('milestoneId'))
|
||||
->select([
|
||||
'kpi', 'data1', 'startDate', 'endDate', 'setting', 'metricType', 'impact',
|
||||
'effort', 'probability', 'action', 'assignedTo', 'startValue', 'currentValue', 'endValue',
|
||||
])
|
||||
->where('canvasId', $canvasId);
|
||||
|
||||
$this->connection->table('zp_canvas_items')->insertUsing($columns, $selectQuery);
|
||||
|
||||
return (int) $newCanvasId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $canvasId Target canvas ID
|
||||
* @param int $mergeId Source canvas ID
|
||||
*/
|
||||
public function mergeCanvas(int $canvasId, int $mergeId): bool
|
||||
{
|
||||
$columns = [
|
||||
'title', 'description', 'assumptions', 'data', 'conclusion', 'box', 'author',
|
||||
'created', 'modified', 'canvasId', 'status', 'relates', 'milestoneId', 'kpi',
|
||||
'data1', 'startDate', 'endDate', 'setting', 'metricType', 'impact', 'effort',
|
||||
'probability', 'action', 'assignedTo', 'startValue', 'currentValue', 'endValue',
|
||||
];
|
||||
|
||||
$selectQuery = $this->connection->table('zp_canvas_items')
|
||||
->select([
|
||||
'title', 'description', 'assumptions', 'data', 'conclusion', 'box', 'author',
|
||||
])
|
||||
->selectRaw($this->dbHelper->currentTimestamp().' as created')
|
||||
->selectRaw($this->dbHelper->currentTimestamp().' as modified')
|
||||
->selectRaw('? as '.$this->dbHelper->wrapColumn('canvasId'), [$canvasId])
|
||||
->select(['status', 'relates'])
|
||||
->selectRaw("'' as ".$this->dbHelper->wrapColumn('milestoneId'))
|
||||
->select([
|
||||
'kpi', 'data1', 'startDate', 'endDate', 'setting', 'metricType', 'impact',
|
||||
'effort', 'probability', 'action', 'assignedTo', 'startValue', 'currentValue', 'endValue',
|
||||
])
|
||||
->where('canvasId', $mergeId);
|
||||
|
||||
$this->connection->table('zp_canvas_items')->insertUsing($columns, $selectQuery);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $projectId Project ID
|
||||
* @param array<int, string> $boards Board types to query
|
||||
* @return array<int, array<string, mixed>>|bool
|
||||
*/
|
||||
public function getCanvasProgressCount(int $projectId, array $boards): array|bool
|
||||
{
|
||||
$query = $this->connection->table('zp_canvas')
|
||||
->select([
|
||||
'zp_canvas.id as canvasId',
|
||||
'zp_canvas.type as canvasType',
|
||||
'zp_canvas_items.box',
|
||||
])
|
||||
->selectRaw('COUNT(zp_canvas_items.id) AS '.$this->dbHelper->wrapColumn('boxItems'))
|
||||
->leftJoin('zp_canvas_items', 'zp_canvas.id', '=', 'zp_canvas_items.canvasId');
|
||||
|
||||
if ($projectId != '') {
|
||||
$query->where('projectId', $projectId);
|
||||
}
|
||||
|
||||
if (count($boards) > 0) {
|
||||
$query->whereIn('type', $boards);
|
||||
}
|
||||
|
||||
$results = $query->groupBy(['zp_canvas.id', 'zp_canvas.type', 'zp_canvas_items.box'])
|
||||
->orderBy('zp_canvas.title')
|
||||
->orderBy('zp_canvas.created')
|
||||
->get();
|
||||
|
||||
return array_map(fn ($item) => (array) $item, $results->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $projectId Project ID
|
||||
* @param array<int, string> $boards Board types to query
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getLastUpdatedCanvas(int $projectId, array $boards): array
|
||||
{
|
||||
$query = $this->connection->table('zp_canvas')
|
||||
->select([
|
||||
'zp_canvas.id as id',
|
||||
'zp_canvas.type as type',
|
||||
'zp_canvas.title as title',
|
||||
])
|
||||
->selectRaw('COALESCE(MAX(zp_canvas_items.modified), zp_canvas.created) AS modified')
|
||||
->leftJoin('zp_canvas_items', 'zp_canvas.id', '=', 'zp_canvas_items.canvasId');
|
||||
|
||||
if ($projectId > 0) {
|
||||
$query->where('projectId', $projectId);
|
||||
}
|
||||
|
||||
if (count($boards) > 0) {
|
||||
$query->whereIn('type', $boards);
|
||||
}
|
||||
|
||||
$results = $query->groupBy(['zp_canvas.id', 'zp_canvas.type', 'zp_canvas.title', 'zp_canvas.created'])
|
||||
->orderByDesc('modified')
|
||||
->get();
|
||||
|
||||
return array_map(fn ($item) => (array) $item, $results->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $projectId Project ID
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getTags(int $projectId): array
|
||||
{
|
||||
$results = $this->connection->table('zp_canvas_items')
|
||||
->select('zp_canvas_items.tags')
|
||||
->leftJoin('zp_canvas', 'zp_canvas.id', '=', 'zp_canvas_items.canvasId')
|
||||
->where('zp_canvas.projectId', $projectId)
|
||||
->get();
|
||||
|
||||
return array_map(fn ($item) => (array) $item, $results->toArray());
|
||||
}
|
||||
}
|
||||
992
app/Domain/Blueprints/Services/Blueprints.php
Normal file
992
app/Domain/Blueprints/Services/Blueprints.php
Normal file
@@ -0,0 +1,992 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Leantime\Domain\Blueprints\Services;
|
||||
|
||||
use DOMDocument;
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
use Leantime\Core\Domains\BaseService;
|
||||
use Leantime\Core\Exceptions\AuthorizationException;
|
||||
use Leantime\Core\Language as LanguageCore;
|
||||
use Leantime\Domain\Blueprints\Events\CanvasItemUpdated;
|
||||
use Leantime\Domain\Blueprints\Models\CanvasTemplate;
|
||||
use Leantime\Domain\Blueprints\Permissions\BlueprintsPermissions;
|
||||
use Leantime\Domain\Blueprints\Repositories\Blueprints as BlueprintsRepository;
|
||||
use Leantime\Domain\ContentTemplates\Services\ContentTemplateRegistry;
|
||||
use Leantime\Domain\Users\Repositories\Users as UserRepository;
|
||||
|
||||
/**
|
||||
* Blueprints service - business logic for unified canvas boards.
|
||||
*
|
||||
* Replaces the old Canvas\Services\Canvas by using the Blueprints repo
|
||||
* and TemplateRegistry directly instead of dynamically resolving variant repos.
|
||||
*
|
||||
* Authorization: canvas boards and items are PROJECT-scoped (each board belongs to one
|
||||
* project; items belong to a board). Every by-id board/item operation routes through this
|
||||
* service, which resolves the entity's REAL project via the repository's fail-closed
|
||||
* resolvers and authorizes the matching {@see BlueprintsPermissions} verb against it. A
|
||||
* resolver returning null (missing id, or an id whose board is a different canvas type — the
|
||||
* shared `zp_canvas`/`zp_canvas_items` tables hold every variant under one id sequence) is
|
||||
* treated as DENY, never as "fall back to the session project". Controllers therefore call
|
||||
* these methods instead of the repository directly.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class Blueprints extends BaseService
|
||||
{
|
||||
private BlueprintsRepository $blueprintsRepo;
|
||||
|
||||
private TemplateRegistry $templateRegistry;
|
||||
|
||||
private LanguageCore $language;
|
||||
|
||||
private ContentTemplateRegistry $contentTemplates;
|
||||
|
||||
/**
|
||||
* @param BlueprintsRepository $blueprintsRepo Blueprints repository
|
||||
* @param TemplateRegistry $templateRegistry Canvas template registry
|
||||
* @param LanguageCore $language Language service for translations
|
||||
* @param ContentTemplateRegistry $contentTemplates Content templates registry — used to auto-apply a blueprint's optional startContent on board creation
|
||||
*/
|
||||
public function __construct(
|
||||
BlueprintsRepository $blueprintsRepo,
|
||||
TemplateRegistry $templateRegistry,
|
||||
LanguageCore $language,
|
||||
ContentTemplateRegistry $contentTemplates,
|
||||
) {
|
||||
$this->blueprintsRepo = $blueprintsRepo;
|
||||
$this->templateRegistry = $templateRegistry;
|
||||
$this->language = $language;
|
||||
$this->contentTemplates = $contentTemplates;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Secured by-id board/item CRUD chokepoint.
|
||||
//
|
||||
// Controllers call these instead of the repository so authorization happens against the
|
||||
// entity's REAL project. Reads soft-deny (return the same neutral value as "missing") so
|
||||
// they never become a cross-project existence oracle; writes fail closed with an
|
||||
// AuthorizationException. These are intentionally NOT @api — they are the canvas
|
||||
// controllers' write surface, not part of the JSON-RPC API.
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch a single canvas item by id, authorized for VIEW against the item's real project.
|
||||
*
|
||||
* @param int $id Canvas item id
|
||||
* @param string $canvasType Board type the item must belong to (e.g. "swotcanvas")
|
||||
* @return array<string, mixed>|false The item, or false when missing/foreign/unauthorized
|
||||
*/
|
||||
public function getCanvasItem(int $id, string $canvasType): array|false
|
||||
{
|
||||
$projectId = $this->blueprintsRepo->getCanvasItemProjectId($id, $canvasType);
|
||||
if ($projectId === null || ! $this->can(BlueprintsPermissions::VIEW, $projectId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->blueprintsRepo->getSingleCanvasItem($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single canvas board by id, authorized for VIEW against the board's real project.
|
||||
*
|
||||
* @param int $canvasId Canvas board id
|
||||
* @param string $canvasType Board type the board must be of
|
||||
* @return array<int, array<string, mixed>>|false Board rows, or false when missing/foreign/unauthorized
|
||||
*/
|
||||
public function getBoard(int $canvasId, string $canvasType): array|false
|
||||
{
|
||||
$projectId = $this->blueprintsRepo->getCanvasProjectId($canvasId, $canvasType);
|
||||
if ($projectId === null || ! $this->can(BlueprintsPermissions::VIEW, $projectId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->blueprintsRepo->getSingleCanvas($canvasId, $canvasType);
|
||||
}
|
||||
|
||||
/**
|
||||
* List the items of a canvas board, authorized for VIEW against the board's real project.
|
||||
* Returns an empty array (the neutral "no items" value) for a missing/foreign/unauthorized
|
||||
* board, so a foreign board id is indistinguishable from an empty one.
|
||||
*
|
||||
* @param int $canvasId Canvas board id
|
||||
* @param string $canvasType Board type the board must be of
|
||||
* @param string $commentModule Comment module key for the item comment count join
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getBoardItems(int $canvasId, string $canvasType, string $commentModule): array
|
||||
{
|
||||
$projectId = $this->blueprintsRepo->getCanvasProjectId($canvasId, $canvasType);
|
||||
if ($projectId === null || ! $this->can(BlueprintsPermissions::VIEW, $projectId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->blueprintsRepo->getCanvasItemsById($canvasId, $commentModule);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a canvas item, authorized for CREATE against the target board's real project.
|
||||
*
|
||||
* @param array<string, mixed> $values Item values (must include `canvasId`)
|
||||
* @param string $canvasType Board type the target board must be of
|
||||
* @return false|string New item id, or false on insert failure
|
||||
*
|
||||
* @throws AuthorizationException When the target board is unknown/foreign or CREATE is denied.
|
||||
*/
|
||||
public function createCanvasItem(array $values, string $canvasType): false|string
|
||||
{
|
||||
$projectId = $this->blueprintsRepo->getCanvasProjectId((int) ($values['canvasId'] ?? 0), $canvasType);
|
||||
if ($projectId === null) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
$this->authorize(BlueprintsPermissions::CREATE, $projectId);
|
||||
|
||||
return $this->blueprintsRepo->addCanvasItem($values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a canvas item, authorized for EDIT against the item's real project. The board id
|
||||
* is resolved from the existing item — `canvasId` in the payload is ignored for scope, so
|
||||
* an item cannot be relocated into another project.
|
||||
*
|
||||
* @param array<string, mixed> $values Item values (must include `itemId` or `id`)
|
||||
* @param string $canvasType Board type the item must belong to
|
||||
*
|
||||
* @throws AuthorizationException When the item is unknown/foreign or EDIT is denied.
|
||||
*/
|
||||
public function updateCanvasItem(array $values, string $canvasType): void
|
||||
{
|
||||
$itemId = (int) ($values['itemId'] ?? $values['id'] ?? 0);
|
||||
$projectId = $this->blueprintsRepo->getCanvasItemProjectId($itemId, $canvasType);
|
||||
if ($projectId === null) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
$this->authorize(BlueprintsPermissions::EDIT, $projectId);
|
||||
|
||||
$this->blueprintsRepo->editCanvasItem($values);
|
||||
|
||||
CanvasItemUpdated::dispatch(
|
||||
canvasItemId: $itemId,
|
||||
changedFields: $this->fieldNames($values),
|
||||
legacyHook: __FUNCTION__,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the canvas-item field names from a controller payload for the
|
||||
* CanvasItemUpdated event, dropping the transport/identifier keys (id,
|
||||
* itemId, canvasId, changeItem, routing params) that ride along in the
|
||||
* payload but are not columns — so `changedFields` reads as field names,
|
||||
* not request plumbing.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function fieldNames(array $payload): array
|
||||
{
|
||||
$transportKeys = ['id', 'itemId', 'canvasId', 'changeItem', 'action', 'module'];
|
||||
|
||||
return array_values(array_diff(array_map('strval', array_keys($payload)), $transportKeys));
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch allowlisted columns of a canvas item, authorized for EDIT against the item's real
|
||||
* project. Used by the inline board-update API.
|
||||
*
|
||||
* @param int $id Canvas item id
|
||||
* @param array<string, mixed> $params Fields to patch (allowlisted in the repository)
|
||||
* @param string $canvasType Board type the item must belong to
|
||||
* @return bool False when no allowlisted columns were present (a client error, not a denial)
|
||||
*
|
||||
* @throws AuthorizationException When the item is unknown/foreign or EDIT is denied.
|
||||
*/
|
||||
public function patchCanvasItem(int $id, array $params, string $canvasType): bool
|
||||
{
|
||||
$projectId = $this->blueprintsRepo->getCanvasItemProjectId($id, $canvasType);
|
||||
if ($projectId === null) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
$this->authorize(BlueprintsPermissions::EDIT, $projectId);
|
||||
|
||||
$patched = $this->blueprintsRepo->patchCanvasItem($id, $params);
|
||||
|
||||
if ($patched) {
|
||||
CanvasItemUpdated::dispatch(
|
||||
canvasItemId: $id,
|
||||
changedFields: $this->fieldNames($params),
|
||||
legacyHook: __FUNCTION__,
|
||||
);
|
||||
}
|
||||
|
||||
return $patched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a canvas item, authorized for DELETE against the item's real project.
|
||||
*
|
||||
* @param int $id Canvas item id
|
||||
* @param string $canvasType Board type the item must belong to
|
||||
*
|
||||
* @throws AuthorizationException When the item is unknown/foreign or DELETE is denied.
|
||||
*/
|
||||
public function deleteCanvasItem(int $id, string $canvasType): void
|
||||
{
|
||||
$projectId = $this->blueprintsRepo->getCanvasItemProjectId($id, $canvasType);
|
||||
if ($projectId === null) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
$this->authorize(BlueprintsPermissions::DELETE, $projectId);
|
||||
|
||||
$this->blueprintsRepo->delCanvasItem($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a canvas board, authorized for CREATE against the target project.
|
||||
*
|
||||
* @param array<string, mixed> $values Board values (must include `projectId`)
|
||||
* @param string $canvasType Board type to create
|
||||
* @return false|string New board id, or false on insert failure
|
||||
*
|
||||
* @throws AuthorizationException When projectId is missing or CREATE is denied.
|
||||
*/
|
||||
public function createBoard(array $values, string $canvasType): false|string
|
||||
{
|
||||
$projectId = (int) ($values['projectId'] ?? 0);
|
||||
if ($projectId === 0) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
$this->authorize(BlueprintsPermissions::CREATE, $projectId);
|
||||
|
||||
$newId = $this->blueprintsRepo->addCanvas($values, $canvasType);
|
||||
|
||||
if ($newId !== false) {
|
||||
$this->applyStartContent((int) $newId, $canvasType);
|
||||
}
|
||||
|
||||
return $newId;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the blueprint for this canvas type declares a startContent
|
||||
* reference, look up the matching ContentTemplate and apply it to the
|
||||
* freshly-created board. Silent no-op when the blueprint has no
|
||||
* starter content or the referenced template can't be found — board
|
||||
* creation never fails because of a missing/broken starter.
|
||||
*/
|
||||
private function applyStartContent(int $canvasId, string $canvasType): void
|
||||
{
|
||||
// createBoard() is invoked with the DATABASE type (e.g. "swotcanvas",
|
||||
// "logicmodelcanvas"), but both registries key by the SLUG form
|
||||
// ("swot", "logicmodel"). Use getByDatabaseType() to bridge, then
|
||||
// pass the resolved slug to the ContentTemplates lookups so both
|
||||
// sides agree on the identifier. Prior to this the initial registry
|
||||
// read silently returned null and the whole feature was a no-op.
|
||||
$blueprint = $this->templateRegistry->getByDatabaseType($canvasType);
|
||||
if ($blueprint === null || $blueprint->startContent === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$slug = $blueprint->slug;
|
||||
|
||||
$contentTpl = $this->contentTemplates->get($slug, $blueprint->startContent);
|
||||
if ($contentTpl === null) {
|
||||
Log::debug(sprintf(
|
||||
'Blueprints::createBoard: blueprint "%s" references startContent "%s" but the template was not found.',
|
||||
$slug,
|
||||
$blueprint->startContent
|
||||
));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$applier = $this->contentTemplates->applierFor($slug);
|
||||
if ($applier === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$applier->apply($canvasId, $contentTpl, [
|
||||
'userId' => (int) session('userdata.id'),
|
||||
'mode' => 'add',
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// Don't let a bad starter break board creation. Log + move on.
|
||||
Log::warning(sprintf(
|
||||
'Blueprints::createBoard: startContent "%s" failed to apply to canvas %d: %s',
|
||||
$blueprint->startContent,
|
||||
$canvasId,
|
||||
$e->getMessage()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a canvas board, authorized for EDIT against the board's real project.
|
||||
*
|
||||
* @param int $canvasId Canvas board id
|
||||
* @param string $title New title
|
||||
* @param string $canvasType Board type the board must be of
|
||||
*
|
||||
* @throws AuthorizationException When the board is unknown/foreign or EDIT is denied.
|
||||
*/
|
||||
public function renameBoard(int $canvasId, string $title, string $canvasType): mixed
|
||||
{
|
||||
$projectId = $this->blueprintsRepo->getCanvasProjectId($canvasId, $canvasType);
|
||||
if ($projectId === null) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
$this->authorize(BlueprintsPermissions::EDIT, $projectId);
|
||||
|
||||
return $this->blueprintsRepo->updateCanvas(['id' => $canvasId, 'title' => $title]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a canvas board into a target project. Requires VIEW on the SOURCE board's real
|
||||
* project (you must be able to read what you copy) AND CREATE in the target project.
|
||||
*
|
||||
* @param int $sourceCanvasId Source board id
|
||||
* @param int $targetProjectId Destination project id
|
||||
* @param int $authorId Author of the new board
|
||||
* @param string $title New board title
|
||||
* @param string $canvasType Board type the source must be of (and the copy will be)
|
||||
* @return int New board id
|
||||
*
|
||||
* @throws AuthorizationException When the source is unknown/foreign, or VIEW/CREATE is denied.
|
||||
*/
|
||||
public function copyBoard(int $sourceCanvasId, int $targetProjectId, int $authorId, string $title, string $canvasType): int
|
||||
{
|
||||
$sourceProjectId = $this->blueprintsRepo->getCanvasProjectId($sourceCanvasId, $canvasType);
|
||||
if ($sourceProjectId === null || $targetProjectId <= 0) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
$this->authorize(BlueprintsPermissions::VIEW, $sourceProjectId);
|
||||
$this->authorize(BlueprintsPermissions::CREATE, $targetProjectId);
|
||||
|
||||
return $this->blueprintsRepo->copyCanvas($targetProjectId, $sourceCanvasId, $authorId, $title, $canvasType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a source board's items into a target board. Requires EDIT on the TARGET board's
|
||||
* real project and VIEW on the SOURCE board's real project — both resolved by id, so
|
||||
* neither can cross a project boundary.
|
||||
*
|
||||
* @param int $targetCanvasId Board receiving the items
|
||||
* @param int $sourceCanvasId Board whose items are copied
|
||||
* @param string $canvasType Board type both boards must be of
|
||||
*
|
||||
* @throws AuthorizationException When either board is unknown/foreign, or EDIT/VIEW is denied.
|
||||
*/
|
||||
public function mergeBoard(int $targetCanvasId, int $sourceCanvasId, string $canvasType): bool
|
||||
{
|
||||
$targetProjectId = $this->blueprintsRepo->getCanvasProjectId($targetCanvasId, $canvasType);
|
||||
$sourceProjectId = $this->blueprintsRepo->getCanvasProjectId($sourceCanvasId, $canvasType);
|
||||
if ($targetProjectId === null || $sourceProjectId === null) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
$this->authorize(BlueprintsPermissions::EDIT, $targetProjectId);
|
||||
$this->authorize(BlueprintsPermissions::VIEW, $sourceProjectId);
|
||||
|
||||
return $this->blueprintsRepo->mergeCanvas($targetCanvasId, $sourceCanvasId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a canvas board (and its items), authorized for DELETE against the board's real
|
||||
* project.
|
||||
*
|
||||
* @param int $canvasId Canvas board id
|
||||
* @param string $canvasType Board type the board must be of
|
||||
*
|
||||
* @throws AuthorizationException When the board is unknown/foreign or DELETE is denied.
|
||||
*/
|
||||
public function deleteBoard(int $canvasId, string $canvasType): void
|
||||
{
|
||||
$projectId = $this->blueprintsRepo->getCanvasProjectId($canvasId, $canvasType);
|
||||
if ($projectId === null) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
$this->authorize(BlueprintsPermissions::DELETE, $projectId);
|
||||
|
||||
$this->blueprintsRepo->deleteCanvas($canvasId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a resolved import file path is safe to read.
|
||||
*
|
||||
* Rejects files outside a fixed allow-list of local directories and
|
||||
* requires a known extension. The caller must resolve the path via
|
||||
* {@see realpath()} first — realpath canonicalizes the path (resolves
|
||||
* symlinks, relative segments, and `..` traversal) so the allow-list
|
||||
* check operates on the true absolute path rather than the
|
||||
* user-supplied string.
|
||||
*
|
||||
* The allow-list covers two directories:
|
||||
* - the PHP upload temp directory (UI file-upload flow), and
|
||||
* - the shipped fixture directory under the Blueprints domain.
|
||||
*
|
||||
* base_path('userfiles') is intentionally EXCLUDED: the global userfiles
|
||||
* storage is managed by the Files domain with per-file authorization.
|
||||
* Allowing import() to read arbitrary .xml files from userfiles would
|
||||
* bypass that authorization — a caller with CREATE on any project could
|
||||
* ingest files they should not have access to.
|
||||
*
|
||||
* .xml files in sys_get_temp_dir() are accepted by design: this is how PHP
|
||||
* delivers uploaded files to the application (upload_tmp_dir / sys_temp_dir).
|
||||
* On Unix systems the temp directory is typically world-writable with the
|
||||
* sticky bit; the allow-list check is the gate, and we accept the residual
|
||||
* risk that another local user could place a malicious .xml there — that
|
||||
* attacker already has local code execution as the web server user, so
|
||||
* crafting a temp file does not represent an additional escalation.
|
||||
*
|
||||
* @param string $resolvedPath Already-resolved absolute path (from realpath)
|
||||
* @return bool True when the path is within an allowed directory
|
||||
* and has an allowed extension
|
||||
*/
|
||||
private function isImportPathAllowed(string $resolvedPath): bool
|
||||
{
|
||||
$allowedDirs = [
|
||||
sys_get_temp_dir(),
|
||||
APP_ROOT.'/app/Domain/Blueprints/imports',
|
||||
];
|
||||
|
||||
// Validate file extension — only XML is permitted because import()
|
||||
// parses via DOMDocument::loadXML(). Shipped fixture files under the
|
||||
// imports/ directory are .xml as well.
|
||||
$ext = strtolower(pathinfo($resolvedPath, PATHINFO_EXTENSION));
|
||||
if ($ext !== 'xml') {
|
||||
Log::warning('Blueprints import: disallowed file extension', [
|
||||
'resolvedPath' => $resolvedPath,
|
||||
'extension' => $ext,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Anchor each allowed directory with a trailing separator so
|
||||
// str_starts_with doesn't match sibling-prefix paths (e.g.
|
||||
// /tmp-evil/x must NOT match against allowed /tmp).
|
||||
foreach ($allowedDirs as $allowedDir) {
|
||||
$resolvedAllowed = realpath($allowedDir);
|
||||
|
||||
if ($resolvedAllowed === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_starts_with($resolvedPath, $resolvedAllowed.DIRECTORY_SEPARATOR)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Log::warning('Blueprints import: path traversal or SSRF attempt blocked', [
|
||||
'resolvedPath' => $resolvedPath,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a canvas board from an XML file.
|
||||
*
|
||||
* Parses the XML, validates its structure, then creates a new canvas board
|
||||
* with all items from the file.
|
||||
*
|
||||
* @param string $filename Path to the XML file
|
||||
* @param string $canvasSlug Canvas type slug (e.g., "swot", "lean")
|
||||
* @param int $projectId Project identifier
|
||||
* @param int $authorId Author user identifier
|
||||
* @return bool|int False on failure, or the new canvas board ID on success
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
* @throws AuthorizationException When the user cannot create canvases in $projectId.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
#[RequiresPermission(BlueprintsPermissions::CREATE, entityScoped: true)]
|
||||
public function import(string $filename, string $canvasSlug, int $projectId, int $authorId): bool|int
|
||||
{
|
||||
// Authorize CREATE against the TARGET project (the destination of the import), not the
|
||||
// session project — import is reachable via RPC with an arbitrary projectId.
|
||||
$this->authorize(BlueprintsPermissions::CREATE, $projectId);
|
||||
|
||||
$template = $this->templateRegistry->get($canvasSlug);
|
||||
if ($template === null) {
|
||||
Log::error("Blueprints import failed: unknown canvas slug '{$canvasSlug}'");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$dom = new DOMDocument('1.0', 'UTF-8');
|
||||
|
||||
// Validate the file path and extension to prevent SSRF and Local File
|
||||
// Inclusion. Reject URL wrappers (http://, ftp://, etc.), restrict
|
||||
// reads to allowed local directories, and require a known import
|
||||
// extension.
|
||||
$resolvedPath = realpath($filename);
|
||||
if ($resolvedPath === false) {
|
||||
Log::warning('Blueprints import: file not found or path does not exist', [
|
||||
'filename' => $filename,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->isImportPathAllowed($resolvedPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Guard against non-regular files (FIFO, device, socket) in
|
||||
// world-writable /tmp — a named pipe named *.xml would hang the
|
||||
// request if read without this check.
|
||||
if (! is_file($resolvedPath) || ! is_readable($resolvedPath)) {
|
||||
Log::warning('Blueprints import: path is not a readable regular file', [
|
||||
'resolvedPath' => $resolvedPath,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$canvasData = file_get_contents($resolvedPath);
|
||||
if ($canvasData === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Defend against XXE-based SSRF: LIBXML_NONET disables network access
|
||||
// during parsing. PHP 8.0+ disables external entity loading by default;
|
||||
// this flag provides defense-in-depth for older or misconfigured builds.
|
||||
$oldInternalErrors = libxml_use_internal_errors(true);
|
||||
$oldErrorReporting = error_reporting(error_reporting() & ~E_WARNING);
|
||||
$status = $dom->loadXML($canvasData, LIBXML_NONET);
|
||||
error_reporting($oldErrorReporting);
|
||||
libxml_use_internal_errors($oldInternalErrors);
|
||||
if ($status === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$canvasAry = ['projectId' => $projectId, 'author' => $authorId];
|
||||
$recordsAry = [];
|
||||
|
||||
$canvasNodeList = $dom->getElementsByTagName('canvas');
|
||||
if ($canvasNodeList->count() !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$importedCanvasName = $canvasNodeList->item(0)->getAttribute('key');
|
||||
|
||||
$titleNodeList = $canvasNodeList->item(0)->getElementsByTagName('title');
|
||||
if ($titleNodeList->count() !== 1) {
|
||||
return false;
|
||||
}
|
||||
$canvasAry['title'] = $titleNodeList->item(0)->nodeValue;
|
||||
|
||||
$dataNodeList = $canvasNodeList->item(0)->getElementsByTagName('content');
|
||||
if ($dataNodeList->count() !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$elementNodeList = $dataNodeList->item(0)->getElementsByTagName('element');
|
||||
|
||||
// Resolved here rather than at the top of the method: it is only needed to map
|
||||
// item authors below, so a rejected path or malformed document never pays for
|
||||
// building a database-backed repository.
|
||||
$users = app()->make(UserRepository::class);
|
||||
|
||||
foreach ($elementNodeList as $elementNode) {
|
||||
if (! $elementNode->hasAttribute('key')) {
|
||||
return false;
|
||||
}
|
||||
$elementKey = $elementNode->getAttribute('key');
|
||||
|
||||
$itemNodeList = $elementNode->getElementsByTagName('item');
|
||||
foreach ($itemNodeList as $itemName) {
|
||||
$authorNodeList = $itemName->getElementsByTagName('author');
|
||||
if ($authorNodeList->count() !== 1) {
|
||||
return false;
|
||||
}
|
||||
if (! $authorNodeList->item(0)->hasAttribute('firstname')) {
|
||||
return false;
|
||||
}
|
||||
$authorFirstname = $authorNodeList->item(0)->getAttribute('firstname');
|
||||
if (! $authorNodeList->item(0)->hasAttribute('lastname')) {
|
||||
return false;
|
||||
}
|
||||
$authorLastname = $authorNodeList->item(0)->getAttribute('lastname');
|
||||
$author = $users->getUserIdByName($authorFirstname, $authorLastname);
|
||||
if ($author === false) {
|
||||
$author = $authorId;
|
||||
}
|
||||
|
||||
$descriptionNodeList = $itemName->getElementsByTagName('description');
|
||||
if ($descriptionNodeList->count() !== 1) {
|
||||
return false;
|
||||
}
|
||||
$description = $descriptionNodeList->item(0)->nodeValue;
|
||||
|
||||
$statusNodeList = $itemName->getElementsByTagName('status');
|
||||
if ($statusNodeList->count() !== 1) {
|
||||
return false;
|
||||
}
|
||||
if (! $statusNodeList->item(0)->hasAttribute('key')) {
|
||||
return false;
|
||||
}
|
||||
$statusKey = $statusNodeList->item(0)->getAttribute('key');
|
||||
|
||||
$relatesNodeList = $itemName->getElementsByTagName('relates');
|
||||
if ($relatesNodeList->count() !== 1) {
|
||||
return false;
|
||||
}
|
||||
if (! $relatesNodeList->item(0)->hasAttribute('key')) {
|
||||
return false;
|
||||
}
|
||||
$relates = $relatesNodeList->item(0)->getAttribute('key');
|
||||
|
||||
$assumptionsNodeList = $itemName->getElementsByTagName('assumptions');
|
||||
if ($assumptionsNodeList->count() !== 1) {
|
||||
return false;
|
||||
}
|
||||
$assumptions = empty($assumptionsNodeList->item(0)->nodeValue) ? '' :
|
||||
$dom->saveHTML($assumptionsNodeList->item(0)->firstChild);
|
||||
|
||||
$importDataNodeList = $itemName->getElementsByTagName('data');
|
||||
if ($importDataNodeList->count() !== 1) {
|
||||
return false;
|
||||
}
|
||||
$data = empty($importDataNodeList->item(0)->nodeValue) ? '' :
|
||||
$dom->saveHTML($importDataNodeList->item(0)->firstChild);
|
||||
|
||||
$conclusionNodeList = $itemName->getElementsByTagName('conclusion');
|
||||
if ($conclusionNodeList->count() !== 1) {
|
||||
return false;
|
||||
}
|
||||
$conclusion = empty($conclusionNodeList->item(0)->nodeValue) ? '' :
|
||||
$dom->saveHTML($conclusionNodeList->item(0)->firstChild);
|
||||
|
||||
$recordsAry[] = [
|
||||
'description' => $description,
|
||||
'assumptions' => $assumptions,
|
||||
'data' => $data,
|
||||
'conclusion' => $conclusion,
|
||||
'box' => $elementKey,
|
||||
'author' => $author,
|
||||
'status' => $statusKey,
|
||||
'relates' => $relates,
|
||||
'milestoneId' => '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$expectedCanvasKey = $template->getDatabaseType();
|
||||
if ($expectedCanvasKey !== $importedCanvasName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$canvasType = $template->getDatabaseType();
|
||||
|
||||
$canvasAry['title'] .= ' [imported]';
|
||||
if ($this->blueprintsRepo->existCanvas($projectId, $canvasAry['title'], $canvasType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$canvasId = $this->blueprintsRepo->addCanvas($canvasAry, $canvasType);
|
||||
if ($canvasId === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($recordsAry as $record) {
|
||||
$record['canvasId'] = $canvasId;
|
||||
$this->blueprintsRepo->addCanvasItem($record);
|
||||
}
|
||||
|
||||
return (int) $canvasId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get progress percentages for canvas boards in a project.
|
||||
*
|
||||
* Counts items per box type for each canvas and calculates what fraction
|
||||
* of box types have at least one item.
|
||||
*
|
||||
* @param string $projectId Project identifier (empty string for all)
|
||||
* @param array<int, string> $boards Array of database canvas types to check
|
||||
* @return array<string, float> Map of canvas type to max progress (0.0 to 1.0)
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
#[RequiresPermission(BlueprintsPermissions::VIEW, projectIdParam: 'projectId')]
|
||||
public function getBoardProgress(string $projectId = '', array $boards = []): array
|
||||
{
|
||||
$values = $this->blueprintsRepo->getCanvasProgressCount((int) $projectId, $boards);
|
||||
|
||||
$results = [];
|
||||
|
||||
foreach ($values as $row) {
|
||||
$canvasType = $row['canvasType'];
|
||||
|
||||
if (! isset($results[$canvasType])) {
|
||||
$results[$canvasType] = [];
|
||||
}
|
||||
|
||||
if (! isset($results[$canvasType][$row['canvasId']])) {
|
||||
$template = $this->templateRegistry->getByDatabaseType($canvasType);
|
||||
$results[$canvasType][$row['canvasId']] = [];
|
||||
|
||||
if ($template !== null) {
|
||||
foreach ($template->boxes as $type => $box) {
|
||||
$results[$canvasType][$row['canvasId']][$type] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($row['box'] != '' && $row['boxItems'] > 0) {
|
||||
$results[$canvasType][$row['canvasId']][$row['box']]++;
|
||||
}
|
||||
}
|
||||
|
||||
$progressResults = [];
|
||||
|
||||
foreach ($results as $key => &$canvas) {
|
||||
$template = $this->templateRegistry->getByDatabaseType($key);
|
||||
$numOfBoxes = $template !== null ? count($template->boxes) : 1;
|
||||
|
||||
if (! isset($progressResults[$key])) {
|
||||
$progressResults[$key] = '';
|
||||
}
|
||||
|
||||
$maxProgress = 0;
|
||||
foreach ($canvas as $canvasId => $singleCanvas) {
|
||||
$numOfBoxesFilled = 0;
|
||||
foreach ($singleCanvas as $box) {
|
||||
if ($box > 0) {
|
||||
$numOfBoxesFilled++;
|
||||
}
|
||||
}
|
||||
$progress = $numOfBoxesFilled / $numOfBoxes;
|
||||
if ($progress > $maxProgress) {
|
||||
$maxProgress = $progress;
|
||||
}
|
||||
}
|
||||
|
||||
$progressResults[$key] = $maxProgress;
|
||||
}
|
||||
|
||||
return $progressResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get canvas boards ordered by last updated item.
|
||||
*
|
||||
* @param int|null $projectId Project identifier (null for all)
|
||||
* @param array<int, string> $boards Array of database canvas types to filter by
|
||||
* @return array<int, array<string, mixed>> List of canvas boards with modification dates
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
#[RequiresPermission(BlueprintsPermissions::VIEW, projectIdParam: 'projectId')]
|
||||
public function getLastUpdatedCanvas(?int $projectId = null, array $boards = []): array
|
||||
{
|
||||
return $this->blueprintsRepo->getLastUpdatedCanvas((int) $projectId, $boards);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate the box labels from a CanvasTemplate.
|
||||
*
|
||||
* Returns the boxes array with title values run through the language service.
|
||||
*
|
||||
* @param CanvasTemplate $template Canvas template
|
||||
* @return array<string, array<string, mixed>> Translated box definitions
|
||||
*/
|
||||
public function getTranslatedBoxes(CanvasTemplate $template): array
|
||||
{
|
||||
$boxes = $template->boxes;
|
||||
foreach ($boxes as $key => $data) {
|
||||
if (isset($data['title'])) {
|
||||
$boxes[$key]['title'] = $this->language->__($data['title']);
|
||||
}
|
||||
}
|
||||
|
||||
return $boxes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate the status labels from a CanvasTemplate.
|
||||
*
|
||||
* @param CanvasTemplate $template Canvas template
|
||||
* @return array<string, array<string, mixed>> Translated status labels
|
||||
*/
|
||||
public function getTranslatedStatusLabels(CanvasTemplate $template): array
|
||||
{
|
||||
$statusLabels = $template->statusLabels;
|
||||
foreach ($statusLabels as $key => $data) {
|
||||
if (isset($data['title'])) {
|
||||
$statusLabels[$key]['title'] = $this->language->__($data['title']);
|
||||
}
|
||||
}
|
||||
|
||||
return $statusLabels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate the relates labels from a CanvasTemplate.
|
||||
*
|
||||
* @param CanvasTemplate $template Canvas template
|
||||
* @return array<string, array<string, mixed>> Translated relates labels
|
||||
*/
|
||||
public function getTranslatedRelatesLabels(CanvasTemplate $template): array
|
||||
{
|
||||
$relatesLabels = $template->relatesLabels;
|
||||
foreach ($relatesLabels as $key => $data) {
|
||||
if (isset($data['title'])) {
|
||||
$relatesLabels[$key]['title'] = $this->language->__($data['title']);
|
||||
}
|
||||
}
|
||||
|
||||
return $relatesLabels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate the data labels from a CanvasTemplate.
|
||||
*
|
||||
* @param CanvasTemplate $template Canvas template
|
||||
* @return array<int, array<string, mixed>> Translated data labels
|
||||
*/
|
||||
public function getTranslatedDataLabels(CanvasTemplate $template): array
|
||||
{
|
||||
$dataLabels = $template->dataLabels;
|
||||
foreach ($dataLabels as $key => $data) {
|
||||
if (isset($data['title'])) {
|
||||
$dataLabels[$key]['title'] = $this->language->__($data['title']);
|
||||
}
|
||||
}
|
||||
|
||||
return $dataLabels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate the disclaimer string from a CanvasTemplate.
|
||||
*
|
||||
* @param CanvasTemplate $template Canvas template
|
||||
* @return string Translated disclaimer, or empty string if none
|
||||
*/
|
||||
public function getTranslatedDisclaimer(CanvasTemplate $template): string
|
||||
{
|
||||
if (empty($template->disclaimer)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $this->language->__($template->disclaimer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the metadata map for every selectable blueprint board (canvas) type.
|
||||
*
|
||||
* Each entry holds the routing module, the translatable name/description labels,
|
||||
* an icon class and the (empty) placeholders used when no board of that type exists yet.
|
||||
*
|
||||
* @return array<string, array<string, string>> Board type keyed metadata map.
|
||||
*/
|
||||
public function getBoardMetadata(): array
|
||||
{
|
||||
return [
|
||||
'logicmodelcanvas' => ['module' => 'logicmodelcanvas', 'name' => 'label.logicmodelcanvas', 'description' => 'description.logicmodelcanvas', 'icon' => 'fa-solid fa-diagram-project', 'numberOfBoards' => '', 'lastTitle' => '', 'lastCanvasId' => '', 'lastUpdate' => ''],
|
||||
'valuecanvas' => ['module' => 'blueprints/value', 'name' => 'label.valuecanvas', 'description' => 'description.valuecanvas', 'icon' => 'fa-solid fa-ranking-star', 'numberOfBoards' => '', 'lastTitle' => '', 'lastCanvasId' => '', 'lastUpdate' => ''],
|
||||
'swotcanvas' => ['module' => 'blueprints/swot', 'name' => 'label.swotcanvas', 'description' => 'description.swotcanvas', 'icon' => 'fa-solid fa-dumbbell', 'numberOfBoards' => '', 'lastTitle' => '', 'lastCanvasId' => '', 'lastUpdate' => ''],
|
||||
'obmcanvas' => ['module' => 'blueprints/obm', 'name' => 'label.obmcanvas', 'description' => 'description.obmcanvas', 'icon' => 'fa-solid fa-object-group', 'numberOfBoards' => '', 'lastTitle' => '', 'lastCanvasId' => '', 'lastUpdate' => ''],
|
||||
'leancanvas' => ['module' => 'blueprints/lean', 'name' => 'label.leancanvas', 'description' => 'description.leancanvas', 'icon' => 'fa-solid fa-person-circle-question', 'numberOfBoards' => '', 'lastTitle' => '', 'lastCanvasId' => '', 'lastUpdate' => ''],
|
||||
'minempathycanvas' => ['module' => 'blueprints/minempathy', 'name' => 'label.minempathycanvas', 'description' => 'description.minempathycanvas', 'icon' => 'fa-solid fa-heart-circle-check', 'numberOfBoards' => '', 'lastTitle' => '', 'lastCanvasId' => '', 'lastUpdate' => ''],
|
||||
'sbcanvas' => ['module' => 'blueprints/sb', 'name' => 'label.sbcanvas', 'description' => 'description.sbcanvas', 'icon' => 'fa-solid fa-briefcase', 'numberOfBoards' => '', 'lastTitle' => '', 'lastCanvasId' => '', 'lastUpdate' => ''],
|
||||
'riskscanvas' => ['module' => 'blueprints/risks', 'name' => 'label.riskscanvas', 'description' => 'description.riskscanvas', 'icon' => 'fa-solid fa-triangle-exclamation', 'numberOfBoards' => '', 'lastTitle' => '', 'lastCanvasId' => '', 'lastUpdate' => ''],
|
||||
'eacanvas' => ['module' => 'blueprints/ea', 'name' => 'label.eacanvas', 'description' => 'description.eacanvas', 'icon' => 'fa-solid fa-seedling', 'numberOfBoards' => '', 'lastTitle' => '', 'lastCanvasId' => '', 'lastUpdate' => ''],
|
||||
'lbmcanvas' => ['visible' => '0', 'module' => 'blueprints/lbm', 'name' => 'label.lbmcanvas', 'description' => 'description.lbmcanvas', 'icon' => 'fa-solid fa-building', 'numberOfBoards' => '', 'lastTitle' => '', 'lastCanvasId' => '', 'lastUpdate' => ''],
|
||||
'dbmcanvas' => ['visible' => '0', 'module' => 'blueprints/dbm', 'name' => 'label.dbmcanvas', 'description' => 'description.dbmcanvas', 'icon' => 'fa-solid fa-city', 'numberOfBoards' => '', 'lastTitle' => '', 'lastCanvasId' => '', 'lastUpdate' => ''],
|
||||
'sqcanvas' => ['visible' => '0', 'module' => 'blueprints/sq', 'name' => 'label.sqcanvas', 'description' => 'description.sqcanvas', 'icon' => 'fa fa-chess', 'numberOfBoards' => '', 'lastTitle' => '', 'lastCanvasId' => '', 'lastUpdate' => ''],
|
||||
'insightscanvas' => ['module' => 'blueprints/insights', 'name' => 'label.insightscanvas', 'description' => 'description.insightscanvas', 'icon' => 'fa-solid fa-arrows-down-to-people', 'numberOfBoards' => '', 'lastTitle' => '', 'lastCanvasId' => '', 'lastUpdate' => ''],
|
||||
'cpcanvas' => ['visible' => '0', 'module' => 'blueprints/cp', 'name' => 'label.cpcanvas', 'description' => 'description.cpcanvas', 'icon' => 'fa-solid fa-list-check', 'numberOfBoards' => '', 'lastTitle' => '', 'lastCanvasId' => '', 'lastUpdate' => ''],
|
||||
'smcanvas' => ['visible' => '0', 'module' => 'blueprints/sm', 'name' => 'label.smcanvas', 'description' => 'description.smcanvas', 'icon' => 'fa-solid fa-comments-dollar', 'numberOfBoards' => '', 'lastTitle' => '', 'lastCanvasId' => '', 'lastUpdate' => ''],
|
||||
'emcanvas' => ['visible' => '0', 'module' => 'blueprints/em', 'name' => 'label.emcanvas', 'description' => 'description.emcanvas', 'icon' => 'fa-solid fa-hand-holding-heart', 'numberOfBoards' => '', 'lastTitle' => '', 'lastCanvasId' => '', 'lastUpdate' => ''],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ordered list of blueprint board (canvas) types used to query progress and recent activity.
|
||||
*
|
||||
* @return array<int, string> List of board type keys.
|
||||
*/
|
||||
public function getBoardTypes(): array
|
||||
{
|
||||
return [
|
||||
'emcanvas', 'smcanvas', 'cpcanvas', 'insightscanvas',
|
||||
'sqcanvas', 'dbmcanvas', 'lbmcanvas', 'eacanvas', 'riskscanvas', 'sbcanvas',
|
||||
'swotcanvas', 'obmcanvas', 'valuecanvas', 'leancanvas', 'minempathycanvas',
|
||||
];
|
||||
// Note: logicmodelcanvas is intentionally absent. It is its own domain (no
|
||||
// Blueprints YAML template), so it can't go through the template-based
|
||||
// progress/recent computation here — it would hit undefined box keys
|
||||
// (e.g. "lm_inputs"). It still appears in the hub via getBoardMetadata().
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges the recently updated canvas boards into the board metadata map.
|
||||
*
|
||||
* For the first occurrence of a board type the metadata entry is seeded with the
|
||||
* latest board's count, title, modified date and id, and that type is removed from the
|
||||
* remaining "other" board list. Subsequent occurrences only increment the count.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $recentlyUpdatedCanvas Canvas rows ordered by last updated item.
|
||||
* @param array<string, array<string, string>> $boardMetadata Board type keyed metadata map (passed by reference so the consumed types are removed).
|
||||
* @return array<string, array<string, mixed>> The recently used board metadata keyed by board type.
|
||||
*/
|
||||
public function buildRecentProgressCanvas(array $recentlyUpdatedCanvas, array &$boardMetadata): array
|
||||
{
|
||||
$recentProgressCanvas = [];
|
||||
|
||||
foreach ($recentlyUpdatedCanvas as $canvas) {
|
||||
if (! isset($recentProgressCanvas[$canvas['type']])) {
|
||||
$recentProgressCanvas[$canvas['type']] = $boardMetadata[$canvas['type']];
|
||||
$recentProgressCanvas[$canvas['type']]['count'] = 1;
|
||||
$recentProgressCanvas[$canvas['type']]['lastTitle'] = $canvas['title'];
|
||||
$recentProgressCanvas[$canvas['type']]['lastUpdate'] = $canvas['modified'];
|
||||
$recentProgressCanvas[$canvas['type']]['lastCanvasId'] = $canvas['id'];
|
||||
unset($boardMetadata[$canvas['type']]);
|
||||
} else {
|
||||
$recentProgressCanvas[$canvas['type']]['count']++;
|
||||
}
|
||||
}
|
||||
|
||||
return $recentProgressCanvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the blueprints boards overview for a project.
|
||||
*
|
||||
* Loads the recently updated boards and board progress for the project, merges the recent
|
||||
* activity into the board metadata and returns a ready-to-render structure for the boards page.
|
||||
*
|
||||
* @param int $projectId Active project identifier.
|
||||
* @return array{recentProgressCanvas: array<string, array<string, mixed>>, otherBoards: array<string, array<string, string>>, recentlyUpdatedCanvas: array<int, array<string, mixed>>, canvasProgress: array<string, float|string>} Render-ready overview data.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function getBoardsOverview(int $projectId): array
|
||||
{
|
||||
$boardMetadata = $this->getBoardMetadata();
|
||||
$boards = $this->getBoardTypes();
|
||||
|
||||
$recentlyUpdatedCanvas = $this->getLastUpdatedCanvas($projectId, $boards);
|
||||
|
||||
$recentProgressCanvas = $this->buildRecentProgressCanvas($recentlyUpdatedCanvas, $boardMetadata);
|
||||
|
||||
$canvasProgress = $this->getBoardProgress((string) $projectId, $boards);
|
||||
|
||||
return [
|
||||
'recentProgressCanvas' => $recentProgressCanvas,
|
||||
'otherBoards' => $boardMetadata,
|
||||
'recentlyUpdatedCanvas' => $recentlyUpdatedCanvas,
|
||||
'canvasProgress' => $canvasProgress,
|
||||
];
|
||||
}
|
||||
}
|
||||
108
app/Domain/Blueprints/Services/BlueprintsExport.php
Normal file
108
app/Domain/Blueprints/Services/BlueprintsExport.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Leantime\Domain\Blueprints\Services;
|
||||
|
||||
/**
|
||||
* BlueprintsExport service - builds the XML export for a blueprint canvas board.
|
||||
*
|
||||
* The XML generation used to live in the Export controller; it is business logic
|
||||
* and belongs in the service layer so the controller stays thin.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class BlueprintsExport
|
||||
{
|
||||
/**
|
||||
* @param Blueprints $blueprintsService Blueprints service (VIEW-authorized board reads + label translation)
|
||||
* @param TemplateRegistry $templateRegistry Canvas template registry
|
||||
*/
|
||||
public function __construct(
|
||||
private Blueprints $blueprintsService,
|
||||
private TemplateRegistry $templateRegistry,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* exportToXml - generate the XML document for a canvas board.
|
||||
*
|
||||
* @param int $canvasId Canvas board identifier
|
||||
* @param string $canvasSlug Canvas type slug (e.g. "swot")
|
||||
* @return string|null XML document, or null if the canvas type or board does not exist,
|
||||
* or the user cannot view it (export is reachable via JSON-RPC with
|
||||
* an arbitrary board id, so the VIEW authorization happens here).
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function exportToXml(int $canvasId, string $canvasSlug): ?string
|
||||
{
|
||||
$template = $this->templateRegistry->get($canvasSlug);
|
||||
if ($template === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$canvasType = $template->getDatabaseType();
|
||||
// getBoard authorizes VIEW against the board's real project and returns false for a
|
||||
// missing/foreign/unauthorized board — so a foreign id is indistinguishable from a
|
||||
// non-existent one (no cross-project existence oracle).
|
||||
$canvasAry = $this->blueprintsService->getBoard($canvasId, $canvasType);
|
||||
if ($canvasAry === false || empty($canvasAry)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$records = $this->blueprintsService->getBoardItems($canvasId, $canvasType, $template->getCommentModule());
|
||||
$canvasTypes = $this->blueprintsService->getTranslatedBoxes($template);
|
||||
|
||||
$xml = '<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>'.PHP_EOL.PHP_EOL;
|
||||
$xml .= $this->buildXml($canvasType, $canvasAry[0]['title'], $records, $canvasTypes);
|
||||
|
||||
return $xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* buildXml - generate XML markup for canvas data.
|
||||
*
|
||||
* @param string $canvasKey Database canvas type (e.g. "swotcanvas")
|
||||
* @param string $canvasTitle Canvas board title
|
||||
* @param array<int, array<string, mixed>> $records Canvas item records
|
||||
* @param array<string, array<string, mixed>> $canvasTypes Translated box definitions
|
||||
* @param int $indent Indent level
|
||||
* @return string XML data
|
||||
*/
|
||||
private function buildXml(string $canvasKey, string $canvasTitle, array $records, array $canvasTypes, int $indent = 0): string
|
||||
{
|
||||
$is = str_repeat(' ', 4 * $indent);
|
||||
$tab = str_repeat(' ', 4);
|
||||
$xml = $is.'<canvas key="'.$canvasKey.'">'.PHP_EOL;
|
||||
$xml .= $is.$tab.'<title>'.$canvasTitle.'</title>'.PHP_EOL;
|
||||
$xml .= $is.$tab.'<content>'.PHP_EOL;
|
||||
|
||||
foreach ($canvasTypes as $key => $data) {
|
||||
$xml .= $is.$tab.$tab.'<element key="'.$key.'">'.PHP_EOL;
|
||||
|
||||
foreach ($records as $record) {
|
||||
if ($record['box'] === $key) {
|
||||
$xml .= $is.$tab.$tab.$tab.'<item>'.PHP_EOL;
|
||||
$xml .= $is.$tab.$tab.$tab.$tab.'<created>'.($record['created'] ?? '').'</created>'.PHP_EOL;
|
||||
$xml .= $is.$tab.$tab.$tab.$tab.'<modified>'.($record['modified'] ?? '').'</modified>'.PHP_EOL;
|
||||
$xml .= $is.$tab.$tab.$tab.$tab.'<author id="'.$record['author'].'" firstname="'.($record['authorFirstname'] ?? '').'" '.
|
||||
'lastname="'.($record['authorLastname'] ?? '').'"/>'.PHP_EOL;
|
||||
|
||||
$xml .= $is.$tab.$tab.$tab.$tab.'<description>'.($record['description'] ?? '').'</description>'.PHP_EOL;
|
||||
$xml .= $is.$tab.$tab.$tab.$tab.'<status key="'.($record['status'] ?? '').'" />'.PHP_EOL;
|
||||
$xml .= $is.$tab.$tab.$tab.$tab.'<relates key="'.($record['relates'] ?? '').'" />'.PHP_EOL;
|
||||
$xml .= $is.$tab.$tab.$tab.$tab.'<assumptions>'.($record['assumptions'] ?? '').'</assumptions>'.PHP_EOL;
|
||||
$xml .= $is.$tab.$tab.$tab.$tab.'<data>'.($record['data'] ?? '').'</data>'.PHP_EOL;
|
||||
$xml .= $is.$tab.$tab.$tab.$tab.'<conclusion>'.($record['conclusion'] ?? '').'</conclusion>'.PHP_EOL;
|
||||
$xml .= $is.$tab.$tab.$tab.'</item>'.PHP_EOL;
|
||||
}
|
||||
}
|
||||
|
||||
$xml .= $is.$tab.$tab.'</element>'.PHP_EOL;
|
||||
}
|
||||
$xml .= $is.$tab.'</content>'.PHP_EOL;
|
||||
$xml .= $is.'</canvas>'.PHP_EOL;
|
||||
|
||||
return $xml;
|
||||
}
|
||||
}
|
||||
94
app/Domain/Blueprints/Services/TemplateRegistry.php
Normal file
94
app/Domain/Blueprints/Services/TemplateRegistry.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Blueprints\Services;
|
||||
|
||||
use Leantime\Domain\Blueprints\Models\CanvasTemplate;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class TemplateRegistry
|
||||
{
|
||||
/** @var array<string, CanvasTemplate|null> */
|
||||
private array $templates = [];
|
||||
|
||||
private string $definitionsPath;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->definitionsPath = APP_ROOT.'/app/Domain/Blueprints/Templates/definitions';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $slug Canvas type slug (e.g., "swot", "lean")
|
||||
*/
|
||||
public function get(string $slug): ?CanvasTemplate
|
||||
{
|
||||
$slug = strtolower(trim($slug));
|
||||
|
||||
if (array_key_exists($slug, $this->templates)) {
|
||||
return $this->templates[$slug];
|
||||
}
|
||||
|
||||
$path = $this->definitionsPath.'/'.$slug.'.yaml';
|
||||
if (! file_exists($path)) {
|
||||
$this->templates[$slug] = null;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = Yaml::parseFile($path);
|
||||
$template = new CanvasTemplate($data);
|
||||
$this->templates[$slug] = $template;
|
||||
|
||||
return $template;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, CanvasTemplate>
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
$this->loadAll();
|
||||
|
||||
return array_filter($this->templates);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function slugs(): array
|
||||
{
|
||||
return array_keys($this->all());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $dbType Database type value (e.g., "swotcanvas")
|
||||
*/
|
||||
public function getByDatabaseType(string $dbType): ?CanvasTemplate
|
||||
{
|
||||
// Strip a trailing "canvas" suffix only — a naive str_replace would
|
||||
// corrupt any type whose name embeds the word (e.g., "canvassing").
|
||||
// Require something *before* the suffix so the bare "canvas" type
|
||||
// resolves to itself, not an empty slug; -strlen() avoids a brittle -6.
|
||||
$suffix = 'canvas';
|
||||
$slug = str_ends_with($dbType, $suffix) && strlen($dbType) > strlen($suffix)
|
||||
? substr($dbType, 0, -strlen($suffix))
|
||||
: $dbType;
|
||||
|
||||
return $this->get($slug);
|
||||
}
|
||||
|
||||
private function loadAll(): void
|
||||
{
|
||||
$files = glob($this->definitionsPath.'/*.yaml');
|
||||
if ($files === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($files as $file) {
|
||||
$slug = basename($file, '.yaml');
|
||||
if (! array_key_exists($slug, $this->templates)) {
|
||||
$this->get($slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
25
app/Domain/Blueprints/Templates/boardDialog.blade.php
Normal file
25
app/Domain/Blueprints/Templates/boardDialog.blade.php
Normal file
@@ -0,0 +1,25 @@
|
||||
@php
|
||||
$canvasTitle = $canvasTitle ?? '';
|
||||
$canvasSlug = $canvasSlug ?? '';
|
||||
@endphp
|
||||
|
||||
<form action="{{ BASE_URL }}/blueprints/{{ $canvasSlug }}/boardDialog{{ isset($_GET['id']) ? '/' . (int) $_GET['id'] : '' }}" method="post" class="formModal">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title"><i class='fa fa-plus'></i> {!! __('subtitles.create_new_board') !!}</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label>{!! __('label.title_new') !!}</label><br />
|
||||
<x-global::forms.text-input name="canvastitle" value="{{ $canvasTitle }}" placeholder="{{ __('input.placeholders.enter_title_for_board') }}"
|
||||
style="width: 100%" />
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@if(isset($_GET['id']))
|
||||
<input type="hidden" name="editCanvas" value="{{ (int) $_GET['id'] }}">
|
||||
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save_board')" name="editCanvas" />
|
||||
@else
|
||||
<input type="hidden" name="newCanvas" value="true">
|
||||
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.create_board')" name="newCanvas" />
|
||||
@endif
|
||||
<x-global::forms.button inputType="button" contentRole="tertiary" onclick="jQuery.nmTop().close();">{!! __('buttons.close') !!}</x-global::forms.button>
|
||||
</div>
|
||||
</form>
|
||||
54
app/Domain/Blueprints/Templates/canvasComment.blade.php
Normal file
54
app/Domain/Blueprints/Templates/canvasComment.blade.php
Normal file
@@ -0,0 +1,54 @@
|
||||
@php
|
||||
$canvasSlug = $canvasSlug ?? '';
|
||||
$canvasItem = $canvasItem ?? ['id' => '', 'box' => '', 'description' => ''];
|
||||
$canvasTypes = $canvasTypes ?? [];
|
||||
|
||||
$id = '';
|
||||
if (isset($canvasItem['id']) && $canvasItem['id'] != '') {
|
||||
$id = $canvasItem['id'];
|
||||
}
|
||||
@endphp
|
||||
|
||||
<script type="text/javascript">
|
||||
window.onload = function() {
|
||||
if (!window.jQuery) {
|
||||
//It's not a modal
|
||||
location.href="{{ BASE_URL }}/blueprints/{{ $canvasSlug }}/showCanvas?showModal={{ $canvasItem['id'] }}";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="showDialogOnLoad" style="display:none;">
|
||||
|
||||
<h4 class="widgettitle title-light" style="padding-bottom: 0"><i class="fas {{ $canvasTypes[$canvasItem['box']]['icon'] ?? '' }}"></i> {{ $canvasTypes[$canvasItem['box']]['title'] ?? '' }}</h4>
|
||||
<hr style="margin-top: 5px; margin-bottom: 15px;">
|
||||
|
||||
{!! $tpl->displayNotification() !!}
|
||||
|
||||
<h5 style="padding-left: 40px"><strong>{{ $canvasItem['description'] }}</strong></h5>
|
||||
|
||||
@if($id !== '')
|
||||
<br />
|
||||
<input type="hidden" name="comment" value="1" />
|
||||
<h4 class="widgettitle title-light"><span class="fa fa-comments"></span>{!! __('subtitles.discussion') !!}</h4>
|
||||
@include('comments::submodules.generalComment', ['formUrl' => '/blueprints/' . $canvasSlug . '/editCanvasComment/' . $id])
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
jQuery(document).ready(function(){
|
||||
|
||||
if (window.leantime && window.leantime.tiptapController) {
|
||||
leantime.tiptapController.initSimpleEditor();
|
||||
}
|
||||
|
||||
@if(! $login::userIsAtLeast($roles::$editor))
|
||||
leantime.authController.makeInputReadonly(".nyroModalCont");
|
||||
@endif
|
||||
|
||||
@if($login::userHasRole([$roles::$commenter]))
|
||||
leantime.commentsController.enableCommenterForms();
|
||||
@endif
|
||||
|
||||
})
|
||||
</script>
|
||||
229
app/Domain/Blueprints/Templates/canvasDialog.blade.php
Normal file
229
app/Domain/Blueprints/Templates/canvasDialog.blade.php
Normal file
@@ -0,0 +1,229 @@
|
||||
@php
|
||||
$canvasSlug = $canvasSlug ?? '';
|
||||
$currentCanvas = $currentCanvas ?? '';
|
||||
$canvasItem = $canvasItem ?? ['id' => '', 'box' => '', 'description' => '', 'status' => '', 'relates' => '', 'milestoneId' => '', 'milestoneHeadline' => ''];
|
||||
$canvasTypes = $canvasTypes ?? [];
|
||||
$hiddenStatusLabels = $statusLabels ?? [];
|
||||
$statusLabels = $statusLabels ?? [];
|
||||
$hiddenRelatesLabels = $relatesLabels ?? [];
|
||||
$relatesLabels = $relatesLabels ?? [];
|
||||
$dataLabels = $dataLabels ?? [1 => ['active' => false, 'field' => '', 'title' => ''], 2 => ['active' => false, 'field' => '', 'title' => ''], 3 => ['active' => false, 'field' => '', 'title' => '']];
|
||||
$milestones = $milestones ?? [];
|
||||
$users = $users ?? [];
|
||||
$searchCriteria = $searchCriteria ?? [];
|
||||
|
||||
$id = '';
|
||||
if (isset($canvasItem['id']) && $canvasItem['id'] != '') {
|
||||
$id = $canvasItem['id'];
|
||||
}
|
||||
|
||||
$boxMeta = $canvasTypes[$canvasItem['box']] ?? ['icon' => '', 'title' => ''];
|
||||
@endphp
|
||||
|
||||
<script type="text/javascript">
|
||||
window.onload = function() {
|
||||
if (!window.jQuery) {
|
||||
//It's not a modal
|
||||
location.href="{{ BASE_URL }}/blueprints/{{ $canvasSlug }}/showCanvas?showModal={{ $canvasItem['id'] }}";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="" style="width:900px;">
|
||||
|
||||
<h4 class="widgettitle title-light" style="padding-bottom: 0"><i class="fas {{ $boxMeta['icon'] }}"></i> {{ $boxMeta['title'] }}</h4>
|
||||
<hr style="margin-top: 5px; margin-bottom: 15px;">
|
||||
{!! $tpl->displayNotification() !!}
|
||||
|
||||
<form class="formModal" method="post" action="{{ BASE_URL }}/blueprints/{{ $canvasSlug }}/editCanvasItem/{{ $id }}">
|
||||
|
||||
<input type="hidden" value="{{ $currentCanvas }}" name="canvasId" />
|
||||
<input type="hidden" value="{{ $canvasItem['box'] }}" name="box" id="box"/>
|
||||
<input type="hidden" value="{{ $id }}" name="itemId" id="itemId"/>
|
||||
|
||||
<label>{!! __('label.description') !!}</label>
|
||||
<x-global::forms.text-input name="description" value="{{ $canvasItem['description'] }}" style="width:100%" /><br />
|
||||
|
||||
@if(! empty($statusLabels))
|
||||
<label>{!! __('label.status') !!}</label>
|
||||
<select name="status" style="width: 50%" id="statusCanvas">
|
||||
</select><br /><br />
|
||||
@else
|
||||
<input type="hidden" name="status" value="{{ $canvasItem['status'] ?? array_key_first($hiddenStatusLabels) }}" />
|
||||
@endif
|
||||
|
||||
@if(! empty($relatesLabels))
|
||||
<label>{!! __('label.relates') !!}</label>
|
||||
<select name="relates" style="width: 50%" id="relatesCanvas">
|
||||
</select><br />
|
||||
@else
|
||||
<input type="hidden" name="relates" value="{{ $canvasItem['relates'] ?? array_key_first($hiddenRelatesLabels) }}" />
|
||||
@endif
|
||||
|
||||
@if($dataLabels[1]['active'])
|
||||
<label>{!! __($dataLabels[1]['title']) !!}</label>
|
||||
@if(isset($dataLabels[1]['type']) && $dataLabels[1]['type'] == 'int')
|
||||
<x-global::forms.text-input type="number" name="{{ $dataLabels[1]['field'] }}" value="{{ $canvasItem[$dataLabels[1]['field']] }}"/><br />
|
||||
@elseif(isset($dataLabels[1]['type']) && $dataLabels[1]['type'] == 'string')
|
||||
<x-global::forms.text-input name="{{ $dataLabels[1]['field'] }}" value="{{ $canvasItem[$dataLabels[1]['field']] }}" style="width:100%"/><br />
|
||||
@else
|
||||
<textarea style="width:100%" rows="3" cols="10" name="{{ $dataLabels[1]['field'] }}" class="modalTextArea tiptapSimple">{{ $canvasItem[$dataLabels[1]['field']] }}</textarea><br />
|
||||
@endif
|
||||
@else
|
||||
<input type="hidden" name="{{ $dataLabels[1]['field'] }}" value="" />
|
||||
@endif
|
||||
|
||||
@if($dataLabels[2]['active'])
|
||||
<label>{!! __($dataLabels[2]['title']) !!}</label>
|
||||
@if(isset($dataLabels[2]['type']) && $dataLabels[2]['type'] == 'int')
|
||||
<x-global::forms.text-input type="number" name="{{ $dataLabels[2]['field'] }}" value="{{ $canvasItem[$dataLabels[2]['field']] }}"/><br />
|
||||
@elseif(isset($dataLabels[2]['type']) && $dataLabels[2]['type'] == 'string')
|
||||
<x-global::forms.text-input name="{{ $dataLabels[2]['field'] }}" value="{{ $canvasItem[$dataLabels[2]['field']] }}" style="width:100%"/><br />
|
||||
@else
|
||||
<textarea style="width:100%" rows="3" cols="10" name="{{ $dataLabels[2]['field'] }}" class="modalTextArea tiptapSimple">{{ $canvasItem[$dataLabels[2]['field']] }}</textarea><br />
|
||||
@endif
|
||||
@else
|
||||
<input type="hidden" name="{{ $dataLabels[2]['field'] }}" value="" />
|
||||
@endif
|
||||
|
||||
@if($dataLabels[3]['active'])
|
||||
<label>{!! __($dataLabels[3]['title']) !!}</label>
|
||||
@if(isset($dataLabels[3]['type']) && $dataLabels[3]['type'] == 'int')
|
||||
<x-global::forms.text-input type="number" name="{{ $dataLabels[3]['field'] }}" value="{{ $canvasItem[$dataLabels[3]['field']] }}"/><br />
|
||||
@elseif(isset($dataLabels[3]['type']) && $dataLabels[3]['type'] == 'string')
|
||||
<x-global::forms.text-input name="{{ $dataLabels[3]['field'] }}" value="{{ $canvasItem[$dataLabels[3]['field']] }}"/><br />
|
||||
@else
|
||||
<textarea style="width:100%" rows="3" cols="10" name="{{ $dataLabels[3]['field'] }}" class="modalTextArea tiptapSimple">{{ $canvasItem[$dataLabels[3]['field']] }}</textarea><br />
|
||||
@endif
|
||||
@else
|
||||
<input type="hidden" name="{{ $dataLabels[3]['field'] }}" value="" />
|
||||
@endif
|
||||
|
||||
<input type="hidden" name="milestoneId" value="{{ $canvasItem['milestoneId'] }}" />
|
||||
<input type="hidden" name="changeItem" value="1" />
|
||||
|
||||
@if($id != '')
|
||||
<x-global::forms.button tag="a" link="{{ BASE_URL }}/blueprints/{{ $canvasSlug }}/delCanvasItem/{{ $id }}" class="blueprintsCanvasModal delete right" state="danger" variant="outline"><i class='fa fa-trash-can'></i> {!! __('links.delete') !!}</x-global::forms.button>
|
||||
@endif
|
||||
|
||||
@if($login::userIsAtLeast($roles::$editor))
|
||||
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" id="primaryCanvasSubmitButton" />
|
||||
<x-global::forms.button inputType="submit" contentRole="secondary" value="closeModal" id="saveAndClose" onclick="leantime.blueprintsController.setCloseModal();">{!! __('buttons.save_and_close') !!}</x-global::forms.button>
|
||||
@endif
|
||||
|
||||
@if($id !== '')
|
||||
<br /><br />
|
||||
<h4 class="widgettitle title-light"><span class="fa fa-link"></span> {!! __('headlines.linked_milestone') !!} <i class="fa fa-question-circle-o helperTooltip" data-tippy-content="{{ __('tooltip.link_milestones_tooltip') }}"></i></h4>
|
||||
|
||||
@if($canvasItem['milestoneId'] == '')
|
||||
<center>
|
||||
<h4>{!! __('headlines.no_milestone_link') !!}</h4>
|
||||
|
||||
<div class="row" id="milestoneSelectors">
|
||||
@if($login::userIsAtLeast($roles::$editor))
|
||||
<div class="col-md-12">
|
||||
<a href="javascript:void(0);" onclick="leantime.blueprintsController.toggleMilestoneSelectors('new');">{!! __('links.create_link_milestone') !!}</a>
|
||||
@if(count($milestones) > 0)
|
||||
| <a href="javascript:void(0);" onclick="leantime.blueprintsController.toggleMilestoneSelectors('existing');">{!! __('links.link_existing_milestone') !!}</a>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="row" id="newMilestone" style="display:none;">
|
||||
<div class="col-md-12">
|
||||
<x-global::forms.text-input width="50%" name="newMilestone" /><br />
|
||||
<input type="hidden" name="type" value="milestone" />
|
||||
<input type="hidden" name="blueprintscanvasitemid" value="{{ $id }} " />
|
||||
<x-global::forms.button tag="input" inputType="button" :labelText="__('buttons.save')" onclick="jQuery('#primaryCanvasSubmitButton').click()" contentRole="primary" />
|
||||
<x-global::forms.button tag="input" inputType="button" :labelText="__('buttons.cancel')" onclick="leantime.blueprintsController.toggleMilestoneSelectors('hide')" contentRole="tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" id="existingMilestone" style="display:none;">
|
||||
<div class="col-md-12">
|
||||
<select data-placeholder="{{ __('input.placeholders.filter_by_milestone') }}" name="existingMilestone" class="user-select">
|
||||
<option value=""></option>
|
||||
@foreach($milestones as $milestoneRow)
|
||||
<option value="{{ $milestoneRow->id }}"
|
||||
@if(isset($searchCriteria['milestone']) && $searchCriteria['milestone'] == $milestoneRow->id) selected='selected' @endif
|
||||
>{{ $milestoneRow->headline }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<input type="hidden" name="type" value="milestone" />
|
||||
<input type="hidden" name="blueprintscanvasitemid" value="{{ $id }} " />
|
||||
<x-global::forms.button tag="input" inputType="button" :labelText="__('buttons.save')" onclick="jQuery('#primaryCanvasSubmitButton').click()" contentRole="primary" />
|
||||
<x-global::forms.button tag="input" inputType="button" :labelText="__('buttons.cancel')" onclick="leantime.blueprintsController.toggleMilestoneSelectors('hide')" contentRole="tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
</center>
|
||||
@else
|
||||
<div hx-trigger="load"
|
||||
hx-indicator=".htmx-indicator"
|
||||
hx-get="{{ BASE_URL }}/hx/tickets/milestones/showCard?milestoneId={{ $canvasItem['milestoneId'] }}">
|
||||
<div class="htmx-indicator">
|
||||
{!! __('label.loading_milestone') !!}
|
||||
</div>
|
||||
</div>
|
||||
<x-global::forms.button tag="a" link="{{ CURRENT_URL }}?removeMilestone={{ $canvasItem['milestoneId'] }}" class="blueprintsCanvasModal delete formModal" state="danger" variant="outline"><i class="fa fa-close"></i> {!! __('links.remove') !!}</x-global::forms.button>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
</form>
|
||||
|
||||
@if($id !== '')
|
||||
<br />
|
||||
<input type="hidden" name="comment" value="1" />
|
||||
<h4 class="widgettitle title-light"><span class="fa fa-comments"></span>{!! __('subtitles.discussion') !!}</h4>
|
||||
@include('comments::submodules.generalComment', ['formUrl' => '/blueprints/' . $canvasSlug . '/editCanvasItem/' . $id])
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
jQuery(document).ready(function(){
|
||||
|
||||
@if(! empty($statusLabels))
|
||||
new SlimSelect({
|
||||
select: '#statusCanvas',
|
||||
showSearch: false,
|
||||
valuesUseText: false,
|
||||
data: [
|
||||
@foreach($statusLabels as $key => $data)
|
||||
@if($data['active'])
|
||||
{ innerHTML: '<i class="fas fa-fw {{ $data['icon'] }}"></i> {{ $data['title'] }}',
|
||||
text: "{{ $data['title'] }}", value: "{{ $key }}", selected: {{ $canvasItem['status'] == $key ? 'true' : 'false' }}},
|
||||
@endif
|
||||
@endforeach
|
||||
]
|
||||
});
|
||||
@endif
|
||||
|
||||
@if(! empty($relatesLabels))
|
||||
new SlimSelect({
|
||||
select: '#relatesCanvas',
|
||||
showSearch: false,
|
||||
valuesUseText: false,
|
||||
data: [
|
||||
@foreach($relatesLabels as $key => $data)
|
||||
@if($data['active'])
|
||||
{ innerHTML: '<i class="fas fa-fw {{ $data['icon'] }}"></i> {{ $data['title'] }}',
|
||||
text: "{{ $data['title'] }}", value: "{{ $key }}", selected: {{ $canvasItem['relates'] == $key ? 'true' : 'false' }}},
|
||||
@endif
|
||||
@endforeach
|
||||
]
|
||||
});
|
||||
@endif
|
||||
|
||||
if (window.leantime && window.leantime.tiptapController) {
|
||||
leantime.tiptapController.initSimpleEditor();
|
||||
}
|
||||
|
||||
@if(! $login::userIsAtLeast($roles::$editor))
|
||||
leantime.authController.makeInputReadonly(".nyroModalCont");
|
||||
@endif
|
||||
|
||||
@if($login::userHasRole([$roles::$commenter]))
|
||||
leantime.commentsController.enableCommenterForms();
|
||||
@endif
|
||||
|
||||
})
|
||||
</script>
|
||||
89
app/Domain/Blueprints/Templates/definitions/cp.yaml
Normal file
89
app/Domain/Blueprints/Templates/definitions/cp.yaml
Normal file
@@ -0,0 +1,89 @@
|
||||
slug: "cp"
|
||||
icon: "fa-city"
|
||||
disclaimer: "text.cp.disclaimer"
|
||||
minColumns: 7
|
||||
|
||||
boxes:
|
||||
cp_cj_rv:
|
||||
icon: "fa-money-bills"
|
||||
title: "box.cp.cj_rv"
|
||||
cp_cj_rc:
|
||||
icon: "fa-hand-holding-dollar"
|
||||
title: "box.cp.cj_rc"
|
||||
cp_cj_e:
|
||||
icon: "fa-thumbs-up"
|
||||
title: "box.cp.cj_e"
|
||||
cp_ou_rv:
|
||||
icon: "fa-money-bills"
|
||||
title: "box.cp.ou_rv"
|
||||
cp_ou_rc:
|
||||
icon: "fa-hand-holding-dollar"
|
||||
title: "box.cp.ou_rc"
|
||||
cp_ou_e:
|
||||
icon: "fa-thumbs-up"
|
||||
title: "box.cp.ou_e"
|
||||
cp_os_rv:
|
||||
icon: "fa-money-bills"
|
||||
title: "box.cp.os_rv"
|
||||
cp_os_rc:
|
||||
icon: "fa-hand-holding-dollar"
|
||||
title: "box.cp.os_rc"
|
||||
cp_os_e:
|
||||
icon: "fa-thumbs-up"
|
||||
title: "box.cp.os_e"
|
||||
cp_oi_rv:
|
||||
icon: "fa-money-bills"
|
||||
title: "box.cp.oi_rv"
|
||||
cp_oi_rc:
|
||||
icon: "fa-hand-holding-dollar"
|
||||
title: "box.cp.oi_rc"
|
||||
cp_oi_e:
|
||||
icon: "fa-thumbs-up"
|
||||
title: "box.cp.oi_e"
|
||||
|
||||
statusLabels: default
|
||||
relatesLabels: {}
|
||||
|
||||
layout:
|
||||
- type: header
|
||||
columns:
|
||||
- { width: 16, empty: true }
|
||||
- { width: 84, header: { icon: "fa fa-user-doctor", title: "box.header.cp.cj" } }
|
||||
- type: boxes
|
||||
id: firstRow
|
||||
columns:
|
||||
- { width: 16, label: "box.label.cp.need" }
|
||||
- { width: 28, box: "cp_cj_rv" }
|
||||
- { width: 28, box: "cp_cj_rc" }
|
||||
- { width: 28, box: "cp_cj_e" }
|
||||
- type: separator
|
||||
columns:
|
||||
- { width: 16, empty: true }
|
||||
- { width: 28, icon: "fa fa-arrows-up-down" }
|
||||
- { width: 28, icon: "fa fa-arrows-up-down" }
|
||||
- { width: 28, icon: "fa fa-arrows-up-down" }
|
||||
- type: header
|
||||
columns:
|
||||
- { width: 16, empty: true }
|
||||
- { width: 84, header: { icon: "fa fa-barcode", title: "box.header.cp.ovp" } }
|
||||
- type: boxes
|
||||
id: secondRow
|
||||
columns:
|
||||
- { width: 16, label: "box.label.cp.unique" }
|
||||
- { width: 28, box: "cp_ou_rv" }
|
||||
- { width: 28, box: "cp_ou_rc" }
|
||||
- { width: 28, box: "cp_ou_e" }
|
||||
- type: boxes
|
||||
id: thirdRow
|
||||
columns:
|
||||
- { width: 16, label: "box.label.cp.superior" }
|
||||
- { width: 28, box: "cp_os_rv" }
|
||||
- { width: 28, box: "cp_os_rc" }
|
||||
- { width: 28, box: "cp_os_e" }
|
||||
- type: boxes
|
||||
id: fourthRow
|
||||
columns:
|
||||
- { width: 16, label: "box.label.cp.indifferent" }
|
||||
- { width: 28, box: "cp_oi_rv" }
|
||||
- { width: 28, box: "cp_oi_rc" }
|
||||
- { width: 28, box: "cp_oi_e" }
|
||||
101
app/Domain/Blueprints/Templates/definitions/dbm.yaml
Normal file
101
app/Domain/Blueprints/Templates/definitions/dbm.yaml
Normal file
@@ -0,0 +1,101 @@
|
||||
slug: "dbm"
|
||||
icon: "fa-building"
|
||||
disclaimer: "text.dbm.disclaimer"
|
||||
minColumns: 8
|
||||
|
||||
boxes:
|
||||
dbm_cs:
|
||||
icon: "fa-users"
|
||||
color: "#ccffcc"
|
||||
title: "box.dbm.cs"
|
||||
dbm_cj:
|
||||
icon: "fa-user-doctor"
|
||||
color: "#ccffcc"
|
||||
title: "box.dbm.cj"
|
||||
dbm_cr:
|
||||
icon: "fa-heart"
|
||||
color: "#ccffcc"
|
||||
title: "box.dbm.cr"
|
||||
dbm_cd:
|
||||
icon: "fa-truck"
|
||||
color: "#ccffcc"
|
||||
title: "box.dbm.cd"
|
||||
dbm_ovp:
|
||||
icon: "fa-money-bill-transfer"
|
||||
color: "#ffcccc"
|
||||
title: "box.dbm.ovp"
|
||||
dbm_ops:
|
||||
icon: "fa-barcode"
|
||||
color: "#ffcccc"
|
||||
title: "box.dbm.ops"
|
||||
dbm_kad:
|
||||
icon: "fa-chess"
|
||||
color: "#ccecff"
|
||||
title: "box.dbm.kad"
|
||||
dbm_kac:
|
||||
icon: "fa-hand-holding-dollar"
|
||||
color: "#ccecff"
|
||||
title: "box.dbm.kac"
|
||||
dbm_kao:
|
||||
icon: "fa-handshake"
|
||||
color: "#ccecff"
|
||||
title: "box.dbm.kao"
|
||||
dbm_krp:
|
||||
icon: "fa-apple-whole"
|
||||
color: "#ccecff"
|
||||
title: "box.dbm.krp"
|
||||
dbm_krc:
|
||||
icon: "fa-industry"
|
||||
color: "#ccecff"
|
||||
title: "box.dbm.krc"
|
||||
dbm_krl:
|
||||
icon: "fa-person-digging"
|
||||
color: "#ccecff"
|
||||
title: "box.dbm.krl"
|
||||
dbm_krs:
|
||||
icon: "fa-lightbulb"
|
||||
color: "#ccecff"
|
||||
title: "box.dbm.krs"
|
||||
dbm_fr:
|
||||
icon: "fa-sack-dollar"
|
||||
color: "#ffffaa"
|
||||
title: "box.dbm.fr"
|
||||
dbm_fc:
|
||||
icon: "fa-tags"
|
||||
color: "#ffffaa"
|
||||
title: "box.dbm.fc"
|
||||
|
||||
statusLabels: default
|
||||
relatesLabels: {}
|
||||
|
||||
layout:
|
||||
- type: boxes
|
||||
id: firstRow
|
||||
columns:
|
||||
- { width: 20, box: "dbm_cs" }
|
||||
- { width: 20, box: "dbm_cr" }
|
||||
- { width: 20, box: "dbm_ovp" }
|
||||
- { width: 13.33, box: "dbm_kad" }
|
||||
- { width: 13.33, box: "dbm_kac" }
|
||||
- { width: 13.33, box: "dbm_kao" }
|
||||
- type: boxes
|
||||
id: secondRow
|
||||
columns:
|
||||
- { width: 20, box: "dbm_cj" }
|
||||
- { width: 20, box: "dbm_cd" }
|
||||
- { width: 20, box: "dbm_ops" }
|
||||
- { width: 40, nested: true, rows: [
|
||||
{ id: "secondRowTop", columns: [
|
||||
{ width: 50, box: "dbm_krp" },
|
||||
{ width: 50, box: "dbm_krc" }
|
||||
]},
|
||||
{ id: "secondRowBottom", columns: [
|
||||
{ width: 50, box: "dbm_krl" },
|
||||
{ width: 50, box: "dbm_krs" }
|
||||
]}
|
||||
]}
|
||||
- type: boxes
|
||||
id: thirdRow
|
||||
columns:
|
||||
- { width: 50, box: "dbm_fr" }
|
||||
- { width: 50, box: "dbm_fc" }
|
||||
95
app/Domain/Blueprints/Templates/definitions/ea.yaml
Normal file
95
app/Domain/Blueprints/Templates/definitions/ea.yaml
Normal file
@@ -0,0 +1,95 @@
|
||||
slug: "ea"
|
||||
icon: "fa-seedling"
|
||||
disclaimer: ""
|
||||
minColumns: 4
|
||||
|
||||
boxes:
|
||||
ea_political:
|
||||
icon: "fa-landmark"
|
||||
title: "box.ea.political"
|
||||
ea_economic:
|
||||
icon: "fa-chart-line"
|
||||
title: "box.ea.economic"
|
||||
ea_societal:
|
||||
icon: "fa-people-arrows"
|
||||
title: "box.ea.societal"
|
||||
ea_technological:
|
||||
icon: "fa-computer"
|
||||
title: "box.ea.technological"
|
||||
ea_legal:
|
||||
icon: "fa-scale-balanced"
|
||||
title: "box.ea.legal"
|
||||
ea_ecological:
|
||||
icon: "fa-cloud-sun"
|
||||
title: "box.ea.ecological"
|
||||
|
||||
statusLabels:
|
||||
status_observation:
|
||||
icon: "fa-tower-observation"
|
||||
color: "blue"
|
||||
title: "status.ea.observation"
|
||||
dropdown: "info"
|
||||
active: true
|
||||
status_threat:
|
||||
icon: "fa-cloud-bolt"
|
||||
color: "red"
|
||||
title: "status.ea.threat"
|
||||
dropdown: "danger"
|
||||
active: true
|
||||
status_trend:
|
||||
icon: "fa-arrow-trend-up"
|
||||
color: "lightgreen"
|
||||
title: "status.ea.trend"
|
||||
dropdown: "success"
|
||||
active: true
|
||||
|
||||
relatesLabels:
|
||||
relates_none:
|
||||
icon: "fa-border-none"
|
||||
color: "grey"
|
||||
title: "relates.none"
|
||||
dropdown: "default"
|
||||
active: true
|
||||
relates_customers:
|
||||
icon: "fa-users"
|
||||
color: "green"
|
||||
title: "relates.customers"
|
||||
dropdown: "success"
|
||||
active: true
|
||||
relates_offerings:
|
||||
icon: "fa-barcode"
|
||||
color: "red"
|
||||
title: "relates.offerings"
|
||||
dropdown: "danger"
|
||||
active: true
|
||||
relates_markets:
|
||||
icon: "fa-shop"
|
||||
color: "brown"
|
||||
title: "relates.markets"
|
||||
dropdown: "default"
|
||||
active: true
|
||||
relates_stakeholders:
|
||||
icon: "fa-handshake"
|
||||
color: "orange"
|
||||
title: "relates.stakeholders"
|
||||
dropdown: "warning"
|
||||
active: true
|
||||
|
||||
dataLabels:
|
||||
1: { title: "label.description", field: "conclusion", active: true }
|
||||
2: { title: "label.data", field: "data", active: false }
|
||||
3: { title: "label.assumption", field: "assumptions", active: false }
|
||||
|
||||
layout:
|
||||
- type: boxes
|
||||
id: firstRow
|
||||
columns:
|
||||
- { width: 33.33, box: "ea_political" }
|
||||
- { width: 33.33, box: "ea_economic" }
|
||||
- { width: 33.33, box: "ea_societal" }
|
||||
- type: boxes
|
||||
id: secondRow
|
||||
columns:
|
||||
- { width: 33.33, box: "ea_technological" }
|
||||
- { width: 33.33, box: "ea_legal" }
|
||||
- { width: 33.33, box: "ea_ecological" }
|
||||
73
app/Domain/Blueprints/Templates/definitions/em.yaml
Normal file
73
app/Domain/Blueprints/Templates/definitions/em.yaml
Normal file
@@ -0,0 +1,73 @@
|
||||
slug: "em"
|
||||
icon: "fa-heart"
|
||||
disclaimer: "text.em.disclaimer"
|
||||
minColumns: 4
|
||||
|
||||
boxes:
|
||||
em_who:
|
||||
icon: "fa-1"
|
||||
title: "box.em.who"
|
||||
em_what:
|
||||
icon: "fa-2"
|
||||
title: "box.em.what"
|
||||
em_see:
|
||||
icon: "fa-3"
|
||||
title: "box.em.see"
|
||||
em_say:
|
||||
icon: "fa-4"
|
||||
title: "box.em.say"
|
||||
em_do:
|
||||
icon: "fa-5"
|
||||
title: "box.em.do"
|
||||
em_hear:
|
||||
icon: "fa-6"
|
||||
title: "box.em.hear"
|
||||
em_pains:
|
||||
icon: "fa-face-frown"
|
||||
title: "box.em.pains"
|
||||
em_gains:
|
||||
icon: "fa-face-smile"
|
||||
title: "box.em.gains"
|
||||
em_motives:
|
||||
icon: "fa-face-rolling-eyes"
|
||||
title: "box.em.motives"
|
||||
|
||||
statusLabels: default
|
||||
relatesLabels: {}
|
||||
|
||||
dataLabels:
|
||||
1: { title: "label.em.description", field: "conclusion", active: true }
|
||||
2: { title: "label.data", field: "data", active: false }
|
||||
3: { title: "label.conclusion", field: "assumptions", active: false }
|
||||
|
||||
layout:
|
||||
- type: header
|
||||
columns:
|
||||
- { width: 100, header: { icon: "fas fa-bullseye", title: "box.em.header.goal" } }
|
||||
- type: boxes
|
||||
id: firstRow
|
||||
columns:
|
||||
- { width: 50, box: "em_who" }
|
||||
- { width: 50, box: "em_what" }
|
||||
- type: header
|
||||
columns:
|
||||
- { width: 100, header: { icon: "fas fa-heart", title: "box.em.header.empathy" } }
|
||||
- type: boxes
|
||||
id: secondRow
|
||||
columns:
|
||||
- { width: 25, box: "em_see" }
|
||||
- { width: 25, box: "em_say" }
|
||||
- { width: 25, box: "em_do" }
|
||||
- { width: 25, box: "em_hear" }
|
||||
- type: header
|
||||
columns:
|
||||
- { width: 100, header: { icon: "fas fa-7", title: "box.em.header.think_feel" } }
|
||||
- type: boxes
|
||||
id: thirdRow
|
||||
columns:
|
||||
- { width: 50, box: "em_pains" }
|
||||
- { width: 50, box: "em_gains" }
|
||||
- type: boxes
|
||||
id: fourthRow
|
||||
columns:
|
||||
- { width: 100, box: "em_motives" }
|
||||
40
app/Domain/Blueprints/Templates/definitions/insights.yaml
Normal file
40
app/Domain/Blueprints/Templates/definitions/insights.yaml
Normal file
@@ -0,0 +1,40 @@
|
||||
slug: "insights"
|
||||
icon: "fa-note-sticky"
|
||||
disclaimer: ""
|
||||
minColumns: 5
|
||||
|
||||
boxes:
|
||||
insights_oberve:
|
||||
icon: "fa-tower-observation"
|
||||
title: "box.insights.observe"
|
||||
insights_interview:
|
||||
icon: "fa-people-arrows"
|
||||
title: "box.insights.interview"
|
||||
insights_focus_groups:
|
||||
icon: "fa-people-line"
|
||||
title: "box.insights.focus_groups"
|
||||
insights_secondary_research:
|
||||
icon: "fa-book"
|
||||
title: "box.insights.secondary_research"
|
||||
insights_knowledge:
|
||||
icon: "fa-file-signature"
|
||||
title: "box.insights.knowledge"
|
||||
color: "#e3e3e3"
|
||||
|
||||
statusLabels: default
|
||||
relatesLabels: default
|
||||
|
||||
dataLabels:
|
||||
1: { title: "label.insights.insight", field: "conclusion", active: true }
|
||||
2: { title: "label.insights.data", field: "data", active: true }
|
||||
3: { title: "", field: "assumptions", active: false }
|
||||
|
||||
layout:
|
||||
- type: boxes
|
||||
id: firstRow
|
||||
columns:
|
||||
- { width: 20, box: "insights_oberve" }
|
||||
- { width: 20, box: "insights_interview" }
|
||||
- { width: 20, box: "insights_focus_groups" }
|
||||
- { width: 20, box: "insights_secondary_research" }
|
||||
- { width: 20, box: "insights_knowledge" }
|
||||
37
app/Domain/Blueprints/Templates/definitions/lbm.yaml
Normal file
37
app/Domain/Blueprints/Templates/definitions/lbm.yaml
Normal file
@@ -0,0 +1,37 @@
|
||||
slug: "lbm"
|
||||
icon: "fa-building"
|
||||
disclaimer: "text.lbm.disclaimer"
|
||||
minColumns: 3
|
||||
|
||||
boxes:
|
||||
lbm_customers:
|
||||
icon: "fa-users"
|
||||
color: "#ccffcc"
|
||||
title: "box.lbm.customers"
|
||||
lbm_offerings:
|
||||
icon: "fa-barcode"
|
||||
color: "#ffcccc"
|
||||
title: "box.lbm.offerings"
|
||||
lbm_capabilities:
|
||||
icon: "fa-pen-ruler"
|
||||
color: "#ccecff"
|
||||
title: "box.lbm.capabilities"
|
||||
lbm_financials:
|
||||
icon: "fa-money-bill"
|
||||
color: "#ffffaa"
|
||||
title: "box.lbm.financials"
|
||||
|
||||
statusLabels: default
|
||||
relatesLabels: {}
|
||||
|
||||
layout:
|
||||
- type: boxes
|
||||
id: firstRow
|
||||
columns:
|
||||
- { width: 33.33, box: "lbm_customers" }
|
||||
- { width: 33.33, box: "lbm_offerings" }
|
||||
- { width: 33.33, box: "lbm_capabilities" }
|
||||
- type: boxes
|
||||
id: secondRow
|
||||
columns:
|
||||
- { width: 100, box: "lbm_financials" }
|
||||
68
app/Domain/Blueprints/Templates/definitions/lean.yaml
Normal file
68
app/Domain/Blueprints/Templates/definitions/lean.yaml
Normal file
@@ -0,0 +1,68 @@
|
||||
slug: "lean"
|
||||
icon: "fa-flask"
|
||||
disclaimer: "text.lean.disclaimer"
|
||||
minColumns: 5
|
||||
|
||||
boxes:
|
||||
problem:
|
||||
icon: "fa-lock"
|
||||
title: "box.lean.problem"
|
||||
alternatives:
|
||||
icon: "fa-arrow-down-up-across-line"
|
||||
title: "box.lean.alternatives"
|
||||
solution:
|
||||
icon: "fa-key"
|
||||
title: "box.lean.solution"
|
||||
keymetrics:
|
||||
icon: "fa-chart-column"
|
||||
title: "box.lean.keymetrics"
|
||||
uniquevalue:
|
||||
icon: "fa-gift"
|
||||
title: "box.lean.uniquevalue"
|
||||
highlevelconcept:
|
||||
icon: "fa-wand-magic-sparkles"
|
||||
title: "box.lean.highlevelconcept"
|
||||
unfairadvantage:
|
||||
icon: "fa-person-running"
|
||||
title: "box.lean.unfairadvantage"
|
||||
channels:
|
||||
icon: "fa-truck"
|
||||
title: "box.lean.channels"
|
||||
customersegment:
|
||||
icon: "fa-user"
|
||||
title: "box.lean.customersegment"
|
||||
earlyadopters:
|
||||
icon: "fa-chart-pie"
|
||||
title: "box.lean.earlyadopters"
|
||||
cost:
|
||||
icon: "fa-file-invoice-dollar"
|
||||
title: "box.lean.cost"
|
||||
revenue:
|
||||
icon: "fa-sack-dollar"
|
||||
title: "box.lean.revenue"
|
||||
|
||||
statusLabels: default
|
||||
relatesLabels: {}
|
||||
|
||||
layout:
|
||||
- type: boxes
|
||||
id: firstRow
|
||||
columns:
|
||||
- { width: 20, box: "problem" }
|
||||
- { width: 20, box: "solution" }
|
||||
- { width: 20, box: "uniquevalue" }
|
||||
- { width: 20, box: "unfairadvantage" }
|
||||
- { width: 20, box: "customersegment" }
|
||||
- type: boxes
|
||||
id: secondRow
|
||||
columns:
|
||||
- { width: 20, box: "alternatives" }
|
||||
- { width: 20, box: "keymetrics" }
|
||||
- { width: 20, box: "highlevelconcept" }
|
||||
- { width: 20, box: "channels" }
|
||||
- { width: 20, box: "earlyadopters" }
|
||||
- type: boxes
|
||||
id: thirdRow
|
||||
columns:
|
||||
- { width: 50, box: "cost" }
|
||||
- { width: 50, box: "revenue" }
|
||||
47
app/Domain/Blueprints/Templates/definitions/minempathy.yaml
Normal file
47
app/Domain/Blueprints/Templates/definitions/minempathy.yaml
Normal file
@@ -0,0 +1,47 @@
|
||||
slug: "minempathy"
|
||||
icon: "fa-solid fa-heart-circle-check"
|
||||
disclaimer: ""
|
||||
minColumns: 2
|
||||
|
||||
boxes:
|
||||
minempathy_who:
|
||||
icon: ""
|
||||
title: "box.minempathy.who"
|
||||
minempathy_struggles:
|
||||
icon: ""
|
||||
title: "box.minempathy.struggles"
|
||||
minempathy_where:
|
||||
icon: ""
|
||||
title: "box.minempathy.where"
|
||||
minempathy_why:
|
||||
icon: ""
|
||||
title: "box.minempathy.why"
|
||||
minempathy_how:
|
||||
icon: ""
|
||||
title: "box.minempathy.how"
|
||||
|
||||
statusLabels: default
|
||||
relatesLabels: {}
|
||||
|
||||
dataLabels:
|
||||
1: { title: "label.description", field: "conclusion", active: true }
|
||||
2: { title: "label.data", field: "data", active: true }
|
||||
3: { title: "label.assumptions", field: "assumptions", active: true }
|
||||
|
||||
layout:
|
||||
- type: boxes
|
||||
id: firstRow
|
||||
columns:
|
||||
- { width: 50, box: "minempathy_who" }
|
||||
- { width: 50, box: "minempathy_struggles" }
|
||||
- type: boxes
|
||||
id: secondRow
|
||||
columns:
|
||||
- { width: 25, empty: true }
|
||||
- { width: 50, box: "minempathy_where" }
|
||||
- { width: 25, empty: true }
|
||||
- type: boxes
|
||||
id: thirdRow
|
||||
columns:
|
||||
- { width: 50, box: "minempathy_why" }
|
||||
- { width: 50, box: "minempathy_how" }
|
||||
58
app/Domain/Blueprints/Templates/definitions/obm.yaml
Normal file
58
app/Domain/Blueprints/Templates/definitions/obm.yaml
Normal file
@@ -0,0 +1,58 @@
|
||||
slug: "obm"
|
||||
icon: "fa-object-group"
|
||||
disclaimer: "text.obm.disclaimer"
|
||||
minColumns: 5
|
||||
minWidthOffset: 50
|
||||
|
||||
boxes:
|
||||
obm_kp:
|
||||
icon: "fa-ring"
|
||||
title: "box.obm.kp"
|
||||
obm_kr:
|
||||
icon: "fa-hammer"
|
||||
title: "box.obm.kr"
|
||||
obm_ka:
|
||||
icon: "fa-person-digging"
|
||||
title: "box.obm.ka"
|
||||
obm_vp:
|
||||
icon: "fa-gift"
|
||||
title: "box.obm.vp"
|
||||
obm_ch:
|
||||
icon: "fa-truck"
|
||||
title: "box.obm.ch"
|
||||
obm_cr:
|
||||
icon: "fa-heart"
|
||||
title: "box.obm.cr"
|
||||
obm_cs:
|
||||
icon: "fa-person"
|
||||
title: "box.obm.cs"
|
||||
obm_fc:
|
||||
icon: "fa-file-invoice-dollar"
|
||||
title: "box.obm.fc"
|
||||
obm_fr:
|
||||
icon: "fa-cash-register"
|
||||
title: "box.obm.fr"
|
||||
|
||||
statusLabels: default
|
||||
relatesLabels: {}
|
||||
|
||||
layout:
|
||||
- type: boxes
|
||||
id: firstRow
|
||||
columns:
|
||||
- { width: 20, box: "obm_kp" }
|
||||
- { width: 20, nested: true, rows: [
|
||||
{ id: "firstRowTop", columns: [{ width: 100, box: "obm_ka" }] },
|
||||
{ id: "firstRowBottom", columns: [{ width: 100, box: "obm_kr" }] }
|
||||
]}
|
||||
- { width: 20, box: "obm_vp" }
|
||||
- { width: 20, nested: true, rows: [
|
||||
{ id: "firstRowTop2", columns: [{ width: 100, box: "obm_cr" }] },
|
||||
{ id: "firstRowBottom2", columns: [{ width: 100, box: "obm_ch" }] }
|
||||
]}
|
||||
- { width: 20, box: "obm_cs" }
|
||||
- type: boxes
|
||||
id: secondRow
|
||||
columns:
|
||||
- { width: 50, box: "obm_fc" }
|
||||
- { width: 50, box: "obm_fr" }
|
||||
31
app/Domain/Blueprints/Templates/definitions/retros.yaml
Normal file
31
app/Domain/Blueprints/Templates/definitions/retros.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
slug: "retros"
|
||||
icon: "fa-hand-spock"
|
||||
disclaimer: ""
|
||||
minColumns: 3
|
||||
|
||||
boxes:
|
||||
well:
|
||||
icon: "fa-circle-check"
|
||||
title: "box.retros.continue"
|
||||
notwell:
|
||||
icon: "fa-circle-xmark"
|
||||
title: "box.retros.stop_doing"
|
||||
startdoing:
|
||||
icon: "fa-circle-plus"
|
||||
title: "box.retros.start_doing"
|
||||
|
||||
statusLabels: {}
|
||||
relatesLabels: {}
|
||||
|
||||
dataLabels:
|
||||
1: { title: "label.description", field: "conclusion", active: true }
|
||||
2: { title: "label.data", field: "data", active: false }
|
||||
3: { title: "label.assumptions", field: "assumptions", active: false }
|
||||
|
||||
layout:
|
||||
- type: boxes
|
||||
id: firstRow
|
||||
columns:
|
||||
- { width: 33, box: "well" }
|
||||
- { width: 33, box: "notwell" }
|
||||
- { width: 33, box: "startdoing" }
|
||||
38
app/Domain/Blueprints/Templates/definitions/risks.yaml
Normal file
38
app/Domain/Blueprints/Templates/definitions/risks.yaml
Normal file
@@ -0,0 +1,38 @@
|
||||
slug: "risks"
|
||||
icon: "fa-person-falling"
|
||||
disclaimer: ""
|
||||
minColumns: 2
|
||||
|
||||
boxes:
|
||||
risks_imp_low_pro_low:
|
||||
icon: ""
|
||||
title: "box.risks.imp_low_pro_low"
|
||||
risks_imp_low_pro_high:
|
||||
icon: ""
|
||||
title: "box.risks.imp_low_pro_high"
|
||||
risks_imp_high_pro_low:
|
||||
icon: ""
|
||||
title: "box.risks.imp_high_pro_low"
|
||||
risks_imp_high_pro_high:
|
||||
icon: ""
|
||||
title: "box.risks.imp_high_pro_high"
|
||||
|
||||
statusLabels: default
|
||||
relatesLabels: default
|
||||
|
||||
dataLabels:
|
||||
1: { title: "label.risks.description", field: "conclusion", active: true }
|
||||
2: { title: "label.data", field: "data", active: true }
|
||||
3: { title: "label.risks.mitigation", field: "assumptions", active: true }
|
||||
|
||||
layout:
|
||||
- type: boxes
|
||||
id: firstRow
|
||||
columns:
|
||||
- { width: 50, box: "risks_imp_low_pro_high" }
|
||||
- { width: 50, box: "risks_imp_high_pro_high" }
|
||||
- type: boxes
|
||||
id: secondRow
|
||||
columns:
|
||||
- { width: 50, box: "risks_imp_low_pro_low" }
|
||||
- { width: 50, box: "risks_imp_high_pro_low" }
|
||||
97
app/Domain/Blueprints/Templates/definitions/sb.yaml
Normal file
97
app/Domain/Blueprints/Templates/definitions/sb.yaml
Normal file
@@ -0,0 +1,97 @@
|
||||
slug: "sb"
|
||||
icon: "fa-briefcase"
|
||||
disclaimer: ""
|
||||
minColumns: 4
|
||||
|
||||
boxes:
|
||||
sb_industry:
|
||||
icon: "fa-industry"
|
||||
title: "box.sb.industry"
|
||||
sb_description:
|
||||
icon: "fa-file-lines"
|
||||
title: "box.sb.description"
|
||||
sb_st_design:
|
||||
icon: "fa-user-tie"
|
||||
title: "box.sb.st_design"
|
||||
sb_st_decision:
|
||||
icon: "fa-sitemap"
|
||||
title: "box.sb.st_decision"
|
||||
sb_st_experts:
|
||||
icon: "fa-chalkboard-user"
|
||||
title: "box.sb.st_experts"
|
||||
sb_st_support:
|
||||
icon: "fa-person-circle-question"
|
||||
title: "box.sb.st_support"
|
||||
sb_budget:
|
||||
icon: "fa-money-bills"
|
||||
title: "box.sb.budget"
|
||||
sb_time:
|
||||
icon: "fa-business-time"
|
||||
title: "box.sb.time"
|
||||
sb_culture:
|
||||
icon: "fa-masks-theater"
|
||||
title: "box.sb.culture"
|
||||
sb_change:
|
||||
icon: "fa-book-skull"
|
||||
title: "box.sb.change"
|
||||
sb_principles:
|
||||
icon: "fa-ruler-combined"
|
||||
title: "box.sb.principles"
|
||||
|
||||
statusLabels:
|
||||
status_pending:
|
||||
icon: "fa-person-circle-question"
|
||||
color: "blue"
|
||||
title: "status.pending"
|
||||
dropdown: "info"
|
||||
active: true
|
||||
status_accepted:
|
||||
icon: "fa-person-circle-check"
|
||||
color: "green"
|
||||
title: "status.accepted"
|
||||
dropdown: "success"
|
||||
active: true
|
||||
status_rejected:
|
||||
icon: "fa-person-circle-xmark"
|
||||
color: "red"
|
||||
title: "status.rejected"
|
||||
dropdown: "danger"
|
||||
active: true
|
||||
|
||||
relatesLabels: {}
|
||||
|
||||
dataLabels:
|
||||
1: { title: "label.description", field: "conclusion", active: true }
|
||||
2: { title: "label.data", field: "data", active: false }
|
||||
3: { title: "label.assumptions", field: "assumptions", active: false }
|
||||
|
||||
layout:
|
||||
- type: boxes
|
||||
columns:
|
||||
- { width: 100, box: "sb_description", statusLabels: {} }
|
||||
- type: boxes
|
||||
columns:
|
||||
- { width: 100, box: "sb_industry", statusLabels: {} }
|
||||
- type: boxes
|
||||
id: stakeholderRow
|
||||
columns:
|
||||
- { width: 25, box: "sb_st_design", statusLabels: "inherit" }
|
||||
- { width: 25, box: "sb_st_decision", statusLabels: "inherit" }
|
||||
- { width: 25, box: "sb_st_experts", statusLabels: "inherit" }
|
||||
- { width: 25, box: "sb_st_support", statusLabels: "inherit" }
|
||||
- type: boxes
|
||||
id: financialsRow
|
||||
columns:
|
||||
- { width: 50, box: "sb_budget", statusLabels: {} }
|
||||
- { width: 50, box: "sb_time", statusLabels: {} }
|
||||
- type: boxes
|
||||
id: culturechangeRow
|
||||
columns:
|
||||
- { width: 50, box: "sb_culture", statusLabels: {} }
|
||||
- { width: 50, box: "sb_change", statusLabels: {} }
|
||||
- type: boxes
|
||||
columns:
|
||||
- { width: 100, box: "sb_principles", statusLabels: {} }
|
||||
- type: static
|
||||
columns:
|
||||
- { width: 100, icon: "fas fa-person-falling", title: "box.sb.risks", content: "text.sb.risks_analysis" }
|
||||
83
app/Domain/Blueprints/Templates/definitions/sm.yaml
Normal file
83
app/Domain/Blueprints/Templates/definitions/sm.yaml
Normal file
@@ -0,0 +1,83 @@
|
||||
slug: "sm"
|
||||
icon: "fa-chess"
|
||||
disclaimer: ""
|
||||
minColumns: 2
|
||||
|
||||
boxes:
|
||||
sm_qa:
|
||||
icon: "fa-clipboard-question"
|
||||
title: "box.sm.qa"
|
||||
sm_qb:
|
||||
icon: "fa-clipboard-question"
|
||||
title: "box.sm.qb"
|
||||
sm_qc:
|
||||
icon: "fa-clipboard-question"
|
||||
title: "box.sm.qc"
|
||||
sm_qd:
|
||||
icon: "fa-clipboard-question"
|
||||
title: "box.sm.qd"
|
||||
sm_qe:
|
||||
icon: "fa-clipboard-question"
|
||||
title: "box.sm.qe"
|
||||
sm_qf:
|
||||
icon: "fa-clipboard-question"
|
||||
title: "box.sm.qf"
|
||||
sm_qg:
|
||||
icon: "fa-clipboard-question"
|
||||
title: "box.sm.qg"
|
||||
|
||||
statusLabels:
|
||||
status_draft:
|
||||
icon: "fa-circle-question"
|
||||
color: "blue"
|
||||
title: "status.draft"
|
||||
dropdown: "info"
|
||||
active: true
|
||||
status_review:
|
||||
icon: "fa-circle-exclamation"
|
||||
color: "orange"
|
||||
title: "status.review"
|
||||
dropdown: "warning"
|
||||
active: true
|
||||
status_accepted:
|
||||
icon: "fa-circle-check"
|
||||
color: "green"
|
||||
title: "status.accepted"
|
||||
dropdown: "success"
|
||||
active: true
|
||||
status_rejected:
|
||||
icon: "fa-circle-xmark"
|
||||
color: "red"
|
||||
title: "status.rejected"
|
||||
dropdown: "danger"
|
||||
active: true
|
||||
|
||||
relatesLabels: {}
|
||||
|
||||
dataLabels:
|
||||
1: { title: "label.sm.description", field: "conclusion", active: true }
|
||||
2: { title: "label.data", field: "data", active: false }
|
||||
3: { title: "label.assumptions", field: "assumptions", active: false }
|
||||
|
||||
layout:
|
||||
- type: boxes
|
||||
columns:
|
||||
- { width: 100, box: "sm_qa" }
|
||||
- type: boxes
|
||||
columns:
|
||||
- { width: 100, box: "sm_qb" }
|
||||
- type: boxes
|
||||
columns:
|
||||
- { width: 100, box: "sm_qc" }
|
||||
- type: boxes
|
||||
columns:
|
||||
- { width: 100, box: "sm_qd" }
|
||||
- type: boxes
|
||||
columns:
|
||||
- { width: 100, box: "sm_qe" }
|
||||
- type: boxes
|
||||
columns:
|
||||
- { width: 100, box: "sm_qf" }
|
||||
- type: boxes
|
||||
columns:
|
||||
- { width: 100, box: "sm_qg" }
|
||||
71
app/Domain/Blueprints/Templates/definitions/sq.yaml
Normal file
71
app/Domain/Blueprints/Templates/definitions/sq.yaml
Normal file
@@ -0,0 +1,71 @@
|
||||
slug: "sq"
|
||||
icon: "fa-chess"
|
||||
disclaimer: ""
|
||||
minColumns: 2
|
||||
|
||||
boxes:
|
||||
sq_qa:
|
||||
icon: "fa-1"
|
||||
title: "box.sq.qa"
|
||||
sq_qb:
|
||||
icon: "fa-2"
|
||||
title: "box.sq.qb"
|
||||
sq_qc:
|
||||
icon: "fa-3"
|
||||
title: "box.sq.qc"
|
||||
sq_qd:
|
||||
icon: "fa-4"
|
||||
title: "box.sq.qd"
|
||||
sq_qe:
|
||||
icon: "fa-5"
|
||||
title: "box.sq.qe"
|
||||
|
||||
statusLabels:
|
||||
status_draft:
|
||||
icon: "fa-circle-question"
|
||||
color: "blue"
|
||||
title: "status.draft"
|
||||
dropdown: "info"
|
||||
active: true
|
||||
status_review:
|
||||
icon: "fa-circle-exclamation"
|
||||
color: "orange"
|
||||
title: "status.review"
|
||||
dropdown: "warning"
|
||||
active: true
|
||||
status_accepted:
|
||||
icon: "fa-circle-check"
|
||||
color: "green"
|
||||
title: "status.accepted"
|
||||
dropdown: "success"
|
||||
active: true
|
||||
status_rejected:
|
||||
icon: "fa-circle-xmark"
|
||||
color: "red"
|
||||
title: "status.rejected"
|
||||
dropdown: "danger"
|
||||
active: true
|
||||
|
||||
relatesLabels: {}
|
||||
|
||||
dataLabels:
|
||||
1: { title: "label.sq.description", field: "conclusion", active: true }
|
||||
2: { title: "label.data", field: "data", active: false }
|
||||
3: { title: "label.assumptions", field: "assumptions", active: false }
|
||||
|
||||
layout:
|
||||
- type: boxes
|
||||
columns:
|
||||
- { width: 100, box: "sq_qa" }
|
||||
- type: boxes
|
||||
columns:
|
||||
- { width: 100, box: "sq_qb" }
|
||||
- type: boxes
|
||||
columns:
|
||||
- { width: 100, box: "sq_qc" }
|
||||
- type: boxes
|
||||
columns:
|
||||
- { width: 100, box: "sq_qd" }
|
||||
- type: boxes
|
||||
columns:
|
||||
- { width: 100, box: "sq_qe" }
|
||||
42
app/Domain/Blueprints/Templates/definitions/swot.yaml
Normal file
42
app/Domain/Blueprints/Templates/definitions/swot.yaml
Normal file
@@ -0,0 +1,42 @@
|
||||
slug: "swot"
|
||||
icon: "fa-chess-board"
|
||||
disclaimer: ""
|
||||
minColumns: 2
|
||||
|
||||
boxes:
|
||||
swot_strengths:
|
||||
icon: "fa-dumbbell"
|
||||
title: "box.swot.strengths"
|
||||
swot_weaknesses:
|
||||
icon: "fa-fire"
|
||||
title: "box.swot.weaknesses"
|
||||
swot_opportunities:
|
||||
icon: "fa-clover"
|
||||
title: "box.swot.opportunities"
|
||||
swot_threats:
|
||||
icon: "fa-bolt-lightning"
|
||||
title: "box.swot.threats"
|
||||
|
||||
statusLabels: {}
|
||||
relatesLabels: default
|
||||
|
||||
dataLabels:
|
||||
1: { title: "label.description", field: "conclusion", active: true }
|
||||
2: { title: "label.data", field: "data", active: true }
|
||||
3: { title: "label.assumptions", field: "assumptions", active: false }
|
||||
|
||||
layout:
|
||||
- type: header
|
||||
columns:
|
||||
- { width: 50, header: { icon: "far fa-thumbs-up", title: "box.header.swot.helpful" } }
|
||||
- { width: 50, header: { icon: "far fa-thumbs-down", title: "box.header.swot.harmful" } }
|
||||
- type: boxes
|
||||
id: firstRow
|
||||
columns:
|
||||
- { width: 50, box: "swot_strengths" }
|
||||
- { width: 50, box: "swot_weaknesses" }
|
||||
- type: boxes
|
||||
id: secondRow
|
||||
columns:
|
||||
- { width: 50, box: "swot_opportunities" }
|
||||
- { width: 50, box: "swot_threats" }
|
||||
35
app/Domain/Blueprints/Templates/definitions/value.yaml
Normal file
35
app/Domain/Blueprints/Templates/definitions/value.yaml
Normal file
@@ -0,0 +1,35 @@
|
||||
slug: "value"
|
||||
icon: "fa-ranking-star"
|
||||
disclaimer: ""
|
||||
minColumns: 5
|
||||
|
||||
boxes:
|
||||
customersegment:
|
||||
icon: "fa-user"
|
||||
title: "box.lean.customersegment"
|
||||
problem:
|
||||
icon: "fa-lock"
|
||||
title: "box.lean.problem"
|
||||
solution:
|
||||
icon: "fa-key"
|
||||
title: "box.lean.solution"
|
||||
uniquevalue:
|
||||
icon: "fa-gift"
|
||||
title: "box.value.benefit"
|
||||
|
||||
statusLabels: default
|
||||
relatesLabels: {}
|
||||
|
||||
dataLabels:
|
||||
1: { title: "label.valueCanvas.assumptions", field: "assumptions", active: true }
|
||||
2: { title: "label.valueCanvas.data", field: "data", active: true }
|
||||
3: { title: "label.valueCanvas.conclusion", field: "conclusion", active: true }
|
||||
|
||||
layout:
|
||||
- type: boxes
|
||||
id: firstRow
|
||||
columns:
|
||||
- { width: 25, box: "customersegment" }
|
||||
- { width: 25, box: "problem" }
|
||||
- { width: 25, box: "solution" }
|
||||
- { width: 25, box: "uniquevalue" }
|
||||
8
app/Domain/Blueprints/Templates/delCanvas.blade.php
Normal file
8
app/Domain/Blueprints/Templates/delCanvas.blade.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<h4 class="widgettitle title-light">{!! __('subtitles.delete') !!}</h4>
|
||||
|
||||
<form method="post" action="{{ BASE_URL }}/blueprints/{{ $canvasSlug }}/delCanvas/{{ $id }}">
|
||||
<p>{!! __('text.confirm_board_deletion') !!}</p><br />
|
||||
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.yes_delete')" name="del" />
|
||||
<x-global::forms.button tag="a" contentRole="tertiary"
|
||||
link="{{ BASE_URL }}/blueprints/{{ $canvasSlug }}/showCanvas">{!! __('buttons.back') !!}</x-global::forms.button>
|
||||
</form>
|
||||
8
app/Domain/Blueprints/Templates/delCanvasItem.blade.php
Normal file
8
app/Domain/Blueprints/Templates/delCanvasItem.blade.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<h4 class="widgettitle title-light">{!! __('subtitles.delete') !!}</h4>
|
||||
<hr style="margin-top: 5px; margin-bottom: 15px;">
|
||||
|
||||
<form method="post" action="{{ BASE_URL }}/blueprints/{{ $canvasSlug }}/delCanvasItem/{{ $id }}">
|
||||
<p>{!! __('text.confirm_board_item_deletion') !!}</p><br />
|
||||
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.yes_delete')" name="del" />
|
||||
<x-global::forms.button tag="a" contentRole="tertiary" link="{{ BASE_URL }}/blueprints/{{ $canvasSlug }}/showCanvas">{!! __('buttons.back') !!}</x-global::forms.button>
|
||||
</form>
|
||||
147
app/Domain/Blueprints/Templates/element.blade.php
Normal file
147
app/Domain/Blueprints/Templates/element.blade.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<h4 class="widgettitle title-primary">
|
||||
@if(isset($canvasTypes[$elementName]['icon']))
|
||||
<i class="fas {{ $canvasTypes[$elementName]['icon'] }}"></i>
|
||||
@endif
|
||||
{{ $canvasTypes[$elementName]['title'] }}
|
||||
</h4>
|
||||
<div class="contentInner even status_{{ $elementName }}"
|
||||
{!! isset($canvasTypes[$elementName]['color']) ? 'style="background: ' . $canvasTypes[$elementName]['color'] . ';"' : '' !!}>
|
||||
|
||||
@foreach($canvasItems as $row)
|
||||
@php
|
||||
$filterStatus = $filter['status'] ?? 'all';
|
||||
$filterRelates = $filter['relates'] ?? 'all';
|
||||
@endphp
|
||||
|
||||
@if($row['box'] === $elementName && ($filterStatus == 'all' || $filterStatus == $row['status']) && ($filterRelates == 'all' || $filterRelates == $row['relates']))
|
||||
@php
|
||||
// Use the module-scoped count already computed by getCanvasItemsById
|
||||
// (avoids an unscoped per-item query that miscounts across modules).
|
||||
$nbcomments = (int) ($row['commentCount'] ?? 0);
|
||||
@endphp
|
||||
|
||||
<div class="ticketBox" id="item_{{ $row['id'] }}">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="inlineDropDownContainer" style="float:right;">
|
||||
|
||||
@if($login::userIsAtLeast($roles::$editor))
|
||||
<a href="javascript:void(0)" class="dropdown-toggle ticketDropDown" data-toggle="dropdown">
|
||||
<i class="fa fa-ellipsis-v" aria-hidden="true"></i>
|
||||
</a>
|
||||
@endif
|
||||
|
||||
@if($login::userIsAtLeast($roles::$editor))
|
||||
|
||||
<ul class="dropdown-menu">
|
||||
<li class="nav-header">{!! __('subtitles.edit') !!}</li>
|
||||
<li><a href="#/blueprints/{{ $canvasSlug }}/editCanvasItem/{{ $row['id'] }}"
|
||||
data="item_{{ $row['id'] }}"> {!! __('links.edit_canvas_item') !!}</a></li>
|
||||
<li><a href="#/blueprints/{{ $canvasSlug }}/delCanvasItem/{{ $row['id'] }}"
|
||||
class="delete"
|
||||
data="item_{{ $row['id'] }}"> {!! __('links.delete_canvas_item') !!}</a></li>
|
||||
</ul>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<h4><a href="#/blueprints/{{ $canvasSlug }}/editCanvasItem/{{ $row['id'] }}"
|
||||
data="item_{{ $row['id'] }}">{{ $row['description'] }}</a></h4>
|
||||
|
||||
@if($row['conclusion'] != '')
|
||||
<small>{!! $tpl->convertRelativePaths($row['conclusion']) !!}</small>
|
||||
@endif
|
||||
|
||||
<div class="clearfix" style="padding-bottom: 8px;"></div>
|
||||
|
||||
@if(! empty($statusLabels))
|
||||
<div class="dropdown ticketDropdown statusDropdown colorized show firstDropdown">
|
||||
<a class="dropdown-toggle f-left status label-{{ $statusLabels[$row['status']]['dropdown'] }}"
|
||||
href="javascript:void(0);" role="button"
|
||||
id="statusDropdownMenuLink{{ $row['id'] }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="text">{{ $statusLabels[$row['status']]['title'] }}</span> <i class="fa fa-caret-down" aria-hidden="true"></i>
|
||||
</a>
|
||||
<ul class="dropdown-menu" aria-labelledby="statusDropdownMenuLink{{ $row['id'] }}">
|
||||
<li class="nav-header border">{!! __('dropdown.choose_status') !!}</li>
|
||||
@foreach($statusLabels as $key => $data)
|
||||
@if($data['active'] || true)
|
||||
<li class='dropdown-item'>
|
||||
<a href="javascript:void(0);" class="label-{{ $data['dropdown'] }}"
|
||||
data-label='{{ $data['title'] }}' data-value="{{ $row['id'] . '/' . $key }}"
|
||||
id="ticketStatusChange{{ $row['id'] }}{{ $key }}">{{ $data['title'] }}</a>
|
||||
</li>
|
||||
@endif
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(! empty($relatesLabels))
|
||||
<div class="dropdown ticketDropdown relatesDropdown colorized show firstDropdown">
|
||||
<a class="dropdown-toggle f-left relates label-{{ $relatesLabels[$row['relates']]['dropdown'] }}"
|
||||
href="javascript:void(0);" role="button"
|
||||
id="relatesDropdownMenuLink{{ $row['id'] }}" data-toggle="dropdown" aria-haspopup="true"
|
||||
aria-expanded="false">
|
||||
<span class="text">{{ $relatesLabels[$row['relates']]['title'] }}</span> <i class="fa fa-caret-down" aria-hidden="true"></i>
|
||||
</a>
|
||||
<ul class="dropdown-menu" aria-labelledby="relatesDropdownMenuLink{{ $row['id'] }}">
|
||||
<li class="nav-header border">{!! __('dropdown.choose_relates') !!}</li>
|
||||
@foreach($relatesLabels as $key => $data)
|
||||
@if($data['active'] || true)
|
||||
<li class='dropdown-item'>
|
||||
<a href="javascript:void(0);" class="label-{{ $data['dropdown'] }}"
|
||||
data-label='{{ $data['title'] }}'
|
||||
data-value="{{ $row['id'] . '/' . $key }}"
|
||||
id="ticketRelatesChange{{ $row['id'] }}{{ $key }}">{{ $data['title'] }}</a>
|
||||
</li>
|
||||
@endif
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="dropdown ticketDropdown userDropdown noBg show right lastDropdown dropRight">
|
||||
<a class="dropdown-toggle f-left" href="javascript:void(0);" role="button" id="userDropdownMenuLink{{ $row['id'] }}"
|
||||
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="text">
|
||||
@if($row['authorFirstname'] != '')
|
||||
<span id='userImage{{ $row['id'] }}'><img src='{{ BASE_URL }}/api/users?profileImage={{ $row['author'] }}' width='25' style='vertical-align: middle;'/></span><span id='user{{ $row['id'] }}'></span>
|
||||
@else
|
||||
<span id='userImage{{ $row['id'] }}'><img src='{{ BASE_URL }}/api/users?profileImage=false' width='25' style='vertical-align: middle;'/></span><span id='user{{ $row['id'] }}'></span>
|
||||
@endif
|
||||
</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu" aria-labelledby="userDropdownMenuLink{{ $row['id'] }}">
|
||||
<li class="nav-header border">{!! __('dropdown.choose_user') !!}</li>
|
||||
@foreach($users as $user)
|
||||
<li class='dropdown-item'>
|
||||
<a href='javascript:void(0);' data-label='{{ sprintf(__('text.full_name'), e($user['firstname']), e($user['lastname'])) }}' data-value='{{ $row['id'] }}_{{ $user['id'] }}_{{ $user['profileId'] }}' id='userStatusChange{{ $row['id'] }}{{ $user['id'] }}'><img src='{{ BASE_URL }}/api/users?profileImage={{ $user['id'] }}&v={{ $user['modified'] }}' width='25' style='vertical-align: middle; margin-right:5px;'/>{{ sprintf(__('text.full_name'), e($user['firstname']), e($user['lastname'])) }}</a>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
<div class="pull-right" style="margin-right:10px;">
|
||||
<span class="fas fa-comments"></span> <small>{{ $nbcomments }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($row['milestoneHeadline'] != '')
|
||||
<br/>
|
||||
<div hx-trigger="load"
|
||||
hx-indicator=".htmx-indicator"
|
||||
hx-get="{{ BASE_URL }}/hx/tickets/milestones/showCard?milestoneId={{ $row['milestoneId'] }}">
|
||||
<div class="htmx-indicator">
|
||||
{!! __('label.loading_milestone') !!}
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
<br />
|
||||
@if($login::userIsAtLeast($roles::$editor))
|
||||
<a href="#/blueprints/{{ $canvasSlug }}/editCanvasItem?type={{ $elementName }}"
|
||||
class="" id="{{ $elementName }}"
|
||||
style="padding-bottom: 10px;">{!! __('links.add_new_canvas_item') !!}</a>
|
||||
@endif
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user