Merge branch 'develop-unstable' into develop

pull/73/head
iglocska 2021-10-14 09:58:25 +02:00
commit 1d57a94803
No known key found for this signature in database
GPG Key ID: BEA224F1FEF113AC
215 changed files with 123464 additions and 17550 deletions

View File

@ -5,6 +5,14 @@ An Ubuntu server (18.04/20.04 should both work fine) - though other linux instal
- php extensions for intl, mysql, sqlite3, mbstring, xml need to be installed and running
- composer
## Network requirements
Cerebrate communicates via HTTPS so in order to be able to connect to other cerebrate nodes, requiring the following ports to be open:
- port 443 needs to be open for outbound connections to be able to pull contactdb / sharing group information in
- Cerebrate also needs to be accessible (via port 443) from the outside if:
- you wish to pull interconnect local tools with remote cerebrate instances
- you wish to act as a hub node for a community where members are expected to pull data from your node
## Cerebrate installation instructions

View File

@ -6,6 +6,7 @@
"license": "MIT",
"require": {
"php": ">=7.2",
"admad/cakephp-social-auth": "^1.1",
"cakephp/authentication": "^2.0",
"cakephp/authorization": "^2.0",
"cakephp/cakephp": "^4.0",
@ -29,12 +30,14 @@
},
"autoload": {
"psr-4": {
"App\\": "src/"
"App\\": "src/",
"Tags\\": "plugins/Tags/src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Test\\": "tests/",
"Tags\\Test\\": "plugins/Tags/tests/",
"Cake\\Test\\": "vendor/cakephp/cakephp/tests/"
}
},

View File

@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
use Migrations\AbstractMigration;
class TimestampBehavior extends AbstractMigration
{
public function change()
{
$alignments = $this->table('alignments')
->addColumn('created', 'datetime', [
'default' => null,
'null' => false,
])
->addColumn('modified', 'datetime', [
'default' => null,
'null' => false,
])
->update();
$broods = $this->table('broods')
->addColumn('created', 'datetime', [
'default' => null,
'null' => false,
])
->addColumn('modified', 'datetime', [
'default' => null,
'null' => false,
])
->update();
$encryption_keys = $this->table('encryption_keys')
->addColumn('created', 'datetime', [
'default' => null,
'null' => false,
])
->addColumn('modified', 'datetime', [
'default' => null,
'null' => false,
])
->update();
$inbox = $this->table('inbox')
->addColumn('modified', 'datetime', [
'default' => null,
'null' => false,
])
->update();
$outbox = $this->table('outbox')
->addColumn('modified', 'datetime', [
'default' => null,
'null' => false,
])
->update();
$individuals = $this->table('individuals')
->addColumn('created', 'datetime', [
'default' => null,
'null' => false,
])
->addColumn('modified', 'datetime', [
'default' => null,
'null' => false,
])
->update();
$local_tools = $this->table('local_tools')
->addColumn('created', 'datetime', [
'default' => null,
'null' => false,
])
->addColumn('modified', 'datetime', [
'default' => null,
'null' => false,
])
->update();
$meta_templates = $this->table('meta_templates')
->addColumn('created', 'datetime', [
'default' => null,
'null' => false,
])
->addColumn('modified', 'datetime', [
'default' => null,
'null' => false,
])
->update();
$organisations = $this->table('organisations')
->addColumn('created', 'datetime', [
'default' => null,
'null' => false,
])
->addColumn('modified', 'datetime', [
'default' => null,
'null' => false,
])
->update();
$sharing_groups = $this->table('sharing_groups')
->addColumn('created', 'datetime', [
'default' => null,
'null' => false,
])
->addColumn('modified', 'datetime', [
'default' => null,
'null' => false,
])
->update();
$users = $this->table('users')
->addColumn('created', 'datetime', [
'default' => null,
'null' => false,
])
->addColumn('modified', 'datetime', [
'default' => null,
'null' => false,
])
->update();
}
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
use Migrations\AbstractMigration;
class RolesPermOrgAdmin extends AbstractMigration
{
public function change()
{
$table = $this->table('roles')
->addColumn('perm_org_admin', 'boolean', [
'default' => 0,
'null' => false,
])
->update();
}
}

View File

@ -89,9 +89,4 @@ return [
'url' => env('EMAIL_TRANSPORT_DEFAULT_URL', null),
],
],
'Cerebrate' => [
'open' => [],
'dark' => 0,
'baseurl' => ''
]
];

View File

@ -87,6 +87,7 @@ try {
*/
if (file_exists(CONFIG . 'app_local.php')) {
Configure::load('app_local', 'default');
Configure::load('cerebrate', 'default', true);
}
/*

9
config/cerebrate.php Normal file
View File

@ -0,0 +1,9 @@
<?php
return [
'Cerebrate' => [
'open' => [],
'app.baseurl' => 'http://localhost:8000/',
'app.uuid' => 'cc9b9358-7c4b-4464-9a2c-f0cb089ff974',
'ui.bsTheme' => 'default',
]
];

View File

@ -18,18 +18,18 @@ $footerButtons = [
$tools = sprintf(
'<div class="mx-auto mb-3 mw-75 d-flex align-items-center">
<span class="flex-grow-1 text-right" style="font-size: large;">%s</span>
<span class="flex-grow-1 text-end" style="font-size: large;">%s</span>
<span class="mx-3">%s</span>
<span class="flex-grow-1 text-left" style="font-size: large;">%s</span>
<span class="flex-grow-1 text-start" style="font-size: large;">%s</span>
</div>',
sprintf('<span class="mr-2 d-inline-flex flex-column"><a href="%s" target="_blank" title="%s">%s</a><i style="font-size: medium;" class="text-center">%s</i></span>',
sprintf('<span class="me-2 d-inline-flex flex-column"><a href="%s" target="_blank" title="%s">%s</a><i style="font-size: medium;" class="text-center">%s</i></span>',
sprintf('/localTools/view/%s', h($request['localTool']->id)),
h($request['localTool']->description),
h($request['localTool']->name),
__('(local tool)')
),
sprintf('<i class="%s fa-lg"></i>', $this->FontAwesome->getClass('long-arrow-alt-right')),
sprintf('<span class="ml-2 d-inline-flex flex-column"><a href="%s" target="_blank" title="%s">%s</a><i style="font-size: medium;" class="text-center">%s</i></span>',
sprintf('<span class="ms-2 d-inline-flex flex-column"><a href="%s" target="_blank" title="%s">%s</a><i style="font-size: medium;" class="text-center">%s</i></span>',
sprintf('/localTools/broodTools/%s', h($request['data']['remote_tool']['id'])),
h($request['data']['remote_tool']['description'] ?? ''),
h($request['data']['remote_tool']['name']),
@ -77,9 +77,9 @@ $requestData = $this->Bootstrap->collapse([
sprintf('<pre class="p-2 rounded mb-0" style="background: #eeeeee55;"><code>%s</code></pre>', json_encode($request['data']['sent'], JSON_PRETTY_PRINT))
);
$rows = sprintf('<tr><td class="font-weight-bold">%s</td><td>%s</td></tr>', __('URL'), h($request['data']['url']));
$rows .= sprintf('<tr><td class="font-weight-bold">%s</td><td>%s</td></tr>', __('Reason'), h($request['data']['reason']['message']) ?? '');
$rows .= sprintf('<tr><td class="font-weight-bold">%s</td><td>%s</td></tr>', __('Errors'), h(json_encode($request['data']['reason']['errors'])) ?? '');
$rows = sprintf('<tr><td class="fw-bold">%s</td><td>%s</td></tr>', __('URL'), h($request['data']['url']));
$rows .= sprintf('<tr><td class="fw-bold">%s</td><td>%s</td></tr>', __('Reason'), h($request['data']['reason']['message']) ?? '');
$rows .= sprintf('<tr><td class="fw-bold">%s</td><td>%s</td></tr>', __('Errors'), h(json_encode($request['data']['reason']['errors'])) ?? '');
$table2 = sprintf('<table class="table table-sm table-borderless"><tbody>%s</tbody></table>', $rows);
$form = $this->element('genericElements/Form/genericForm', [

View File

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
use Migrations\AbstractMigration;
class TagSystem extends AbstractMigration
{
public function change() {
$tags = $this->table('tags_tags')
->addColumn('namespace', 'string', [
'default' => null,
'limit' => 255,
'null' => true,
])
->addColumn('predicate', 'string', [
'default' => null,
'limit' => 255,
'null' => true,
])
->addColumn('value', 'string', [
'default' => null,
'limit' => 255,
'null' => true,
])
->addColumn('name', 'string', [
'default' => null,
'limit' => 255,
'null' => false,
])
->addColumn('colour', 'string', [
'default' => null,
'limit' => 7,
'null' => false,
])
->addColumn('counter', 'integer', [
'default' => 0,
'length' => 11,
'null' => false,
'signed' => false,
'comment' => 'Field used by the CounterCache behaviour to count the occurence of tags'
])
->addColumn('created', 'datetime', [
'default' => null,
'null' => false,
])
->addColumn('modified', 'datetime', [
'default' => null,
'null' => false,
])
->create();
$tagged = $this->table('tags_tagged')
->addColumn('tag_id', 'integer', [
'default' => null,
'null' => false,
'signed' => false,
'length' => 10,
])
->addColumn('fk_id', 'integer', [
'default' => null,
'null' => true,
'signed' => false,
'length' => 10,
'comment' => 'The ID of the entity being tagged'
])
->addColumn('fk_model', 'string', [
'default' => null,
'limit' => 255,
'null' => false,
'comment' => 'The model name of the entity being tagged'
])
->addColumn('created', 'datetime', [
'default' => null,
'null' => false,
])
->addColumn('modified', 'datetime', [
'default' => null,
'null' => false,
])
->create();
$tags->addIndex(['name'], ['unique' => true])
->update();
$tagged->addIndex(['tag_id', 'fk_id', 'fk_table'], ['unique' => true])
->update();
}
}

View File

@ -0,0 +1,36 @@
<?php
use Cake\Routing\Route\DashedRoute;
use Cake\Routing\RouteBuilder;
$routes->plugin(
'Tags',
['path' => '/tags'],
function ($routes) {
$routes->setRouteClass(DashedRoute::class);
$routes->connect(
'/{action}/*',
['controller' => 'Tags']
);
$routes->get('/', ['controller' => 'Tags', 'action' => 'index']);
// $routes->get('/{id}', ['controller' => 'Tags', 'action' => 'view']);
// $routes->put('/{id}', ['controller' => 'Tags', 'action' => 'edit']);
}
);
$routes->plugin(
'Tags',
['path' => '/Tags'],
function ($routes) {
$routes->setRouteClass(DashedRoute::class);
$routes->connect(
'/{action}/*',
['controller' => 'Tags']
);
$routes->get('/', ['controller' => 'Tags', 'action' => 'index']);
// $routes->get('/{id}', ['controller' => 'Tags', 'action' => 'view']);
// $routes->put('/{id}', ['controller' => 'Tags', 'action' => 'edit']);
}
);

View File

@ -0,0 +1,13 @@
<?php
namespace Tags\Controller;
use App\Controller\AppController as BaseController;
class AppController extends BaseController
{
public function initialize(): void
{
parent::initialize();
}
}

View File

@ -0,0 +1,111 @@
<?php
namespace Tags\Controller;
use Tags\Controller\AppController;
use Cake\Utility\Hash;
use Cake\Utility\Inflector;
use Cake\Utility\Text;
use Cake\Database\Expression\QueryExpression;
use Cake\Http\Exception\NotFoundException;
use Cake\Http\Exception\MethodNotAllowedException;
use Cake\Http\Exception\ForbiddenException;
use Cake\ORM\TableRegistry;
class TagsController extends AppController
{
public function index()
{
$this->CRUD->index([
'filters' => ['name', 'colour'],
'quickFilters' => [['name' => true], 'colour']
]);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
}
public function add()
{
$this->CRUD->add();
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
}
public function view($id)
{
$this->CRUD->view($id);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
}
public function edit($id)
{
$this->CRUD->edit($id);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
$this->render('add');
}
public function delete($id)
{
$this->CRUD->delete($id);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
}
// public function tag($model, $id)
// {
// $controller = $this->getControllerBeingTagged($model);
// $controller->CRUD->tag($id);
// $responsePayload = $controller->CRUD->getResponsePayload();
// if (!empty($responsePayload)) {
// return $responsePayload;
// }
// return $controller->getResponse();
// }
// public function untag($model, $id)
// {
// $controller = $this->getControllerBeingTagged($model);
// $controller->CRUD->untag($id);
// $responsePayload = $controller->CRUD->getResponsePayload();
// if (!empty($responsePayload)) {
// return $responsePayload;
// }
// return $controller->getResponse();
// }
// public function viewTags($model, $id)
// {
// $controller = $this->getControllerBeingTagged($model);
// $controller->CRUD->viewTags($id);
// $responsePayload = $controller->CRUD->getResponsePayload();
// if (!empty($responsePayload)) {
// return $responsePayload;
// }
// return $controller->getResponse();
// }
// private function getControllerBeingTagged($model)
// {
// $modelName = Inflector::camelize($model);
// $controllerName = "\\App\\Controller\\{$modelName}Controller";
// if (!class_exists($controllerName)) {
// throw new MethodNotAllowedException(__('Model `{0}` does not exists', $model));
// }
// $controller = new $controllerName;
// // Make sure that the request is correctly assigned to this controller
// return $controller;
// }
}

View File

@ -0,0 +1,355 @@
<?php
namespace Tags\Model\Behavior;
use Cake\ORM\Behavior;
use Cake\ORM\Entity;
use Cake\ORM\Query;
use Cake\ORM\Table;
class TagBehavior extends Behavior
{
protected $_defaultConfig = [
'finderField' => 'name',
'tagsAssoc' => [
'className' => 'Tags.Tags',
'joinTable' => 'tags_tagged',
'foreignKey' => 'fk_id',
'targetForeignKey' => 'tag_id',
'propertyName' => 'tags',
],
'tagsCounter' => ['counter'],
'taggedAssoc' => [
'className' => 'Tags.Tagged',
'foreignKey' => 'fk_id'
],
'implementedEvents' => [
'Model.beforeMarshal' => 'beforeMarshal',
'Model.beforeFind' => 'beforeFind',
'Model.beforeSave' => 'beforeSave',
],
'implementedMethods' => [
'normalizeTags' => 'normalizeTags',
],
'implementedFinders' => [
'tagged' => 'findByTag',
'untagged' => 'findUntagged',
],
];
public function initialize(array $config): void {
$this->bindAssociations();
$this->attachCounters();
}
public function bindAssociations() {
$config = $this->getConfig();
$tagsAssoc = $config['tagsAssoc'];
$taggedAssoc = $config['taggedAssoc'];
$table = $this->_table;
$tableAlias = $this->_table->getAlias();
$assocConditions = ['Tagged.fk_model' => $tableAlias];
if (!$table->hasAssociation('Tagged')) {
$table->hasMany('Tagged', array_merge(
$taggedAssoc,
[
'conditions' => $assocConditions
]
));
}
if (!$table->hasAssociation('Tags')) {
$table->belongsToMany('Tags', array_merge(
$tagsAssoc,
[
'through' => $table->Tagged->getTarget(),
'conditions' => $assocConditions,
]
));
}
if (!$table->Tags->hasAssociation($tableAlias)) {
$table->Tags->belongsToMany($tableAlias, array_merge(
$tagsAssoc,
[
'className' => get_class($table),
]
));
}
if (!$table->Tagged->hasAssociation($tableAlias)) {
$table->Tagged->belongsTo($tableAlias, [
'className' => get_class($table),
'foreignKey' => $tagsAssoc['foreignKey'],
'conditions' => $assocConditions,
'joinType' => 'INNER',
]);
}
if (!$table->Tagged->hasAssociation($tableAlias . 'Tags')) {
$table->Tagged->belongsTo($tableAlias . 'Tags', [
'className' => $tagsAssoc['className'],
'foreignKey' => $tagsAssoc['targetForeignKey'],
'conditions' => $assocConditions,
'joinType' => 'INNER',
]);
}
}
public function attachCounters() {
$config = $this->getConfig();
$taggedTable = $this->_table->Tagged;
if (!$taggedTable->hasBehavior('CounterCache')) {
$taggedTable->addBehavior('CounterCache', [
'Tags' => $config['tagsCounter']
]);
}
}
public function beforeMarshal($event, $data, $options) {
$property = $this->getConfig('tagsAssoc.propertyName');
$options['accessibleFields'][$property] = true;
$options['associated']['Tags']['accessibleFields']['id'] = true;
if (isset($data['tags'])) {
if (!empty($data['tags'])) {
$data[$property] = $this->normalizeTags($data['tags']);
}
}
}
public function beforeSave($event, $entity, $options)
{
if (empty($entity->tags)) {
return;
}
foreach ($entity->tags as $k => $tag) {
if (!$tag->isNew()) {
continue;
}
$existingTag = $this->getExistingTag($tag->name);
if (!$existingTag) {
continue;
}
$joinData = $tag->_joinData;
$tag = $existingTag;
$tag->_joinData = $joinData;
$entity->tags[$k] = $tag;
}
}
public function normalizeTags($tags) {
$result = [];
$modelAlias = $this->_table->getAlias();
$common = [
'_joinData' => [
'fk_model' => $modelAlias
]
];
$tagsTable = $this->_table->Tags;
$displayField = $tagsTable->getDisplayField();
$tagIdentifiers = [];
foreach ($tags as $tag) {
if (empty($tag)) {
continue;
}
if (is_object($tag)) {
$result[] = $tag->toArray();
}
$tagIdentifier = $this->getTagIdentifier($tag);
if (isset($tagIdentifiers[$tagIdentifier])) {
continue;
}
$tagIdentifiers[$tagIdentifier] = true;
$existingTag = $this->getExistingTag($tagIdentifier);
if ($existingTag) {
$result[] = array_merge($common, ['id' => $existingTag->id]);
continue;
}
$result[] = array_merge(
$common,
[
'name' => $tagIdentifier,
]
);
}
return $result;
}
protected function getTagIdentifier($tag)
{
if (is_object($tag)) {
return $tag->name;
} else {
return trim($tag);
}
}
protected function getExistingTag($tagName)
{
$tagsTable = $this->_table->Tags->getTarget();
$query = $tagsTable->find()->where([
'Tags.name' => $tagName
])
->select('Tags.id');
return $query->first();
}
public function findByTag(Query $query, array $options) {
$finderField = $optionsKey = $this->getConfig('finderField');
if (!$finderField) {
$finderField = $optionsKey = 'name';
}
if (!isset($options[$optionsKey])) {
throw new RuntimeException(__('Expected key `{0}` not present in find(\'tagged\') options argument.', $optionsKey));
}
$isAndOperator = isset($options['OperatorAND']) ? $options['OperatorAND'] : true;
$filterValue = $options[$optionsKey];
if (!$filterValue) {
return $query;
}
$filterValue = $this->dissectArgs($filterValue);
if (!empty($filterValue['NOT']) || !empty($filterValue['LIKE'])) {
return $this->findByComplexQueryConditions($query, $filterValue, $finderField, $isAndOperator);
}
$subQuery = $this->buildQuerySnippet($filterValue, $finderField, $isAndOperator);
if (is_string($subQuery)) {
$query->matching('Tags', function ($q) use ($finderField, $subQuery) {
$key = 'Tags.' . $finderField;
return $q->where([
$key => $subQuery,
]);
});
return $query;
}
$modelAlias = $this->_table->getAlias();
return $query->where([$modelAlias . '.id IN' => $subQuery]);
}
public function findUntagged(Query $query, array $options) {
$modelAlias = $this->_table->getAlias();
$foreignKey = $this->getConfig('tagsAssoc.foreignKey');
$conditions = ['fk_model' => $modelAlias];
$this->_table->hasOne('NoTags', [
'className' => $this->getConfig('taggedAssoc.className'),
'foreignKey' => $foreignKey,
'conditions' => $conditions
]);
$query = $query->contain(['NoTags'])->where(['NoTags.id IS' => null]);
return $query;
}
protected function dissectArgs($filterValue): array
{
if (!is_array($filterValue)) {
return $filterValue;
}
$dissected = [
'AND' => [],
'NOT' => [],
'LIKE' => [],
];
foreach ($filterValue as $value) {
if (substr($value, 0, 1) == '!') {
$dissected['NOT'][] = substr($value, 1);
}
else if (strpos($value, '%') != false) {
$dissected['LIKE'][] = $value;
} else {
$dissected['AND'][] = $value;
}
}
if (empty($dissected['NOT']) && empty($dissected['LIKE'])) {
return $dissected['AND'];
}
return $dissected;
}
protected function buildQuerySnippet($filterValue, string $finderField, bool $OperatorAND=true)
{
if (!is_array($filterValue)) {
return $filterValue;
}
$key = 'Tags.' . $finderField;
$foreignKey = $this->getConfig('tagsAssoc.foreignKey');
$conditions = [
$key . ' IN' => $filterValue,
];
$query = $this->_table->Tagged->find();
if ($OperatorAND) {
$query->contain(['Tags'])
->group('Tagged.' . $foreignKey)
->having('COUNT(*) = ' . count($filterValue))
->select('Tagged.' . $foreignKey)
->where($conditions);
} else {
$query->contain(['Tags'])
->select('Tagged.' . $foreignKey)
->where($conditions);
}
return $query;
}
protected function findByComplexQueryConditions($query, $filterValue, string $finderField, bool $OperatorAND=true)
{
$key = 'Tags.' . $finderField;
$taggedAlias = 'Tagged';
$foreignKey = $this->getConfig('tagsAssoc.foreignKey');
if (!empty($filterValue['AND'])) {
$subQuery = $this->buildQuerySnippet($filterValue['AND'], $finderField, $OperatorAND);
$modelAlias = $this->_table->getAlias();
$query->where([$modelAlias . '.id IN' => $subQuery]);
}
if (!empty($filterValue['NOT'])) {
$subQuery = $this->buildQuerySnippet($filterValue['NOT'], $finderField, false);
$modelAlias = $this->_table->getAlias();
$query->where([$modelAlias . '.id NOT IN' => $subQuery]);
}
if (!empty($filterValue['LIKE'])) {
$conditions = ['OR' => []];
foreach($filterValue['LIKE'] as $likeValue) {
$conditions['OR'][] = [
$key . ' LIKE' => $likeValue,
];
}
$subQuery = $this->buildQuerySnippet($filterValue['NOT'], $finderField, $OperatorAND);
if ($OperatorAND) {
$subQuery = $this->_table->Tagged->find()
->contain(['Tags'])
->group('Tagged.' . $foreignKey)
->having('COUNT(*) >= ' . count($filterValue['LIKE']))
->select('Tagged.' . $foreignKey)
->where($conditions);
} else {
$subQuery = $this->_table->Tagged->find()
->contain(['Tags'])
->select('Tagged.' . $foreignKey)
->where($conditions);
}
$modelAlias = $this->_table->getAlias();
$query->where([$modelAlias . '.id IN' => $subQuery]);
}
return $query;
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace Tags\Model\Entity;
use App\Model\Entity\AppModel;
class Tag extends AppModel {
protected $_accessible = [
'id' => false,
'counter' => false,
'*' => true,
];
protected $_accessibleOnNew = [
'name' => true,
'colour' => true,
];
protected $_virtual = ['text_colour'];
protected function _getTextColour()
{
$textColour = null;
if (!empty($this->colour)) {
$textColour = $this->getTextColour($this->colour);
}
return $textColour;
}
protected function getTextColour($RGB) {
$r = hexdec(substr($RGB, 1, 2));
$g = hexdec(substr($RGB, 3, 2));
$b = hexdec(substr($RGB, 5, 2));
$average = ((2 * $r) + $b + (3 * $g))/6;
if ($average < 127) {
return 'white';
} else {
return 'black';
}
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace Tags\Model\Entity;
use App\Model\Entity\AppModel;
class Tagged extends AppModel {
protected $_accessible = [
'id' => false,
'*' => true,
];
}

View File

@ -0,0 +1,33 @@
<?php
namespace Tags\Model\Table;
use App\Model\Table\AppTable;
use Cake\Validation\Validator;
class TaggedTable extends AppTable
{
protected $_accessible = [
'id' => false
];
public function initialize(array $config): void
{
$this->setTable('tags_tagged');
$this->belongsTo('Tags', [
'className' => 'Tags.Tags',
'foreignKey' => 'tag_id',
'propertyName' => 'tag',
]);
$this->addBehavior('Timestamp');
}
public function validationDefault(Validator $validator): Validator
{
$validator
->notBlank('fk_model')
->notBlank('fk_id')
->notBlank('tag_id');
return $validator;
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace Tags\Model\Table;
use App\Model\Table\AppTable;
use Cake\Validation\Validator;
class TagsTable extends AppTable
{
protected $_accessible = [
'id' => false
];
public function initialize(array $config): void
{
$this->setTable('tags_tags');
$this->setDisplayField('name'); // Change to name?
$this->addBehavior('Timestamp');
}
public function validationDefault(Validator $validator): Validator
{
$validator
->notBlank('name');
return $validator;
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace Tags;
use Cake\Core\BasePlugin;
use Cake\Core\PluginApplicationInterface;
use Cake\Console\CommandCollection;
use Cake\Http\MiddlewareQueue;
class Plugin extends BasePlugin
{
public function middleware(MiddlewareQueue $middleware): MiddlewareQueue
{
// Add middleware here.
$middleware = parent::middleware($middleware);
return $middleware;
}
public function console(CommandCollection $commands): CommandCollection
{
// Add console commands here.
$commands = parent::console($commands);
return $commands;
}
public function bootstrap(PluginApplicationInterface $app): void
{
// Add constants, load configuration defaults.
// By default will load `config/bootstrap.php` in the plugin.
parent::bootstrap($app);
}
public function routes($routes): void
{
// Add routes.
// By default will load `config/routes.php` in the plugin.
parent::routes($routes);
}
}

View File

@ -0,0 +1,141 @@
<?php
namespace Tags\View\Helper;
use Cake\View\Helper;
use Cake\Utility\Hash;
class TagHelper extends Helper
{
public $helpers = [
'Bootstrap',
'TextColour',
'FontAwesome',
'Form',
'Url',
'Tags.Tag',
];
protected $defaultConfig = [
'default_colour' => '#924da6',
'picker' => false,
'editable' => false,
];
public function control(array $options = [])
{
$field = 'tag_list';
$values = !empty($options['allTags']) ? array_map(function($tag) {
return [
'text' => h($tag['name']),
'value' => h($tag['name']),
'data-colour' => h($tag['colour']),
'data-text-colour' => h($tag['text_colour']),
];
}, $options['allTags']) : [];
$classes = ['tag-input', 'flex-grow-1'];
$url = '';
if (!empty($this->getConfig('editable'))) {
$url = $this->Url->build([
'controller' => $this->getView()->getName(),
'action' => 'tag',
$this->getView()->get('entity')['id']
]);
$classes[] = 'd-none';
}
$selectConfig = [
'multiple' => true,
'class' => $classes,
'data-url' => $url,
];
return $this->Form->select($field, $values, $selectConfig);
}
protected function picker(array $options = [])
{
$html = $this->Tag->control($options);
if (!empty($this->getConfig('editable'))) {
$html .= $this->Bootstrap->button([
'size' => 'sm',
'icon' => 'plus',
'variant' => 'secondary',
'class' => ['badge'],
'params' => [
'onclick' => 'createTagPicker(this)',
]
]);
} else {
$html .= '<script>$(document).ready(function() { initSelect2Pickers() })</script>';
}
return $html;
}
public function tags($tags = [], array $options = [])
{
$tags = is_null($tags) ? [] : $tags;
$this->_config = array_merge($this->defaultConfig, $options);
$html = '<div class="tag-container-wrapper">';
$html .= '<div class="tag-container my-1 d-flex">';
$html .= '<div class="tag-list d-inline-block">';
foreach ($tags as $tag) {
if (is_object($tag)) {
$html .= $this->tag($tag);
} else {
$html .= $this->tag([
'name' => $tag
]);
}
}
$html .= '</div>';
if (!empty($this->getConfig('picker'))) {
$html .= $this->picker($options);
}
$html .= '</div>';
$html .= '</div>';
return $html;
}
public function tag($tag, array $options = [])
{
if (empty($this->_config)) {
$this->_config = array_merge($this->defaultConfig, $options);
}
$tag['colour'] = !empty($tag['colour']) ? $tag['colour'] : $this->getConfig('default_colour');
$textColour = !empty($tag['text_colour']) ? $tag['text_colour'] : $this->TextColour->getTextColour(h($tag['colour']));;
if (!empty($this->getConfig('editable'))) {
$deleteButton = $this->Bootstrap->button([
'size' => 'sm',
'icon' => 'times',
'class' => ['ms-1', 'border-0', "text-${textColour}"],
'variant' => 'text',
'title' => __('Delete tag'),
'params' => [
'onclick' => sprintf('deleteTag(\'%s\', \'%s\', this)',
$this->Url->build([
'controller' => $this->getView()->getName(),
'action' => 'untag',
$this->getView()->get('entity')['id']
]),
h($tag['name'])
),
],
]);
} else {
$deleteButton = '';
}
$html = $this->Bootstrap->genNode('span', [
'class' => [
'tag',
'badge',
'mx-1',
'align-middle',
],
'title' => h($tag['name']),
'style' => sprintf('color:%s; background-color:%s', $textColour, h($tag['colour'])),
], h($tag['name']) . $deleteButton);
return $html;
}
}

View File

@ -0,0 +1,22 @@
<?php
echo $this->element('genericElements/Form/genericForm', array(
'data' => array(
'description' => __('Tags can be attached to entity to quickly classify them, allowing further filtering and searches.'),
'model' => 'Tags',
'fields' => array(
array(
'field' => 'name'
),
array(
'field' => 'colour',
'type' => 'color',
),
),
'metaTemplates' => empty($metaTemplates) ? [] : $metaTemplates,
'submit' => array(
'action' => $this->request->getParam('action')
)
)
));
?>
</div>

View File

@ -0,0 +1,79 @@
<?php
echo $this->element('genericElements/IndexTable/index_table', [
'data' => [
'data' => $data,
'top_bar' => [
'children' => [
[
'type' => 'simple',
'children' => [
'data' => [
'type' => 'simple',
'text' => __('Add tag'),
'popover_url' => '/tags/add'
]
]
],
[
'type' => 'context_filters',
'context_filters' => $filteringContexts
],
[
'type' => 'search',
'button' => __('Filter'),
'placeholder' => __('Enter value to search'),
'data' => '',
'searchKey' => 'value'
]
]
],
'fields' => [
[
'name' => '#',
'sort' => 'id',
'data_path' => 'id',
],
[
'name' => __('Name'),
'sort' => 'name',
'element' => 'tag'
],
[
'name' => __('Counter'),
'sort' => 'couter',
'data_path' => 'counter',
],
[
'name' => __('Colour'),
'sort' => 'colour',
'data_path' => 'colour',
],
[
'name' => __('Created'),
'sort' => 'created',
'data_path' => 'created',
],
],
'title' => __('Tag index'),
'description' => __('The list of all tags existing on this instance'),
'actions' => [
[
'url' => '/tags/view',
'url_params_data_paths' => ['id'],
'icon' => 'eye'
],
[
'open_modal' => '/tags/edit/[onclick_params_data_path]',
'modal_params_data_path' => 'id',
'icon' => 'edit'
],
[
'open_modal' => '/tags/delete/[onclick_params_data_path]',
'modal_params_data_path' => 'id',
'icon' => 'trash'
],
]
]
]);
echo '</div>';
?>

View File

@ -0,0 +1,32 @@
<?php
echo $this->element(
'/genericElements/SingleViews/single_view',
[
'data' => $entity,
'fields' => [
[
'key' => __('ID'),
'path' => 'id'
],
[
'key' => __('Name'),
'path' => '',
'type' => 'tag',
],
[
'key' => __('Counter'),
'path' => 'counter',
'type' => 'string',
],
[
'key' => __('Colour'),
'path' => 'colour',
],
[
'key' => __('Created'),
'path' => 'created',
],
],
'children' => []
]
);

View File

@ -0,0 +1,3 @@
.tag {
filter: drop-shadow(2px 2px 2px rgba(0, 0, 0, 0.5));
}

View File

@ -0,0 +1,142 @@
function createTagPicker(clicked) {
function closePicker($select, $container) {
$select.appendTo($container)
$container.parent().find('.picker-container').remove()
}
function getEditableButtons($select, $container) {
const $saveButton = $('<button></button>').addClass(['btn btn-primary btn-sm', 'align-self-start']).attr('type', 'button')
.append($('<span></span>').text('Save').addClass('text-nowrap').prepend($('<i></i>').addClass('fa fa-save me-1')))
.click(function() {
const tags = $select.select2('data').map(tag => tag.text)
addTags($select.data('url'), tags, $(this))
})
const $cancelButton = $('<button></button>').addClass(['btn btn-secondary btn-sm', 'align-self-start']).attr('type', 'button')
.append($('<span></span>').text('Cancel').addClass('text-nowrap').prepend($('<i></i>').addClass('fa fa-times me-1')))
.click(function() {
closePicker($select, $container)
})
const $buttons = $('<span></span>').addClass(['picker-action', 'btn-group']).append($saveButton, $cancelButton)
return $buttons
}
const $clicked = $(clicked)
const $container = $clicked.closest('.tag-container')
const $select = $container.parent().find('select.tag-input').removeClass('d-none')
closePicker($select, $container)
const $pickerContainer = $('<div></div>').addClass(['picker-container', 'd-flex'])
$select.prependTo($pickerContainer)
$pickerContainer.append(getEditableButtons($select, $container))
$container.parent().append($pickerContainer)
initSelect2Picker($select)
}
function deleteTag(url, tags, clicked) {
if (!Array.isArray(tags)) {
tags = [tags];
}
const data = {
tag_list: JSON.stringify(tags)
}
const $statusNode = $(clicked).closest('.tag')
const APIOptions = {
statusNode: $statusNode,
skipFeedback: true,
}
return AJAXApi.quickFetchAndPostForm(url, data, APIOptions).then((apiResult) => {
let $container = $statusNode.closest('.tag-container-wrapper')
refreshTagList(apiResult, $container).then(($tagContainer) => {
$container = $tagContainer // old container might not exist anymore since it was replaced after the refresh
})
const theToast = UI.toast({
variant: 'success',
title: apiResult.message,
bodyHtml: $('<div/>').append(
$('<span/>').text('Cancel untag operation.'),
$('<button/>').addClass(['btn', 'btn-primary', 'btn-sm', 'ms-3']).text('Restore tag').click(function() {
const split = url.split('/')
const controllerName = split[1]
const id = split[3]
const urlRetag = `/${controllerName}/tag/${id}`
addTags(urlRetag, tags, $container.find('.tag-container')).then(() => {
theToast.removeToast()
})
}),
),
})
}).catch((e) => {})
}
function addTags(url, tags, $statusNode) {
const data = {
tag_list: JSON.stringify(tags)
}
const APIOptions = {
statusNode: $statusNode
}
return AJAXApi.quickFetchAndPostForm(url, data, APIOptions).then((apiResult) => {
const $container = $statusNode.closest('.tag-container-wrapper')
refreshTagList(apiResult, $container)
}).catch((e) => {})
}
function refreshTagList(apiResult, $container) {
const controllerName = apiResult.url.split('/')[1]
const entityId = apiResult.data.id
const url = `/${controllerName}/viewTags/${entityId}`
return UI.reload(url, $container)
}
function initSelect2Pickers() {
$('select.tag-input').each(function() {
if (!$(this).hasClass("select2-hidden-accessible")) {
initSelect2Picker($(this))
}
})
}
function initSelect2Picker($select) {
function templateTag(state, $select) {
if (!state.id) {
return state.name;
}
if (state.colour === undefined) {
state.colour = $(state.element).data('colour')
}
if ($select !== undefined && state.text[0] === '!') {
// fetch corresponding tag and set colors?
// const baseTag = state.text.slice(1)
// const existingBaseTag = $select.find('option').filter(function() {
// return $(this).val() === baseTag
// })
// if (existingBaseTag.length > 0) {
// state.colour = existingBaseTag.data('colour')
// state.text = baseTag
// }
}
return buildTag(state)
}
$select.select2({
placeholder: 'Pick a tag',
tags: true,
width: '100%',
templateResult: (state) => templateTag(state),
templateSelection: (state) => templateTag(state, $select),
})
}
function buildTag(options={}) {
if (!options.colour) {
options.colour = '#924da6'
}
const $tag = $('<span/>')
.addClass(['tag', 'badge', 'align-text-top'])
.css({color: getTextColour(options.colour), 'background-color': options.colour})
.text(options.text)
return $tag
}

View File

@ -29,6 +29,10 @@ use Authentication\AuthenticationServiceInterface;
use Authentication\AuthenticationServiceProviderInterface;
use Authentication\Middleware\AuthenticationMiddleware;
use Psr\Http\Message\ServerRequestInterface;
use Tags\Plugin as TagsPlugin;
use App\Event\SocialAuthListener;
use Cake\Event\EventManager;
/**
* Application setup class.
*
@ -44,6 +48,8 @@ class Application extends BaseApplication implements AuthenticationServiceProvid
*/
public function bootstrap(): void
{
$this->addPlugin('ADmad/SocialAuth');
// Call parent to load bootstrap from files.
parent::bootstrap();
@ -59,6 +65,8 @@ class Application extends BaseApplication implements AuthenticationServiceProvid
$this->addPlugin('DebugKit');
}
$this->addPlugin('Authentication');
$this->addPlugin('Tags', ['routes' => true]);
EventManager::instance()->on(new SocialAuthListener());
// Load more plugins here
}
@ -86,8 +94,32 @@ class Application extends BaseApplication implements AuthenticationServiceProvid
// creating the middleware instance specify the cache config name by
// using it's second constructor argument:
// `new RoutingMiddleware($this, '_cake_routes_')`
->add(new RoutingMiddleware($this))
->add(new AuthenticationMiddleware($this))
->add(new RoutingMiddleware($this));
if (!empty(Configure::read('keycloak'))) {
$middlewareQueue->add(new \ADmad\SocialAuth\Middleware\SocialAuthMiddleware([
'requestMethod' => 'POST',
'loginUrl' => '/users/login',
'loginRedirect' => '/',
'userEntity' => false,
'userModel' => 'Users',
'socialProfileModel' => 'ADmad/SocialAuth.SocialProfiles',
'finder' => 'all',
'fields' => [
'password' => 'password',
],
'sessionKey' => 'Auth',
'getUserCallback' => 'getUser',
'serviceConfig' => [
'provider' => [
'keycloak' => Configure::read('keycloak.provider')
],
],
'collectionFactory' => null,
'logErrors' => true,
]));
}
$middlewareQueue->add(new AuthenticationMiddleware($this))
->add(new BodyParserMiddleware());
return $middlewareQueue;
}

View File

@ -40,6 +40,7 @@ class AppController extends Controller
public $isRest = null;
public $restResponsePayload = null;
public $user = null;
public $breadcrumb = [];
/**
* Initialization hook method.
@ -63,9 +64,10 @@ class AppController extends Controller
]);
$this->loadModel('MetaFields');
$this->loadModel('MetaTemplates');
$table = $this->getTableLocator()->get($this->modelClass);
$this->loadComponent('CRUD', [
'request' => $this->request,
'table' => $this->{$this->modelClass},
'table' => $table,
'MetaFields' => $this->MetaFields,
'MetaTemplates' => $this->MetaTemplates
]);
@ -74,6 +76,9 @@ class AppController extends Controller
'request' => $this->request,
'Authentication' => $this->Authentication
]);
$this->loadComponent('Navigation', [
'request' => $this->request,
]);
if (Configure::read('debug')) {
Configure::write('DebugKit.panels', ['DebugKit.Packages' => true]);
Configure::write('DebugKit.forceEnable', true);
@ -122,10 +127,15 @@ class AppController extends Controller
$this->ACL->checkAccess();
$this->set('menu', $this->ACL->getMenu());
$this->set('breadcrumb', $this->Navigation->getBreadcrumb());
$this->set('ajax', $this->request->is('ajax'));
$this->request->getParam('prefix');
$this->set('darkMode', !empty(Configure::read('Cerebrate.dark')));
$this->set('baseurl', Configure::read('App.fullBaseUrl'));
$this->set('bsTheme', Configure::read('Cerebrate')['ui.bsTheme']);
if ($this->modelClass == 'Tags.Tags') {
$this->set('metaGroup', !empty($this->isAdmin) ? 'Administration' : 'Cerebrate');
}
}
private function authApiUser(): void

View File

@ -14,12 +14,16 @@ use Cake\Error\Debugger;
class AuthKeysController extends AppController
{
public $filterFields = ['Users.username', 'authkey', 'comment', 'Users.id'];
public $quickFilterFields = ['authkey', ['comment' => true]];
public $containFields = ['Users'];
public function index()
{
$this->CRUD->index([
'filters' => ['Users.username', 'authkey', 'comment', 'Users.id'],
'quickFilters' => ['authkey', 'comment'],
'contain' => ['Users'],
'filters' => $this->filterFields,
'quickFilters' => $this->quickFilterFields,
'contain' => $this->containFields,
'exclude_fields' => ['authkey']
]);
$responsePayload = $this->CRUD->getResponsePayload();

View File

@ -10,17 +10,21 @@ use Cake\ORM\TableRegistry;
class BroodsController extends AppController
{
public $filterFields = ['Broods.name', 'Broods.uuid', 'Broods.url', 'Broods.description', 'Organisations.id', 'Broods.trusted', 'pull', 'authkey'];
public $quickFilterFields = [['Broods.name' => true], 'Broods.uuid', ['Broods.description' => true]];
public $containFields = ['Organisations'];
public function index()
{
$this->CRUD->index([
'filters' => ['Broods.name', 'Broods.uuid', 'Broods.url', 'Broods.description', 'Organisations.id', 'Broods.trusted', 'pull', 'authkey'],
'quickFilters' => [['Broods.name' => true], 'Broods.uuid', ['Broods.description' => true]],
'filters' => $this->filterFields,
'quickFilters' => $this->quickFilterFields,
'contextFilters' => [
'fields' => [
'pull',
]
],
'contain' => ['Organisations']
'contain' => $this->containFields
]);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {

View File

@ -11,11 +11,13 @@ use Cake\ORM\TableRegistry;
use Cake\Core\Configure;
use Cake\Core\Configure\Engine\PhpConfig;
use Cake\Utility\Inflector;
use Cake\Routing\Router;
class ACLComponent extends Component
{
private $user = null;
protected $components = ['Navigation'];
public function initialize(array $config): void
{
@ -453,447 +455,7 @@ class ACLComponent extends Component
public function getMenu()
{
$open = Configure::read('Cerebrate.open');
$menu = [
'ContactDB' => [
'Individuals' => [
'label' => __('Individuals'),
'url' => '/individuals/index',
'children' => [
'index' => [
'url' => '/individuals/index',
'label' => __('List individuals')
],
'add' => [
'url' => '/individuals/add',
'label' => __('Add individual'),
'popup' => 1
],
'view' => [
'url' => '/individuals/view/{{id}}',
'label' => __('View individual'),
'actions' => ['delete', 'edit', 'view'],
'skipTopMenu' => 1
],
'edit' => [
'url' => '/individuals/edit/{{id}}',
'label' => __('Edit individual'),
'actions' => ['edit', 'delete', 'view'],
'skipTopMenu' => 1,
'popup' => 1
],
'delete' => [
'url' => '/individuals/delete/{{id}}',
'label' => __('Delete individual'),
'actions' => ['delete', 'edit', 'view'],
'skipTopMenu' => 1,
'popup' => 1
]
]
],
'Organisations' => [
'label' => __('Organisations'),
'url' => '/organisations/index',
'children' => [
'index' => [
'url' => '/organisations/index',
'label' => __('List organisations')
],
'add' => [
'url' => '/organisations/add',
'label' => __('Add organisation'),
'popup' => 1
],
'view' => [
'url' => '/organisations/view/{{id}}',
'label' => __('View organisation'),
'actions' => ['delete', 'edit', 'view'],
'skipTopMenu' => 1
],
'edit' => [
'url' => '/organisations/edit/{{id}}',
'label' => __('Edit organisation'),
'actions' => ['edit', 'delete', 'view'],
'skipTopMenu' => 1,
'popup' => 1
],
'delete' => [
'url' => '/organisations/delete/{{id}}',
'label' => __('Delete organisation'),
'actions' => ['delete', 'edit', 'view'],
'skipTopMenu' => 1,
'popup' => 1
]
]
],
'EncryptionKeys' => [
'label' => __('Encryption keys'),
'url' => '/encryptionKeys/index',
'children' => [
'index' => [
'url' => '/encryptionKeys/index',
'label' => __('List encryption keys')
],
'add' => [
'url' => '/encryptionKeys/add',
'label' => __('Add encryption key'),
'popup' => 1
],
'edit' => [
'url' => '/encryptionKeys/edit/{{id}}',
'label' => __('Edit organisation'),
'actions' => ['edit'],
'skipTopMenu' => 1,
'popup' => 1
]
]
]
],
'Trust Circles' => [
'SharingGroups' => [
'label' => __('Sharing Groups'),
'url' => '/sharingGroups/index',
'children' => [
'index' => [
'url' => '/sharingGroups/index',
'label' => __('List sharing groups')
],
'add' => [
'url' => '/SharingGroups/add',
'label' => __('Add sharing group'),
'popup' => 1
],
'edit' => [
'url' => '/SharingGroups/edit/{{id}}',
'label' => __('Edit sharing group'),
'actions' => ['edit', 'view'],
'skipTopMenu' => 1,
'popup' => 1
],
'delete' => [
'url' => '/SharingGroups/delete/{{id}}',
'label' => __('Delete sharing group'),
'actions' => ['delete', 'edit', 'view'],
'skipTopMenu' => 1,
'popup' => 1
]
]
]
],
'Sync' => [
'Broods' => [
'label' => __('Broods'),
'url' => '/broods/index',
'children' => [
'index' => [
'url' => '/broods/index',
'label' => __('List broods')
],
'add' => [
'url' => '/broods/add',
'label' => __('Add brood'),
'popup' => 1
],
'view' => [
'url' => '/broods/view/{{id}}',
'label' => __('View brood'),
'actions' => ['delete', 'edit', 'view'],
'skipTopMenu' => 1
],
'edit' => [
'url' => '/broods/edit/{{id}}',
'label' => __('Edit brood'),
'actions' => ['edit', 'delete', 'view'],
'skipTopMenu' => 1,
'popup' => 1
],
'delete' => [
'url' => '/broods/delete/{{id}}',
'label' => __('Delete brood'),
'actions' => ['delete', 'edit', 'view'],
'skipTopMenu' => 1,
'popup' => 1
]
]
]
],
'Administration' => [
'Roles' => [
'label' => __('Roles'),
'url' => '/roles/index',
'children' => [
'index' => [
'url' => '/roles/index',
'label' => __('List roles')
],
'add' => [
'url' => '/roles/add',
'label' => __('Add role'),
'popup' => 1
],
'view' => [
'url' => '/roles/view/{{id}}',
'label' => __('View role'),
'actions' => ['delete', 'edit', 'view'],
'skipTopMenu' => 1
],
'edit' => [
'url' => '/roles/edit/{{id}}',
'label' => __('Edit role'),
'actions' => ['edit', 'delete', 'view'],
'skipTopMenu' => 1,
'popup' => 1
],
'delete' => [
'url' => '/roles/delete/{{id}}',
'label' => __('Delete role'),
'actions' => ['delete', 'edit', 'view'],
'skipTopMenu' => 1,
'popup' => 1
]
]
],
'Users' => [
'label' => __('Users'),
'url' => '/users/index',
'children' => [
'index' => [
'url' => '/users/index',
'label' => __('List users')
],
'add' => [
'url' => '/users/add',
'label' => __('Add user'),
'popup' => 1
],
'view' => [
'url' => '/users/view/{{id}}',
'label' => __('View user'),
'actions' => ['delete', 'edit', 'view'],
'skipTopMenu' => 1
],
'edit' => [
'url' => '/users/edit/{{id}}',
'label' => __('Edit user'),
'actions' => ['edit', 'delete', 'view'],
'skipTopMenu' => 1,
'popup' => 1
],
'delete' => [
'url' => '/users/delete/{{id}}',
'label' => __('Delete user'),
'actions' => ['delete', 'edit', 'view'],
'skipTopMenu' => 1,
'popup' => 1
]
]
],
'Inbox' => [
'label' => __('Inbox'),
'url' => '/inbox/index',
'children' => [
'index' => [
'url' => '/inbox/index',
'label' => __('Inbox')
],
'outbox' => [
'url' => '/outbox/index',
'label' => __('Outbox')
],
'view' => [
'url' => '/inbox/view/{{id}}',
'label' => __('View Message'),
'actions' => ['delete', 'edit', 'view'],
'skipTopMenu' => 1
],
'delete' => [
'url' => '/inbox/delete/{{id}}',
'label' => __('Delete Message'),
'actions' => ['delete', 'edit', 'view'],
'skipTopMenu' => 1,
'popup' => 1
],
'listProcessors' => [
'url' => '/inbox/listProcessors',
'label' => __('List Inbox Processors'),
'skipTopMenu' => 1
]
]
],
'Outbox' => [
'label' => __('Outbox'),
'url' => '/outbox/index',
'children' => [
'index' => [
'url' => '/outbox/index',
'label' => __('Outbox'),
'skipTopMenu' => 1
],
'view' => [
'url' => '/outbox/view/{{id}}',
'label' => __('View Message'),
'actions' => ['delete', 'edit', 'view'],
'skipTopMenu' => 1
],
'delete' => [
'url' => '/outbox/delete/{{id}}',
'label' => __('Delete Message'),
'actions' => ['delete', 'edit', 'view'],
'skipTopMenu' => 1,
'popup' => 1
],
'listProcessors' => [
'url' => '/outbox/listProcessors',
'label' => __('List Outbox Processors'),
'skipTopMenu' => 1
]
]
],
'MetaTemplates' => [
'label' => __('Meta Field Templates'),
'url' => '/metaTemplates/index',
'children' => [
'index' => [
'url' => '/metaTemplates/index',
'label' => __('List Meta Templates')
],
'view' => [
'url' => '/metaTemplates/view/{{id}}',
'label' => __('View Meta Template'),
'actions' => ['delete', 'edit', 'view'],
'skipTopMenu' => 1
],
'delete' => [
'url' => '/metaTemplates/delete/{{id}}',
'label' => __('Delete Meta Template'),
'actions' => ['delete', 'edit', 'view'],
'skipTopMenu' => 1,
'popup' => 1
],
'update' => [
'url' => '/metaTemplates/update',
'label' => __('Update Meta Templates'),
'actions' => ['index', 'view'],
'skipTopMenu' => 1,
'popup' => 1
]
]
],
'LocalTools' => [
'label' => __('Local Tools'),
'url' => '/localTools/index',
'children' => [
'index' => [
'url' => '/localTools/index',
'label' => __('List Connectors')
],
'viewConnector' => [
'url' => '/localTools/viewConnector/{{connector}}',
'label' => __('View Connector'),
'actions' => ['view'],
'skipTopMenu' => 1
],
'add' => [
'url' => '/localTools/add/{{connector}}',
'label' => __('Add connection'),
'actions' => ['viewConnector'],
'skipTopMenu' => 1
],
'view' => [
'url' => '/localTools/view/{{id}}',
'label' => __('View Connection'),
'actions' => ['view'],
'skipTopMenu' => 1
]
]
],
'Instance' => [
__('Instance'),
'url' => '/instance/home',
'children' => [
'migration' => [
'url' => '/instance/migrationIndex',
'label' => __('Database migration')
]
]
],
],
'Cerebrate' => [
'Roles' => [
'label' => __('Roles'),
'url' => '/roles/index',
'children' => [
'index' => [
'url' => '/roles/index',
'label' => __('List roles')
],
'view' => [
'url' => '/roles/view/{{id}}',
'label' => __('View role'),
'actions' => ['delete', 'edit', 'view'],
'skipTopMenu' => 1
],
'delete' => [
'url' => '/roles/delete/{{id}}',
'label' => __('Delete Role'),
'actions' => ['delete', 'edit', 'view'],
'skipTopMenu' => 1,
'popup' => 1
]
]
],
'Instance' => [
__('Instance'),
'url' => '/instance/home',
'children' => [
'home' => [
'url' => '/instance/home',
'label' => __('Home')
],
]
],
'Users' => [
__('My Profile'),
'children' => [
'View My Profile' => [
'url' => '/users/view',
'label' => __('View My Profile')
],
'Edit My Profile' => [
'url' => '/users/edit',
'label' => __('Edit My Profile'),
'actions' => ['delete', 'edit', 'view'],
'skipTopMenu' => 1,
'popup' => 1
]
]
]
],
'Open' => [
'Organisations' => [
'label' => __('Organisations'),
'url' => '/open/organisations/index',
'children' => [
'index' => [
'url' => '/open/organisations/index',
'label' => __('List organisations')
],
],
'open' => in_array('organisations', Configure::read('Cerebrate.open'))
],
'Individuals' => [
'label' => __('Individuals'),
'url' => '/open/individuals/index',
'children' => [
'index' => [
'url' => '/open/individuals/index',
'label' => __('List individuals')
],
],
'open' => in_array('individuals', Configure::read('Cerebrate.open'))
]
]
];
$menu = $this->Navigation->getSideMenu();
foreach ($menu as $group => $subMenu) {
foreach ($subMenu as $subMenuElementName => $subMenuElement) {
if (!empty($subMenuElement['url']) && !$this->checkAccessUrl($subMenuElement['url'], true) === true) {

View File

@ -7,6 +7,7 @@ use Cake\Error\Debugger;
use Cake\Utility\Hash;
use Cake\Utility\Inflector;
use Cake\View\ViewBuilder;
use Cake\ORM\TableRegistry;
use Cake\Http\Exception\MethodNotAllowedException;
use Cake\Http\Exception\NotFoundException;
@ -34,6 +35,9 @@ class CRUDComponent extends Component
$options['filters'][] = 'quickFilter';
}
$options['filters'][] = 'filteringLabel';
if ($this->taggingSupported()) {
$options['filters'][] = 'filteringTags';
}
$optionFilters = empty($options['filters']) ? [] : $options['filters'];
foreach ($optionFilters as $i => $filter) {
@ -49,6 +53,9 @@ class CRUDComponent extends Component
if (!empty($options['contain'])) {
$query->contain($options['contain']);
}
if ($this->taggingSupported()) {
$query->contain('Tags');
}
if (!empty($options['fields'])) {
$query->select($options['fields']);
}
@ -72,15 +79,17 @@ class CRUDComponent extends Component
$data = $this->Table->{$options['afterFind']}($data);
}
}
if (!empty($options['contextFilters'])) {
$this->setFilteringContext($options['contextFilters'], $params);
}
$this->setFilteringContext($options['contextFilters'] ?? [], $params);
$this->Controller->set('data', $data);
}
}
public function filtering(): void
{
if ($this->taggingSupported()) {
$this->Controller->set('taggingEnabled', true);
$this->setAllTags();
}
$filters = !empty($this->Controller->filters) ? $this->Controller->filters : [];
$this->Controller->set('filters', $filters);
$this->Controller->viewBuilder()->setLayout('ajax');
@ -140,8 +149,14 @@ class CRUDComponent extends Component
$patchEntityParams['fields'] = $params['fields'];
}
$data = $this->Table->patchEntity($data, $input, $patchEntityParams);
if (isset($params['beforeSave'])) {
$data = $params['beforeSave']($data);
}
$savedData = $this->Table->save($data);
if ($savedData !== false) {
if (isset($params['afterSave'])) {
$params['afterSave']($data);
}
$message = __('{0} added.', $this->ObjectAlias);
if (!empty($input['metaFields'])) {
$this->saveMetaFields($data->id, $input);
@ -184,7 +199,7 @@ class CRUDComponent extends Component
$this->Controller->set('entity', $data);
}
private function prepareValidationMessage($errors)
public function prepareValidationMessage($errors)
{
$validationMessage = '';
if (!empty($errors)) {
@ -243,7 +258,11 @@ class CRUDComponent extends Component
throw new NotFoundException(__('Invalid {0}.', $this->ObjectAlias));
}
$this->getMetaTemplates();
$data = $this->Table->get($id, isset($params['get']) ? $params['get'] : []);
if ($this->taggingSupported()) {
$params['contain'][] = 'Tags';
$this->setAllTags();
}
$data = $this->Table->get($id, isset($params['get']) ? $params['get'] : $params);
$data = $this->getMetaFields($id, $data);
if (!empty($params['fields'])) {
$this->Controller->set('fields', $params['fields']);
@ -257,8 +276,14 @@ class CRUDComponent extends Component
$patchEntityParams['fields'] = $params['fields'];
}
$data = $this->Table->patchEntity($data, $input, $patchEntityParams);
if (isset($params['beforeSave'])) {
$data = $params['beforeSave']($data);
}
$savedData = $this->Table->save($data);
if ($savedData !== false) {
if (isset($params['afterSave'])) {
$params['afterSave']($data);
}
$message = __('{0} `{1}` updated.', $this->ObjectAlias, $savedData->{$this->Table->getDisplayField()});
if (!empty($input['metaFields'])) {
$this->MetaFields->deleteAll(['scope' => $this->Table->metaFields, 'parent_id' => $savedData->id]);
@ -280,8 +305,9 @@ class CRUDComponent extends Component
$validationErrors = $data->getErrors();
$validationMessage = $this->prepareValidationMessage($validationErrors);
$message = __(
__('{0} could not be modified.'),
$this->ObjectAlias
'{0} could not be modified.{1}',
$this->ObjectAlias,
empty($validationMessage) ? '' : PHP_EOL . __('Reason:{0}', $validationMessage)
);
if ($this->Controller->ParamHandler->isRest()) {
} else if ($this->Controller->ParamHandler->isAjax()) {
@ -349,6 +375,11 @@ class CRUDComponent extends Component
throw new NotFoundException(__('Invalid {0}.', $this->ObjectAlias));
}
if ($this->taggingSupported()) {
$params['contain'][] = 'Tags';
$this->setAllTags();
}
$data = $this->Table->get($id, $params);
$data = $this->attachMetaData($id, $data);
if (isset($params['afterFind'])) {
@ -403,6 +434,149 @@ class CRUDComponent extends Component
$this->Controller->render('/genericTemplates/delete');
}
public function tag($id=false): void
{
if (!$this->taggingSupported()) {
throw new Exception("Table {$this->TableAlias} does not support tagging");
}
if ($this->request->is('get')) {
$this->setAllTags();
if(!empty($id)) {
$params = [
'contain' => 'Tags',
];
$entity = $this->Table->get($id, $params);
$this->Controller->set('id', $entity->id);
$this->Controller->set('data', $entity);
$this->Controller->set('bulkEnabled', false);
} else {
$this->Controller->set('bulkEnabled', true);
}
} else if ($this->request->is('post') || $this->request->is('delete')) {
$ids = $this->getIdsOrFail($id);
$isBulk = count($ids) > 1;
$bulkSuccesses = 0;
foreach ($ids as $id) {
$params = [
'contain' => 'Tags',
];
$entity = $this->Table->get($id, $params);
$input = $this->request->getData();
$tagsToAdd = json_decode($input['tag_list']);
// patching will mirror tag in the DB, however, we only want to add tags
$input['tags'] = array_merge($tagsToAdd, $entity->tags);
$patchEntityParams = [
'fields' => ['tags'],
];
$entity = $this->Table->patchEntity($entity, $input, $patchEntityParams);
$savedData = $this->Table->save($entity);
$success = true;
if ($success) {
$bulkSuccesses++;
}
}
$message = $this->getMessageBasedOnResult(
$bulkSuccesses == count($ids),
$isBulk,
__('{0} tagged with `{1}`.', $this->ObjectAlias, $input['tag_list']),
__('All {0} have been tagged.', Inflector::pluralize($this->ObjectAlias)),
__('Could not tag {0} with `{1}`.', $this->ObjectAlias, $input['tag_list']),
__('{0} / {1} {2} have been tagged.',
$bulkSuccesses,
count($ids),
Inflector::pluralize($this->ObjectAlias)
)
);
$this->setResponseForController('tag', $bulkSuccesses, $message, $savedData);
}
$this->Controller->viewBuilder()->setLayout('ajax');
$this->Controller->render('/genericTemplates/tagForm');
}
public function untag($id=false): void
{
if (!$this->taggingSupported()) {
throw new Exception("Table {$this->TableAlias} does not support tagging");
}
if ($this->request->is('get')) {
$this->setAllTags();
if(!empty($id)) {
$params = [
'contain' => 'Tags',
];
$entity = $this->Table->get($id, $params);
$this->Controller->set('id', $entity->id);
$this->Controller->set('data', $entity);
$this->Controller->set('bulkEnabled', false);
} else {
$this->Controller->set('bulkEnabled', true);
}
} else if ($this->request->is('post') || $this->request->is('delete')) {
$ids = $this->getIdsOrFail($id);
$isBulk = count($ids) > 1;
$bulkSuccesses = 0;
foreach ($ids as $id) {
$params = [
'contain' => 'Tags',
];
$entity = $this->Table->get($id, $params);
$input = $this->request->getData();
$tagsToRemove = json_decode($input['tag_list']);
// patching will mirror tag in the DB, however, we only want to remove tags
$input['tags'] = array_filter($entity->tags, function ($existingTag) use ($tagsToRemove) {
return !in_array($existingTag->name, $tagsToRemove);
});
$patchEntityParams = [
'fields' => ['tags'],
];
$entity = $this->Table->patchEntity($entity, $input, $patchEntityParams);
$savedData = $this->Table->save($entity);
$success = true;
if ($success) {
$bulkSuccesses++;
}
}
$message = $this->getMessageBasedOnResult(
$bulkSuccesses == count($ids),
$isBulk,
__('{0} untagged with `{1}`.', $this->ObjectAlias, implode(', ', $tagsToRemove)),
__('All {0} have been untagged.', Inflector::pluralize($this->ObjectAlias)),
__('Could not untag {0} with `{1}`.', $this->ObjectAlias, $input['tag_list']),
__('{0} / {1} {2} have been untagged.',
$bulkSuccesses,
count($ids),
Inflector::pluralize($this->ObjectAlias)
)
);
$this->setResponseForController('tag', $bulkSuccesses, $message, $entity);
}
$this->Controller->viewBuilder()->setLayout('ajax');
$this->Controller->render('/genericTemplates/tagForm');
}
public function viewTags(int $id, array $params = []): void
{
if (!$this->taggingSupported()) {
throw new Exception("Table {$this->TableAlias} does not support tagging");
}
if (empty($id)) {
throw new NotFoundException(__('Invalid {0}.', $this->ObjectAlias));
}
$params['contain'][] = 'Tags';
$data = $this->Table->get($id, $params);
if (isset($params['afterFind'])) {
$data = $params['afterFind']($data);
}
if ($this->Controller->ParamHandler->isRest()) {
$this->Controller->restResponsePayload = $this->RestResponse->viewData($data, 'json');
}
$this->Controller->set('entity', $data);
$this->setAllTags();
$this->Controller->viewBuilder()->setLayout('ajax');
$this->Controller->render('/genericTemplates/tag');
}
public function setResponseForController($action, $success, $message, $data=[], $errors=null)
{
if ($success) {
@ -494,7 +668,7 @@ class CRUDComponent extends Component
return $massagedFilters;
}
protected function setQuickFilters(array $params, \Cake\ORM\Query $query, array $quickFilterFields): \Cake\ORM\Query
public function setQuickFilters(array $params, \Cake\ORM\Query $query, array $quickFilterFields): \Cake\ORM\Query
{
$queryConditions = [];
$this->Controller->set('quickFilter', empty($quickFilterFields) ? [] : $quickFilterFields);
@ -521,6 +695,8 @@ class CRUDComponent extends Component
{
$filteringLabel = !empty($params['filteringLabel']) ? $params['filteringLabel'] : '';
unset($params['filteringLabel']);
$filteringTags = !empty($params['filteringTags']) && $this->taggingSupported() ? $params['filteringTags'] : '';
unset($params['filteringTags']);
$customFilteringFunction = '';
$chosenFilter = '';
if (!empty($options['contextFilters']['custom'])) {
@ -564,10 +740,26 @@ class CRUDComponent extends Component
}
}
}
if ($this->taggingSupported() && !empty($filteringTags)) {
$activeFilters['filteringTags'] = $filteringTags;
$query = $this->setTagFilters($query, $filteringTags);
}
$this->Controller->set('activeFilters', $activeFilters);
return $query;
}
protected function setTagFilters($query, $tags)
{
$modelAlias = $this->Table->getAlias();
$subQuery = $this->Table->find('tagged', [
'name' => $tags,
'forceAnd' => true
])->select($modelAlias . '.id');
return $query->where([$modelAlias . '.id IN' => $subQuery]);
}
protected function setNestedRelatedCondition($query, $filterParts, $filterValue)
{
$modelName = $filterParts[0];
@ -682,6 +874,18 @@ class CRUDComponent extends Component
return $prefixedConditions;
}
public function taggingSupported()
{
return $this->Table->behaviors()->has('Tag');
}
public function setAllTags()
{
$this->Tags = TableRegistry::getTableLocator()->get('Tags.Tags');
$allTags = $this->Tags->find()->all()->toList();
$this->Controller->set('allTags', $allTags);
}
public function toggle(int $id, string $fieldName = 'enabled', array $params = []): void
{
if (empty($id)) {

View File

@ -0,0 +1,430 @@
<?php
namespace App\Controller\Component;
use Cake\Controller\Component;
use Cake\Core\Configure;
use Cake\Utility\Inflector;
use Cake\Utility\Hash;
use Cake\Routing\Router;
use Cake\ORM\TableRegistry;
class NavigationComponent extends Component
{
private $user = null;
public $breadcrumb = null;
public $iconToTableMapping = [
'Individuals' => 'address-book',
'Organisations' => 'building',
'EncryptionKeys' => 'key',
'SharingGroups' => 'user-friends',
'Broods' => 'network-wired',
'Roles' => 'id-badge',
'Users' => 'users',
'Inbox' => 'inbox',
'Outbox' => 'inbox',
'MetaTemplates' => 'object-group',
'LocalTools' => 'tools',
'Instance' => 'server',
'Tags' => 'tags',
];
public function initialize(array $config): void
{
$this->request = $config['request'];
}
public function beforeFilter($event)
{
$this->fullBreadcrumb = $this->genBreadcrumb();
$this->breadcrumb = $this->getBreadcrumb();
}
public function getSideMenu(): array
{
return [
'ContactDB' => [
'Individuals' => [
'label' => __('Individuals'),
'icon' => $this->iconToTableMapping['Individuals'],
'url' => '/individuals/index',
],
'Organisations' => [
'label' => __('Organisations'),
'icon' => $this->iconToTableMapping['Organisations'],
'url' => '/organisations/index',
],
'EncryptionKeys' => [
'label' => __('Encryption keys'),
'icon' => $this->iconToTableMapping['EncryptionKeys'],
'url' => '/encryptionKeys/index',
]
],
'Trust Circles' => [
'SharingGroups' => [
'label' => __('Sharing Groups'),
'icon' => $this->iconToTableMapping['SharingGroups'],
'url' => '/sharingGroups/index',
]
],
'Sync' => [
'Broods' => [
'label' => __('Broods'),
'icon' => $this->iconToTableMapping['Broods'],
'url' => '/broods/index',
]
],
'Administration' => [
'Roles' => [
'label' => __('Roles'),
'icon' => $this->iconToTableMapping['Roles'],
'url' => '/roles/index',
],
'Users' => [
'label' => __('Users'),
'icon' => $this->iconToTableMapping['Users'],
'url' => '/users/index',
],
'Messages' => [
'label' => __('Messages'),
'icon' => $this->iconToTableMapping['Inbox'],
'url' => '/inbox/index',
'children' => [
'index' => [
'url' => '/inbox/index',
'label' => __('Inbox')
],
'outbox' => [
'url' => '/outbox/index',
'label' => __('Outbox')
],
]
],
'Add-ons' => [
'label' => __('Add-ons'),
'icon' => 'puzzle-piece',
'children' => [
'MetaTemplates.index' => [
'label' => __('Meta Field Templates'),
'icon' => $this->iconToTableMapping['MetaTemplates'],
'url' => '/metaTemplates/index',
],
'LocalTools.index' => [
'label' => __('Local Tools'),
'icon' => $this->iconToTableMapping['LocalTools'],
'url' => '/localTools/index',
],
'Tags.index' => [
'label' => __('Tags'),
'icon' => $this->iconToTableMapping['Tags'],
'url' => '/tags/index',
],
]
],
'Instance' => [
'label' => __('Instance'),
'icon' => $this->iconToTableMapping['Instance'],
'children' => [
'Settings' => [
'label' => __('Settings'),
'url' => '/instance/settings',
'icon' => 'cogs',
],
'Database' => [
'label' => __('Database'),
'url' => '/instance/migrationIndex',
'icon' => 'database',
],
]
],
],
'Open' => [
'Organisations' => [
'label' => __('Organisations'),
'icon' => $this->iconToTableMapping['Organisations'],
'url' => '/open/organisations/index',
'children' => [
'index' => [
'url' => '/open/organisations/index',
'label' => __('List organisations')
],
],
'open' => in_array('organisations', Configure::read('Cerebrate.open'))
],
'Individuals' => [
'label' => __('Individuals'),
'icon' => $this->iconToTableMapping['Individuals'],
'url' => '/open/individuals/index',
'children' => [
'index' => [
'url' => '/open/individuals/index',
'label' => __('List individuals')
],
],
'open' => in_array('individuals', Configure::read('Cerebrate.open'))
]
]
];
}
public function getBreadcrumb(): array
{
$controller = $this->request->getParam('controller');
$action = $this->request->getParam('action');
if (empty($this->fullBreadcrumb[$controller]['routes']["{$controller}:{$action}"])) {
return [[
'label' => $controller,
'url' => Router::url(['controller' => $controller, 'action' => $action]),
]]; // no breadcrumb defined for this endpoint
}
$currentRoute = $this->fullBreadcrumb[$controller]['routes']["{$controller}:{$action}"];
$breadcrumbPath = $this->getBreadcrumbPath("{$controller}:{$action}", $currentRoute);
return $breadcrumbPath['objects'];
}
public function getBreadcrumbPath(string $startRoute, array $currentRoute): array
{
$route = $startRoute;
$path = [
'routes' => [],
'objects' => [],
];
$visited = [];
while (empty($visited[$route])) {
$visited[$route] = true;
$path['routes'][] = $route;
$path['objects'][] = $currentRoute;
if (!empty($currentRoute['after'])) {
$route = $currentRoute['after'];
$split = explode(':', $currentRoute['after']);
$currentRoute = $this->fullBreadcrumb[$split[0]]['routes'][$currentRoute['after']];
}
}
$path['routes'] = array_reverse($path['routes']);
$path['objects'] = array_reverse($path['objects']);
return $path;
}
private function insertInheritance(array $config, array $fullConfig): array
{
if (!empty($config['routes'])) {
foreach ($config['routes'] as $routeName => $value) {
$config['routes'][$routeName]['route_path'] = $routeName;
if (!empty($value['inherit'])) {
$default = $config['defaults'][$value['inherit']] ?? [];
$config['routes'][$routeName] = array_merge($config['routes'][$routeName], $default);
unset($config['routes'][$routeName]['inherit']);
}
}
}
return $config;
}
private function insertRelated(array $config, array $fullConfig): array
{
if (!empty($config['routes'])) {
foreach ($config['routes'] as $routeName => $value) {
if (!empty($value['links'])) {
foreach ($value['links'] as $i => $linkedRoute) {
$split = explode(':', $linkedRoute);
if (!empty($fullConfig[$split[0]]['routes'][$linkedRoute])) {
$linkedRouteObject = $fullConfig[$split[0]]['routes'][$linkedRoute];
if (!empty($linkedRouteObject)) {
$config['routes'][$routeName]['links'][$i] = $linkedRouteObject;
continue;
}
}
unset($config['routes'][$routeName]['links'][$i]);
}
}
if (!empty($value['actions'])) {
foreach ($value['actions'] as $i => $linkedRoute) {
$split = explode(':', $linkedRoute);
if (!empty($fullConfig[$split[0]]['routes'][$linkedRoute])) {
$linkedRouteObject = $fullConfig[$split[0]]['routes'][$linkedRoute];
if (!empty($linkedRouteObject)) {
$config['routes'][$routeName]['actions'][$i] = $linkedRouteObject;
continue;
}
}
unset($config['routes'][$routeName]['actions'][$i]);
}
}
}
}
return $config;
}
public function getDefaultCRUDConfig(string $controller, array $overrides=[], array $merges=[]): array
{
$table = TableRegistry::getTableLocator()->get($controller);
$default = [
'defaults' => [
'depth-1' => [
'after' => "{$controller}:index",
'textGetter' => !empty($table->getDisplayField()) ? $table->getDisplayField() : 'id',
'links' => [
"{$controller}:view",
"{$controller}:edit",
],
'actions' => [
"{$controller}:delete",
],
]
],
'routes' => [
"{$controller}:index" => [
'label' => Inflector::humanize($controller),
'url' => "/{$controller}/index",
'icon' => $this->iconToTableMapping[$controller]
],
"{$controller}:view" => [
'label' => __('View'),
'icon' => 'eye',
'inherit' => 'depth-1',
'url' => "/{$controller}/view/{{id}}",
'url_vars' => ['id' => 'id'],
],
"{$controller}:edit" => [
'label' => __('Edit'),
'icon' => 'edit',
'inherit' => 'depth-1',
'url' => "/{$controller}/edit/{{id}}",
'url_vars' => ['id' => 'id'],
],
"{$controller}:delete" => [
'label' => __('Delete'),
'icon' => 'trash',
'inherit' => 'depth-1',
'url' => "/{$controller}/delete/{{id}}",
'url_vars' => ['id' => 'id'],
],
]
];
$merged = array_merge_recursive($default, $merges);
$overridden = array_replace_recursive($merged, $overrides);
return $overridden;
}
public function genBreadcrumb(): array
{
$fullConfig = [
'Individuals' => $this->getDefaultCRUDConfig('Individuals'),
'Organisations' => $this->getDefaultCRUDConfig('Organisations'),
'EncryptionKeys' => $this->getDefaultCRUDConfig('EncryptionKeys'),
'SharingGroups' => $this->getDefaultCRUDConfig('SharingGroups'),
'Broods' => $this->getDefaultCRUDConfig('Broods', [], [
'defaults' => ['depth-1' => ['links' => 'LocalTools:brood_tools']]
]),
'Roles' => $this->getDefaultCRUDConfig('Roles'),
'Users' => $this->getDefaultCRUDConfig('Users'),
'Inbox' => $this->getDefaultCRUDConfig('Inbox', [
'defaults' => ['depth-1' => [
'links' => ['Inbox:view', 'Inbox:process'],
'actions' => ['Inbox:process', 'Inbox:delete'],
]]
], [
'routes' => [
'Inbox:discard' => [
'label' => __('Discard request'),
'inherit' => 'depth-1',
'url' => '/inbox/discard/{{id}}',
'url_vars' => ['id' => 'id'],
],
'Inbox:process' => [
'label' => __('Process request'),
'inherit' => 'depth-1',
'url' => '/inbox/process/{{id}}',
'url_vars' => ['id' => 'id'],
],
]
]),
'Outbox' => $this->getDefaultCRUDConfig('Outbox', [
'defaults' => ['depth-1' => [
'links' => ['Outbox:view', 'Outbox:process'],
'actions' => ['Outbox:process', 'Outbox:delete'],
]]
], [
'routes' => [
'Outbox:discard' => [
'label' => __('Discard request'),
'inherit' => 'depth-1',
'url' => '/outbox/discard/{{id}}',
'url_vars' => ['id' => 'id'],
],
'Outbox:process' => [
'label' => __('Process request'),
'inherit' => 'depth-1',
'url' => '/outbox/process/{{id}}',
'url_vars' => ['id' => 'id'],
],
]
]),
'MetaTemplates' => $this->getDefaultCRUDConfig('MetaTemplates', [
'defaults' => ['depth-1' => [
'links' => ['MetaTemplates:view', ''], // '' to remove leftovers. Related to https://www.php.net/manual/en/function.array-replace-recursive.php#124705
'actions' => ['MetaTemplates:toggle'],
]]
], [
'routes' => [
'MetaTemplates:toggle' => [
'label' => __('Toggle Meta-template'),
'inherit' => 'depth-1',
'url' => '/MetaTemplates/toggle/{{id}}',
'url_vars' => ['id' => 'id'],
],
]
]),
'Tags' => $this->getDefaultCRUDConfig('Tags', [
'defaults' => ['depth-1' => ['textGetter' => 'name']]
]),
'LocalTools' => [
'routes' => [
'LocalTools:index' => [
'label' => __('Local Tools'),
'url' => '/localTools/index',
'icon' => $this->iconToTableMapping['LocalTools'],
],
'LocalTools:viewConnector' => [
'label' => __('View'),
'textGetter' => 'name',
'url' => '/localTools/viewConnector/{{connector}}',
'url_vars' => ['connector' => 'connector'],
'after' => 'LocalTools:index',
],
'LocalTools:broodTools' => [
'label' => __('Brood Tools'),
'url' => '/localTools/broodTools/{{id}}',
'url_vars' => ['id' => 'id'],
],
]
],
'Instance' => [
'routes' => [
'Instance:home' => [
'label' => __('Home'),
'url' => '/',
'icon' => 'home'
],
'Instance:settings' => [
'label' => __('Settings'),
'url' => '/instance/settings',
'icon' => 'cogs'
],
'Instance:migrationIndex' => [
'label' => __('Database Migration'),
'url' => '/instance/migrationIndex',
'icon' => 'database'
],
]
]
];
foreach ($fullConfig as $controller => $config) {
$fullConfig[$controller] = $this->insertInheritance($config, $fullConfig);
}
foreach ($fullConfig as $controller => $config) {
$fullConfig[$controller] = $this->insertRelated($config, $fullConfig);
}
return $fullConfig;
}
}

View File

@ -14,17 +14,21 @@ use Cake\Error\Debugger;
class EncryptionKeysController extends AppController
{
public $filterFields = ['owner_model', 'organisation_id', 'individual_id', 'encryption_key'];
public $quickFilterFields = ['encryption_key'];
public $containFields = ['Individuals', 'Organisations'];
public function index()
{
$this->CRUD->index([
'quickFilters' => ['encryption_key'],
'filters' => ['owner_model', 'organisation_id', 'individual_id', 'encryption_key'],
'quickFilters' => $this->quickFilterFields,
'filters' => $this->filterFields,
'contextFilters' => [
'fields' => [
'type'
]
],
'contain' => ['Individuals', 'Organisations']
'contain' => $this->containFields
]);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {

View File

@ -16,7 +16,9 @@ use Cake\Http\Exception\ForbiddenException;
class InboxController extends AppController
{
public $filters = ['scope', 'action', 'title', 'origin', 'comment'];
public $filterFields = ['scope', 'action', 'title', 'origin', 'comment'];
public $quickFilterFields = ['scope', 'action', ['title' => true], ['comment' => true]];
public $containFields = ['Users'];
public function beforeFilter(EventInterface $event)
{
@ -28,14 +30,14 @@ class InboxController extends AppController
public function index()
{
$this->CRUD->index([
'filters' => $this->filters,
'quickFilters' => ['scope', 'action', ['title' => true], ['comment' => true]],
'filters' => $this->filterFields,
'quickFilters' => $this->quickFilterFields,
'contextFilters' => [
'fields' => [
'scope',
]
],
'contain' => ['Users']
'contain' => $this->containFields
]);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {

View File

@ -9,20 +9,25 @@ use Cake\Database\Expression\QueryExpression;
use Cake\Http\Exception\NotFoundException;
use Cake\Http\Exception\MethodNotAllowedException;
use Cake\Http\Exception\ForbiddenException;
use Cake\ORM\TableRegistry;
class IndividualsController extends AppController
{
public $quickFilterFields = ['uuid', ['email' => true], ['first_name' => true], ['last_name' => true], 'position'];
public $filterFields = ['uuid', 'email', 'first_name', 'last_name', 'position', 'Organisations.id', 'Alignments.type'];
public $containFields = ['Alignments' => 'Organisations'];
public function index()
{
$this->CRUD->index([
'filters' => ['uuid', 'email', 'first_name', 'last_name', 'position', 'Organisations.id', 'Alignments.type'],
'quickFilters' => ['uuid', 'email', 'first_name', 'last_name', 'position'],
'filters' => $this->filterFields,
'quickFilters' => $this->quickFilterFields,
'contextFilters' => [
'fields' => [
'Alignments.type'
]
],
'contain' => ['Alignments' => 'Organisations']
'contain' => $this->containFields
]);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
@ -32,6 +37,11 @@ class IndividualsController extends AppController
$this->set('metaGroup', 'ContactDB');
}
public function filtering()
{
$this->CRUD->filtering();
}
public function add()
{
$this->CRUD->add();
@ -72,4 +82,31 @@ class IndividualsController extends AppController
}
$this->set('metaGroup', 'ContactDB');
}
public function tag($id)
{
$this->CRUD->tag($id);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
}
public function untag($id)
{
$this->CRUD->untag($id);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
}
public function viewTags($id)
{
$this->CRUD->viewTags($id);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
}
}

View File

@ -3,10 +3,13 @@
namespace App\Controller;
use App\Controller\AppController;
use Cake\Utility\Inflector;
use Cake\Utility\Hash;
use Cake\Utility\Text;
use \Cake\Database\Expression\QueryExpression;
use Cake\ORM\TableRegistry;
use Cake\Event\EventInterface;
use Cake\Core\Configure;
class InstanceController extends AppController
{
@ -18,7 +21,9 @@ class InstanceController extends AppController
public function home()
{
$this->set('md', file_get_contents(ROOT . '/README.md'));
// $this->set('md', file_get_contents(ROOT . '/README.md'));
$statistics = $this->Instance->getStatistics();
$this->set('statistics', $statistics);
}
public function status()
@ -29,6 +34,24 @@ class InstanceController extends AppController
return $this->RestResponse->viewData($data, 'json');
}
public function searchAll()
{
$searchValue = $this->request->getQuery('search');
$model = $this->request->getQuery('model', null);
$limit = $this->request->getQuery('limit', 5);
if (!empty($this->request->getQuery('show_all', false))) {
$limit = null;
}
$data = [];
if (!empty($searchValue)) {
$data = $this->Instance->searchAll($searchValue, $limit, $model);
}
if ($this->ParamHandler->isRest()) {
return $this->RestResponse->viewData($data, 'json');
}
$this->set('data', $data);
}
public function migrationIndex()
{
$migrationStatus = $this->Instance->getMigrationStatus();
@ -36,6 +59,17 @@ class InstanceController extends AppController
$this->loadModel('Phinxlog');
$status = $this->Phinxlog->mergeMigrationLogIntoStatus($migrationStatus['status']);
foreach ($status as $i => $entry) {
if (!empty($entry['plugin'])) {
$pluginTablename = sprintf('%s_phinxlog', Inflector::underscore($entry['plugin']));
$pluginTablename = str_replace(['\\', '/', '.'], '_', $pluginTablename);
$status[$i] = $this->Phinxlog->mergeMigrationLogIntoStatus([$entry], $pluginTablename)[0];
}
}
usort($status, function($a, $b) {
return strcmp($b['id'], $a['id']);
});
$this->set('status', $status);
$this->set('updateAvailables', $migrationStatus['updateAvailables']);
}
@ -101,4 +135,36 @@ class InstanceController extends AppController
$this->set('path', ['controller' => 'instance', 'action' => 'rollback']);
$this->render('/genericTemplates/confirm');
}
public function settings()
{
$this->Settings = $this->getTableLocator()->get('Settings');
$all = $this->Settings->getSettings(true);
$this->set('settingsProvider', $all['settingsProvider']);
$this->set('settings', $all['settings']);
$this->set('settingsFlattened', $all['settingsFlattened']);
$this->set('notices', $all['notices']);
}
public function saveSetting()
{
if ($this->request->is('post')) {
$data = $this->ParamHandler->harvestParams([
'name',
'value'
]);
$this->Settings = $this->getTableLocator()->get('Settings');
$errors = $this->Settings->saveSetting($data['name'], $data['value']);
$message = __('Could not save setting `{0}`', $data['name']);
if (empty($errors)) {
$message = __('Setting `{0}` saved', $data['name']);
$data = $this->Settings->getSetting($data['name']);
}
$this->CRUD->setResponseForController('saveSetting', empty($errors), $message, $data, $errors);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
}
}
}

View File

@ -23,12 +23,16 @@ class LocalToolsController extends AppController
$this->set('metaGroup', 'Administration');
}
public function connectorIndex()
public function connectorIndex($connectorName)
{
$this->set('metaGroup', 'Admin');
$this->CRUD->index([
'filters' => ['name', 'connector'],
'quickFilters' => ['name', 'connector'],
'filterFunction' => function($query) use ($connectorName) {
$query->where(['connector' => $connectorName]);
return $query;
},
'afterFind' => function($data) {
foreach ($data as $connector) {
$connector['health'] = [$this->LocalTools->healthCheckIndividual($connector)];
@ -40,7 +44,56 @@ class LocalToolsController extends AppController
if (!empty($responsePayload)) {
return $responsePayload;
}
$connector = $this->LocalTools->getConnectors($connectorName)[$connectorName];
$this->set('metaGroup', 'Administration');
$this->set('connectorName', $connectorName);
$this->set('connector', $connector);
}
public function batchAction($actionName)
{
$params = $this->ParamHandler->harvestParams(['connection_ids']);
$params['connection_ids'] = explode(',', $params['connection_ids']);
$connections = $this->LocalTools->query()->where(['id IN' => $params['connection_ids']])->all();
if (empty($connections)) {
throw new NotFoundException(__('Invalid connector.'));
}
$connection = $connections->first();
if ($this->request->is(['post', 'put'])) {
$actionParams = $this->LocalTools->getActionFilterOptions($connection->connector, $actionName);
$params = array_merge($params, $this->ParamHandler->harvestParams($actionParams));
$results = [];
$successes = 0;
$this->LocalTools->loadConnector($connection->connector);
foreach ($connections as $connection) {
$actionDetails = $this->LocalTools->getActionDetails($actionName);
$params['connection'] = $connection;
$tmpResult = $this->LocalTools->action($this->ACL->getUser()['id'], $connection->connector, $actionName, $params, $this->request);
$tmpResult['connection'] = $connection;
$results[$connection->id] = $tmpResult;
$successes += $tmpResult['success'] ? 1 : 0;
}
$success = $successes > 0;
$message = __('{0} / {1} operations were successful', $successes, count($results));
$this->CRUD->setResponseForController('batchAction', $success, $message, $results, $results);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
if (!empty($success)) {
$this->Flash->success($message);
$this->redirect(['controller' => 'localTools', 'action' => 'connectorIndex', $actionName]);
} else {
$this->Flash->error($message);
$this->redirect(['controller' => 'localTools', 'action' => 'connectorIndex', $actionName]);
}
} else {
$params['connection'] = $connection;
$results = $this->LocalTools->action($this->ACL->getUser()['id'], $connection->connector, $actionName, $params, $this->request);
$this->set('data', $results);
$this->set('metaGroup', 'Administration');
$this->render('/Common/getForm');
}
}
public function action($connectionId, $actionName)
@ -100,18 +153,25 @@ class LocalToolsController extends AppController
}
}
public function add($connector = false)
public function add($connectorName = false)
{
$this->CRUD->add();
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
$connectors = $this->LocalTools->extractMeta($this->LocalTools->getConnectors());
$localConnectors = $this->LocalTools->extractMeta($this->LocalTools->getConnectors());
$dropdownData = ['connectors' => []];
foreach ($connectors as $connector) {
$dropdownData['connectors'][$connector['connector']] = $connector['name'];
$connector = false;
$connectors = [];
foreach ($localConnectors as $c) {
if (empty($connectorName) || $c['connector'] == $connectorName) {
$dropdownData['connectors'][$c['connector']] = $c['name'];
$connectors[] = $c;
}
}
$this->set('connectorName', $connectorName);
$this->set('connectors', $connectors);
$this->set(compact('dropdownData'));
$this->set('metaGroup', 'Administration');
}
@ -121,7 +181,7 @@ class LocalToolsController extends AppController
$connectors = $this->LocalTools->extractMeta($this->LocalTools->getConnectors());
$connector = false;
foreach ($connectors as $c) {
if ($connector === false || version_compare($c['version'], $connectors['version']) > 0) {
if ($connector_name == $c['connector'] && ($connector === false || version_compare($c['version'], $connectors['version']) > 0)) {
$connector = $c;
}
}
@ -134,20 +194,25 @@ class LocalToolsController extends AppController
public function edit($id)
{
$localTool = $this->LocalTools->get($id);
$this->CRUD->edit($id);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
if ($this->ParamHandler->isAjax() && !empty($this->ajaxResponsePayload)) {
return $this->ajaxResponsePayload;
}
$connectors = $this->LocalTools->extractMeta($this->LocalTools->getConnectors());
$localConnectors = $this->LocalTools->extractMeta($this->LocalTools->getConnectors());
$dropdownData = ['connectors' => []];
foreach ($connectors as $connector) {
$dropdownData['connectors'][$connector['connector']] = $connector['name'];
$connector = false;
$connectors = [];
foreach ($localConnectors as $c) {
if (empty($localTool->connector) || $c['connector'] == $localTool->connector) {
$dropdownData['connectors'][$c['connector']] = $c['name'];
$connectors[] = $c;
}
}
$this->set(compact('dropdownData'));
$this->set('connectorName', $localTool->connector);
$this->set('connectors', $connectors);
$this->set('metaGroup', 'Administration');
$this->render('add');
}

View File

@ -9,11 +9,15 @@ use \Cake\Database\Expression\QueryExpression;
class MetaTemplateFieldsController extends AppController
{
public $quickFilterFields = ['field', 'type'];
public $filterFields = ['field', 'type', 'meta_template_id'];
public $containFields = [];
public function index()
{
$this->CRUD->index([
'filters' => ['field', 'type', 'meta_template_id'],
'quickFilters' => ['field', 'type']
'filters' => $this->filterFields,
'quickFilters' => $this->quickFilterFields
]);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {

View File

@ -9,6 +9,9 @@ use \Cake\Database\Expression\QueryExpression;
class MetaTemplatesController extends AppController
{
public $quickFilterFields = ['name', 'uuid', 'scope'];
public $filterFields = ['name', 'uuid', 'scope', 'namespace'];
public $containFields = ['MetaTemplateFields'];
public function update()
{
@ -34,8 +37,8 @@ class MetaTemplatesController extends AppController
public function index()
{
$this->CRUD->index([
'filters' => ['name', 'uuid', 'scope', 'namespace'],
'quickFilters' => ['name', 'uuid', 'scope'],
'filters' => $this->filterFields,
'quickFilters' => $this->quickFilterFields,
'contextFilters' => [
'fields' => ['scope'],
'custom' => [
@ -49,7 +52,7 @@ class MetaTemplatesController extends AppController
],
]
],
'contain' => ['MetaTemplateFields']
'contain' => $this->containFields
]);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {

View File

@ -13,13 +13,15 @@ use Cake\Http\Exception\ForbiddenException;
class OrganisationsController extends AppController
{
public $filters = ['name', 'uuid', 'nationality', 'sector', 'type', 'url', 'Alignments.id', 'MetaFields.field', 'MetaFields.value', 'MetaFields.MetaTemplates.name'];
public $quickFilterFields = [['name' => true], 'uuid', 'nationality', 'sector', 'type', 'url'];
public $filterFields = ['name', 'uuid', 'nationality', 'sector', 'type', 'url', 'Alignments.id', 'MetaFields.field', 'MetaFields.value', 'MetaFields.MetaTemplates.name'];
public $containFields = ['Alignments' => 'Individuals'];
public function index()
{
$this->CRUD->index([
'filters' => $this->filters,
'quickFilters' => [['name' => true], 'uuid', 'nationality', 'sector', 'type', 'url'],
'filters' => $this->filterFields,
'quickFilters' => $this->quickFilterFields,
'contextFilters' => [
'custom' => [
[
@ -57,7 +59,7 @@ class OrganisationsController extends AppController
]
],
],
'contain' => ['Alignments' => 'Individuals']
'contain' => $this->containFields
]);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
@ -112,4 +114,31 @@ class OrganisationsController extends AppController
}
$this->set('metaGroup', 'ContactDB');
}
public function tag($id)
{
$this->CRUD->tag($id);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
}
public function untag($id)
{
$this->CRUD->untag($id);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
}
public function viewTags($id)
{
$this->CRUD->viewTags($id);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
}
}

View File

@ -16,7 +16,9 @@ use Cake\Http\Exception\ForbiddenException;
class OutboxController extends AppController
{
public $filters = ['scope', 'action', 'title', 'comment'];
public $filterFields = ['scope', 'action', 'title', 'comment'];
public $quickFilterFields = ['scope', 'action', ['title' => true], ['comment' => true]];
public $containFields = ['Users'];
public function beforeFilter(EventInterface $event)
{
@ -28,14 +30,14 @@ class OutboxController extends AppController
public function index()
{
$this->CRUD->index([
'filters' => $this->filters,
'quickFilters' => ['scope', 'action', ['title' => true], ['comment' => true]],
'filters' => $this->filterFields,
'quickFilters' => $this->quickFilterFields,
'contextFilters' => [
'fields' => [
'scope',
]
],
'contain' => ['Users']
'contain' => $this->containFields
]);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {

View File

@ -12,11 +12,15 @@ use Cake\Http\Exception\ForbiddenException;
class RolesController extends AppController
{
public $filterFields = ['name', 'uuid', 'perm_admin', 'Users.id', 'perm_org_admin'];
public $quickFilterFields = ['name'];
public $containFields = [];
public function index()
{
$this->CRUD->index([
'filters' => ['name', 'uuid', 'perm_admin', 'Users.id'],
'quickFilters' => ['name']
'filters' => $this->filterFields,
'quickFilters' => $this->quickFilterFields
]);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {

View File

@ -10,11 +10,16 @@ use Cake\Error\Debugger;
class SharingGroupsController extends AppController
{
public $filterFields = ['SharingGroups.uuid', 'SharingGroups.name', 'description', 'releasability', 'Organisations.name', 'Organisations.uuid'];
public $quickFilterFields = ['SharingGroups.uuid', ['SharingGroups.name' => true], ['description' => true], ['releasability' => true]];
public $containFields = ['SharingGroupOrgs', 'Organisations', 'Users' => ['fields' => ['id', 'username']]];
public function index()
{
$this->CRUD->index([
'contain' => ['SharingGroupOrgs', 'Organisations', 'Users' => ['fields' => ['id', 'username']]],
'filters' => ['uuid', 'description', 'releasability', 'Organisations.name', 'Organisations.uuid']
'contain' => $this->containFields,
'filters' => $this->filterFields,
'quickFilters' => $this->quickFilterFields
]);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {

View File

@ -9,12 +9,16 @@ use \Cake\Database\Expression\QueryExpression;
class UsersController extends AppController
{
public $filterFields = ['Individuals.uuid', 'username', 'Individuals.email', 'Individuals.first_name', 'Individuals.last_name'];
public $quickFilterFields = ['Individuals.uuid', ['username' => true], ['Individuals.first_name' => true], ['Individuals.last_name' => true], 'Individuals.email'];
public $containFields = ['Individuals', 'Roles'];
public function index()
{
$this->CRUD->index([
'contain' => ['Individuals', 'Roles'],
'filters' => ['Users.email', 'uuid'],
'quickFilters' => ['uuid', ['username' => true], ['Individuals.first_name' => true], ['Individuals.last_name' => true], 'Individuals.email'],
'contain' => $this->containFields,
'filters' => $this->filterFields,
'quickFilters' => $this->quickFilterFields,
]);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
@ -25,7 +29,12 @@ class UsersController extends AppController
public function add()
{
$this->CRUD->add();
$this->CRUD->add([
'beforeSave' => function($data) {
$this->Users->enrollUserRouter($data);
return $data;
}
]);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;

View File

@ -0,0 +1,93 @@
<?php
namespace App\Event;
use ADmad\SocialAuth\Middleware\SocialAuthMiddleware;
use Cake\Datasource\EntityInterface;
use Cake\Event\EventInterface;
use Cake\Event\EventListenerInterface;
use Cake\Http\ServerRequest;
use Cake\I18n\FrozenTime;
use Cake\ORM\Locator\LocatorAwareTrait;
class SocialAuthListener implements EventListenerInterface
{
use LocatorAwareTrait;
public function implementedEvents(): array
{
return [
SocialAuthMiddleware::EVENT_AFTER_IDENTIFY => 'afterIdentify',
SocialAuthMiddleware::EVENT_BEFORE_REDIRECT => 'beforeRedirect',
// Uncomment below if you want to use the event listener to return
// an entity for a new user instead of directly using `createUser()` table method.
// SocialAuthMiddleware::EVENT_CREATE_USER => 'createUser',
];
}
public function afterIdentify(EventInterface $event, EntityInterface $user): EntityInterface
{
// Update last login time
// $user->set('last_login', new FrozenTime());
// You can access the profile using $user->social_profile
$this->getTableLocator()->get('Users')->saveOrFail($user);
return $user;
}
/**
* @param \Cake\Event\EventInterface $event
* @param string|array $url
* @param string $status
* @param \Cake\Http\ServerRequest $request
* @return void
*/
public function beforeRedirect(EventInterface $event, $url, string $status, ServerRequest $request): void
{
$messages = (array)$request->getSession()->read('Flash.flash');
// Set flash message
switch ($status) {
case SocialAuthMiddleware::AUTH_STATUS_SUCCESS:
$loggedInUser = $request->getAttribute('session')->read('Auth');
$messages[] = [
'message' => __('You are now logged in as <strong>{0}</strong> via {1}', $loggedInUser['username'], $loggedInUser['social_profile']['provider']),
'key' => 'flash',
'element' => 'flash/success',
'params' => [
'escape' => false,
'toast' => true
],
];
break;
// Auth through provider failed. Details will be logged in
// `error.log` if `logErrors` option is set to `true`.
case SocialAuthMiddleware::AUTH_STATUS_PROVIDER_FAILURE:
// Table finder failed to return user record. An e.g. of this is a
// user has been authenticated through provider but your finder has
// a condition to not return an inactivated user.
case SocialAuthMiddleware::AUTH_STATUS_FINDER_FAILURE:
$messages[] = [
'message' => __('Authentication failed'),
'key' => 'flash',
'element' => 'flash/error',
'params' => [],
];
break;
}
$request->getSession()->write('Flash.flash', $messages);
// You can return a modified redirect URL if needed.
}
public function createUser(EventInterface $event, EntityInterface $profile, Session $session): EntityInterface
{
// Create and save entity for new user as shown in "createUser()" method above
return $user;
}
}

View File

@ -25,6 +25,13 @@ class CommonConnectorTools
$this->exposedFunctions[] = $functionName;
}
public function getBatchActionFunctions(): array
{
return array_filter($this->exposedFunctions, function($function) {
return $function['type'] == 'batchAction';
});
}
public function runAction($action, $params) {
if (!in_array($action, $exposedFunctions)) {
throw new MethodNotAllowedException(__('Invalid connector function called.'));

View File

@ -94,9 +94,45 @@ class MispConnector extends CommonConnectorTools
'sort',
'direction'
]
],
'batchAPIAction' => [
'type' => 'batchAction',
'scope' => 'childAction',
'params' => [
'method',
'url',
'body',
],
'ui' => [
'text' => 'Batch API',
'icon' => 'terminal',
'variant' => 'primary',
]
]
];
public $version = '0.1';
public $settings = [
'url' => [
'type' => 'text'
],
'authkey' => [
'type' => 'text'
],
'skip_ssl' => [
'type' => 'boolean'
],
];
public function addSettingValidatorRules($validator)
{
return $validator
->requirePresence('url')
->notEmpty('url', __('An URL must be provided'))
->requirePresence('authkey')
->notEmpty('authkey', __('An Authkey must be provided'))
->lengthBetween('authkey', [40, 40], __('The authkey must be 40 character long'))
->boolean('skip_ssl');
}
public function addExposedFunction(string $functionName): void
{
@ -208,7 +244,10 @@ class MispConnector extends CommonConnectorTools
throw new NotFoundException(__('No connection object received.'));
}
$url = $this->urlAppendParams($url, $params);
$response = $this->HTTPClientPOST($url, $params['connection'], json_encode($params['body']));
if (!is_string($params['body'])) {
$params['body'] = json_encode($params['body']);
}
$response = $this->HTTPClientPOST($url, $params['connection'], $params['body']);
if ($response->isOk()) {
return $response;
} else {
@ -769,6 +808,57 @@ class MispConnector extends CommonConnectorTools
throw new MethodNotAllowedException(__('Invalid http request type for the given action.'));
}
public function batchAPIAction(array $params): array
{
if ($params['request']->is(['get'])) {
return [
'data' => [
'title' => __('Execute API Request'),
'description' => __('Perform an API Request on the list of selected connections'),
'fields' => [
[
'field' => 'connection_ids',
'type' => 'hidden',
'value' => $params['connection_ids']
],
[
'field' => 'method',
'label' => __('Method'),
'type' => 'dropdown',
'options' => ['GET' => 'GET', 'POST' => 'POST']
],
[
'field' => 'url',
'label' => __('Relative URL'),
'type' => 'text',
],
[
'field' => 'body',
'label' => __('POST Body'),
'type' => 'codemirror',
],
],
'submit' => [
'action' => $params['request']->getParam('action')
],
'url' => ['controller' => 'localTools', 'action' => 'batchAction', 'batchAPIAction']
]
];
} else if ($params['request']->is(['post'])) {
if ($params['method'] == 'GET') {
$response = $this->getData($params['url'], $params);
} else {
$response = $this->postData($params['url'], $params);
}
if ($response->getStatusCode() == 200) {
return ['success' => 1, 'message' => __('API query successful'), 'data' => $response->getJson()];
} else {
return ['success' => 0, 'message' => __('API query failed'), 'data' => $response->getJson()];
}
}
throw new MethodNotAllowedException(__('Invalid http request type for the given action.'));
}
public function initiateConnection(array $params): array
{
$params['connection_settings'] = json_decode($params['connection']['settings'], true);

View File

@ -0,0 +1,169 @@
<?php
namespace App\Model\Behavior;
use ArrayObject;
use Cake\Datasource\EntityInterface;
use Cake\Event\EventInterface;
use Cake\ORM\Behavior;
use Cake\ORM\Entity;
use Cake\ORM\Query;
use Cake\Utility\Text;
use Cake\Utility\Security;
use \Cake\Http\Session;
use Cake\Core\Configure;
use Cake\Http\Client;
use Cake\Http\Client\FormData;
class AuthKeycloakBehavior extends Behavior
{
public function getUser(EntityInterface $profile, Session $session)
{
$userId = $session->read('Auth.User.id');
if ($userId) {
return $this->_table->get($userId);
}
$raw_profile_payload = $profile->access_token->getJwt()->getPayload();
$user = $this->extractProfileData($raw_profile_payload);
if (!$user) {
throw new \RuntimeException('Unable to save new user');
}
return $user;
}
private function extractProfileData($profile_payload)
{
$mapping = Configure::read('keycloak.mapping');
$fields = [
'org_uuid' => 'org_uuid',
'role_name' => 'role_name',
'username' => 'preferred_username',
'email' => 'email',
'first_name' => 'given_name',
'last_name' => 'family_name'
];
foreach ($fields as $field => $default) {
if (!empty($mapping[$field])) {
$fields[$field] = $mapping[$field];
}
}
$user = [
'individual' => [
'email' => $profile_payload[$fields['email']],
'first_name' => $profile_payload[$fields['first_name']],
'last_name' => $profile_payload[$fields['last_name']]
],
'user' => [
'username' => $profile_payload[$fields['username']],
],
'organisation' => [
'uuid' => $profile_payload[$fields['org_uuid']],
],
'role' => [
'name' => $profile_payload[$fields['role_name']],
]
];
$user['user']['individual_id'] = $this->_table->captureIndividual($user);
$user['user']['role_id'] = $this->_table->captureRole($user);
$existingUser = $this->_table->find()->where(['username' => $user['user']['username']])->first();
if (empty($existingUser)) {
$user['user']['password'] = Security::randomString(16);
$existingUser = $this->_table->newEntity($user['user']);
if (!$this->_table->save($existingUser)) {
return false;
}
} else {
$dirty = false;
if ($user['user']['individual_id'] != $existingUser['individual_id']) {
$existingUser['individual_id'] = $user['user']['individual_id'];
$dirty = true;
}
if ($user['user']['role_id'] != $existingUser['role_id']) {
$existingUser['role_id'] = $user['user']['role_id'];
$dirty = true;
}
$existingUser;
if ($dirty) {
if (!$this->_table->save($existingUser)) {
return false;
}
}
}
return $existingUser;
}
public function enrollUser($data): bool
{
$individual = $this->_table->Individuals->find()->where(
['id' => $data['individual_id']]
)->contain(['Organisations'])->first();
$roleConditions = [
'id' => $data['role_id']
];
if (!empty(Configure::read('keycloak.user_management.actions'))) {
$roleConditions['name'] = Configure::read('keycloak.default_role_name');
}
$role = $this->_table->Roles->find()->where($roleConditions)->first();
$orgs = [];
foreach ($individual['organisations'] as $org) {
$orgs[] = $org['uuid'];
}
$token = $this->getAdminAccessToken();
$keyCloakUser = [
'firstName' => $individual['first_name'],
'lastName' => $individual['last_name'],
'username' => $data['username'],
'email' => $individual['email'],
'attributes' => [
'role_name' => empty($role['name']) ? Configure::read('keycloak.default_role_name') : $role['name'],
'org_uuid' => empty($orgs[0]) ? '' : $orgs[0]
]
];
$keycloakConfig = Configure::read('keycloak');
$http = new Client();
$url = sprintf(
'%s/admin/realms/%s/users',
$keycloakConfig['provider']['baseUrl'],
$keycloakConfig['provider']['realm']
);
$response = $http->post(
$url,
json_encode($keyCloakUser),
[
'headers' => [
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $token
]
]
);
return true;
}
private function getAdminAccessToken()
{
$keycloakConfig = Configure::read('keycloak');
$http = new Client();
$tokenUrl = sprintf(
'%s/realms/%s/protocol/openid-connect/token',
$keycloakConfig['provider']['baseUrl'],
$keycloakConfig['provider']['realm']
);
$response = $http->post(
$tokenUrl,
sprintf(
'grant_type=client_credentials&client_id=%s&client_secret=%s',
urlencode(Configure::read('keycloak.provider.applicationId')),
urlencode(Configure::read('keycloak.provider.applicationSecret'))
),
[
'headers' => [
'Content-Type' => 'application/x-www-form-urlencoded'
]
]
);
$parsedResponse = json_decode($response->getStringBody(), true);
return $parsedResponse['access_token'];
}
}

View File

@ -13,6 +13,7 @@ class AlignmentsTable extends AppTable
parent::initialize($config);
$this->belongsTo('Individuals');
$this->belongsTo('Organisations');
$this->addBehavior('Timestamp');
}
public function validationDefault(Validator $validator): Validator

View File

@ -18,6 +18,7 @@ class BroodsTable extends AppTable
{
parent::initialize($config);
$this->addBehavior('UUID');
$this->addBehavior('Timestamp');
$this->BelongsTo(
'Organisations'
);

View File

@ -14,6 +14,7 @@ class EncryptionKeysTable extends AppTable
{
parent::initialize($config);
$this->addBehavior('UUID');
$this->addBehavior('Timestamp');
$this->belongsTo(
'Individuals',
[

View File

@ -14,6 +14,8 @@ class IndividualsTable extends AppTable
{
parent::initialize($config);
$this->addBehavior('UUID');
$this->addBehavior('Timestamp');
$this->addBehavior('Tags.Tag');
$this->hasMany(
'Alignments',
[

View File

@ -4,11 +4,17 @@ namespace App\Model\Table;
use App\Model\Table\AppTable;
use Cake\ORM\Table;
use Cake\ORM\TableRegistry;
use Cake\Validation\Validator;
use Migrations\Migrations;
use Cake\Filesystem\Folder;
use Cake\Http\Exception\MethodNotAllowedException;
class InstanceTable extends AppTable
{
protected $activePlugins = ['Tags', 'ADmad/SocialAuth'];
public $seachAllTables = ['Broods', 'Individuals', 'Organisations', 'SharingGroups', 'Users', 'EncryptionKeys', ];
public function initialize(array $config): void
{
parent::initialize($config);
@ -19,10 +25,115 @@ class InstanceTable extends AppTable
return $validator;
}
public function getStatistics($days=30): array
{
$models = ['Individuals', 'Organisations', 'Alignments', 'EncryptionKeys', 'SharingGroups', 'Users', 'Broods', 'Tags.Tags'];
foreach ($models as $model) {
$table = TableRegistry::getTableLocator()->get($model);
$statistics[$model]['amount'] = $table->find()->all()->count();
if ($table->behaviors()->has('Timestamp')) {
$query = $table->find();
$query->select([
'count' => $query->func()->count('id'),
'date' => 'DATE(modified)',
])
->where(['modified >' => new \DateTime("-{$days} days")])
->group(['date'])
->order(['date']);
$data = $query->toArray();
$interval = new \DateInterval('P1D');
$period = new \DatePeriod(new \DateTime("-{$days} days"), $interval, new \DateTime());
$timeline = [];
foreach ($period as $date) {
$timeline[$date->format("Y-m-d")] = [
'time' => $date->format("Y-m-d"),
'count' => 0
];
}
foreach ($data as $entry) {
$timeline[$entry->date]['count'] = $entry->count;
}
$statistics[$model]['timeline'] = array_values($timeline);
$startCount = $table->find()->where(['modified <' => new \DateTime("-{$days} days")])->all()->count();
$endCount = $statistics[$model]['amount'];
$statistics[$model]['variation'] = $endCount - $startCount;
} else {
$statistics[$model]['timeline'] = [];
$statistics[$model]['variation'] = 0;
}
}
return $statistics;
}
public function searchAll($value, $limit=5, $model=null)
{
$results = [];
$models = $this->seachAllTables;
if (!is_null($model)) {
if (in_array($model, $this->seachAllTables)) {
$models = [$model];
} else {
return $results; // Cannot search in this model
}
}
foreach ($models as $tableName) {
$controller = $this->getController($tableName);
$table = TableRegistry::get($tableName);
$query = $table->find();
$quickFilterOptions = $this->getQuickFiltersFieldsFromController($controller);
$containFields = $this->getContainFieldsFromController($controller);
if (empty($quickFilterOptions)) {
continue; // make sure we are filtering on something
}
$params = ['quickFilter' => $value];
$query = $controller->CRUD->setQuickFilters($params, $query, $quickFilterOptions);
if (!empty($containFields)) {
$query->contain($containFields);
}
$results[$tableName]['amount'] = $query->count();
$result = $query->limit($limit)->all()->toList();
if (!empty($result)) {
$results[$tableName]['entries'] = $result;
}
}
return $results;
}
public function getController($name)
{
$controllerName = "\\App\\Controller\\{$name}Controller";
if (!class_exists($controllerName)) {
throw new MethodNotAllowedException(__('Model `{0}` does not exists', $model));
}
$controller = new $controllerName;
return $controller;
}
public function getQuickFiltersFieldsFromController($controller)
{
return !empty($controller->quickFilterFields) ? $controller->quickFilterFields : [];
}
public function getContainFieldsFromController($controller)
{
return !empty($controller->containFields) ? $controller->containFields : [];
}
public function getMigrationStatus()
{
$migrations = new Migrations();
$status = $migrations->status();
foreach ($this->activePlugins as $pluginName) {
$pluginStatus = $migrations->status([
'plugin' => $pluginName
]);
$pluginStatus = array_map(function ($entry) use ($pluginName) {
$entry['plugin'] = $pluginName;
return $entry;
}, $pluginStatus);
$status = array_merge($status, $pluginStatus);
}
$status = array_reverse($status);
$updateAvailables = array_filter($status, function ($update) {
@ -41,6 +152,8 @@ class InstanceTable extends AppTable
} else {
$migrationResult = $migrations->migrate(['target' => $version]);
}
$command = ROOT . '/bin/cake schema_cache clear';
$output = shell_exec($command);
return [
'success' => true
];
@ -57,4 +170,22 @@ class InstanceTable extends AppTable
'success' => true
];
}
public function getAvailableThemes()
{
$themesPath = ROOT . '/webroot/css/themes';
$dir = new Folder($themesPath);
$filesRegex = 'bootstrap-(?P<themename>\w+)\.css';
$themeRegex = '/' . 'bootstrap-(?P<themename>\w+)\.css' . '/';
$files = $dir->find($filesRegex);
$themes = [];
foreach ($files as $filename) {
$matches = [];
$themeName = preg_match($themeRegex, $filename, $matches);
if (!empty($matches['themename'])) {
$themes[] = $matches['themename'];
}
}
return $themes;
}
}

View File

@ -4,6 +4,7 @@ namespace App\Model\Table;
use App\Model\Table\AppTable;
use Cake\ORM\Table;
use Cake\ORM\RulesChecker;
use Cake\Validation\Validator;
use Migrations\Migrations;
use Cake\Filesystem\Folder;
@ -29,11 +30,19 @@ class LocalToolsTable extends AppTable
public function initialize(array $config): void
{
parent::initialize($config);
$this->addBehavior('Timestamp');
}
public function validationDefault(Validator $validator): Validator
{
return $validator;
return $validator->add('settings', 'validSettings', [
'rule' => 'isValidSettings',
'provider' => 'table',
'message' => __('Invalid settings'),
'on' => function ($context) {
return !empty($context['data']['settings']);
}
]);
}
public function loadConnector(string $connectorName): void
@ -132,7 +141,8 @@ class LocalToolsTable extends AppTable
'name' => $connector_class->name,
'connector' => $connector_type,
'connector_version' => $connector_class->version,
'connector_description' => $connector_class->description
'connector_description' => $connector_class->description,
'connector_settings' => $connector_class->settings ?? []
];
if ($includeConnections) {
$connector['connections'] = $this->healthCheck($connector_type, $connector_class);
@ -297,4 +307,32 @@ class LocalToolsTable extends AppTable
}
return $connection;
}
public function isValidSettings($settings, array $context)
{
$settings = json_decode($settings, true);
$validationErrors = $this->getLocalToolsSettingValidationErrors($context['data']['connector'], $settings);
return $this->getValidationMessage($validationErrors);
}
public function getValidationMessage($validationErrors)
{
$messages = [];
foreach ($validationErrors as $key => $errors) {
$messages[] = sprintf('%s: %s', $key, implode(', ', $errors));
}
return empty($messages) ? true : implode('; ', $messages);
}
public function getLocalToolsSettingValidationErrors($connectorName, array $settings): array
{
$connector = array_values($this->getConnectors($connectorName))[0];
$errors = [];
if (method_exists($connector, 'addSettingValidatorRules')) {
$validator = new Validator();
$validator = $connector->addSettingValidatorRules($validator);
$errors = $validator->validate($settings);
}
return $errors;
}
}

View File

@ -13,13 +13,14 @@ class MetaTemplatesTable extends AppTable
public function initialize(array $config): void
{
parent::initialize($config);
$this->addBehavior('Timestamp');
$this->hasMany(
'MetaTemplateFields',
[
'foreignKey' => 'meta_template_id'
]
);
$this->setDisplayField('field');
$this->setDisplayField('name');
}
public function validationDefault(Validator $validator): Validator

View File

@ -18,6 +18,8 @@ class OrganisationsTable extends AppTable
public function initialize(array $config): void
{
parent::initialize($config);
$this->addBehavior('Timestamp');
$this->addBehavior('Tags.Tag');
$this->hasMany(
'Alignments',
[

View File

@ -12,8 +12,11 @@ class PhinxlogTable extends AppTable
parent::initialize($config);
}
public function mergeMigrationLogIntoStatus(array $status): array
public function mergeMigrationLogIntoStatus(array $status, $table=null): array
{
if (!is_null($table)) {
$this->setTable($table);
}
$logs = $this->find('list', [
'keyField' => 'version',
'valueField' => function ($entry) {

View File

@ -0,0 +1,405 @@
<?php
namespace App\Model\Table;
use App\Model\Table\AppTable;
use Cake\Validation\Validator;
use Cake\ORM\TableRegistry;
class SettingsProviderTable extends AppTable
{
private $settingsConfiguration = [];
private $error_critical = '',
$error_warning = '',
$error_info = '';
private $severities = ['info', 'warning', 'critical'];
public function initialize(array $config): void
{
parent::initialize($config);
$this->settingsConfiguration = $this->generateSettingsConfiguration();
$this->setTable(false);
$this->error_critical = __('Cerebrate will not operate correctly or will be unsecure until these issues are resolved.');
$this->error_warning = __('Some of the features of Cerebrate cannot be utilised until these issues are resolved.');
$this->error_info = __('There are some optional tweaks that could be done to improve the looks of your Cerebrate instance.');
$this->settingValidator = new SettingValidator();
}
/**
* Supports up to 3 levels:
* Application -> Network -> Proxy -> Proxy.URL
* page -> [group] -> [panel] -> setting
* Keys of setting configuration are the actual setting name.
* Accepted setting configuration:
* name [required]: The human readable name of the setting.
* type [required]: The type of the setting.
* description [required]: A description of the setting.
* Default severity level is `info` if a `default` value is provided otherwise it becomes `critical`
* default [optional]: The default value of the setting if not specified in the configuration.
* options [optional]: Used to populate the select with options. Keys are values to be saved, values are human readable version of the value.
* Required paramter if `type` == `select`.
* severity [optional]: Severity level of the setting if the configuration is incorrect.
* dependsOn [optional]: If the validation of this setting depends on the validation of the provided setting name
* test [optional]: Could be either a string or an anonymous function to be called in order to warn user if setting is invalid.
* Could be either: `string`, `boolean`, `integer`, `select`
* beforeSave [optional]: Could be either a string or an anonymous function to be called in order to block a setting to be saved.
* afterSave [optional]: Could be either a string or an anonymous function to be called allowing to execute a function after the setting is saved.
* redacted [optional]: Should the setting value be redacted. FIXME: To implement
* cli_only [optional]: Should this setting be modified only via the CLI.
*/
private function generateSettingsConfiguration()
{
return [
'Application' => [
'General' => [
'Essentials' => [
'_description' => __('Ensentials settings required for the application to run normally.'),
'_icon' => 'user-cog',
'app.baseurl' => [
'name' => __('Base URL'),
'type' => 'string',
'description' => __('The base url of the application (in the format https://www.mymispinstance.com or https://myserver.com/misp). Several features depend on this setting being correctly set to function.'),
'default' => '',
'severity' => 'critical',
'test' => 'testBaseURL',
],
'app.uuid' => [
'name' => 'UUID',
'type' => 'string',
'description' => __('The Cerebrate instance UUID. This UUID is used to identify this instance.'),
'default' => '',
'severity' => 'critical',
'test' => 'testUuid',
],
],
'Miscellaneous' => [
'sc2.hero' => [
'description' => 'The true hero',
'default' => 'Sarah Kerrigan',
'name' => 'Hero',
'options' => [
'Jim Raynor' => 'Jim Raynor',
'Sarah Kerrigan' => 'Sarah Kerrigan',
'Artanis' => 'Artanis',
'Zeratul' => 'Zeratul',
],
'type' => 'select'
],
'sc2.antagonists' => [
'description' => 'The bad guys',
'default' => 'Amon',
'name' => 'Antagonists',
'options' => function($settingsProviders) {
return [
'Amon' => 'Amon',
'Sarah Kerrigan' => 'Sarah Kerrigan',
'Narud' => 'Narud',
];
},
'severity' => 'warning',
'type' => 'multi-select'
],
],
'floating-setting' => [
'description' => 'floaringSetting',
// 'default' => 'A default value',
'name' => 'Uncategorized Setting',
// 'severity' => 'critical',
'severity' => 'warning',
// 'severity' => 'info',
'type' => 'integer'
],
],
'Network' => [
'Proxy' => [
'proxy.host' => [
'name' => __('Host'),
'type' => 'string',
'description' => __('The hostname of an HTTP proxy for outgoing sync requests. Leave empty to not use a proxy.'),
'test' => 'testHostname',
],
'proxy.port' => [
'name' => __('Port'),
'type' => 'integer',
'description' => __('The TCP port for the HTTP proxy.'),
'test' => 'testForRangeXY',
],
'proxy.user' => [
'name' => __('User'),
'type' => 'string',
'description' => __('The authentication username for the HTTP proxy.'),
'default' => 'admin',
'dependsOn' => 'proxy.host',
],
'proxy.password' => [
'name' => __('Password'),
'type' => 'string',
'description' => __('The authentication password for the HTTP proxy.'),
'default' => '',
'dependsOn' => 'proxy.host',
],
],
],
'UI' => [
'General' => [
'ui.bsTheme' => [
'description' => 'The Bootstrap theme to use for the application',
'default' => 'default',
'name' => 'UI Theme',
'options' => function($settingsProviders) {
$instanceTable = TableRegistry::getTableLocator()->get('Instance');
$themes = $instanceTable->getAvailableThemes();
return array_combine($themes, $themes);
},
'severity' => 'info',
'type' => 'select'
],
],
],
],
'Security' => [
'Development' => [
'Debugging' => [
'security.debug' => [
'name' => __('Debug Level'),
'type' => 'select',
'description' => __('The debug level of the instance'),
'default' => 0,
'options' => [
0 => __('Debug Off'),
1 => __('Debug On'),
2 => __('Debug On + SQL Dump'),
],
'test' => function($value, $setting, $validator) {
$validator->range('value', [0, 3]);
return testValidator($value, $validator);
},
],
],
]
],
'Features' => [
'Demo Settings' => [
'demo.switch' => [
'name' => __('Switch'),
'type' => 'boolean',
'description' => __('A switch acting as a checkbox'),
'default' => false,
'test' => function() {
return 'Fake error';
},
],
]
],
];
}
/**
* getSettingsConfiguration Return the setting configuration and merge existing settings into it if provided
*
* @param null|array $settings - Settings to be merged in the provided setting configuration
* @return array
*/
public function getSettingsConfiguration($settings = null) {
$settingConf = $this->settingsConfiguration;
if (!is_null($settings)) {
$settingConf = $this->mergeSettingsIntoSettingConfiguration($settingConf, $settings);
}
return $settingConf;
}
/**
* mergeSettingsIntoSettingConfiguration Inject the provided settings into the configuration while performing depencency and validation checks
*
* @param array $settingConf the setting configuration to have the setting injected into
* @param array $settings the settings
* @return void
*/
private function mergeSettingsIntoSettingConfiguration(array $settingConf, array $settings, string $path=''): array
{
foreach ($settingConf as $key => $value) {
if ($this->isSettingMetaKey($key)) {
continue;
}
if ($this->isLeaf($value)) {
if (isset($settings[$key])) {
$settingConf[$key]['value'] = $settings[$key];
}
$settingConf[$key] = $this->evaluateLeaf($settingConf[$key], $settingConf);
$settingConf[$key]['setting-path'] = $path;
$settingConf[$key]['true-name'] = $key;
} else {
$currentPath = empty($path) ? $key : sprintf('%s.%s', $path, $key);
$settingConf[$key] = $this->mergeSettingsIntoSettingConfiguration($value, $settings, $currentPath);
}
}
return $settingConf;
}
public function flattenSettingsConfiguration(array $settingsProvider, $flattenedSettings=[]): array
{
foreach ($settingsProvider as $key => $value) {
if ($this->isSettingMetaKey($key)) {
continue;
}
if ($this->isLeaf($value)) {
$flattenedSettings[$key] = $value;
} else {
$flattenedSettings = $this->flattenSettingsConfiguration($value, $flattenedSettings);
}
}
return $flattenedSettings;
}
/**
* getNoticesFromSettingsConfiguration Summarize the validation errors
*
* @param array $settingsProvider the setting configuration having setting value assigned
* @return void
*/
public function getNoticesFromSettingsConfiguration(array $settingsProvider): array
{
$notices = [];
foreach ($settingsProvider as $key => $value) {
if ($this->isSettingMetaKey($key)) {
continue;
}
if ($this->isLeaf($value)) {
if (!empty($value['error'])) {
if (empty($notices[$value['severity']])) {
$notices[$value['severity']] = [];
}
$notices[$value['severity']][] = $value;
}
} else {
$notices = array_merge_recursive($notices, $this->getNoticesFromSettingsConfiguration($value));
}
}
return $notices;
}
private function isLeaf($setting)
{
return !empty($setting['name']) && !empty($setting['type']);
}
private function evaluateLeaf($setting, $settingSection)
{
$skipValidation = false;
if ($setting['type'] == 'select' || $setting['type'] == 'multi-select') {
if (!empty($setting['options']) && is_callable($setting['options'])) {
$setting['options'] = $setting['options']($this);
}
}
if (isset($setting['dependsOn'])) {
$parentSetting = null;
foreach ($settingSection as $settingSectionName => $settingSectionConfig) {
if ($settingSectionName == $setting['dependsOn']) {
$parentSetting = $settingSectionConfig;
}
}
if (!is_null($parentSetting)) {
$parentSetting = $this->evaluateLeaf($parentSetting, $settingSection);
$skipValidation = $parentSetting['error'] === true || empty($parentSetting['value']);
}
}
$setting['error'] = false;
if (!$skipValidation) {
$validationResult = true;
if (!isset($setting['value'])) {
$validationResult = $this->settingValidator->testEmptyBecomesDefault(null, $setting);
} else if (isset($setting['test'])) {
$setting['value'] = $setting['value'] ?? '';
$validationResult = $this->evaluateFunctionForSetting($setting['test'], $setting);
}
if ($validationResult !== true) {
$setting['severity'] = $setting['severity'] ?? 'warning';
if (!in_array($setting['severity'], $this->severities)) {
$setting['severity'] = 'warning';
}
$setting['errorMessage'] = $validationResult;
}
$setting['error'] = $validationResult !== true ? true : false;
}
return $setting;
}
/**
* evaluateFunctionForSetting - evaluate the provided function. If function could not be evaluated, its result is defaulted to true
*
* @param mixed $fun
* @param array $setting
* @return mixed
*/
public function evaluateFunctionForSetting($fun, $setting)
{
$functionResult = true;
if (is_callable($fun)) { // Validate with anonymous function
$functionResult = $fun($setting['value'], $setting, new Validator());
} else if (method_exists($this->settingValidator, $fun)) { // Validate with function defined in settingValidator class
$functionResult = $this->settingValidator->{$fun}($setting['value'], $setting);
} else {
$validator = new Validator();
if (method_exists($validator, $fun)) { // Validate with cake's validator function
$validator->{$fun};
$functionResult = $validator->validate($setting['value']);
}
}
return $functionResult;
}
function isSettingMetaKey($key)
{
return substr($key, 0, 1) == '_';
}
}
function testValidator($value, $validator)
{
$errors = $validator->validate(['value' => $value]);
return !empty($errors) ? implode(', ', $errors['value']) : true;
}
class SettingValidator
{
public function testEmptyBecomesDefault($value, &$setting)
{
if (!empty($value)) {
return true;
} else if (isset($setting['default'])) {
$setting['value'] = $setting['default'];
$setting['severity'] = $setting['severity'] ?? 'info';
if ($setting['type'] == 'boolean') {
return __('Setting is not set, fallback to default value: {0}', empty($setting['default']) ? 'false' : 'true');
} else {
return __('Setting is not set, fallback to default value: {0}', $setting['default']);
}
} else {
$setting['severity'] = $setting['severity'] ?? 'critical';
return __('Cannot be empty. Setting does not have a default value.');
}
}
public function testForEmpty($value, &$setting)
{
return !empty($value) ? true : __('Cannot be empty');
}
public function testBaseURL($value, &$setting)
{
if (empty($value)) {
return __('Cannot be empty');
}
if (!empty($value) && !preg_match('/^http(s)?:\/\//i', $value)) {
return __('Invalid URL, please make sure that the protocol is set.');
}
return true;
}
public function testUuid($value, &$setting) {
if (empty($value) || !preg_match('/^\{?[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}\}?$/', $value)) {
return __('Invalid UUID.');
}
return true;
}
}

View File

@ -0,0 +1,97 @@
<?php
namespace App\Model\Table;
use App\Model\Table\AppTable;
use Cake\ORM\Table;
use Cake\Validation\Validator;
use Cake\Core\Configure;
use Cake\ORM\TableRegistry;
class SettingsTable extends AppTable
{
private static $FILENAME = 'cerebrate';
private static $CONFIG_KEY = 'Cerebrate';
public function initialize(array $config): void
{
parent::initialize($config);
$this->setTable(false);
$this->SettingsProvider = TableRegistry::getTableLocator()->get('SettingsProvider');
}
public function getSettings($full=false): array
{
$settings = $this->readSettings();
if (empty($full)) {
return $settings;
} else {
$settingsProvider = $this->SettingsProvider->getSettingsConfiguration($settings);
$settingsFlattened = $this->SettingsProvider->flattenSettingsConfiguration($settingsProvider);
$notices = $this->SettingsProvider->getNoticesFromSettingsConfiguration($settingsProvider, $settings);
return [
'settings' => $settings,
'settingsProvider' => $settingsProvider,
'settingsFlattened' => $settingsFlattened,
'notices' => $notices,
];
}
}
public function getSetting($name=false): array
{
$settings = $this->readSettings();
$settingsProvider = $this->SettingsProvider->getSettingsConfiguration($settings);
$settingsFlattened = $this->SettingsProvider->flattenSettingsConfiguration($settingsProvider);
return $settingsFlattened[$name] ?? [];
}
public function saveSetting(string $name, string $value): array
{
$errors = [];
$setting = $this->getSetting($name);
$value = $this->normaliseValue($value, $setting);
if ($setting['type'] == 'select') {
if (!in_array($value, array_keys($setting['options']))) {
$errors[] = __('Invalid option provided');
}
}
if (empty($errors) && !empty($setting['beforeSave'])) {
$setting['value'] = $value ?? '';
$beforeSaveResult = $this->SettingsProvider->evaluateFunctionForSetting($setting['beforeSave'], $setting);
if ($beforeSaveResult !== true) {
$errors[] = $beforeSaveResult;
}
}
if (empty($errors)) {
$saveResult = $this->saveSettingOnDisk($name, $value);
if ($saveResult) {
if (!empty($setting['afterSave'])) {
$this->SettingsProvider->evaluateFunctionForSetting($setting['afterSave'], $setting);
}
}
}
return $errors;
}
private function normaliseValue($value, $setting)
{
if ($setting['type'] == 'boolean') {
return (bool) $value;
}
return $value;
}
private function readSettings()
{
return Configure::read()[$this::$CONFIG_KEY];
}
private function saveSettingOnDisk($name, $value)
{
$settings = $this->readSettings();
$settings[$name] = $value;
Configure::write($this::$CONFIG_KEY, $settings);
Configure::dump($this::$FILENAME, 'default', [$this::$CONFIG_KEY]);
return true;
}
}

View File

@ -14,6 +14,7 @@ class SharingGroupsTable extends AppTable
{
parent::initialize($config);
$this->addBehavior('UUID');
$this->addBehavior('Timestamp');
$this->belongsTo(
'Users'
);

View File

@ -7,6 +7,11 @@ use Cake\ORM\Table;
use Cake\Validation\Validator;
use Cake\ORM\RulesChecker;
use Cake\ORM\TableRegistry;
use \Cake\Datasource\EntityInterface;
use \Cake\Http\Session;
use Cake\Http\Client;
use Cake\Utility\Security;
use Cake\Core\Configure;
class UsersTable extends AppTable
{
@ -14,6 +19,7 @@ class UsersTable extends AppTable
{
parent::initialize($config);
$this->addBehavior('UUID');
$this->initAuthBehaviors();
$this->belongsTo(
'Individuals',
[
@ -31,6 +37,13 @@ class UsersTable extends AppTable
$this->setDisplayField('username');
}
private function initAuthBehaviors()
{
if (!empty(Configure::read('keycloak'))) {
$this->addBehavior('AuthKeycloak');
}
}
public function validationDefault(Validator $validator): Validator
{
$validator
@ -93,4 +106,51 @@ class UsersTable extends AppTable
}
return true;
}
public function captureIndividual($user): int
{
$individual = $this->Individuals->find()->where(['email' => $user['individual']['email']])->first();
if (empty($individual)) {
$individual = $this->Individuals->newEntity($user['individual']);
if (!$this->Individuals->save($individual)) {
throw new BadRequestException(__('Could not save the associated individual'));
}
}
return $individual->id;
}
public function captureOrganisation($user): int
{
$organisation = $this->Organisations->find()->where(['uuid' => $user['organisation']['uuid']])->first();
if (empty($organisation)) {
$user['organisation']['name'] = $user['organisation']['uuid'];
$organisation = $this->Organisations->newEntity($user['organisation']);
if (!$this->Organisations->save($organisation)) {
throw new BadRequestException(__('Could not save the associated organisation'));
}
}
return $organisation->id;
}
public function captureRole($user): int
{
$role = $this->Roles->find()->where(['name' => $user['role']['name']])->first();
if (empty($role)) {
if (!empty(Configure::read('keycloak.default_role_name'))) {
$default_role_name = Configure::read('keycloak.default_role_name');
$role = $this->Roles->find()->where(['name' => $default_role_name])->first();
}
if (empty($role)) {
throw new NotFoundException(__('Invalid role'));
}
}
return $role->id;
}
public function enrollUserRouter($data): void
{
if (!empty(Configure::read('keycloak'))) {
$this->enrollUser($data);
}
}
}

View File

@ -43,5 +43,6 @@ class AppView extends View
$this->loadHelper('PrettyPrint');
$this->loadHelper('FormFieldMassage');
$this->loadHelper('Paginator', ['templates' => 'cerebrate-pagination-templates']);
$this->loadHelper('Tags.Tag');
}
}

View File

@ -65,16 +65,28 @@ class BootstrapHelper extends Helper
public function table($options, $data)
{
$bsTable = new BoostrapTable($options, $data);
$bsTable = new BoostrapTable($options, $data, $this);
return $bsTable->table();
}
public function listTable($options, $data)
{
$bsListTable = new BoostrapListTable($options, $data, $this);
return $bsListTable->table();
}
public function button($options)
{
$bsButton = new BoostrapButton($options);
return $bsButton->button();
}
public function icon($icon, $options=[])
{
$bsIcon = new BoostrapIcon($icon, $options);
return $bsIcon->icon();
}
public function badge($options)
{
$bsBadge = new BoostrapBadge($options);
@ -110,22 +122,39 @@ class BootstrapHelper extends Helper
$bsProgressTimeline = new BoostrapProgressTimeline($options, $this);
return $bsProgressTimeline->progressTimeline();
}
public function listGroup($options, $data)
{
$bsListGroup = new BootstrapListGroup($options, $data, $this);
return $bsListGroup->listGroup();
}
public function genNode($node, $params=[], $content='')
{
return BootstrapGeneric::genNode($node, $params, $content);
}
public function switch($options)
{
$bsSwitch = new BoostrapSwitch($options, $this);
return $bsSwitch->switch();
}
}
class BootstrapGeneric
{
public static $variants = ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark', 'white', 'transparent'];
public static $textClassByVariants = [
'primary' => 'text-white',
'secondary' => 'text-white',
'success' => 'text-white',
'danger' => 'text-white',
'warning' => 'text-black',
'info' => 'text-white',
'light' => 'text-black',
'dark' => 'text-white',
'white' => 'text-black',
'transparent' => 'text-black'
'primary' => 'text-light',
'secondary' => 'text-light',
'success' => 'text-light',
'danger' => 'text-light',
'warning' => 'text-dark',
'info' => 'text-light',
'light' => 'text-dark',
'dark' => 'text-light',
'white' => 'text-dark',
'transparent' => 'text-dark'
];
protected $allowedOptionValues = [];
protected $options = [];
@ -142,7 +171,7 @@ class BootstrapGeneric
}
}
protected static function genNode($node, $params=[], $content="")
public static function genNode($node, $params=[], $content="")
{
return sprintf('<%s %s>%s</%s>', $node, BootstrapGeneric::genHTMLParams($params), $content, $node);
}
@ -161,7 +190,9 @@ class BootstrapGeneric
{
$html = '';
foreach ($params as $k => $v) {
$html .= BootstrapGeneric::genHTMLParam($k, $v) . ' ';
if (!empty($k) && !empty($v)) {
$html .= BootstrapGeneric::genHTMLParam($k, $v) . ' ';
}
}
return $html;
}
@ -178,12 +209,10 @@ class BootstrapGeneric
{
return BootstrapGeneric::genNode('button', [
'type' => 'button',
'class' => 'close',
'data-dismiss' => $dismissTarget,
'class' => 'btn-close',
'data-bs-dismiss' => $dismissTarget,
'arial-label' => __('Close')
], BootstrapGeneric::genNode('span', [
'arial-hidden' => 'true'
], '&times;'));
]);
}
protected static function getTextClassForVariant($variant)
@ -201,8 +230,9 @@ class BootstrapTabs extends BootstrapGeneric
'vertical' => false,
'vertical-size' => 3,
'card' => false,
'header-variant' => 'light',
'header-variant' => '',
'body-variant' => '',
'body-class' => [],
'nav-class' => [],
'nav-item-class' => [],
'content-class' => [],
@ -217,7 +247,7 @@ class BootstrapTabs extends BootstrapGeneric
$this->allowedOptionValues = [
'justify' => [false, 'center', 'end'],
'body-variant' => array_merge(BootstrapGeneric::$variants, ['']),
'header-variant' => BootstrapGeneric::$variants,
'header-variant' => array_merge(BootstrapGeneric::$variants, ['']),
];
$this->processOptions($options);
}
@ -238,7 +268,6 @@ class BootstrapTabs extends BootstrapGeneric
$this->bsClasses = [
'nav' => [],
'nav-item' => $this->options['nav-item-class'],
];
if (!empty($this->options['justify'])) {
@ -286,7 +315,9 @@ class BootstrapTabs extends BootstrapGeneric
}
$this->data['navs'][$activeTab]['active'] = true;
$this->options['vertical-size'] = $this->options['vertical-size'] < 0 || $this->options['vertical-size'] > 11 ? 3 : $this->options['vertical-size'];
if (!empty($this->options['vertical-size']) && $this->options['vertical-size'] != 'auto') {
$this->options['vertical-size'] = $this->options['vertical-size'] < 0 || $this->options['vertical-size'] > 11 ? 3 : $this->options['vertical-size'];
}
$this->options['header-text-variant'] = $this->options['header-variant'] == 'light' ? 'body' : 'white';
$this->options['header-border-variant'] = $this->options['header-variant'] == 'light' ? '' : $this->options['header-variant'];
@ -321,7 +352,13 @@ class BootstrapTabs extends BootstrapGeneric
$html .= $this->genNav();
if ($this->options['card']) {
$html .= $this->closeNode('div');
$html .= $this->openNode('div', ['class' => array_merge(['card-body'], ["bg-{$this->options['body-variant']}", "text-{$this->options['body-text-variant']}"])]);
$html .= $this->openNode('div', [
'class' => array_merge(
['card-body'],
$this->options['body-class'] ?? [],
["bg-{$this->options['body-variant']}", "text-{$this->options['body-text-variant']}"]
)
]);
}
$html .= $this->genContent();
if ($this->options['card']) {
@ -333,11 +370,37 @@ class BootstrapTabs extends BootstrapGeneric
private function genVerticalTabs()
{
$html = $this->openNode('div', ['class' => array_merge(['row', ($this->options['card'] ? 'card flex-row' : '')], ["border-{$this->options['header-border-variant']}"])]);
$html .= $this->openNode('div', ['class' => array_merge(['col-' . $this->options['vertical-size'], ($this->options['card'] ? 'card-header border-right' : '')], ["bg-{$this->options['header-variant']}", "text-{$this->options['header-text-variant']}", "border-{$this->options['header-border-variant']}"])]);
$html = $this->openNode('div', ['class' => array_merge(
[
'row',
($this->options['card'] ? 'card flex-row' : ''),
($this->options['vertical-size'] == 'auto' ? 'flex-nowrap' : '')
],
[
"border-{$this->options['header-border-variant']}"
]
)]);
$html .= $this->openNode('div', ['class' => array_merge(
[
($this->options['vertical-size'] != 'auto' ? 'col-' . $this->options['vertical-size'] : ''),
($this->options['card'] ? 'card-header border-end' : '')
],
[
"bg-{$this->options['header-variant']}",
"text-{$this->options['header-text-variant']}",
"border-{$this->options['header-border-variant']}"
])]);
$html .= $this->genNav();
$html .= $this->closeNode('div');
$html .= $this->openNode('div', ['class' => array_merge(['col-' . (12 - $this->options['vertical-size']), ($this->options['card'] ? 'card-body2' : '')], ["bg-{$this->options['body-variant']}", "text-{$this->options['body-text-variant']}"])]);
$html .= $this->openNode('div', ['class' => array_merge(
[
($this->options['vertical-size'] != 'auto' ? 'col-' . (12 - $this->options['vertical-size']) : ''),
($this->options['card'] ? 'card-body2' : '')
],
[
"bg-{$this->options['body-variant']}",
"text-{$this->options['body-text-variant']}"
])]);
$html .= $this->genContent();
$html .= $this->closeNode('div');
$html .= $this->closeNode('div');
@ -369,7 +432,7 @@ class BootstrapTabs extends BootstrapGeneric
[!empty($navItem['active']) ? 'active' : ''],
[!empty($navItem['disabled']) ? 'disabled' : '']
),
'data-toggle' => $this->options['pills'] ? 'pill' : 'tab',
'data-bs-toggle' => $this->options['pills'] ? 'pill' : 'tab',
'id' => $navItem['id'] . '-tab',
'href' => '#' . $navItem['id'],
'aria-controls' => $navItem['id'],
@ -487,7 +550,7 @@ class BoostrapTable extends BootstrapGeneric {
private $bsClasses = null;
function __construct($options, $data) {
function __construct($options, $data, $btHelper) {
$this->allowedOptionValues = [
'variant' => array_merge(BootstrapGeneric::$variants, [''])
];
@ -495,6 +558,7 @@ class BoostrapTable extends BootstrapGeneric {
$this->fields = $data['fields'];
$this->items = $data['items'];
$this->caption = !empty($data['caption']) ? $data['caption'] : '';
$this->btHelper = $btHelper;
}
private function processOptions($options)
@ -603,6 +667,11 @@ class BoostrapTable extends BootstrapGeneric {
{
if (isset($field['formatter'])) {
$cellContent = $field['formatter']($value, $row);
} else if (isset($field['element'])) {
$cellContent = $this->btHelper->getView()->element($field['element'], [
'data' => [$value],
'field' => ['path' => '0']
]);
} else {
$cellContent = h($value);
}
@ -619,6 +688,145 @@ class BoostrapTable extends BootstrapGeneric {
}
}
class BoostrapListTable extends BootstrapGeneric {
private $defaultOptions = [
'striped' => true,
'bordered' => false,
'borderless' => false,
'hover' => true,
'small' => false,
'variant' => '',
'tableClass' => [],
'bodyClass' => [],
];
private $bsClasses = null;
function __construct($options, $data, $btHelper) {
$this->allowedOptionValues = [
'variant' => array_merge(BootstrapGeneric::$variants, [''])
];
$this->processOptions($options);
$this->fields = $data['fields'];
$this->item = $data['item'];
$this->caption = !empty($data['caption']) ? $data['caption'] : '';
$this->btHelper = $btHelper;
}
private function processOptions($options)
{
$this->options = array_merge($this->defaultOptions, $options);
$this->checkOptionValidity();
}
public function table()
{
return $this->genTable();
}
private function genTable()
{
$html = $this->openNode('table', [
'class' => [
'table',
"table-{$this->options['variant']}",
$this->options['striped'] ? 'table-striped' : '',
$this->options['bordered'] ? 'table-bordered' : '',
$this->options['borderless'] ? 'table-borderless' : '',
$this->options['hover'] ? 'table-hover' : '',
$this->options['small'] ? 'table-sm' : '',
!empty($this->options['variant']) ? "table-{$this->options['variant']}" : '',
!empty($this->options['tableClass']) ? (is_array($this->options['tableClass']) ? implode(' ', $this->options['tableClass']) : $this->options['tableClass']) : ''
],
'id' => $this->options['id'] ?? ''
]);
$html .= $this->genCaption();
$html .= $this->genBody();
$html .= $this->closeNode('table');
return $html;
}
private function genBody()
{
$body = $this->openNode('tbody', [
'class' => [
!empty($this->options['bodyClass']) ? (is_array($this->options['bodyClass']) ? implode(' ', $this->options['bodyClass']) : $this->options['bodyClass']) : ''
],
]);
foreach ($this->fields as $i => $field) {
$body .= $this->genRow($field);
}
$body .= $this->closeNode('tbody');
return $body;
}
private function genRow($field)
{
$rowValue = $this->genCell($field);
$rowKey = $this->genNode('th', [
'class' => [
'col-sm-2'
],
'scope' => 'row'
], h($field['key']));
$row = $this->genNode('tr',[
'class' => [
'd-flex',
!empty($field['_rowVariant']) ? "table-{$field['_rowVariant']}" : ''
]
], implode('', [$rowKey, $rowValue]));
return $row;
}
private function genCell($field=[])
{
if (isset($field['raw'])) {
$cellContent = h($field['raw']);
} else if (isset($field['formatter'])) {
$cellContent = $field['formatter']($this->getValueFromObject($field), $this->item);
} else if (isset($field['type'])) {
$cellContent = $this->btHelper->getView()->element($this->getElementPath($field['type']), [
'data' => $this->item,
'field' => $field
]);
} else {
$cellContent = h($this->getValueFromObject($field));
}
return $this->genNode('td', [
'class' => [
'col-sm-10',
!empty($row['_cellVariant']) ? "bg-{$row['_cellVariant']}" : ''
]
], $cellContent);
}
private function getValueFromObject($field)
{
if (is_array($field)) {
$key = $field['path'];
} else {
$key = $field;
}
$cellValue = Hash::get($this->item, $key);
return $cellValue;
}
private function getElementPath($type)
{
return sprintf('%s%sField',
$this->options['elementsRootPath'] ?? '',
$type
);
}
private function genCaption()
{
return !empty($this->caption) ? $this->genNode('caption', [], h($this->caption)) : '';
}
}
class BoostrapButton extends BootstrapGeneric {
private $defaultOptions = [
'id' => '',
@ -627,11 +835,12 @@ class BoostrapButton extends BootstrapGeneric {
'variant' => 'primary',
'outline' => false,
'size' => '',
'block' => false,
'icon' => null,
'image' => null,
'class' => [],
'type' => 'button',
'nodeType' => 'button',
'title' => '',
'params' => [],
'badge' => false
];
@ -640,7 +849,7 @@ class BoostrapButton extends BootstrapGeneric {
function __construct($options) {
$this->allowedOptionValues = [
'variant' => BootstrapGeneric::$variants,
'variant' => array_merge(BootstrapGeneric::$variants, ['link', 'text']),
'size' => ['', 'sm', 'lg'],
'type' => ['button', 'submit', 'reset']
];
@ -663,10 +872,11 @@ class BoostrapButton extends BootstrapGeneric {
$this->bsClasses[] = "btn-{$this->options['variant']}";
}
if (!empty($this->options['size'])) {
$this->bsClasses[] = "btn-$this->options['size']";
$this->bsClasses[] = "btn-{$this->options['size']}";
}
if ($this->options['block']) {
$this->bsClasses[] = 'btn-block';
if ($this->options['variant'] == 'text') {
$this->bsClasses[] = 'p-0';
$this->bsClasses[] = 'lh-1';
}
}
@ -680,10 +890,12 @@ class BoostrapButton extends BootstrapGeneric {
$html = $this->openNode($this->options['nodeType'], array_merge($this->options['params'], [
'class' => array_merge($this->options['class'], $this->bsClasses),
'role' => "alert",
'type' => $this->options['type']
'type' => $this->options['type'],
'title' => h($this->options['title']),
]));
$html .= $this->genIcon();
$html .= $this->genImage();
$html .= $this->genContent();
if (!empty($this->options['badge'])) {
$bsBadge = new BoostrapBadge($this->options['badge']);
@ -695,9 +907,27 @@ class BoostrapButton extends BootstrapGeneric {
private function genIcon()
{
return $this->genNode('span', [
'class' => ['mr-1', "fa fa-{$this->options['icon']}"],
]);
if (!empty($this->options['icon'])) {
$bsIcon = new BoostrapIcon($this->options['icon'], [
'class' => [(!empty($this->options['title']) ? 'me-1' : '')]
]);
return $bsIcon->icon();
}
return '';
}
private function genImage()
{
if (!empty($this->options['image'])) {
return $this->genNode('img', [
'src' => $this->options['image']['path'] ?? '',
'class' => ['img-fluid', 'me-1'],
'width' => '26',
'height' => '26',
'alt' => $this->options['image']['alt'] ?? ''
]);
}
return '';
}
private function genContent()
@ -711,7 +941,8 @@ class BoostrapBadge extends BootstrapGeneric {
'text' => '',
'variant' => 'primary',
'pill' => false,
'title' => ''
'title' => '',
'class' => [],
];
function __construct($options) {
@ -735,17 +966,53 @@ class BoostrapBadge extends BootstrapGeneric {
private function genBadge()
{
$html = $this->genNode('span', [
'class' => [
'class' => array_merge($this->options['class'], [
'ms-1',
'badge',
"badge-{$this->options['variant']}",
$this->options['pill'] ? 'badge-pill' : '',
],
"bg-{$this->options['variant']}",
$this->getTextClassForVariant($this->options['variant']),
$this->options['pill'] ? 'rounded-pill' : '',
]),
'title' => $this->options['title']
], h($this->options['text']));
return $html;
}
}
class BoostrapIcon extends BootstrapGeneric {
private $icon = '';
private $defaultOptions = [
'class' => [],
];
function __construct($icon, $options=[]) {
$this->icon = $icon;
$this->processOptions($options);
}
private function processOptions($options)
{
$this->options = array_merge($this->defaultOptions, $options);
$this->checkOptionValidity();
}
public function icon()
{
return $this->genIcon();
}
private function genIcon()
{
$html = $this->genNode('span', [
'class' => array_merge(
is_array($this->options['class']) ? $this->options['class'] : [$this->options['class']],
["fa fa-{$this->icon}"]
),
]);
return $html;
}
}
class BoostrapModal extends BootstrapGeneric {
private $defaultOptions = [
'size' => '',
@ -871,7 +1138,7 @@ class BoostrapModal extends BootstrapGeneric {
'variant' => 'primary',
'text' => __('Ok'),
'params' => [
'data-dismiss' => $this->options['confirmFunction'] ? '' : 'modal',
'data-bs-dismiss' => $this->options['confirmFunction'] ? '' : 'modal',
'onclick' => $this->options['confirmFunction']
]
]))->button();
@ -888,7 +1155,7 @@ class BoostrapModal extends BootstrapGeneric {
'variant' => 'secondary',
'text' => h($this->options['cancelText']),
'params' => [
'data-dismiss' => 'modal',
'data-bs-dismiss' => 'modal',
'onclick' => $this->options['cancelFunction']
]
]))->button();
@ -898,7 +1165,7 @@ class BoostrapModal extends BootstrapGeneric {
'text' => h($this->options['confirmText']),
'class' => 'modal-confirm-button',
'params' => [
// 'data-dismiss' => $this->options['confirmFunction'] ? '' : 'modal',
// 'data-bs-dismiss' => $this->options['confirmFunction'] ? '' : 'modal',
'data-confirmFunction' => sprintf('%s', $this->options['confirmFunction'])
]
]))->button();
@ -914,7 +1181,7 @@ class BoostrapModal extends BootstrapGeneric {
'text' => h($buttonConfig['text']),
'class' => 'modal-confirm-button',
'params' => [
'data-dismiss' => !empty($buttonConfig['clickFunction']) ? '' : 'modal',
'data-bs-dismiss' => !empty($buttonConfig['clickFunction']) ? '' : 'modal',
'data-clickFunction' => sprintf('%s', $buttonConfig['clickFunction'])
]
]))->button();
@ -933,6 +1200,7 @@ class BoostrapCard extends BootstrapGeneric
'headerHTML' => '',
'footerHTML' => '',
'bodyHTML' => '',
'class' => '',
'headerClass' => '',
'bodyClass' => '',
'footerClass' => '',
@ -964,6 +1232,7 @@ class BoostrapCard extends BootstrapGeneric
'card',
!empty($this->options['variant']) ? "bg-{$this->options['variant']}" : '',
!empty($this->options['variant']) ? $this->getTextClassForVariant($this->options['variant']) : '',
h($this->options['class']),
],
], implode('', [$this->genHeader(), $this->genBody(), $this->genFooter()]));
return $card;
@ -1015,6 +1284,60 @@ class BoostrapCard extends BootstrapGeneric
}
}
class BoostrapSwitch extends BootstrapGeneric {
private $defaultOptions = [
'label' => '',
'variant' => 'primary',
'disabled' => false,
'checked' => false,
'title' => '',
'class' => [],
'attrs' => [],
];
function __construct($options) {
$this->allowedOptionValues = [
'variant' => BootstrapGeneric::$variants,
];
$this->processOptions($options);
}
private function processOptions($options)
{
$this->options = array_merge($this->defaultOptions, $options);
$this->checkOptionValidity();
}
public function switch()
{
return $this->genSwitch();
}
private function genSwitch()
{
$tmpId = 'tmp-' . mt_rand();
$html = $this->genNode('div', [
'class' => [
'form-check form-switch',
],
'title' => $this->options['title']
], implode('', [
$this->genNode('input', array_merge([
'type' => "checkbox",
'class' => 'form-check-input',
'id' => $tmpId,
($this->options['disabled'] ? 'disabled' : '') => '',
($this->options['checked'] ? 'checked' : '') => $this->options['checked'] ? 'checked' : '',
], $this->options['attrs'])),
$this->genNode('label', [
'class' => 'form-check-label',
'for' => $tmpId,
], h($this->options['label']))
]));
return $html;
}
}
class BoostrapProgress extends BootstrapGeneric {
private $defaultOptions = [
'value' => 0,
@ -1103,7 +1426,7 @@ class BoostrapCollapse extends BootstrapGeneric {
{
$html = $this->genNode('a', [
'class' => ['text-decoration-none'],
'data-toggle' => 'collapse',
'data-bs-toggle' => 'collapse',
'href' => '#collapseExample',
'role' => 'button',
'aria-expanded' => 'false',
@ -1213,7 +1536,7 @@ class BoostrapProgressTimeline extends BootstrapGeneric {
return $this->genNode('li', [
'class' => [
'text-center',
'font-weight-bold',
'fw-bold',
$isActive ? 'progress-active' : 'progress-inactive',
],
], h($step['text'] ?? ''));
@ -1244,4 +1567,100 @@ class BoostrapProgressTimeline extends BootstrapGeneric {
], $ulIcons . $ulText);
return $html;
}
}
class BootstrapListGroup extends BootstrapGeneric
{
private $defaultOptions = [
'hover' => false,
];
private $bsClasses = null;
function __construct($options, $data, $btHelper) {
$this->data = $data;
$this->processOptions($options);
$this->btHelper = $btHelper;
}
private function processOptions($options)
{
$this->options = array_merge($this->defaultOptions, $options);
}
public function listGroup()
{
return $this->genListGroup();
}
private function genListGroup()
{
$html = $this->openNode('div', [
'class' => ['list-group',],
]);
foreach ($this->data as $item) {
$html .= $this->genItem($item);
}
$html .= $this->closeNode('div');
return $html;
}
private function genItem($item)
{
if (!empty($item['heading'])) { // complex layout with heading, badge and body
$html = $this->genNode('a', [
'class' => ['list-group-item', (!empty($this->options['hover']) ? 'list-group-item-action' : ''),],
], implode('', [
$this->genHeadingGroup($item),
$this->genBody($item),
]));
} else { // simple layout with just <li>-like elements
$html = $this->genNode('a', [
'class' => ['list-group-item', 'd-flex', 'align-items-center', 'justify-content-between'],
], implode('', [
h($item['text']),
$this->genBadge($item)
]));
}
return $html;
}
private function genHeadingGroup($item)
{
$html = $this->genNode('div', [
'class' => ['d-flex', 'w-100', 'justify-content-between',],
], implode('', [
$this->genHeading($item),
$this->genBadge($item)
]));
return $html;
}
private function genHeading($item)
{
if (empty($item['heading'])) {
return '';
}
return $this->genNode('h5', [
'class' => ['mb-1'],
], h($item['heading']));
}
private function genBadge($item)
{
if (empty($item['badge'])) {
return '';
}
return $this->genNode('span', [
'class' => ['badge rounded-pill', (!empty($item['badge-variant']) ? "bg-{$item['badge-variant']}" : 'bg-primary')],
], h($item['badge']));
}
private function genBody($item)
{
if (!empty($item['bodyHTML'])) {
return $item['bodyHTML'];
}
return !empty($item['body']) ? h($item['body']) : '';
}
}

View File

@ -49,10 +49,10 @@ class DataFromPathHelper extends Helper
if (empty($strArg['function'])) {
$varValue = $options['sanitize'] ? h($varValue) : $varValue;
}
$extractedVars[] = $varValue;
$extractedVars[$i] = $varValue;
}
foreach ($extractedVars as $i => $value) {
$value = $options['highlight'] ? "<span class=\"font-weight-light\">${value}</span>" : $value;
$value = $options['highlight'] ? "<span class=\"fw-light\">${value}</span>" : $value;
$str = str_replace(
"{{{$i}}}",
$value,

View File

@ -0,0 +1,38 @@
<?php
namespace App\View\Helper;
use Cake\View\Helper;
use Cake\Utility\Hash;
class SocialProviderHelper extends Helper
{
public $helpers = ['Bootstrap'];
private $providerImageMapping = [
'keycloak' => '/img/keycloak_logo.png',
];
public function getIcon($identity)
{
if (!empty($identity['social_profile'])) {
$provider = $identity['social_profile']['provider'];
if (!empty($this->providerImageMapping[$provider])) {
return $this->genImage($this->providerImageMapping[$provider], h($provider));
}
}
return '';
}
private function genImage($url, $alt)
{
return $this->Bootstrap->genNode('img', [
'src' => $url,
'class' => ['img-fluid'],
'width' => '16',
'height' => '16',
'alt' => $alt,
'title' => __('Authentication provided by {0}', $alt),
]);
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\View\Helper;
use Cake\View\Helper;
// This helper helps determining the brightness of a colour (initially only used for the tagging) in order to decide
// what text colour to use against the background (black or white)
class TextColourHelper extends Helper {
public function getTextColour($RGB) {
$r = hexdec(substr($RGB, 1, 2));
$g = hexdec(substr($RGB, 3, 2));
$b = hexdec(substr($RGB, 5, 2));
$average = ((2 * $r) + $b + (3 * $g))/6;
if ($average < 127) {
return 'white';
} else {
return 'black';
}
}
}

View File

@ -5,9 +5,9 @@ echo $this->element('genericElements/genericModal', [
'<p>%s</p><p>%s</p><p>%s</p>',
__('Please make sure that you note down the authkey below, this is the only time the authkey is shown in plain text, so make sure you save it. If you lose the key, simply remove the entry and generate a new one.'),
__('Cerebrate will use the first and the last 4 digit for identification purposes.'),
sprintf('%s: <span class="font-weight-bold">%s</span>', __('Authkey'), h($entity->authkey_raw))
sprintf('%s: <span class="fw-bold">%s</span>', __('Authkey'), h($entity->authkey_raw))
),
'actionButton' => sprintf('<button" class="btn btn-primary" data-dismiss="modal">%s</button>', __('I have noted down my key, take me back now')),
'actionButton' => sprintf('<button" class="btn btn-primary" data-bs-dismiss="modal">%s</button>', __('I have noted down my key, take me back now')),
'noCancel' => true,
'staticBackdrop' => true,
]);

View File

@ -130,7 +130,7 @@ echo $this->element('genericElements/IndexTable/index_table', [
)
const $footer = $(modalObject.ajaxApi.statusNode).parent()
modalObject.ajaxApi.statusNode.remove()
const $cancelButton = $footer.find('button[data-dismiss="modal"]')
const $cancelButton = $footer.find('button[data-bs-dismiss="modal"]')
$cancelButton.text('<?= __('OK') ?>').removeClass('btn-secondary').addClass('btn-primary')
}
UI.submissionModal('/inbox/delete', successCallback, failCallback).then(([modalObject, ajaxApi]) => {

View File

@ -20,7 +20,11 @@
),
array(
'field' => 'position'
)
),
array(
'field' => 'tag_list',
'type' => 'tags'
),
),
'metaTemplates' => empty($metaTemplates) ? [] : $metaTemplates,
'submit' => array(

View File

@ -23,7 +23,8 @@ echo $this->element('genericElements/IndexTable/index_table', [
'button' => __('Filter'),
'placeholder' => __('Enter value to search'),
'data' => '',
'searchKey' => 'value'
'searchKey' => 'value',
'allowFilering' => true
]
]
],
@ -54,6 +55,11 @@ echo $this->element('genericElements/IndexTable/index_table', [
'element' => 'alignments',
'scope' => $alignmentScope
],
[
'name' => __('Tags'),
'data_path' => 'tags',
'element' => 'tags',
],
[
'name' => __('UUID'),
'sort' => 'uuid',

View File

@ -28,6 +28,10 @@ echo $this->element(
'key' => __('Position'),
'path' => 'position'
],
[
'key' => __('Tags'),
'type' => 'tags',
],
[
'key' => __('Alignments'),
'type' => 'alignment',

View File

@ -1,3 +1,30 @@
<?php
$Parsedown = new Parsedown();
echo $Parsedown->text($md);
// $Parsedown = new Parsedown();
// echo $Parsedown->text($md);
?>
<h2><?= __('Home') ?></h2>
<div class="row">
<?php foreach ($statistics as $modelName => $statistics): ?>
<div class="col-sm-6 col-md-5 col-l-4 col-xl-3 mb-3">
<?php
$exploded = explode('.', $modelName);
$modelForDisplay = $exploded[count($exploded)-1];
$panelTitle = $this->Html->link(
h($modelForDisplay),
$this->Url->build([
'controller' => $modelForDisplay,
'action' => 'index',
]),
['class' => 'text-white text-decoration-none fw-light stretched-link']
);
echo $this->element('widgets/highlight-panel', [
'titleHtml' => $panelTitle,
'number' => $statistics['amount'],
'variation' => $statistics['variation'] ?? '',
'chartData' => $statistics['timeline'] ?? []
]);
?>
</div>
<?php endforeach ?>
</div>

View File

@ -27,6 +27,10 @@ foreach ($status as $i => &$update) {
} else if ($update['status'] == 'down') {
$update['_rowVariant'] = 'danger';
}
if (!empty($update['plugin'])) {
$update['name'] = "{$update['plugin']}.{$update['name']}";
}
}
echo $this->Bootstrap->table([], [

View File

@ -0,0 +1,17 @@
<?php
echo $this->element('genericElements/Form/genericForm', [
'data' => [
'description' => __('Authkeys are used for API access. A user can have more than one authkey, so if you would like to use separate keys per tool that queries Cerebrate, add additional keys. Use the comment field to make identifying your keys easier.'),
'fields' => [
[
'field' => 'name',
],
[
'field' => 'value'
],
],
'submit' => [
'action' => $this->request->getParam('action')
]
]
]);

View File

@ -0,0 +1,72 @@
<?php
$sections = [];
if (!empty($ajax)) {
$sections[] = '<div class="search-results-wrapper">';
}
foreach ($data as $tableName => $tableResult) {
if (empty($tableResult['amount'])) {
continue;
}
$section = '';
$table = Cake\ORM\TableRegistry::get($tableName);
$fieldPath = !empty($table->getDisplayField()) ? $table->getDisplayField() : 'id';
$section .= sprintf('<span class="d-flex text-nowrap px-2 search-container-model">
<span class="text-uppercase text-muted me-3 model-text">%s</span>
<span class="d-flex align-items-center search-container-divider">
<hr class="m-0"/>
</span>
<span class="fw-light text-muted ms-3 model-text">%s</span>
</span>', h($tableName), $tableResult['amount']);
foreach ($tableResult['entries'] as $entry) {
$section .= sprintf('<a class="dropdown-item" href="%s">%s</a>',
Cake\Routing\Router::URL([
'controller' => Cake\Utility\Inflector::pluralize($entry->getSource()),
'action' => 'view',
h($entry['id'])
]),
h($entry[$fieldPath])
);
}
$remaining = $tableResult['amount'] - count($tableResult['entries']);
if ($remaining > 0) {
$section .= sprintf('<a href="%s" class="dropdown-item total-found d-block pe-2">%s <strong class="total-found-number text-primary">%s</strong><span class="total-found-text d-inline ms-1" href="#">%s</span></a>',
Cake\Routing\Router::URL([
'controller' => 'instance',
'action' => 'search_all',
'?' => [
'model' => h($tableName),
'search' => h($this->request->getParam('?')['search'] ?? ''),
'show_all' => 1
]
]),
__('Load'),
$remaining,
__('more results')
);
}
$sections[] = $section;
}
if (!empty($ajax)) {
$sections[] = sprintf('<a class="dropdown-item border-top text-center text-muted p-2" href="%s"><i class="%s me-2"></i>%s</a>',
Cake\Routing\Router::URL([
'controller' => 'instance',
'action' => 'search_all',
'?' => [
'search' => h($this->request->getParam('?')['search'] ?? '')
]
]),
$this->FontAwesome->getClass('search-plus'),
__('View all results')
);
$sections[] = '</div>';
} else {
echo sprintf('<h2 class="fw-light mb-4">%s <span class="font-monospace">%s</span></h2>', __('Global search results for:'), h($this->request->getParam('?')['search'] ?? ''));
}
if (!empty($sections)) {
echo implode('', $sections);
} else {
echo sprintf('<span class="dropdown-item p-0 pb-1 text-center">%s</span>', __('- No result -'));
}

View File

@ -0,0 +1,299 @@
<?php
$variantFromSeverity = [
'critical' => 'danger',
'warning' => 'warning',
'info' => 'info',
];
$this->set('variantFromSeverity', $variantFromSeverity);
$settingTable = genNavcard($settingsProvider, $this);
?>
<script>
const variantFromSeverity = <?= json_encode($variantFromSeverity) ?>;
const settingsFlattened = <?= json_encode($settingsFlattened) ?>;
</script>
<div class="px-5">
<div class="mb-3 mt-2">
<?=
$this->element('Settings/search', [
]);
?>
</div>
<?= $settingTable; ?>
</div>
<?php
function genNavcard($settingsProvider, $appView)
{
$cardContent = [];
$cardNavs = array_keys($settingsProvider);
foreach ($settingsProvider as $navName => $sectionSettings) {
if (!empty($sectionSettings)) {
$cardContent[] = genContentForNav($sectionSettings, $appView);
} else {
$cardContent[] = __('No Settings available yet');
}
}
array_unshift($cardNavs, __('Settings Diagnostic'));
$notice = $appView->element('Settings/notice', [
'variantFromSeverity' => $appView->get('variantFromSeverity'),
]);
array_unshift($cardContent, $notice);
$tabsOptions0 = [
// 'vertical' => true,
// 'vertical-size' => 2,
'card' => false,
'pills' => false,
'justify' => 'center',
'nav-class' => ['settings-tabs'],
'data' => [
'navs' => $cardNavs,
'content' => $cardContent
]
];
$table0 = $appView->Bootstrap->tabs($tabsOptions0);
return $table0;
}
function genContentForNav($sectionSettings, $appView)
{
$groupedContent = [];
$groupedSetting = [];
foreach ($sectionSettings as $sectionName => $subSectionSettings) {
if (!empty($subSectionSettings)) {
$groupedContent[] = genSection($sectionName, $subSectionSettings, $appView);
} else {
$groupedContent[] = '';
}
if (!isLeaf($subSectionSettings)) {
$groupedSetting[$sectionName] = array_filter( // only show grouped settings
array_keys($subSectionSettings),
function ($settingGroupName) use ($subSectionSettings) {
return !isLeaf($subSectionSettings[$settingGroupName]) && !empty($subSectionSettings[$settingGroupName]);
}
);
}
}
$contentHtml = implode('', $groupedContent);
$scrollspyNav = $appView->element('Settings/scrollspyNav', [
'groupedSetting' => $groupedSetting
]);
$mainPanelHeight = 'calc(100vh - 42px - 1rem - 56px - 38px - 1rem)';
$container = '<div class="d-flex">';
$container .= "<div class=\"\" style=\"flex: 0 0 10em;\">{$scrollspyNav}</div>";
$container .= "<div data-bs-spy=\"scroll\" data-bs-target=\"#navbar-scrollspy-setting\" data-bs-offset=\"25\" style=\"height: {$mainPanelHeight}\" class=\"p-3 overflow-auto position-relative flex-grow-1\">{$contentHtml}</div>";
$container .= '</div>';
return $container;
}
function genSection($sectionName, $subSectionSettings, $appView)
{
$sectionContent = [];
$sectionContent[] = '<div>';
if (isLeaf($subSectionSettings)) {
$panelHTML = $appView->element('Settings/panel', [
'sectionName' => $sectionName,
'panelName' => $sectionName,
'panelSettings' => $subSectionSettings,
]);
$sectionContent[] = $panelHTML;
} else {
if (count($subSectionSettings) > 0) {
$sectionContent[] = sprintf('<h2 id="%s">%s</h2>', getResolvableID($sectionName), h($sectionName));
}
foreach ($subSectionSettings as $panelName => $panelSettings) {
if (!empty($panelSettings)) {
$panelHTML = $appView->element('Settings/panel', [
'sectionName' => $sectionName,
'panelName' => $panelName,
'panelSettings' => $panelSettings,
]);
$sectionContent[] = $panelHTML;
} else {
$sectionContent[] = '';
}
}
}
$sectionContent[] = '</div>';
return implode('', $sectionContent);
}
function isLeaf($setting)
{
return !empty($setting['name']) && !empty($setting['type']);
}
function getResolvableID($sectionName, $panelName=false)
{
$id = sprintf('sp-%s', preg_replace('/(\.|\W)/', '_', h($sectionName)));
if (!empty($panelName)) {
$id .= '-' . preg_replace('/(\.|\W)/', '_', h($panelName));
}
return $id;
}
?>
<script>
$(document).ready(function() {
new bootstrap.Tooltip('.depends-on-icon', {
placement: 'right',
})
$('select.custom-select[multiple]').select2()
$('.settings-tabs a[data-bs-toggle="tab"]').on('shown.bs.tab', function (event) {
$('[data-bs-spy="scroll"]').trigger('scroll.bs.scrollspy')
})
$('.tab-content input, .tab-content select').on('input', function() {
if ($(this).attr('type') == 'checkbox') {
const $input = $(this)
const $inputGroup = $(this).closest('.setting-group')
const settingName = $(this).data('setting-name')
const settingValue = $(this).is(':checked') ? 1 : 0
saveSetting($inputGroup[0], $input, settingName, settingValue)
} else {
handleSettingValueChange($(this))
}
})
$('.tab-content .setting-group .btn-save-setting').click(function() {
const $input = $(this).closest('.input-group').find('input, select')
const settingName = $input.data('setting-name')
const settingValue = $input.val()
saveSetting(this, $input, settingName, settingValue)
})
$('.tab-content .setting-group .btn-reset-setting').click(function() {
const $btn = $(this)
const $input = $btn.closest('.input-group').find('input, select')
let oldValue = settingsFlattened[$input.data('setting-name')].value
if ($input.is('select')) {
oldValue = oldValue !== undefined ? oldValue : -1
} else {
oldValue = oldValue !== undefined ? oldValue : ''
}
$input.val(oldValue)
handleSettingValueChange($input)
})
const referencedID = window.location.hash
redirectToSetting(referencedID)
})
function saveSetting(statusNode, $input, settingName, settingValue) {
const url = '/instance/saveSetting/'
const data = {
name: settingName,
value: settingValue,
}
const APIOptions = {
statusNode: statusNode,
}
AJAXApi.quickFetchAndPostForm(url, data, APIOptions).then((result) => {
settingsFlattened[settingName] = result.data
if ($input.attr('type') == 'checkbox') {
$input.prop('checked', result.data.value)
} else {
$input.val(result.data.value)
}
handleSettingValueChange($input)
}).catch((e) => {})
}
function handleSettingValueChange($input) {
const oldValue = settingsFlattened[$input.data('setting-name')].value
const newValue = ($input.attr('type') == 'checkbox' ? $input.is(':checked') : $input.val())
if (newValue == oldValue || (newValue == '' && oldValue == undefined)) {
restoreWarnings($input)
} else {
removeWarnings($input)
}
}
function removeWarnings($input) {
const $inputGroup = $input.closest('.input-group')
const $btnSettingAction = $inputGroup.find('.btn-setting-action')
const $saveButton = $('.setting-group button.btn-save-setting')
$input.removeClass(['is-invalid', 'border-warning', 'border-danger', 'border-info', 'warning', 'info'])
$btnSettingAction.removeClass('d-none')
if ($input.is('select') && $input.find('option:selected').data('is-empty-option') == 1) {
$btnSettingAction.addClass('d-none') // hide save button if empty selection picked
}
$inputGroup.parent().find('.invalid-feedback').removeClass('d-block')
}
function restoreWarnings($input) {
const $inputGroup = $input.closest('.input-group')
const $btnSettingAction = $inputGroup.find('.btn-setting-action')
const $saveButton = $('.setting-group button.btn-save-setting')
const setting = settingsFlattened[$input.data('setting-name')]
if (setting.error) {
borderVariant = setting.severity !== undefined ? variantFromSeverity[setting.severity] : 'warning'
$input.addClass(['is-invalid', `border-${borderVariant}`, borderVariant])
$inputGroup.parent().find('.invalid-feedback').addClass('d-block').text(setting.errorMessage)
} else {
removeWarnings($input)
}
const $callout = $input.closest('.settings-group')
updateCalloutColors($callout)
$btnSettingAction.addClass('d-none')
}
function updateCalloutColors($callout) {
if ($callout.length == 0) {
return
}
const $settings = $callout.find('input, select')
const settingNames = Array.from($settings).map((i) => {
return $(i).data('setting-name')
})
const severityMapping = {null: 0, info: 1, warning: 2, critical: 3}
const severityMappingInverted = Object.assign({}, ...Object.entries(severityMapping).map(([k, v]) => ({[v]: k})))
let highestSeverity = severityMapping[null]
settingNames.forEach(name => {
if (settingsFlattened[name].error) {
highestSeverity = severityMapping[settingsFlattened[name].severity] > highestSeverity ? severityMapping[settingsFlattened[name].severity] : highestSeverity
}
});
highestSeverity = severityMappingInverted[highestSeverity]
$callout.removeClass(['callout', 'callout-danger', 'callout-warning', 'callout-info'])
if (highestSeverity !== null) {
$callout.addClass(['callout', `callout-${variantFromSeverity[highestSeverity]}`])
}
}
function redirectToSetting(referencedID) {
const $settingToFocus = $(referencedID)
const pageNavID = $(referencedID).closest('.tab-pane').attr('aria-labelledby')
const $navController = $(`#${pageNavID}`)
const $settingGroup = $settingToFocus.closest('.settings-group')
$navController
.on('shown.bs.tab.after-redirect', () => {
$settingToFocus[0].scrollIntoView()
const inputID = $settingToFocus.parent().attr('for')
$settingToFocus.closest('.setting-group').find(`#${inputID}`).focus()
$navController.off('shown.bs.tab.after-redirect')
$settingGroup.addClass(['to-be-slided', 'slide-in'])
})
.tab('show')
$settingGroup.on('webkitAnimationEnd oanimationend msAnimationEnd animationend', function() {
$(this).removeClass(['to-be-slided', 'slide-in'])
});
}
</script>
<style>
.input-group-actions {
z-index: 5;
}
.form-control[type="number"] ~ div > a.btn-reset-setting {
left: -3em;
}
select.custom-select[multiple][data-setting-name] ~ span.select2-container{
min-width: unset;
}
span.select2-container--open {
min-width: unset;
}
</style>

View File

@ -4,21 +4,25 @@
'description' => __('Add connections to local tools via any of the available connectors below.'),
'model' => 'LocalTools',
'fields' => [
[
'field' => 'name'
],
[
'field' => 'connector',
'options' => $dropdownData['connectors'],
'type' => 'dropdown'
],
[
'field' => 'name'
],
[
'field' => 'exposed',
'type' => 'checkbox'
],
[
'field' => 'settings',
'type' => 'textarea'
'type' => 'codemirror',
'codemirror' => [
'height' => '10rem',
'hints' => $connectors[0]['connector_settings']
]
],
[
'field' => 'description',

View File

@ -1,9 +1,39 @@
<?php
$multiSelectActions = [];
foreach ($connector->getBatchActionFunctions() as $actionName => $actionData) {
$multiSelectActions[] = [
'text' => $actionData['ui']['text'],
'icon' => $actionData['ui']['icon'],
'variant' => $actionData['ui']['variant'],
'params' => ['data-actionname' => $actionName],
'onclick' => 'handleMultiSelectAction'
];
}
echo $this->element('genericElements/IndexTable/index_table', [
'data' => [
'data' => $data,
'top_bar' => [
'children' => [
[
'type' => 'multi_select_actions',
'children' => $multiSelectActions,
'data' => [
'id' => [
'value_path' => 'id'
]
]
],
[
'type' => 'simple',
'children' => [
'data' => [
'type' => 'simple',
'text' => __('Add connection'),
'popover_url' => sprintf('/localTools/add/%s', h($connectorName))
]
]
],
[
'type' => 'search',
'button' => __('Filter'),
@ -53,7 +83,6 @@ echo $this->element('genericElements/IndexTable/index_table', [
],
'title' => false,
'description' => false,
'pull' => 'right',
'actions' => [
[
'url' => '/localTools/view',
@ -63,20 +92,106 @@ echo $this->element('genericElements/IndexTable/index_table', [
[
'open_modal' => '/localTools/connectLocal/[onclick_params_data_path]',
'modal_params_data_path' => 'id',
'reload_url' => sprintf('/localTools/connectorIndex/%s', h($connectorName)),
'icon' => 'plug'
],
[
'open_modal' => '/localTools/edit/[onclick_params_data_path]',
'modal_params_data_path' => 'id',
'reload_url' => sprintf('/localTools/connectorIndex/%s', h($connectorName)),
'icon' => 'edit'
],
[
'open_modal' => '/localTools/delete/[onclick_params_data_path]',
'modal_params_data_path' => 'id',
'reload_url' => sprintf('/localTools/connectorIndex/%s', h($connectorName)),
'icon' => 'trash'
],
]
]
]);
echo '</div>';
?>
<script>
function handleMultiSelectAction(idList, selectedRows, $table, $clicked) {
const url = `/localTools/batchAction/${$clicked.data('actionname')}?connection_ids=${encodeURIComponent(idList)}`
const reloadUrl = '/localTools/connectorIndex/<?= $connectorName ?>'
const successCallback = function([requestData, modalObject]) {
includeResultInModal(requestData, modalObject)
UI.reload(reloadUrl, UI.getContainerForTable($table), $table)
}
const failCallback = function([requestData, modalObject]) {
includeResultInModal(requestData, modalObject)
}
UI.submissionModal(url, successCallback, failCallback, {closeOnSuccess: false})
}
function includeResultInModal(requestData, modalObject) {
const resultsHaveErrors = checkResultsHaveErrors(requestData.data)
let tableData = []
let tableHeader = []
if (resultsHaveErrors) {
tableHeader = ['<?= __('Connection ID') ?>', '<?= __('Connection Name') ?>', '<?= __('Message') ?>', '<?= __('Error') ?>', '<?= __('Success') ?>', '<?= __('Result') ?>']
} else {
tableHeader = ['<?= __('Connection ID') ?>', '<?= __('Connection Name') ?>', '<?= __('Message') ?>', '<?= __('Success') ?>', '<?= __('Result') ?>']
}
for (const key in requestData.data) {
if (Object.hasOwnProperty.call(requestData.data, key)) {
const singleResult = requestData.data[key];
$faIcon = $('<i class="fa"></i>').addClass(singleResult.success ? 'fa-check text-success' : 'fa-times text-danger')
$jsonResult = $('<pre class="p-2 rounded mb-0" style="max-width: 400px; max-height: 300px;background: #eeeeee55;"></pre>').append(
$('<code></code>').text(JSON.stringify(singleResult.data, null, 4))
)
if (resultsHaveErrors) {
tableData.push([singleResult.connection.id, singleResult.connection.name, singleResult.message, JSON.stringify(singleResult.errors, null, 4), $faIcon, $jsonResult])
} else {
tableData.push([singleResult.connection.id, singleResult.connection.name, singleResult.message, $faIcon, $jsonResult])
}
}
}
handleMessageTable(
modalObject.$modal,
tableHeader,
tableData
)
const $footer = $(modalObject.ajaxApi.statusNode).parent()
modalObject.ajaxApi.statusNode.remove()
const $cancelButton = $footer.find('button[data-dismiss="modal"]')
$cancelButton.text('<?= __('OK') ?>').removeClass('btn-secondary').addClass('btn-primary')
}
function constructMessageTable(header, data) {
return HtmlHelper.table(
header,
data,
{
small: true,
borderless: true,
tableClass: ['message-table', 'mt-4 mb-0'],
}
)
}
function handleMessageTable($modal, header, data) {
const $modalBody = $modal.find('.modal-body')
const $messageTable = $modalBody.find('table.message-table')
const messageTableHTML = constructMessageTable(header, data)[0].outerHTML
if ($messageTable.length) {
$messageTable.html(messageTableHTML)
} else {
$modalBody.append(messageTableHTML)
}
}
function checkResultsHaveErrors(result) {
for (const key in result) {
if (Object.hasOwnProperty.call(result, key)) {
const singleResult = result[key];
if(!singleResult.success) {
return true
}
}
}
return false
}
</script>

View File

@ -24,7 +24,7 @@ echo $this->element(
],
'children' => [
[
'url' => '/localTools/connectorIndex/',
'url' => '/localTools/connectorIndex/{{0}}',
'url_params' => ['connector'],
'title' => __('Connections')
]

View File

@ -69,8 +69,8 @@ echo $this->element('genericElements/IndexTable/index_table', [
$conflictingTemplate = getConflictingTemplate($row, $data);
if (!empty($conflictingTemplate)) {
return sprintf(
"<span class=\"text-danger font-weight-bolder\">%s</span> %s.<br />
<ul><li><span class=\"font-weight-bolder\">%s</span> %s <span class=\"font-weight-bolder\">%s</span></li></ul>",
"<span class=\"text-danger fw-bolder\">%s</span> %s.<br />
<ul><li><span class=\"fw-bolder\">%s</span> %s <span class=\"fw-bolder\">%s</span></li></ul>",
__('Conflict with:'),
$this->Html->link(
h($conflictingTemplate->name),

View File

@ -71,7 +71,12 @@ echo $this->element('genericElements/IndexTable/index_table', [
[
'name' => __('Type'),
'data_path' => 'type',
]
],
[
'name' => __('Tags'),
'data_path' => 'tags',
'element' => 'tags',
],
],
'title' => __('ContactDB Organisation Index'),
'description' => __('A list of organisations known by your Cerebrate instance. This list can get populated either directly, by adding new organisations or by fetching them from trusted remote sources.'),

View File

@ -37,6 +37,10 @@ echo $this->element(
'key' => __('Contacts'),
'path' => 'contacts'
],
[
'key' => __('Tags'),
'type' => 'tags',
],
[
'key' => __('Alignments'),
'type' => 'alignment',
@ -45,6 +49,7 @@ echo $this->element(
]
],
'metaTemplates' => empty($metaFields) ? [] : $metaFields,
'combinedFieldsView' => true,
'children' => []
]
);

View File

@ -12,6 +12,11 @@
'type' => 'checkbox',
'label' => 'Full admin privilege'
],
[
'field' => 'perm_org_admin',
'type' => 'checkbox',
'label' => 'Organisation admin privilege'
],
[
'field' => 'perm_sync',
'type' => 'checkbox',

View File

@ -47,6 +47,12 @@ echo $this->element('genericElements/IndexTable/index_table', [
'data_path' => 'perm_admin',
'element' => 'boolean'
],
[
'name' => __('Org Admin'),
'sort' => 'perm_org_admin',
'data_path' => 'perm_org_admin',
'element' => 'boolean'
],
[
'name' => __('Sync'),
'sort' => 'perm_sync',

View File

@ -17,6 +17,11 @@ echo $this->element(
'path' => 'perm_admin',
'type' => 'boolean'
],
[
'key' => __('Organisation admin permission'),
'path' => 'perm_org_admin',
'type' => 'boolean'
],
[
'key' => __('Sync permission'),
'path' => 'perm_sync',

View File

@ -1,11 +1,53 @@
<?php
echo $this->Html->image('logo-purple.png', ['alt' => 'CakePHP', 'class="form-signin"']);
echo '<div class="form-signin">';
echo $this->Form->create(null, ['url' => ['controller' => 'users', 'action' => 'login']]);
echo $this->Form->control('username', ['label' => false, 'class' => 'form-control', 'placeholder' => __('Username')]);
echo $this->Form->control('password', ['type' => 'password', 'label' => false, 'class' => 'form-control', 'placeholder' => __('Password')]);
echo $this->Form->submit(__('Submit'), ['class' => 'btn btn-lg btn-primary btn-block']);
echo $this->Form->end();
use Cake\Core\Configure;
echo '<div class="form-signin panel shadow position-absolute start-50 translate-middle">';
echo sprintf(
'<div class="text-center mb-4">%s</div>',
$this->Html->image('logo-purple.png', [
'alt' => __('Cerebrate logo'),
'width' => 100, 'height' => 100,
'style' => ['filter: drop-shadow(4px 4px 4px #924da666);']
])
);
echo sprintf('<h4 class="text-uppercase fw-light mb-3">%s</h4>', __('Sign in'));
$template = [
'inputContainer' => '<div class="form-floating input {{type}}{{required}}">{{content}}</div>',
'formGroup' => '{{input}}{{label}}',
'submitContainer' => '<div class="submit d-grid">{{content}}</div>',
];
$this->Form->setTemplates($template);
echo $this->Form->create(null, ['url' => ['controller' => 'users', 'action' => 'login']]);
echo $this->Form->control('username', ['label' => 'Username', 'class' => 'form-control mb-2', 'placeholder' => __('Username')]);
echo $this->Form->control('password', ['type' => 'password', 'label' => 'Password', 'class' => 'form-control mb-3', 'placeholder' => __('Password')]);
echo $this->Form->control(__('Submit'), ['type' => 'submit', 'class' => 'btn btn-primary']);
echo $this->Form->end();
if (!empty(Configure::read('keycloak'))) {
echo sprintf('<div class="d-flex align-items-center my-3"><hr class="d-inline-block flex-grow-1"/><span class="mx-3 fw-light">%s</span><hr class="d-inline-block flex-grow-1"/></div>', __('Or'));
echo $this->Form->create(null, [
'url' => Cake\Routing\Router::url([
'prefix' => false,
'plugin' => 'ADmad/SocialAuth',
'controller' => 'Auth',
'action' => 'login',
'provider' => 'keycloak',
'?' => ['redirect' => $this->request->getQuery('redirect')]
]),
]);
echo $this->Bootstrap->button([
'type' => 'submit',
'text' => __('Login with Keycloak'),
'variant' => 'secondary',
'class' => ['d-block', 'w-100'],
'image' => [
'path' => '/img/keycloak_logo.png',
'alt' => 'Keycloak'
]
]);
echo $this->Form->end();
}
echo '</div>';
?>
</div>

View File

@ -0,0 +1,94 @@
<?php
if ($setting['type'] == 'string' || $setting['type'] == 'textarea' || empty($setting['type'])) {
$input = (function ($settingName, $setting, $appView) {
$settingId = str_replace('.', '_', $settingName);
return $appView->Bootstrap->genNode(
$setting['type'] == 'textarea' ? 'textarea' : 'input',
[
'class' => [
'form-control',
'pe-4',
(!empty($setting['error']) ? 'is-invalid' : ''),
(!empty($setting['error']) ? "border-{$appView->get('variantFromSeverity')[$setting['severity']]}" : ''),
(!empty($setting['error']) ? $appView->get('variantFromSeverity')[$setting['severity']] : ''),
],
($setting['type'] == 'textarea' ? '' : 'type') => ($setting['type'] == 'textarea' ? '' : 'text'),
'id' => $settingId,
'data-setting-name' => $settingName,
'value' => isset($setting['value']) ? $setting['value'] : "",
'placeholder' => $setting['default'] ?? '',
'aria-describedby' => "{$settingId}Help"
]
);
})($settingName, $setting, $this);
} elseif ($setting['type'] == 'boolean') {
$input = (function ($settingName, $setting, $appView) {
$settingId = str_replace('.', '_', $settingName);
return $this->Bootstrap->switch([
'label' => h($setting['description']),
'checked' => !empty($setting['value']),
'id' => $settingId,
'class' => [
(!empty($setting['error']) ? 'is-invalid' : ''),
(!empty($setting['error']) ? $appView->get('variantFromSeverity')[$setting['severity']] : ''),
],
'attrs' => [
'data-setting-name' => $settingName
]
]);
})($settingName, $setting, $this);
$description = '';
} elseif ($setting['type'] == 'integer') {
$input = (function ($settingName, $setting, $appView) {
$settingId = str_replace('.', '_', $settingName);
return $appView->Bootstrap->genNode('input', [
'class' => [
'form-control',
(!empty($setting['error']) ? 'is-invalid' : ''),
(!empty($setting['error']) ? "border-{$appView->get('variantFromSeverity')[$setting['severity']]}" : ''),
(!empty($setting['error']) ? $appView->get('variantFromSeverity')[$setting['severity']] : ''),
],
'type' => 'number',
'min' => '0',
'step' => 1,
'id' => $settingId,
'data-setting-name' => $settingName,
'aria-describedby' => "{$settingId}Help"
]);
})($settingName, $setting, $this);
} elseif ($setting['type'] == 'select' || $setting['type'] == 'multi-select') {
$input = (function ($settingName, $setting, $appView) {
$settingId = str_replace('.', '_', $settingName);
$setting['value'] = $setting['value'] ?? '';
$options = [];
if ($setting['type'] == 'select') {
$options[] = $appView->Bootstrap->genNode('option', ['value' => '-1', 'data-is-empty-option' => '1'], __('Select an option'));
}
foreach ($setting['options'] as $key => $value) {
$options[] = $appView->Bootstrap->genNode('option', [
'class' => [],
'value' => $key,
($setting['value'] == $key ? 'selected' : '') => $setting['value'] == $value ? 'selected' : '',
], h($value));
}
$options = implode('', $options);
return $appView->Bootstrap->genNode('select', [
'class' => [
'form-select',
'pe-4',
(!empty($setting['error']) ? 'is-invalid' : ''),
(!empty($setting['error']) ? "border-{$appView->get('variantFromSeverity')[$setting['severity']]}" : ''),
(!empty($setting['error']) ? $appView->get('variantFromSeverity')[$setting['severity']] : ''),
],
($setting['type'] == 'multi-select' ? 'multiple' : '') => '',
'id' => $settingId,
'data-setting-name' => $settingName,
'placeholder' => $setting['default'] ?? '',
'aria-describedby' => "{$settingId}Help"
], $options);
})($settingName, $setting, $this);
}
echo $input;

View File

@ -0,0 +1,62 @@
<?php
$settingId = str_replace('.', '_', $settingName);
$dependsOnHtml = '';
if (!empty($setting['dependsOn'])) {
$dependsOnHtml = $this->Bootstrap->genNode('span', [
'class' => [
'ms-1',
'd-inline-block',
'depends-on-icon'
],
'style' => 'min-width: 0.75em;',
'title' => __('This setting depends on the validity of: {0}', h($setting['dependsOn'])),
], $this->Bootstrap->genNode('sup', [
'class' => $this->FontAwesome->getClass('info'),
]));
}
$label = $this->Bootstrap->genNode('label', [
'class' => ['form-label', 'fw-bolder', 'mb-0'],
'for' => $settingId
], sprintf('<a id="lb-%s" href="#lb-%s" class="text-reset text-decoration-none">%s</a>', h($settingId), h($settingId), h($setting['name'])) . $dependsOnHtml);
$description = '';
if (!empty($setting['description']) && (empty($setting['type']) || $setting['type'] != 'boolean')) {
$description = $this->Bootstrap->genNode('small', [
'class' => ['form-text', 'text-muted', 'mt-0'],
'id' => "{$settingId}Help"
], h($setting['description']));
}
$textColor = 'text-warning';
if (!empty($setting['severity'])) {
$textColor = "text-{$this->get('variantFromSeverity')[$setting['severity']]}";
}
$validationError = $this->Bootstrap->genNode('div', [
'class' => ['d-block', 'invalid-feedback', $textColor],
], (!empty($setting['error']) ? h($setting['errorMessage']) : ''));
$input = $this->element('Settings/field', [
'setting' => $setting,
'settingName' => $settingName,
]);
$inputGroupSave = $this->Bootstrap->button([
'icon' => 'times',
'variant' => 'secondary',
'class' => ['btn-setting-action', 'btn-reset-setting', 'd-none'],
]);
$inputGroupSave .= $this->Bootstrap->button([
'text' => __('save'),
'variant' => 'success',
'class' => ['btn-setting-action', 'btn-save-setting', 'd-none'],
]);
$inputGroup = $this->Bootstrap->genNode('div', [
'class' => ['input-group'],
], implode('', [$input, $inputGroupSave]));
$container = $this->Bootstrap->genNode('div', [
'class' => ['setting-group', 'row', 'mb-2']
], implode('', [$label, $inputGroup, $description, $validationError]));
echo $container;
?>

View File

@ -0,0 +1,118 @@
<?php
$mainNoticeHeading = [
'critical' => __('Your Cerebrate instance requires immediate attention.'),
'warning' => __('Issues found, it is recommended that you resolve them.'),
'info' => __('There are some optional settings that are incorrect or not set.'),
];
$headingPerLevel = [
'critical' => __('Critical settings'),
'warning' => __('Warning settings'),
'info' => __('Info settings'),
];
$noticeDescriptionPerLevel = [
'critical' => __('Cerebrate will not operate correctly or will be unsecure until these issues are resolved.'),
'warning' => __('Some of the features of Cerebrate cannot be utilised until these issues are resolved.'),
'info' => __('There are some optional tweaks that could be done to improve the looks of your Cerebrate instance.'),
];
$settingNoticeListHeader = [];
$settingNoticeList = [];
$alertVariant = 'info';
$skipHeading = false;
$alertBody = '';
$tableItems = [];
foreach (array_keys($mainNoticeHeading) as $level) {
if(!empty($notices[$level])) {
$variant = $variantFromSeverity[$level];
if (!$skipHeading) {
$alertBody .= sprintf('<h5 class="alert-heading">%s</h5>', $mainNoticeHeading[$level]);
$alertVariant = $variant;
$skipHeading = true;
}
$tableItems[] = [
'severity' => $headingPerLevel[$level],
'issues' => count($notices[$level]),
'badge-variant' => $variant,
'description' => $noticeDescriptionPerLevel[$level],
];
$settingNoticeListHeader[] = [
'html' => $this->Bootstrap->badge([
'variant' => $variantFromSeverity[$level],
'text' => $level
])
];
$settingNoticeList[] = $this->Bootstrap->table([
'small' => true,
'striped' => false,
'hover' => false,
'borderless' => true,
'bordered' => false,
], [
'fields' => [
['key' => 'name', 'label' => __('Name'), 'formatter' => function($name, $row) {
$settingID = preg_replace('/(\.|\W)/', '_', h($row['true-name']));
return sprintf('<a style="max-width: 200px; white-space: pre-wrap;" href="#lb-%s" onclick="redirectToSetting(\'#lb-%s\')">%s</a>', $settingID, $settingID, h($name));
}],
['key' => 'setting-path', 'label' => __('Category'), 'formatter' => function($path, $row) {
return '<span class="text-nowrap">' . h(str_replace('.', ' ▸ ', $path)) . '</span>';
}],
['key' => 'value', 'label' => __('Value'), 'formatter' => function($value, $row) {
$formatedValue = '<span class="p-1 rounded mb-0" style="background: #eeeeee55; font-family: monospace;">';
if (is_null($value)) {
$formatedValue .= '<i class="text-nowrap">' . __('No value') . '</i>';
} else if ($value === '') {
$formatedValue .= '<i class="text-nowrap">' . __('Empty string') . '</i>';
} else if (is_bool($value)) {
$formatedValue .= '<i class="text-nowrap">' . ($value ? __('true') : __('false')) . '</i>';
} else {
$formatedValue .= h($value);
}
$formatedValue .= '</span>';
return $formatedValue;
}],
['key' => 'description', 'label' => __('Description')]
],
'items' => $notices[$level],
]);
}
}
$alertBody = $this->Bootstrap->table([
'small' => true,
'striped' => false,
'hover' => false,
'borderless' => true,
'bordered' => false,
'tableClass' => 'mb-0'
], [
'fields' => [
['key' => 'severity', 'label' => __('Severity')],
['key' => 'issues', 'label' => __('Issues'), 'formatter' => function($count, $row) {
return $this->Bootstrap->badge([
'variant' => $row['badge-variant'],
'text' => $count
]);
}],
['key' => 'description', 'label' => __('Description')]
],
'items' => $tableItems,
]);
$settingNotice = $this->Bootstrap->alert([
'dismissible' => false,
'variant' => $alertVariant,
'html' => $alertBody
]);
$settingNotice = sprintf('<div class="mt-3">%s</div>', $settingNotice);
echo $settingNotice;
$tabsOptions = [
'card' => true,
'pills' => false,
'data' => [
'navs' => $settingNoticeListHeader,
'content' => $settingNoticeList
]
];
echo $this->Bootstrap->tabs($tabsOptions);

View File

@ -0,0 +1,57 @@
<?php
$panelHTML = '';
if (isLeaf($panelSettings)) {
$singleSetting = $this->element('Settings/fieldGroup', [
'panelName' => $panelName,
'panelSettings' => $panelSettings,
'settingName' => $panelName,
'setting' => $panelSettings,
]);
$panelHTML = "<div>{$singleSetting}</div>";
} else {
$panelID = getResolvableID($sectionName, $panelName);
$panelHTML .= sprintf('<h4 id="%s"><a class="text-reset text-decoration-none" href="#%s">%s%s</a></h4>',
$panelID,
$panelID,
!empty($panelSettings['_icon']) ? $this->Bootstrap->icon($panelSettings['_icon'], ['class' => 'me-1']) : '',
h($panelName)
);
if (!empty($panelSettings['_description'])) {
$panelHTML .= $this->Bootstrap->genNode('div', [
'class' => ['mb-1',],
], h($panelSettings['_description']));
}
$groupIssueSeverity = false;
foreach ($panelSettings as $singleSettingName => $singleSetting) {
if (substr($singleSettingName, 0, 1) == '_') {
continue;
}
$singleSettingHTML = $this->element('Settings/fieldGroup', [
'panelName' => $panelName,
'panelSettings' => $panelSettings,
'settingName' => $singleSettingName,
'setting' => $singleSetting,
]);
$panelHTML .= sprintf('<div class="ms-3">%s</div>', $singleSettingHTML);
if (!empty($singleSetting['error'])) {
$settingVariant = $this->get('variantFromSeverity')[$singleSetting['severity']];
if ($groupIssueSeverity != 'danger') {
if ($groupIssueSeverity != 'warning') {
$groupIssueSeverity = $settingVariant;
}
}
}
}
$panelHTML = $this->Bootstrap->genNode('div', [
'class' => [
'shadow',
'p-2',
'mb-4',
'rounded',
'settings-group',
'callout',
(!empty($groupIssueSeverity) ? "callout-${groupIssueSeverity}" : ''),
],
], $panelHTML);
}
echo $panelHTML;

View File

@ -0,0 +1,63 @@
<nav id="navbar-scrollspy-setting" class="navbar">
<nav class="nav nav-pills flex-column">
<?php foreach ($groupedSetting as $group => $sections): ?>
<a class="nav-link main-group text-reset p-1" href="#<?= getResolvableID($group) ?>"><?= h($group) ?></a>
<nav class="nav nav-pills sub-group collapse flex-column" data-maingroup="<?= getResolvableID($group) ?>">
<?php foreach ($sections as $section): ?>
<a class="nav-link nav-link-group text-reset ms-3 my-1 p-1" href="#<?= getResolvableID($group, $section) ?>"><?= h($section) ?></a>
<?php endforeach; ?>
</nav>
</a>
<?php endforeach; ?>
</nav>
</nav>
<script>
$(document).ready(function() {
$('[data-bs-spy="scroll"]').on('activate.bs.scrollspy', function({relatedTarget}) {
const $associatedLink = $(`#navbar-scrollspy-setting nav.nav-pills .nav-link[href="${relatedTarget}"]`)
let $associatedNav
if ($associatedLink.hasClass('main-group')) {
$associatedNav = $associatedLink.next()
} else {
$associatedNav = $associatedLink.parent()
}
const $allNavs = $('#navbar-scrollspy-setting nav.nav-pills.sub-group')
$allNavs.removeClass('group-active').hide()
$associatedNav.addClass('group-active').show()
})
})
</script>
<style>
#navbar-scrollspy-setting nav.nav-pills .nav-link {
background-color: unset !important;
color: black;
display: block;
}
#navbar-scrollspy-setting nav.nav-pills .nav-link:not(.main-group).active {
color: #007bff !important;
font-weight: bold;
}
#navbar-scrollspy-setting nav.nav-pills .nav-link.main-group:before {
margin-right: 0.25em;
font-family: 'Font Awesome 5 Free';
font-weight: 900;
-webkit-font-smoothing: antialiased;
display: inline-block;
font-style: normal;
font-variant: normal;
text-rendering: auto;
line-height: 1;
}
#navbar-scrollspy-setting nav.nav-pills .nav-link.main-group.active:before {
content: "\f0d7";
}
#navbar-scrollspy-setting nav.nav-pills .nav-link.main-group:before {
content: "\f0da";
}
</style>

View File

@ -0,0 +1,121 @@
<select id="search-settings" class="d-block w-100 form-select" aria-describedby="<?= __('Search setting input') ?>"><option></option></select>
<script>
let selectData = []
for (const settingName in settingsFlattened) {
if (Object.hasOwnProperty.call(settingsFlattened, settingName)) {
const setting = settingsFlattened[settingName];
const selectID = settingName.replaceAll('.', '_')
selectData.push({
id: selectID,
text: setting.name,
setting: setting
})
}
}
$(document).ready(function() {
$("#search-settings").select2({
data: selectData,
placeholder: '<?= __('Search setting by typing here...') ?>',
templateResult: formatSettingSearchResult,
templateSelection: formatSettingSearchSelection,
matcher: settingMatcher,
sorter: settingSorter,
})
.on('select2:select', function (e) {
const selected = e.params.data
const settingPath = selected.setting['setting-path']
const {tabName, IDtoFocus} = getTabAndSettingIDFromPath(settingPath)
showSetting(selected, tabName, IDtoFocus)
$("#search-settings").val(null).trigger('change.select2');
})
})
function getTabAndSettingIDFromPath(settingPath) {
let settingPathTokenized = settingPath.split('.')
settingPathTokenized = settingPathTokenized.map((elem) => elem.replaceAll(/(\.|\W)/g, '_'))
const tabName = settingPathTokenized[0]
const IDtoFocus = 'sp-' + settingPathTokenized.slice(1).join('-')
return {tabName: tabName, IDtoFocus: IDtoFocus}
}
function showSetting(selected, tabName, IDtoFocus) {
const $navController = $('.settings-tabs').find('a.nav-link').filter(function() {
return $(this).text() == tabName
})
if ($navController.length == 1) {
$toFocus = $(`#${IDtoFocus}`).parent()
if ($navController.hasClass('active')) {
$toFocus[0].scrollIntoView()
$toFocus.find(`input#${selected.id}, textarea#${selected.id}`).focus()
} else {
$navController.on('shown.bs.tab.after-selection', () => {
$toFocus[0].scrollIntoView()
$toFocus.find(`input#${selected.id}, textarea#${selected.id}`).focus()
$navController.off('shown.bs.tab.after-selection')
}).tab('show')
}
}
}
function settingMatcher(params, data) {
if (params.term == null || params.term.trim() === '') {
return data;
}
if (data.text === undefined || data.setting === undefined) {
return null;
}
let modifiedData = $.extend({}, data, true);
const loweredTerms = params.term.trim().toLowerCase().split(' ')
let matchNumber = 0
for (let i = 0; i < loweredTerms.length; i++) {
const loweredTerm = loweredTerms[i];
const settingNameMatch = data.setting['true-name'].toLowerCase().indexOf(loweredTerm) > -1 || data.text.toLowerCase().indexOf(loweredTerm) > -1
const settingGroupMatch = data.setting['setting-path'].toLowerCase().indexOf(loweredTerm) > -1
const settingDescMatch = data.setting.description.toLowerCase().indexOf(loweredTerm) > -1
if (settingNameMatch || settingGroupMatch || settingDescMatch) {
matchNumber += 1
modifiedData.matchPriority = (settingNameMatch ? 10 : 0) + (settingGroupMatch ? 5 : 0) + (settingDescMatch ? 1 : 0)
}
}
if (matchNumber == loweredTerms.length && modifiedData.matchPriority > 0) {
return modifiedData;
}
return null;
}
function settingSorter(data) {
let sortedData = data.slice(0)
sortedData = sortedData.sort((a, b) => {
return a.matchPriority == b.matchPriority ? 0 : (b.matchPriority - a.matchPriority)
})
return sortedData;
}
function formatSettingSearchResult(state) {
if (!state.id) {
return state.text;
}
const $state = $('<div/>').append(
$('<div/>').addClass('d-flex justify-content-between')
.append(
$('<span/>').addClass('fw-bold').text(state.text),
$('<span/>').addClass('fw-light').text(state.setting['setting-path'].replaceAll('.', ' ▸ '))
),
$('<div/>').addClass('font-italic fw-light ms-3').text(state.setting['description'])
)
return $state
}
function formatSettingSearchSelection(state) {
return state.text
}
</script>
<style>
.select2-container {
max-width: 100%;
min-width: 100%;
}
</style>

View File

@ -0,0 +1,65 @@
<?php
$chartOptions = $chartOptions ?? [];
$chartData = $chartData ?? [];
$seed = mt_rand();
$chartId = "chart-{$seed}";
// Transform the chart data into the expected format
$data = [];
foreach ($chartData as $i => $entry) {
$data[] = $entry['count'];
}
?>
<div id="<?= $chartId ?>"></div>
<script>
$(document).ready(function() {
const passedOptions = <?= json_encode($chartOptions) ?>;
const defaultOptions = {
chart: {
id: '<?= $chartId ?>',
type: 'bar',
sparkline: {
enabled: true
},
dropShadow: {
enabled: true,
top: 1,
left: 1,
blur: 2,
opacity: 0.2,
},
animations: {
enabled: false
},
},
series: [{
data: <?= json_encode($data) ?>,
}],
colors: ['var(--bs-light)'],
tooltip: {
x: {
show: false
},
y: {
title: {
formatter: function formatter(val) {
return '';
}
}
},
theme: '<?= !empty($darkMode) ? 'dark' : 'light' ?>'
},
}
const chartOptions = Object.assign({}, defaultOptions, passedOptions)
new ApexCharts(document.querySelector('#<?= $chartId ?>'), chartOptions).render();
})
</script>
<style>
.apexcharts-tooltip.apexcharts-theme-light {
color: black !important
}
</style>

View File

@ -14,7 +14,6 @@ if (!isset($params['escape']) || $params['escape'] !== false) {
?>
<div class="alert <?= h($class) ?> alert-dismissible fade show" role="alert">
<?= $message ?>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close">
</button>
</div>

View File

@ -1,4 +1,5 @@
<?php
/**
* @var \App\View\AppView $this
* @var array $params
@ -8,9 +9,19 @@ if (!isset($params['escape']) || $params['escape'] !== false) {
$message = h($message);
}
?>
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<?= $message ?>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<?php if (!empty($params['toast'])) : ?>
<script>
$(document).ready(function() {
UI.toast({
variant: 'danger',
titleHtml: '<?= $message ?>'
})
})
</script>
<?php else : ?>
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<?= $message ?>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close">
</button>
</div>
<?php endif; ?>

View File

@ -1,4 +1,5 @@
<?php
/**
* @var \App\View\AppView $this
* @var array $params
@ -8,9 +9,19 @@ if (!isset($params['escape']) || $params['escape'] !== false) {
$message = h($message);
}
?>
<div class="alert alert-success alert-dismissible fade show" role="alert">
<?= $message ?>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<?php if (!empty($params['toast'])) : ?>
<script>
$(document).ready(function() {
UI.toast({
variant: 'success',
titleHtml: '<?= $message ?>'
})
})
</script>
<?php else : ?>
<div class="alert alert-success alert-dismissible fade show" role="alert">
<?= $message ?>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close">
</button>
</div>
<?php endif; ?>

View File

@ -0,0 +1,5 @@
<?php
echo $this->element('genericElements/codemirror', [
'data' => $fieldData,
'params' => $params,
]);

View File

@ -2,6 +2,6 @@
$controlParams = [
'options' => $fieldData['options'],
'empty' => $fieldData['empty'] ?? false,
'class' => ($fieldData['class'] ?? '') . ' formDropdown custom-select'
'class' => ($fieldData['class'] ?? '') . ' formDropdown form-select'
];
echo $this->FormFieldMassage->prepareFormElement($this->Form, $controlParams, $fieldData);

View File

@ -0,0 +1,13 @@
<?php
$tagsHtml = $this->Tag->tags($entity['tags'], [
'allTags' => [],
'picker' => true,
'editable' => true,
]);
?>
<div class="row mb-3">
<div class="col-sm-2 col-form-label"><?= __('Tags') ?></div>
<div class="col-sm-10">
<?= $tagsHtml ?>
</div>
</div>

View File

@ -9,11 +9,11 @@
$label = $fieldData['label'];
$formElement = $this->FormFieldMassage->prepareFormElement($this->Form, $params, $fieldData);
$temp = sprintf(
'<div class="form-group row ">
<div class="col-sm-2 col-form-label">%s</div>
'<div class="row mb-3">
<div class="col-sm-2 form-label">%s</div>
<div class="col-sm-10">
<div class="input-group">
%s<span class="input-group-append">%s</span>
%s<span>%s</span>
</div>
</div>
</div>',

View File

@ -17,13 +17,13 @@
}
}
echo sprintf(
'<span id = "%sInfoPopover" class="icon-info-sign" data-toggle="popover" data-trigger="hover"></span>',
'<span id = "%sInfoPopover" class="icon-info-sign" data-bs-toggle="popover" data-bs-trigger="hover"></span>',
h($field['field'])
);
?>
<script type="text/javascript">
$(document).ready(function() {
$('#<?php echo h($field['field']); ?>InfoPopover').popover({
new bootstrap.Popover('#<?php echo h($field['field']); ?>InfoPopover', {
html: true,
content: function() {
var tempSelector = '#<?php echo h($modelForForm . \Cake\Utility\Inflector::camelize($field['field'])); ?>';

Some files were not shown because too many files have changed in this diff Show More