From 943e18464293df3dc0cf144fc4ef56c92f617866 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 11 Aug 2021 13:58:12 +0200 Subject: [PATCH 001/150] chg: [app_local] config defaults --- config/app_local.example.php | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/config/app_local.example.php b/config/app_local.example.php index 03aeef1..aca4ef1 100644 --- a/config/app_local.example.php +++ b/config/app_local.example.php @@ -5,6 +5,16 @@ * Note: It is not recommended to commit files with credentials such as app_local.php * into source code version control. */ + +// set the baseurl here if you want to set it manually +$baseurl = env('CEREBRATE_BASEURL', false); + + +// Do not modify the this block +$temp = parse_url($baseurl); +$base = empty($temp['path']) ? false : $temp['path']; +// end of block + return [ /* * Debug Level: @@ -90,8 +100,12 @@ return [ ], ], 'Cerebrate' => [ - 'open' => [], - 'dark' => 0, - 'baseurl' => '' + 'open' => [], + 'dark' => 0, + 'baseurl' => '' + ], + 'App' => [ + 'base' => $base, + 'fullBaseUrl' => $fullBaseUrl ] ]; From 739dc25b1ee88d2d1eae32e4f4432fe41cb0c2d1 Mon Sep 17 00:00:00 2001 From: Alexandre Dulaunoy Date: Fri, 24 Sep 2021 13:11:39 +0200 Subject: [PATCH 002/150] fix: [Command] typo fixed as mentioned in #71 --- src/Command/ImporterCommand.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Command/ImporterCommand.php b/src/Command/ImporterCommand.php index c79ba76..52e11ba 100644 --- a/src/Command/ImporterCommand.php +++ b/src/Command/ImporterCommand.php @@ -2,7 +2,7 @@ /** * Generic importer to feed data to cerebrate from JSON or CSV. - * + * * - JSON configuration file must have the `format` key which can either take the value `json` or `csv` * - If `csv` is provided, the file must contains the header. * - If `json` is provided, a `mapping` key on how to reach each fields using the cakephp4's Hash syntax must be provided. @@ -10,7 +10,7 @@ * - The key is the field name * - The value * - Can either be the string representing the path from which to get the value - * - Or a JSON containg the `path`, the optional `override` parameter specifying if the existing data should be overriden + * - Or a JSON containg the `path`, the optional `override` parameter specifying if the existing data should be overriden * and an optional `massage` function able to alter the data. * - Example * { @@ -22,7 +22,7 @@ * }, * * - The optional primary key argument provides a way to make import replayable. It can typically be used when an ID or UUID is not provided in the source file but can be replaced by something else (e.g. team-name or other type of unique data). - * + * */ namespace App\Command; @@ -161,7 +161,7 @@ class ImporterCommand extends Command 'valueField' => 'id' ])->where(['meta_template_id' => $metaTemplate->id])->toArray(); } else { - $this->io->error("Unkown template for UUID {$config['metaTemplateUUID']}"); + $this->io->error("Unknown template for UUID {$config['metaTemplateUUID']}"); die(1); } } @@ -192,7 +192,7 @@ class ImporterCommand extends Command $metaEntity->meta_template_field_id = $metaTemplateFieldsMapping[$fieldName]; } else { $hasErrors = true; - $this->io->error("Field $fieldName is unkown for template {$metaTemplate->name}"); + $this->io->error("Field $fieldName is unknown for template {$metaTemplate->name}"); break; } } @@ -525,4 +525,4 @@ class ImporterCommand extends Command { return is_null($value) ? '' : $value; } -} \ No newline at end of file +} From b6c3aee91f2475488c7c7a1f29756c21ca84dbe5 Mon Sep 17 00:00:00 2001 From: iglocska Date: Thu, 21 Oct 2021 13:44:49 +0200 Subject: [PATCH 003/150] fix: [settings] invalid path to setting fixed --- src/Controller/UsersController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Controller/UsersController.php b/src/Controller/UsersController.php index d2235a6..e5258d0 100644 --- a/src/Controller/UsersController.php +++ b/src/Controller/UsersController.php @@ -162,7 +162,7 @@ class UsersController extends AppController public function register() { - if (empty(Configure::read('Cerebrate')['security.registration.self-registration'])) { + if (empty(Configure::read('security.registration.self-registration'))) { throw new UnauthorizedException(__('User self-registration is not open.')); } if ($this->request->is('post')) { From fe500e9796a6004b2c1e8513a08f4440d56979d1 Mon Sep 17 00:00:00 2001 From: iglocska Date: Thu, 21 Oct 2021 13:45:24 +0200 Subject: [PATCH 004/150] fix: [settings] self registration setting path fixed --- templates/Users/login.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/Users/login.php b/templates/Users/login.php index d5cab0e..1f62282 100644 --- a/templates/Users/login.php +++ b/templates/Users/login.php @@ -24,7 +24,7 @@ use Cake\Core\Configure; echo $this->Form->control('password', ['type' => 'password', 'label' => 'Password', 'class' => 'form-control mb-3', 'placeholder' => __('Password')]); echo $this->Form->control(__('Login'), ['type' => 'submit', 'class' => 'btn btn-primary']); echo $this->Form->end(); - if (!empty(Configure::read('Cerebrate')['security.registration.self-registration'])) { + if (!empty(Configure::read('security.registration.self-registration'))) { echo '
'; echo sprintf('%s %s', __('Doesn\'t have an account?'), __('Sign up')); echo '
'; @@ -55,4 +55,4 @@ use Cake\Core\Configure; echo $this->Form->end(); } ?> - \ No newline at end of file + From 7eac09a3023f4b1884b1c9afc914695b69a0ca23 Mon Sep 17 00:00:00 2001 From: Alexandre Dulaunoy Date: Fri, 22 Oct 2021 16:25:47 +0200 Subject: [PATCH 005/150] chg: [doc] README improved for release 1.0 --- README.md | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index c2dfa33..33d50fa 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,31 @@ # cerebrate -The Cerebrate Sync Platform core software. Cerebrate is an open-source platform meant to act as a trusted contact information provider and interconnection orchestrator for other security tools. +Cerebrate is an [open-source platform](https://github.com/cerebrate-project) meant to act as a trusted contact information provider and interconnection orchestrator for other security tools (such as [MISP](https://www.misp-project.org/)). -It is currently being built under the MeliCERTes v2 project and is heavily work in progress. +# Features -# Current features +- Advanced repository to manage individuals and organisations; +- Key store for public encryption and signing cryptographic keys (e.g. PGP); +- Distributed synchronisation model where multiple Cerebrate instances can be interconnected amongst organisations and/or departments; +- Management of individuals and their affiliations to each organisations; +- Advanced API and CLI to integrate with existing tools (e.g. importing existing directory information); +- Dynamic model for creating new organisational structure; +- Support existing organisational structures such as [FIRST.org](https://www.first.org/) directory, EU [CSIRTs network](https://csirtsnetwork.eu/); +- Local tooling interconnection to easily interconnect existing tools with their native protocols; -- Repository of organisations and individuals -- Maintain signing and encryption keys -- Maintain affiliations between organisations and individuals +Cerebrate is developed in the scope of the MeliCERTes v2 project. ## Screenshots +![Dashboard](https://www.cerebrate-project.org/assets/images/screenshots/Screenshot%20from%202021-10-19%2016-31-56.png) + List of individuals along with their affiliations -![List of individuals](/documentation/images/individuals.png) +![List of individuals](https://www.cerebrate-project.org/assets/images/screenshots/Screenshot%20from%202021-10-19%2016-32-35.png) Adding organisations -![Adding an organisation](/documentation/images/add_org.png) +![Adding an organisation](https://www.cerebrate-project.org/assets/images/screenshots/Screenshot%20from%202021-10-19%2016-33-04.png) Everything is available via the API, here an example of a search query for all international organisations in the DB. @@ -28,6 +35,10 @@ Managing public keys and assigning them to users both for communication and vali ![Encryption key management](/documentation/images/add_encryption_key.png) +Dynamic model for creating new organisation structre + +![Meta Field Templates](https://www.cerebrate-project.org/assets/images/screenshots/Screenshot%20from%202021-10-19%2016-38-21.png) + # Requirements and installation The platform is built on CakePHP 4 along with Bootstrap 4 and shares parts of the code-base with [MISP](https://www.github.com/MISP). @@ -45,6 +56,7 @@ For installation via docker, refer to the [cerebrate-docker](https://github.com/ ~~~~ The software is released under the AGPLv3. - Copyright (C) 2019, 2020 Andras Iklody + Copyright (C) 2019, 2021 Andras Iklody + Copyright (C) 2020-2021 Sami Mokaddem Copyright (C) CIRCL - Computer Incident Response Center Luxembourg ~~~~ From 3050f7e23b9d3939020116b6121d6bdd9108a75f Mon Sep 17 00:00:00 2001 From: Andras Iklody Date: Fri, 22 Oct 2021 16:44:11 +0200 Subject: [PATCH 006/150] chg: small changes to the readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 33d50fa..0114d21 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,9 @@ Cerebrate is an [open-source platform](https://github.com/cerebrate-project) mea - Distributed synchronisation model where multiple Cerebrate instances can be interconnected amongst organisations and/or departments; - Management of individuals and their affiliations to each organisations; - Advanced API and CLI to integrate with existing tools (e.g. importing existing directory information); -- Dynamic model for creating new organisational structure; +- Dynamic model for creating new organisational structures; - Support existing organisational structures such as [FIRST.org](https://www.first.org/) directory, EU [CSIRTs network](https://csirtsnetwork.eu/); -- Local tooling interconnection to easily interconnect existing tools with their native protocols; +- Local tooling interconnection to easily connect existing tools with their native protocols; Cerebrate is developed in the scope of the MeliCERTes v2 project. From 058314af52614fae57c0bf10c2cb29ad0ea77042 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Laurent?= <33688507+remil1000@users.noreply.github.com> Date: Mon, 25 Oct 2021 15:46:04 +0200 Subject: [PATCH 007/150] Create docker-publish.yml initial attempt at GH actions docker build and push --- .github/workflows/docker-publish.yml | 52 ++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 .github/workflows/docker-publish.yml diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..c08958f --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,52 @@ +name: Docker + +on: + push: + branches: [ feature/docker-ci ] + tags: [ 'v*.*' ] + pull_request: + branches: [ main ] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Log into registry ${{ env.REGISTRY }} + if: github.event_name != 'pull_request' + uses: docker/login-action@v1 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # https://github.com/docker/metadata-action + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v3 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + # https://github.com/docker/build-push-action + - name: Build and push Docker image + uses: docker/build-push-action@v2 + with: + file: docker/Dockerfile + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + "COMPOSER_VERSION=2.1.5" + "PHP_VERSION=7.4" + "DEBIAN_RELEASE=buster" From cfe8b7cd46aa7410859be93aa153f17e5aa8d378 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Laurent?= Date: Mon, 25 Oct 2021 15:46:47 +0200 Subject: [PATCH 008/150] dockerfile and configuration --- .gitignore | 2 +- docker/Dockerfile | 80 ++++++++++++++++++++++++++++++++ docker/README.md | 47 +++++++++++++++++++ docker/docker-compose.yml | 28 +++++++++++ docker/entrypoint.sh | 20 ++++++++ docker/etc/DocumentRoot.htaccess | 3 ++ docker/etc/app_local.php | 44 ++++++++++++++++++ docker/etc/webroot.htaccess | 3 ++ 8 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 docker/Dockerfile create mode 100644 docker/README.md create mode 100644 docker/docker-compose.yml create mode 100755 docker/entrypoint.sh create mode 100644 docker/etc/DocumentRoot.htaccess create mode 100644 docker/etc/app_local.php create mode 100644 docker/etc/webroot.htaccess diff --git a/.gitignore b/.gitignore index f370d3e..cdb86ef 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,4 @@ config/app_local.php logs tmp vendor - +docker/run/ diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..7e2035e --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,80 @@ +ARG COMPOSER_VERSION +ARG PHP_VERSION +ARG DEBIAN_RELEASE + +FROM php:${PHP_VERSION}-apache-${DEBIAN_RELEASE} + +# we need some extra libs to be installed in the runtime +RUN apt-get update && \ + apt-get install -y --no-install-recommends curl git zip unzip && \ + apt-get install -y --no-install-recommends libicu-dev libxml2-dev && \ + docker-php-ext-install intl pdo pdo_mysql mysqli simplexml && \ + apt-get remove -y --purge libicu-dev libxml2-dev && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +COPY composer.json composer.json + +# install composer as root +ARG COMPOSER_VERSION +RUN curl -sL https://getcomposer.org/installer | \ + php -- --install-dir=/usr/bin/ --filename=composer --version=${COMPOSER_VERSION} + +# switch back to unprivileged user for composer install +USER www-data + +RUN composer install \ + --ignore-platform-reqs \ + --no-interaction \ + --no-plugins \ + --no-scripts \ + --prefer-dist + +# web server configuration +USER root + +# allow .htaccess overrides and push them +RUN a2enmod rewrite +RUN sed -i -r '/DocumentRoot/a \\t\n\t\tAllowOverride all\n\t' /etc/apache2/sites-available/000-default.conf +COPY --chown=www-data docker/etc/DocumentRoot.htaccess /var/www/html/.htaccess +COPY --chown=www-data docker/etc/webroot.htaccess /var/www/html/webroot/.htaccess + +# passing environment variables through apache +RUN a2enmod env +RUN echo 'PassEnv CEREBRATE_DB_HOST' >> /etc/apache2/conf-enabled/environment.conf +RUN echo 'PassEnv CEREBRATE_DB_NAME' >> /etc/apache2/conf-enabled/environment.conf +RUN echo 'PassEnv CEREBRATE_DB_PASSWORD' >> /etc/apache2/conf-enabled/environment.conf +RUN echo 'PassEnv CEREBRATE_DB_PORT' >> /etc/apache2/conf-enabled/environment.conf +RUN echo 'PassEnv CEREBRATE_DB_SCHEMA' >> /etc/apache2/conf-enabled/environment.conf +RUN echo 'PassEnv CEREBRATE_DB_USERNAME' >> /etc/apache2/conf-enabled/environment.conf +RUN echo 'PassEnv CEREBRATE_EMAIL_HOST' >> /etc/apache2/conf-enabled/environment.conf +RUN echo 'PassEnv CEREBRATE_EMAIL_PASSWORD' >> /etc/apache2/conf-enabled/environment.conf +RUN echo 'PassEnv CEREBRATE_EMAIL_PORT' >> /etc/apache2/conf-enabled/environment.conf +RUN echo 'PassEnv CEREBRATE_EMAIL_TLS' >> /etc/apache2/conf-enabled/environment.conf +RUN echo 'PassEnv CEREBRATE_EMAIL_USERNAME' >> /etc/apache2/conf-enabled/environment.conf +RUN echo 'PassEnv CEREBRATE_SECURITY_SALT' >> /etc/apache2/conf-enabled/environment.conf + +# entrypoint +COPY docker/entrypoint.sh /entrypoint.sh +RUN chmod 755 /entrypoint.sh + +# copy actual codebase +COPY --chown=www-data . /var/www/html + +# last checks with unprivileged user +USER www-data + +# CakePHP seems to not handle very well externally installed components +# this will chown/chmod/symlink all in place for its own good +RUN composer install --no-interaction + +# app config override making use of environment variables +COPY --chown=www-data docker/etc/app_local.php /var/www/html/config/app_local.php +# version 1.0 addition requires a config/config.json file +# can still be overriden by a docker volume +RUN cp -a /var/www/html/config/config.example.json /var/www/html/config/config.json + +# also can be overridin by a docker volume +RUN mkdir -p /var/www/html/logs + +ENTRYPOINT [ "/entrypoint.sh" ] diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..1074c0c --- /dev/null +++ b/docker/README.md @@ -0,0 +1,47 @@ +# Database init + +For the `docker-compose` setup to work you must initialize database with +what is in `../INSTALL/mysql.sql` + +``` +mkdir -p run/dbinit/ +cp ../INSTALL/mysql.sql run/dbinit/ +``` + +The MariaDB container has a volume mounted as follow +`- ./run/dbinit:/docker-entrypoint-initdb.d/:ro` + +So that on startup the container will source files in this directory to seed +the database. Once it's done the container will run normally and Cerebrate will +be able to roll its database migration scripts + +# Actual data and volumes + +The actual database will be located in `./run/database` exposed with the +following volume `- ./run/database:/var/lib/mysql` + +Application logs (CakePHP / Cerebrate) will be stored in `./run/logs`, +volume `- ./run/logs:/var/www/html/logs` + +You're free to change those parameters if you're using Swarm, Kubernetes or +your favorite config management tool to deploy this stack + +# Building yourself + +You can create the following Makefile in basedir of this repository +and issue `make image` + +``` +COMPOSER_VERSION?=2.1.5 +PHP_VERSION?=7.4 +DEBIAN_RELEASE?=buster +IMAGE_NAME?=cerebrate:latest + +image: + docker build -t $(IMAGE_NAME) \ + -f docker/Dockerfile \ + --build-arg COMPOSER_VERSION=$(COMPOSER_VERSION) \ + --build-arg PHP_VERSION=$(PHP_VERSION) \ + --build-arg DEBIAN_RELEASE=$(DEBIAN_RELEASE) \ + . +``` diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..e36c8c5 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,28 @@ +version: "3" +services: + database: + image: mariadb:10.6 + restart: always + volumes: + - ./run/database:/var/lib/mysql + - ./run/dbinit:/docker-entrypoint-initdb.d/:ro + environment: + MARIADB_RANDOM_ROOT_PASSWORD: "yes" + MYSQL_DATABASE: "cerebrate" + MYSQL_USER: "cerebrate" + MYSQL_PASSWORD: "etarberec" + www: + image: $IMAGE_NAME + ports: + - "8080:80" + volumes: + - ./run/logs:/var/www/html/logs + environment: + DEBUG: "true" + CEREBRATE_DB_USERNAME: "cerebrate" + CEREBRATE_DB_PASSWORD: "etarberec" + CEREBRATE_DB_NAME: "cerebrate" + CEREBRATE_DB_HOST: database + CEREBRATE_SECURITY_SALT: supersecret + depends_on: + - database diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..35ef6dd --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,20 @@ +#!/bin/sh + +set -e + + +run_all_migrations() { + ./bin/cake migrations migrate + ./bin/cake migrations migrate -p tags + ./bin/cake migrations migrate -p ADmad/SocialAuth +} + +# waiting for DB to come up +for try in 1 2 3 4 5 6; do + echo >&2 "migration - attempt $try" + run_all_migrations && break || true + sleep 5 + [ "$try" = "6" ] && exit 1 +done + +exec /usr/local/bin/apache2-foreground "$@" diff --git a/docker/etc/DocumentRoot.htaccess b/docker/etc/DocumentRoot.htaccess new file mode 100644 index 0000000..ef9940b --- /dev/null +++ b/docker/etc/DocumentRoot.htaccess @@ -0,0 +1,3 @@ +RewriteEngine on +RewriteRule ^$ webroot/ [L] +RewriteRule (.*) webroot/$1 [L] diff --git a/docker/etc/app_local.php b/docker/etc/app_local.php new file mode 100644 index 0000000..ac1d212 --- /dev/null +++ b/docker/etc/app_local.php @@ -0,0 +1,44 @@ + env('CEREBRATE_DB_USERNAME', 'cerebrate'), + 'password' => env('CEREBRATE_DB_PASSWORD', ''), + 'host' => env('CEREBRATE_DB_HOST', 'localhost'), + 'database' => env('CEREBRATE_DB_NAME', 'cerebrate'), +]; + +// non-default port can be set on demand - otherwise the DB driver will choose the default +if (!empty(env('CEREBRATE_DB_PORT'))) { + $db['port'] = env('CEREBRATE_DB_PORT'); +} + +// If not using the default 'public' schema with the PostgreSQL driver set it here. +if (!empty(env('CEREBRATE_DB_SCHEMA'))) { + $db['schema'] = env('CEREBRATE_DB_SCHEMA'); +} + +return [ + 'debug' => filter_var(env('DEBUG', false), FILTER_VALIDATE_BOOLEAN), + + 'Security' => [ + 'salt' => env('CEREBRATE_SECURITY_SALT'), + ], + + 'Datasources' => [ + 'default' => $db, + ], + + 'EmailTransport' => [ + 'default' => [ + // host could be ssl://smtp.gmail.com then set port to 465 + 'host' => env('CEREBRATE_EMAIL_HOST', 'localhost'), + 'port' => env('CEREBRATE_EMAIL_PORT', 25), + 'username' => env('CEREBRATE_EMAIL_USERNAME', null), + 'password' => env('CEREBRATE_EMAIL_PASSWORD', null), + 'tls' => env('CEREBRATE_EMAIL_TLS', null) + ], + ], + 'Cerebrate' => [ + 'open' => [], + 'dark' => 0 + ] +]; diff --git a/docker/etc/webroot.htaccess b/docker/etc/webroot.htaccess new file mode 100644 index 0000000..879f805 --- /dev/null +++ b/docker/etc/webroot.htaccess @@ -0,0 +1,3 @@ +RewriteEngine On +RewriteCond %{REQUEST_FILENAME} !-f +RewriteRule ^ index.php [L] From 4dc79a2a5137853384f945689ae0231eae1c33f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Laurent?= Date: Mon, 25 Oct 2021 16:31:06 +0200 Subject: [PATCH 009/150] [skip ci] changing triggering branch in workflow --- .github/workflows/docker-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index c08958f..dd01dc8 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -2,7 +2,7 @@ name: Docker on: push: - branches: [ feature/docker-ci ] + branches: [ main ] tags: [ 'v*.*' ] pull_request: branches: [ main ] From 3916941e07f8f18badf65365ff83885cb29ed7c3 Mon Sep 17 00:00:00 2001 From: Andras Iklody Date: Mon, 25 Oct 2021 18:00:33 +0200 Subject: [PATCH 010/150] chg: [docker] updated image path to the github package of this repo --- docker/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index e36c8c5..c99ee69 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -12,7 +12,7 @@ services: MYSQL_USER: "cerebrate" MYSQL_PASSWORD: "etarberec" www: - image: $IMAGE_NAME + image: ghcr.io/cerebrate-project/cerebrate:main ports: - "8080:80" volumes: From 8df97082584fc87a1e103fc8df399681eb47f434 Mon Sep 17 00:00:00 2001 From: DocArmoryTech Date: Thu, 28 Oct 2021 22:23:38 +0100 Subject: [PATCH 011/150] Added missing 'Cerebrate' section Second part of resolution to Issue #75 Added missing Cerebrate config section to resolved the following error: ``` warning: Warning (2): in_array() expects parameter 2 to be array, null given in [/var/www/cerebrate/src/Controller/Component/Navigation/sidemenu.php, line 130] Request URL: /users/login Referer URL: http://127.0.0.1:8000/users/login?redirect=%2F Client IP: 127.0.0.1 ``` --- config/app_local.example.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/config/app_local.example.php b/config/app_local.example.php index 87b1568..1ec0f4a 100644 --- a/config/app_local.example.php +++ b/config/app_local.example.php @@ -99,4 +99,8 @@ return [ 'url' => env('EMAIL_TRANSPORT_DEFAULT_URL', null), ], ], + 'Cerebrate' => [ + 'open' => [], + 'dark' => 0 + ] ]; From 27c2d07e3ccdf552d728d4a85561a92432825a12 Mon Sep 17 00:00:00 2001 From: DocArmoryTech Date: Thu, 28 Oct 2021 22:56:25 +0100 Subject: [PATCH 012/150] Keep composer happy with permissions partial resolution to issue #75 create/initialise a `/var/www/.composer` director to keep composer happy and explicitly tell sudo to set the home dir `-H` --- INSTALL/INSTALL.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/INSTALL/INSTALL.md b/INSTALL/INSTALL.md index a962de1..49948e8 100644 --- a/INSTALL/INSTALL.md +++ b/INSTALL/INSTALL.md @@ -32,8 +32,10 @@ sudo -u www-data git clone https://github.com/cerebrate-project/cerebrate.git /v Run composer ```bash +mkdir -p /var/www/.composer +sudo chown www-data:www-data /var/www/.composer cd /var/www/cerebrate -sudo -u www-data composer install +sudo -H -u www-data composer install ``` Create a database for cerebrate From f10e0225634ece299a4a52b9ace2f6292b55c853 Mon Sep 17 00:00:00 2001 From: DocArmoryTech Date: Thu, 28 Oct 2021 22:58:38 +0100 Subject: [PATCH 013/150] Create logs dir --- logs/.gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 logs/.gitkeep diff --git a/logs/.gitkeep b/logs/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/logs/.gitkeep @@ -0,0 +1 @@ + From 5c48de77795acfcdaca1b43d838a46e6db9e0159 Mon Sep 17 00:00:00 2001 From: drizzit56 <60116496+drizzit56@users.noreply.github.com> Date: Fri, 29 Oct 2021 00:29:46 +0100 Subject: [PATCH 014/150] Adding nginx alternative config file and updating INSTALL.md for nginx usage --- INSTALL/INSTALL.md | 36 +++++++++++++++---- ...ate_dev.conf => cerebrate_apache_dev.conf} | 0 INSTALL/cerebrate_nginx.conf | 0 3 files changed, 30 insertions(+), 6 deletions(-) rename INSTALL/{cerebrate_dev.conf => cerebrate_apache_dev.conf} (100%) mode change 100755 => 100644 create mode 100644 INSTALL/cerebrate_nginx.conf diff --git a/INSTALL/INSTALL.md b/INSTALL/INSTALL.md index 49948e8..3685b00 100644 --- a/INSTALL/INSTALL.md +++ b/INSTALL/INSTALL.md @@ -1,8 +1,9 @@ ## Requirements An Ubuntu server (18.04/20.04 should both work fine) - though other linux installations should work too. -- apache2, mysql/mariadb, sqlite need to be installed and running +- apache2 (or nginx), mysql/mariadb, sqlite need to be installed and running - php extensions for intl, mysql, sqlite3, mbstring, xml need to be installed and running +- php extention for curl (not required but makes composer run a little faster) - composer ## Network requirements @@ -17,8 +18,16 @@ Cerebrate communicates via HTTPS so in order to be able to connect to other cere ## Cerebrate installation instructions It should be sufficient to issue the following command to install the dependencies: + +- for apache + ```bash -sudo apt install apache2 mariadb-server git composer php-intl php-mbstring php-dom php-xml unzip php-ldap php-sqlite3 sqlite libapache2-mod-php php-mysql +sudo apt install apache2 mariadb-server git composer php-intl php-mbstring php-dom php-xml unzip php-ldap php-sqlite3 php-curl sqlite libapache2-mod-php php-mysql +``` + +- for nginx +```bash +sudo apt install nginx mariadb-server git composer php-intl php-mbstring php-dom php-xml unzip php-ldap php-sqlite3 sqlite php-fpm php-curl php-mysql ``` Clone this repository (for example into /var/www/cerebrate) @@ -73,7 +82,7 @@ sudo -u www-data cp -a /var/www/cerebrate/config/config.example.json /var/www/ce sudo -u www-data vim /var/www/cerebrate/config/app_local.php ``` -mod_rewrite needs to be enabled: +mod_rewrite needs to be enabled if __using apache__: ```bash sudo a2enmod rewrite @@ -106,16 +115,31 @@ sudo rm /var/www/cerebrate/tmp/cache/persistent/* Create an apache config file for cerebrate / ssh key and point the document root to /var/www/cerebrate/webroot and you're good to go -For development installs the following can be done: +For development installs the following can be done for either apache or nginx: ```bash +# Apache # This configuration is purely meant for local installations for development / testing # Using HTTP on an unhardened apache is by no means meant to be used in any production environment -sudo cp /var/www/cerebrate/INSTALL/cerebrate_dev.conf /etc/apache2/sites-available/ -sudo ln -s /etc/apache2/sites-available/cerebrate_dev.conf /etc/apache2/sites-enabled/ +sudo cp /var/www/cerebrate/INSTALL/cerebrate_apache_dev.conf /etc/apache2/sites-available/ +sudo ln -s /etc/apache2/sites-available/cerebrate_apache_dev.conf /etc/apache2/sites-enabled/ sudo service apache2 restart ``` +OR + +```bash +# NGINX +# This configuration is purely meant for local installations for development / testing +# Using HTTP on an unhardened apache is by no means meant to be used in any production environment +sudo cp /var/www/cerebrate/INSTALL/cerebrate_nginx.conf /etc/nginx/sites-available/ +sudo ln -s /etc/nginx/sites-available/cerebrate_nginx.conf /etc/nginx/sites-enabled/ +sudo systemctl disable apache2 # may be required if apache is using port +sudo service nginx restart +sudo systemctl enable nginx + +``` + Now you can point your browser to: http://localhost:8000 To log in use the default credentials below: diff --git a/INSTALL/cerebrate_dev.conf b/INSTALL/cerebrate_apache_dev.conf old mode 100755 new mode 100644 similarity index 100% rename from INSTALL/cerebrate_dev.conf rename to INSTALL/cerebrate_apache_dev.conf diff --git a/INSTALL/cerebrate_nginx.conf b/INSTALL/cerebrate_nginx.conf new file mode 100644 index 0000000..e69de29 From e3168dacfad7980c9012f2a20e748231c21ce465 Mon Sep 17 00:00:00 2001 From: drizzit56 <60116496+drizzit56@users.noreply.github.com> Date: Fri, 29 Oct 2021 00:33:11 +0100 Subject: [PATCH 015/150] adding nginx config --- INSTALL/cerebrate_nginx.conf | 37 ++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/INSTALL/cerebrate_nginx.conf b/INSTALL/cerebrate_nginx.conf index e69de29..59ad861 100644 --- a/INSTALL/cerebrate_nginx.conf +++ b/INSTALL/cerebrate_nginx.conf @@ -0,0 +1,37 @@ +## Cerebrate Nginx Web Server Configuration +server { + listen 8000; + # listen 443 ssl; + + root /var/www/cerebrate/webroot; + error_log /var/log/nginx/cerebrate_error.log; + access_log /var/log/nginx/cerebrate_access.log; + + # Add index.php to the list if you are using PHP + index index.html index.htm index.nginx-debian.html index.php; + + server_name _; + + # Configure Crypto Keys/Certificates/DH + # If enabling this setting change port above, should also set the server name + # ssl_certificate /path/to/ssl/cert; + # ssl_certificate_key /path/to/ssl/cert; + + # enable HSTS + # add_header Strict-Transport-Security "max-age=15768000; includeSubdomains"; + # add_header X-Frame-Options SAMEORIGIN; + + location / { + try_files $uri $uri/ /index.php?$args; + } + + location ~ \.php$ { + try_files $uri =404; + fastcgi_split_path_info ^(.+\.php)(/.+)$; + fastcgi_pass unix:/var/run/php/php7.4-fpm.sock; + fastcgi_index index.php; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_param PATH_INFO $fastcgi_path_info; + } +} From ae4a01160036bb1f31795d051b1aac5e6cdf1a8a Mon Sep 17 00:00:00 2001 From: Koen Van Impe Date: Mon, 1 Nov 2021 16:58:10 +0100 Subject: [PATCH 016/150] Update INSTALL.md Minor installation documentation changes --- INSTALL/INSTALL.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/INSTALL/INSTALL.md b/INSTALL/INSTALL.md index 49948e8..f09ae73 100644 --- a/INSTALL/INSTALL.md +++ b/INSTALL/INSTALL.md @@ -32,7 +32,7 @@ sudo -u www-data git clone https://github.com/cerebrate-project/cerebrate.git /v Run composer ```bash -mkdir -p /var/www/.composer +sudo mkdir -p /var/www/.composer sudo chown www-data:www-data /var/www/.composer cd /var/www/cerebrate sudo -H -u www-data composer install @@ -40,6 +40,11 @@ sudo -H -u www-data composer install Create a database for cerebrate +With a fresh install of Ubuntu sudo to the (system) root user before logging in as the mysql root +```Bash +sudo -i mysql -u root +``` + From SQL shell: ```mysql mysql @@ -48,6 +53,7 @@ CREATE USER 'cerebrate'@'localhost' IDENTIFIED BY 'YOUR_PASSWORD'; GRANT USAGE ON *.* to cerebrate@localhost; GRANT ALL PRIVILEGES ON cerebrate.* to cerebrate@localhost; FLUSH PRIVILEGES; +QUIT; ``` Or from Bash: @@ -93,9 +99,9 @@ This would be, when following the steps above: Run the database schema migrations ```bash -/var/www/cerebrate/bin/cake migrations migrate -/var/www/cerebrate/bin/cake migrations migrate -p tags -/var/www/cerebrate/bin/cake migrations migrate -p ADmad/SocialAuth +sudo -u www-data /var/www/cerebrate/bin/cake migrations migrate +sudo -u www-data /var/www/cerebrate/bin/cake migrations migrate -p tags +sudo -u www-data /var/www/cerebrate/bin/cake migrations migrate -p ADmad/SocialAuth ``` Clean cakephp caches From 23dc460359b980becc23be813619a200ade7a822 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 17 Nov 2021 14:44:07 +0100 Subject: [PATCH 017/150] new: [auditlog system] added - port of Jakub Onderka's implementation from MISP - Still not fully realised, lacking search functionalities --- src/Controller/AuditLogsController.php | 36 ++++ src/Model/Behavior/AuditLogBehavior.php | 214 ++++++++++++++++++++ src/Model/Entity/AuditLog.php | 68 +++++++ src/Model/Table/AuditLogsTable.php | 250 ++++++++++++++++++++++++ 4 files changed, 568 insertions(+) create mode 100644 src/Controller/AuditLogsController.php create mode 100644 src/Model/Behavior/AuditLogBehavior.php create mode 100644 src/Model/Entity/AuditLog.php create mode 100644 src/Model/Table/AuditLogsTable.php diff --git a/src/Controller/AuditLogsController.php b/src/Controller/AuditLogsController.php new file mode 100644 index 0000000..58a2133 --- /dev/null +++ b/src/Controller/AuditLogsController.php @@ -0,0 +1,36 @@ +CRUD->index([ + 'contain' => $this->containFields, + 'filters' => $this->filterFields, + 'quickFilters' => $this->quickFilterFields, + 'afterFind' => function($data) { + $data['request_ip'] = inet_ntop(stream_get_contents($data['request_ip'])); + $data['change'] = stream_get_contents($data['change']); + return $data; + } + ]); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + $this->set('metaGroup', 'Administration'); + } +} diff --git a/src/Model/Behavior/AuditLogBehavior.php b/src/Model/Behavior/AuditLogBehavior.php new file mode 100644 index 0000000..3eacfe2 --- /dev/null +++ b/src/Model/Behavior/AuditLogBehavior.php @@ -0,0 +1,214 @@ + true, + 'lastpushedid' => true, + 'timestamp' => true, + 'revision' => true, + 'modified' => true, + 'date_modified' => true, // User + 'current_login' => true, // User + 'last_login' => true, // User + 'newsread' => true, // User + 'proposal_email_lock' => true, // Event + ]; + + public function initialize(array $config): void + { + $this->config = $config; + } + + public function beforeSave(EventInterface $event, EntityInterface $entity, ArrayObject $options) + { + $fields = $entity->extract($entity->getVisible(), true); + $skipFields = $this->skipFields; + $fieldsToFetch = array_filter($fields, function($key) use ($skipFields) { + return strpos($key, '_') !== 0 && !isset($skipFields[$key]); + }, ARRAY_FILTER_USE_KEY); + // Do not fetch old version when just few fields will be fetched + $fieldToFetch = []; + if (!empty($options['fieldList'])) { + foreach ($options['fieldList'] as $field) { + if (!isset($this->skipFields[$field])) { + $fieldToFetch[] = $field; + } + } + if (empty($fieldToFetch)) { + $this->old = null; + return true; + } + } + if ($entity->id) { + $this->old = $this->_table->find()->where(['id' => $entity->id])->contain($fieldToFetch)->first(); + } else { + $this->old = null; + } + return true; + } + + public function afterSave(EventInterface $event, EntityInterface $entity, ArrayObject $options) + { + if ($entity->id) { + $id = $entity->id; + } else { + $id = null; + } + + if ($entity->isNew()) { + $action = $entity->getConstant('ACTION_ADD'); + } else { + $action = $entity->getConstant('ACTION_EDIT'); + if (isset($entity['deleted'])) { + if ($entity['deleted']) { + $action = $entity->getConstant('ACTION_SOFT_DELETE'); + } else if (!$entity['deleted'] && $this->old['deleted']) { + $action = $entity->getConstant('ACTION_UNDELETE'); + } + } + } + $changedFields = $this->changedFields($entity, isset($options['fieldList']) ? $options['fieldList'] : null); + if (empty($changedFields)) { + return; + } + + $modelTitleField = $this->_table->getDisplayField(); + if (is_callable($modelTitleField)) { + $modelTitle = $modelTitleField($entity, isset($this->old) ? $this->old : []); + } else if (isset($entity[$modelTitleField])) { + $modelTitle = $entity[$modelTitleField]; + } else if ($this->old[$modelTitleField]) { + $modelTitle = $this->old[$modelTitleField]; + } + $this->auditLogs()->insert([ + 'action' => $action, + 'model' => $entity->getSource(), + 'model_id' => $id, + 'model_title' => $modelTitle, + 'change' => $changedFields + ]); + } + + public function beforeDelete(EventInterface $event, EntityInterface $entity, ArrayObject $options) + { + $this->old = $this->_table->find()->where(['id' => $entity->id])->first(); + return true; + } + + public function afterDelete(EventInterface $event, EntityInterface $entity, ArrayObject $options) + { + $modelTitleField = $this->_table->getDisplayField(); + if (is_callable($modelTitleField)) { + $modelTitle = $modelTitleField($entity, []); + } else if (isset($entity[$modelTitleField])) { + $modelTitle = $entity[$modelTitleField]; + } + + $logEntity = $this->auditLogs()->newEntity([ + 'action' => $entity->getConstant('ACTION_DELETE'), + 'model' => $entity->getSource(), + 'model_id' => $this->old->id, + 'model_title' => $modelTitle, + 'change' => $this->changedFields($entity) + ]); + $logEntity->save(); + } + + /** + * @param Model $model + * @param array|null $fieldsToSave + * @return array + */ + private function changedFields(EntityInterface $entity, $fieldsToSave = null) + { + $dbFields = $this->_table->getSchema()->typeMap(); + $changedFields = []; + foreach ($entity->extract($entity->getVisible()) as $key => $value) { + if (isset($this->skipFields[$key])) { + continue; + } + if (!isset($dbFields[$key])) { + continue; + } + if ($fieldsToSave && !in_array($key, $fieldsToSave, true)) { + continue; + } + if (isset($entity[$key]) && isset($this->old[$key])) { + $old = $this->old[$key]; + } else { + $old = null; + } + // Normalize + if (is_bool($old)) { + $old = $old ? 1 : 0; + } + if (is_bool($value)) { + $value = $value ? 1 : 0; + } + $dbType = $dbFields[$key]; + if ($dbType === 'integer' || $dbType === 'tinyinteger' || $dbType === 'biginteger' || $dbType === 'boolean') { + $value = (int)$value; + if ($old !== null) { + $old = (int)$old; + } + } + if ($value == $old) { + continue; + } + if ($key === 'password' || $key === 'authkey') { + $value = '*****'; + if ($old !== null) { + $old = $value; + } + } + + if ($old === null) { + $changedFields[$key] = $value; + } else { + $changedFields[$key] = [$old, $value]; + } + } + return $changedFields; + } + + /** + * @return AuditLogs + */ + public function auditLogs() + { + if ($this->AuditLogs === null) { + $this->AuditLogs = TableRegistry::getTableLocator()->get('AuditLogs'); + } + return $this->AuditLogs; + } + + public function log() + { + + } +} diff --git a/src/Model/Entity/AuditLog.php b/src/Model/Entity/AuditLog.php new file mode 100644 index 0000000..beabeb8 --- /dev/null +++ b/src/Model/Entity/AuditLog.php @@ -0,0 +1,68 @@ +compressionEnabled = Configure::read('Cerebrate.log_compress') && function_exists('brotli_compress'); + parent::__construct($properties, $options); + } + + protected function _getTitle(): String + { + return $this->generateUserFriendlyTitle($this); + } + + /** + * @param string $change + * @return array|string + * @throws JsonException + */ + private function decodeChange($change) + { + if (substr($change, 0, 4) === self::BROTLI_HEADER) { + if (function_exists('brotli_uncompress')) { + $change = brotli_uncompress(substr($change, 4)); + if ($change === false) { + return 'Compressed'; + } + } else { + return 'Compressed'; + } + } + return json_decode($change, true); + } + + /** + * @param array $auditLog + * @return string + */ + public function generateUserFriendlyTitle($auditLog) + { + if (in_array($auditLog['action'], [self::ACTION_TAG, self::ACTION_TAG_LOCAL, self::ACTION_REMOVE_TAG, self::ACTION_REMOVE_TAG_LOCAL], true)) { + $attached = ($auditLog['action'] === self::ACTION_TAG || $auditLog['action'] === self::ACTION_TAG_LOCAL); + $local = ($auditLog['action'] === self::ACTION_TAG_LOCAL || $auditLog['action'] === self::ACTION_REMOVE_TAG_LOCAL) ? __('local') : __('global'); + if ($attached) { + return __('Attached %s tag "%s" to %s #%s', $local, $auditLog['model_title'], strtolower($auditLog['model']), $auditLog['model_id']); + } else { + return __('Detached %s tag "%s" from %s #%s', $local, $auditLog['model_title'], strtolower($auditLog['model']), $auditLog['model_id']); + } + } + + + $title = "{$auditLog['model']} #{$auditLog['model_id']}"; + + if (isset($auditLog['model_title']) && $auditLog['model_title']) { + $title .= ": {$auditLog['model_title']}"; + } + return $title; + } +} diff --git a/src/Model/Table/AuditLogsTable.php b/src/Model/Table/AuditLogsTable.php new file mode 100644 index 0000000..374a156 --- /dev/null +++ b/src/Model/Table/AuditLogsTable.php @@ -0,0 +1,250 @@ +addBehavior('UUID'); + $this->addBehavior('Timestamp', [ + 'Model.beoreSave' => [ + 'created_at' => 'new' + ] + ]); + $this->belongsTo('Users'); + $this->setDisplayField('info'); + $this->compressionEnabled = Configure::read('Cerebrate.log_new_audit_compress') && function_exists('brotli_compress'); + } + + public function beforeMarshal(EventInterface $event, ArrayObject $data, ArrayObject $options) + { + if (!isset($data['request_ip'])) { + $ipHeader = 'REMOTE_ADDR'; + if (isset($_SERVER[$ipHeader])) { + $data['request_ip'] = $_SERVER[$ipHeader]; + } else { + $data['request_ip'] = '127.0.0.1'; + } + } + foreach (['user_id', 'request_type', 'authkey_id'] as $field) { + if (!isset($data[$field])) { + if (!isset($userInfo)) { + $userInfo = $this->userInfo(); + } + if (!empty($userInfo[$field])) { + $data[$field] = $userInfo[$field]; + } else { + $data[$field] = 0; + } + } + } + + if (!isset($data['request_id'] ) && isset($_SERVER['HTTP_X_REQUEST_ID'])) { + $data['request_id'] = $_SERVER['HTTP_X_REQUEST_ID']; + } + + // Truncate request_id + if (isset($data['request_id']) && strlen($data['request_id']) > 255) { + $data['request_id'] = substr($data['request_id'], 0, 255); + } + + // Truncate model title + if (isset($data['model_title']) && mb_strlen($data['model_title']) > 255) { + $data['model_title'] = mb_substr($data['model_title'], 0, 252) . '...'; + } + + if (isset($data['change'])) { + $change = json_encode($data['change'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($this->compressionEnabled && strlen($change) >= self::BROTLI_MIN_LENGTH) { + $change = self::BROTLI_HEADER . brotli_compress($change, 4, BROTLI_TEXT); + } + $data['change'] = $change; + } + } + + public function beforeSave(EventInterface $event, EntityInterface $entity, ArrayObject $options) + { + $entity->request_ip = inet_pton($entity->request_ip); + $this->logData($entity); + return true; + } + + /** + * @param array $data + * @return bool + */ + private function logData(EntityInterface $entity) + { + if (Configure::read('Plugin.ZeroMQ_enable') && Configure::read('Plugin.ZeroMQ_audit_notifications_enable')) { + $pubSubTool = $this->getPubSubTool(); + $pubSubTool->publish($data, 'audit', 'log'); + } + + //$this->publishKafkaNotification('audit', $data, 'log'); + + if (Configure::read('Plugin.ElasticSearch_logging_enable')) { + // send off our logs to distributed /dev/null + $logIndex = Configure::read("Plugin.ElasticSearch_log_index"); + $elasticSearchClient = $this->getElasticSearchTool(); + $elasticSearchClient->pushDocument($logIndex, "log", $data); + } + + // write to syslogd as well if enabled + if ($this->syslog === null) { + if (Configure::read('Security.syslog')) { + $options = []; + $syslogToStdErr = Configure::read('Security.syslog_to_stderr'); + if ($syslogToStdErr !== null) { + $options['to_stderr'] = $syslogToStdErr; + } + $syslogIdent = Configure::read('Security.syslog_ident'); + if ($syslogIdent) { + $options['ident'] = $syslogIdent; + } + $this->syslog = new SysLog($options); + } else { + $this->syslog = false; + } + } + if ($this->syslog) { + $entry = $data['action']; + $title = $entity->generateUserFriendlyTitle(); + if ($title) { + $entry .= " -- $title"; + } + $this->syslog->write('info', $entry); + } + return true; + } + + /** + * @return array + */ + public function userInfo() + { + if ($this->user !== null) { + return $this->user; + } + + $this->user = ['id' => 0, /*'org_id' => 0, */'authkey_id' => 0, 'request_type' => self::REQUEST_TYPE_DEFAULT]; + + $isShell = (php_sapi_name() === 'cli'); + if ($isShell) { + // do not start session for shell commands and fetch user info from configuration + $this->user['request_type'] = self::REQUEST_TYPE_CLI; + $currentUserId = Configure::read('CurrentUserId'); + if (!empty($currentUserId)) { + $this->user['id'] = $currentUserId; + $userFromDb = $this->Users->find()->where(['id' => $currentUserId])->first(); + $this->user['name'] = $userFromDb['name']; + $this->user['org_id'] = $userFromDb['org_id']; + } + } else { + $authUser = Router::getRequest()->getSession()->read('authUser'); + if (!empty($authUser)) { + $this->user['id'] = $authUser['id']; + $this->user['user_id'] = $authUser['id']; + $this->user['name'] = $authUser['name']; + //$this->user['org_id'] = $authUser['org_id']; + if (isset($authUser['logged_by_authkey']) && $authUser['logged_by_authkey']) { + $this->user['request_type'] = self::REQUEST_TYPE_API; + } + if (isset($authUser['authkey_id'])) { + $this->user['authkey_id'] = $authUser['authkey_id']; + } + } + } + return $this->user; + } + + public function insert(array $data) + { + $logEntity = $this->newEntity($data); + if ($logEntity->getErrors()) { + throw new Exception($logEntity->getErrors()); + } else { + $this->save($logEntity); + } + } + + /** + * @param string|int $org + * @return array + */ + public function returnDates($org = 'all') + { + $conditions = []; + if ($org !== 'all') { + $org = $this->Organisation->fetchOrg($org); + if (empty($org)) { + throw new NotFoundException('Invalid organisation.'); + } + $conditions['org_id'] = $org['id']; + } + + $dataSource = ConnectionManager::getDataSource('default')->config['datasource']; + if ($dataSource === 'Database/Mysql' || $dataSource === 'Database/MysqlObserver') { + $validDates = $this->find('all', [ + 'recursive' => -1, + 'fields' => ['DISTINCT UNIX_TIMESTAMP(DATE(created)) AS Date', 'count(id) AS count'], + 'conditions' => $conditions, + 'group' => ['Date'], + 'order' => ['Date'], + ]); + } elseif ($dataSource === 'Database/Postgres') { + if (!empty($conditions['org_id'])) { + $condOrg = sprintf('WHERE org_id = %s', intval($conditions['org_id'])); + } else { + $condOrg = ''; + } + $sql = 'SELECT DISTINCT EXTRACT(EPOCH FROM CAST(created AS DATE)) AS "Date", COUNT(id) AS count + FROM audit_logs + ' . $condOrg . ' + GROUP BY "Date" ORDER BY "Date"'; + $validDates = $this->query($sql); + } + $data = []; + foreach ($validDates as $date) { + $data[(int)$date[0]['Date']] = (int)$date[0]['count']; + } + return $data; + } +} From de2ee49ccf1cef213b9d96616b507d4160ab8c9b Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 17 Nov 2021 14:44:54 +0100 Subject: [PATCH 018/150] new: [auditlogs] UI --- templates/AuditLogs/index.php | 65 +++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 templates/AuditLogs/index.php diff --git a/templates/AuditLogs/index.php b/templates/AuditLogs/index.php new file mode 100644 index 0000000..46ec90b --- /dev/null +++ b/templates/AuditLogs/index.php @@ -0,0 +1,65 @@ +element('genericElements/IndexTable/index_table', [ + 'data' => [ + 'data' => $data, + 'top_bar' => [ + 'children' => [ + [ + 'type' => 'search', + 'button' => __('Filter'), + 'placeholder' => __('Enter value to search'), + 'data' => '', + 'searchKey' => 'value' + ] + ] + ], + 'fields' => [ + [ + 'name' => '#', + 'sort' => 'id', + 'data_path' => 'id', + ], + [ + 'name' => __('IP'), + 'sort' => 'request_ip', + 'data_path' => 'request_ip', + ], + [ + 'name' => __('Username'), + 'sort' => 'user.username', + 'data_path' => 'user.username', + ], + [ + 'name' => __('Title'), + 'data_path' => 'title', + ], + [ + 'name' => __('Model'), + 'sort' => 'model', + 'data_path' => 'model', + ], + [ + 'name' => __('Model ID'), + 'sort' => 'model', + 'data_path' => 'model_id', + ], + [ + 'name' => __('Action'), + 'sort' => 'action', + 'data_path' => 'action', + ], + [ + 'name' => __('Change'), + 'sort' => 'change', + 'data_path' => 'change', + 'element' => 'json' + ], + ], + 'title' => __('Logs'), + 'description' => null, + 'pull' => 'right', + 'actions' => [] + ] +]); +echo ''; +?> From af4f114f2f1928d933e556ecc016802d0d52f928 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 17 Nov 2021 14:45:20 +0100 Subject: [PATCH 019/150] chg: [audit logs] tied into side menu --- src/Controller/Component/Navigation/sidemenu.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Controller/Component/Navigation/sidemenu.php b/src/Controller/Component/Navigation/sidemenu.php index 16dd350..fcca213 100644 --- a/src/Controller/Component/Navigation/sidemenu.php +++ b/src/Controller/Component/Navigation/sidemenu.php @@ -113,6 +113,11 @@ class Sidemenu { 'url' => '/instance/migrationIndex', 'icon' => 'database', ], + 'AuditLogs' => [ + 'label' => __('Audit Logs'), + 'url' => '/auditLogs/index', + 'icon' => 'history', + ], ] ], ], @@ -144,4 +149,4 @@ class Sidemenu { ] ]; } -} \ No newline at end of file +} From a305bdf9f192e448654091b3c5e7e0e82c958fbe Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 17 Nov 2021 14:45:45 +0100 Subject: [PATCH 020/150] new: [mysql] added new table for audit logs --- INSTALL/mysql.sql | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/INSTALL/mysql.sql b/INSTALL/mysql.sql index 88a3040..e5dcd7a 100644 --- a/INSTALL/mysql.sql +++ b/INSTALL/mysql.sql @@ -390,6 +390,26 @@ CREATE TABLE `meta_template_fields` ( KEY `type` (`type`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS `audit_logs` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `created` datetime NOT NULL, + `user_id` int(10) unsigned NOT NULL, + `authkey_id` int(11) DEFAULT NULL, + `ip` varbinary(16) DEFAULT NULL, + `request_type` tinyint NOT NULL, + `request_id` varchar(191) DEFAULT NULL, + `action` varchar(20) NOT NULL, + `model` varchar(80) NOT NULL, + `model_id` int(10) unsigned NOT NULL, + `model_title` text DEFAULT NULL, + `change` blob, + PRIMARY KEY (`id`), + KEY `user_id` (`user_id`), + KEY `ip` (`ip`), + KEY `model` (`model`), + KEY `action` (`action`), + KEY `model_id` (`model_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET SQL_MODE=@OLD_SQL_MODE */; /*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; From 72bd5641201940cd5e6e0750fbb4a584f5b11639 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 17 Nov 2021 15:40:44 +0100 Subject: [PATCH 021/150] new: [migration] scripts added - also updated mysql.sql --- INSTALL/mysql.sql | 11 ++- .../Migrations/20211117135403_audit_logs.php | 96 +++++++++++++++++++ 2 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 config/Migrations/20211117135403_audit_logs.php diff --git a/INSTALL/mysql.sql b/INSTALL/mysql.sql index e5dcd7a..6bd956c 100644 --- a/INSTALL/mysql.sql +++ b/INSTALL/mysql.sql @@ -393,14 +393,14 @@ CREATE TABLE `meta_template_fields` ( CREATE TABLE IF NOT EXISTS `audit_logs` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `created` datetime NOT NULL, - `user_id` int(10) unsigned NOT NULL, - `authkey_id` int(11) DEFAULT NULL, - `ip` varbinary(16) DEFAULT NULL, + `user_id` int(10) unsigned DEFAULT NULL, + `authkey_id` int(10) unsigned DEFAULT NULL, + `request_ip` varbinary(16) DEFAULT NULL, `request_type` tinyint NOT NULL, `request_id` varchar(191) DEFAULT NULL, - `action` varchar(20) NOT NULL, + `request_action` varchar(20) NOT NULL, `model` varchar(80) NOT NULL, - `model_id` int(10) unsigned NOT NULL, + `model_id` int(10) unsigned DEFAULT NULL, `model_title` text DEFAULT NULL, `change` blob, PRIMARY KEY (`id`), @@ -409,6 +409,7 @@ CREATE TABLE IF NOT EXISTS `audit_logs` ( KEY `model` (`model`), KEY `action` (`action`), KEY `model_id` (`model_id`) + KEY `created` (`created`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET SQL_MODE=@OLD_SQL_MODE */; diff --git a/config/Migrations/20211117135403_audit_logs.php b/config/Migrations/20211117135403_audit_logs.php new file mode 100644 index 0000000..c44847b --- /dev/null +++ b/config/Migrations/20211117135403_audit_logs.php @@ -0,0 +1,96 @@ +hasTable('audit_logs'); + if (!$exists) { + $table = $this->table('audit_logs', [ + 'signed' => false, + 'collation' => 'utf8mb4_unicode_ci' + ]); + $table + ->addColumn('id', 'integer', [ + 'autoIncrement' => true, + 'limit' => 10, + 'signed' => false, + ]) + ->addPrimaryKey('id') + ->addColumn('user_id', 'integer', [ + 'default' => null, + 'null' => true, + 'signed' => false, + 'length' => 10 + ]) + ->addColumn('authkey_id', 'integer', [ + 'default' => null, + 'null' => true, + 'signed' => false, + 'length' => 10 + ]) + ->addColumn('request_ip', 'varbinary', [ + 'default' => null, + 'null' => true, + 'length' => 16 + ]) + ->addColumn('request_type', 'boolean', [ + 'null' => false + ]) + ->addColumn('request_id', 'integer', [ + 'default' => null, + 'null' => true, + 'signed' => false, + 'length' => 10 + ]) + ->addColumn('request_action', 'string', [ + 'null' => false, + 'length' => 20 + ]) + ->addColumn('model', 'string', [ + 'null' => false, + 'length' => 80 + ]) + ->addColumn('model_id', 'integer', [ + 'default' => null, + 'null' => true, + 'signed' => false, + 'length' => 10 + ]) + ->addColumn('model_title', 'text', [ + 'default' => null, + 'null' => true + ]) + ->addColumn('change', 'blob', [ + ]) + ->addColumn('created', 'datetime', [ + 'default' => null, + 'null' => false, + ]) + ->addIndex('user_id') + ->addIndex('request_ip') + ->addIndex('model') + ->addIndex('model_id') + ->addIndex('request_action') + ->addIndex('created'); + $table->create(); + } + } +} From 2e1ee2d064c07322d7f074160a6de9d5512e43f6 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 17 Nov 2021 15:43:52 +0100 Subject: [PATCH 022/150] new: [audit log] behaviour tied into the appropriate models --- src/Model/Table/AlignmentsTable.php | 1 + src/Model/Table/AuditLogsTable.php | 2 +- src/Model/Table/AuthKeysTable.php | 3 ++- src/Model/Table/BroodsTable.php | 5 +++-- src/Model/Table/EncryptionKeysTable.php | 1 + src/Model/Table/InboxTable.php | 4 ++-- src/Model/Table/IndividualsTable.php | 1 + src/Model/Table/InstanceTable.php | 4 +++- src/Model/Table/LocalToolsTable.php | 1 + src/Model/Table/MetaFieldsTable.php | 1 + src/Model/Table/OrganisationsTable.php | 1 + src/Model/Table/OutboxProcessorsTable.php | 5 +++-- src/Model/Table/OutboxTable.php | 2 +- src/Model/Table/RemoteToolConnectionsTable.php | 1 + src/Model/Table/RolesTable.php | 1 + src/Model/Table/SettingsTable.php | 1 + src/Model/Table/SharingGroupsTable.php | 1 + src/Model/Table/UsersTable.php | 1 + 18 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/Model/Table/AlignmentsTable.php b/src/Model/Table/AlignmentsTable.php index 0914e99..c05ad90 100644 --- a/src/Model/Table/AlignmentsTable.php +++ b/src/Model/Table/AlignmentsTable.php @@ -12,6 +12,7 @@ class AlignmentsTable extends AppTable { parent::initialize($config); $this->belongsTo('Individuals'); + $this->addBehavior('AuditLog'); $this->belongsTo('Organisations'); $this->addBehavior('Timestamp'); } diff --git a/src/Model/Table/AuditLogsTable.php b/src/Model/Table/AuditLogsTable.php index 374a156..b65b5b2 100644 --- a/src/Model/Table/AuditLogsTable.php +++ b/src/Model/Table/AuditLogsTable.php @@ -145,7 +145,7 @@ class AuditLogsTable extends AppTable } } if ($this->syslog) { - $entry = $data['action']; + $entry = $data['request_action']; $title = $entity->generateUserFriendlyTitle(); if ($title) { $entry .= " -- $title"; diff --git a/src/Model/Table/AuthKeysTable.php b/src/Model/Table/AuthKeysTable.php index 3663af9..f5336e6 100644 --- a/src/Model/Table/AuthKeysTable.php +++ b/src/Model/Table/AuthKeysTable.php @@ -19,10 +19,11 @@ class AuthKeysTable extends AppTable { parent::initialize($config); $this->addBehavior('UUID'); + $this->addBehavior('AuditLog'); $this->belongsTo( 'Users' ); - $this->setDisplayField('authkey'); + $this->setDisplayField('comment'); } public function beforeMarshal(EventInterface $event, ArrayObject $data, ArrayObject $options) diff --git a/src/Model/Table/BroodsTable.php b/src/Model/Table/BroodsTable.php index e1993b4..b0d3dca 100644 --- a/src/Model/Table/BroodsTable.php +++ b/src/Model/Table/BroodsTable.php @@ -19,6 +19,7 @@ class BroodsTable extends AppTable parent::initialize($config); $this->addBehavior('UUID'); $this->addBehavior('Timestamp'); + $this->addBehavior('AuditLog'); $this->BelongsTo( 'Organisations' ); @@ -278,7 +279,7 @@ class BroodsTable extends AppTable } return $jsonReply; } - + /** * handleSendingFailed - Handle the case if the request could not be sent or if the remote rejected the connection request * @@ -302,7 +303,7 @@ class BroodsTable extends AppTable ]; return $creationResult; } - + /** * handleMessageNotCreated - Handle the case if the request was sent but the remote brood did not save the message in the inbox * diff --git a/src/Model/Table/EncryptionKeysTable.php b/src/Model/Table/EncryptionKeysTable.php index 23b4867..2008e0d 100644 --- a/src/Model/Table/EncryptionKeysTable.php +++ b/src/Model/Table/EncryptionKeysTable.php @@ -14,6 +14,7 @@ class EncryptionKeysTable extends AppTable { parent::initialize($config); $this->addBehavior('UUID'); + $this->addBehavior('AuditLog'); $this->addBehavior('Timestamp'); $this->belongsTo( 'Individuals', diff --git a/src/Model/Table/InboxTable.php b/src/Model/Table/InboxTable.php index 0c62a80..18faf47 100644 --- a/src/Model/Table/InboxTable.php +++ b/src/Model/Table/InboxTable.php @@ -19,7 +19,7 @@ class InboxTable extends AppTable parent::initialize($config); $this->addBehavior('UUID'); $this->addBehavior('Timestamp'); - + $this->addBehavior('AuditLog'); $this->belongsTo('Users'); $this->setDisplayField('title'); } @@ -68,7 +68,7 @@ class InboxTable extends AppTable if (empty($brood)) { $errors[] = __('Unkown brood `{0}`', $entryData['data']['cerebrateURL']); } - + // $found = false; // foreach ($user->individual->organisations as $organisations) { // if ($organisations->id == $brood->organisation_id) { diff --git a/src/Model/Table/IndividualsTable.php b/src/Model/Table/IndividualsTable.php index f0ba07f..70f63fd 100644 --- a/src/Model/Table/IndividualsTable.php +++ b/src/Model/Table/IndividualsTable.php @@ -16,6 +16,7 @@ class IndividualsTable extends AppTable $this->addBehavior('UUID'); $this->addBehavior('Timestamp'); $this->addBehavior('Tags.Tag'); + $this->addBehavior('AuditLog'); $this->hasMany( 'Alignments', [ diff --git a/src/Model/Table/InstanceTable.php b/src/Model/Table/InstanceTable.php index 0ec2a53..a625e79 100644 --- a/src/Model/Table/InstanceTable.php +++ b/src/Model/Table/InstanceTable.php @@ -18,6 +18,8 @@ class InstanceTable extends AppTable public function initialize(array $config): void { parent::initialize($config); + $this->addBehavior('AuditLog'); + $this->setDisplayField('name'); } public function validationDefault(Validator $validator): Validator @@ -54,7 +56,7 @@ class InstanceTable extends AppTable $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; diff --git a/src/Model/Table/LocalToolsTable.php b/src/Model/Table/LocalToolsTable.php index 8f2bfea..ac29bf9 100644 --- a/src/Model/Table/LocalToolsTable.php +++ b/src/Model/Table/LocalToolsTable.php @@ -30,6 +30,7 @@ class LocalToolsTable extends AppTable public function initialize(array $config): void { parent::initialize($config); + $this->addBehavior('AuditLog'); $this->addBehavior('Timestamp'); } diff --git a/src/Model/Table/MetaFieldsTable.php b/src/Model/Table/MetaFieldsTable.php index ba4cc66..bdcb212 100644 --- a/src/Model/Table/MetaFieldsTable.php +++ b/src/Model/Table/MetaFieldsTable.php @@ -12,6 +12,7 @@ class MetaFieldsTable extends AppTable { parent::initialize($config); $this->addBehavior('UUID'); + $this->addBehavior('AuditLog'); $this->setDisplayField('field'); $this->belongsTo('MetaTemplates'); $this->belongsTo('MetaTemplateFields'); diff --git a/src/Model/Table/OrganisationsTable.php b/src/Model/Table/OrganisationsTable.php index 254a570..d4a99b9 100644 --- a/src/Model/Table/OrganisationsTable.php +++ b/src/Model/Table/OrganisationsTable.php @@ -20,6 +20,7 @@ class OrganisationsTable extends AppTable parent::initialize($config); $this->addBehavior('Timestamp'); $this->addBehavior('Tags.Tag'); + $this->addBehavior('AuditLog'); $this->hasMany( 'Alignments', [ diff --git a/src/Model/Table/OutboxProcessorsTable.php b/src/Model/Table/OutboxProcessorsTable.php index da26692..5812cb4 100644 --- a/src/Model/Table/OutboxProcessorsTable.php +++ b/src/Model/Table/OutboxProcessorsTable.php @@ -28,6 +28,7 @@ class OutboxProcessorsTable extends AppTable if (empty($this->outboxProcessors)) { $this->loadProcessors(); } + $this->addBehavior('AuditLog'); } public function getProcessor($scope, $action=null) @@ -87,7 +88,7 @@ class OutboxProcessorsTable extends AppTable } } } - + /** * getProcessorClass * @@ -112,7 +113,7 @@ class OutboxProcessorsTable extends AppTable return $e->getMessage(); } } - + /** * createOutboxEntry * diff --git a/src/Model/Table/OutboxTable.php b/src/Model/Table/OutboxTable.php index 02fd442..a78e0c6 100644 --- a/src/Model/Table/OutboxTable.php +++ b/src/Model/Table/OutboxTable.php @@ -19,8 +19,8 @@ class OutboxTable extends AppTable parent::initialize($config); $this->addBehavior('UUID'); $this->addBehavior('Timestamp'); - $this->belongsTo('Users'); + $this->addBehavior('AuditLog'); $this->setDisplayField('title'); } diff --git a/src/Model/Table/RemoteToolConnectionsTable.php b/src/Model/Table/RemoteToolConnectionsTable.php index 7e8cf21..1e1ee25 100644 --- a/src/Model/Table/RemoteToolConnectionsTable.php +++ b/src/Model/Table/RemoteToolConnectionsTable.php @@ -18,6 +18,7 @@ class RemoteToolConnectionsTable extends AppTable 'LocalTools' ); $this->setDisplayField('id'); + $this->addBehavior('AuditLog'); } public function validationDefault(Validator $validator): Validator diff --git a/src/Model/Table/RolesTable.php b/src/Model/Table/RolesTable.php index 3973b1b..74f290b 100644 --- a/src/Model/Table/RolesTable.php +++ b/src/Model/Table/RolesTable.php @@ -12,6 +12,7 @@ class RolesTable extends AppTable { parent::initialize($config); $this->addBehavior('UUID'); + $this->addBehavior('AuditLog'); $this->hasMany( 'Users', [ diff --git a/src/Model/Table/SettingsTable.php b/src/Model/Table/SettingsTable.php index c2d71c9..7e9bcff 100644 --- a/src/Model/Table/SettingsTable.php +++ b/src/Model/Table/SettingsTable.php @@ -26,6 +26,7 @@ class SettingsTable extends AppTable parent::initialize($config); $this->setTable(false); $this->SettingsProvider = new CerebrateSettingsProvider(); + $this->addBehavior('AuditLog'); } public function getSettings($full=false): array diff --git a/src/Model/Table/SharingGroupsTable.php b/src/Model/Table/SharingGroupsTable.php index ec3791e..ff39220 100644 --- a/src/Model/Table/SharingGroupsTable.php +++ b/src/Model/Table/SharingGroupsTable.php @@ -15,6 +15,7 @@ class SharingGroupsTable extends AppTable parent::initialize($config); $this->addBehavior('UUID'); $this->addBehavior('Timestamp'); + $this->addBehavior('AuditLog'); $this->belongsTo( 'Users' ); diff --git a/src/Model/Table/UsersTable.php b/src/Model/Table/UsersTable.php index d9804a4..3a98b00 100644 --- a/src/Model/Table/UsersTable.php +++ b/src/Model/Table/UsersTable.php @@ -20,6 +20,7 @@ class UsersTable extends AppTable parent::initialize($config); $this->addBehavior('Timestamp'); $this->addBehavior('UUID'); + $this->addBehavior('AuditLog'); $this->initAuthBehaviors(); $this->belongsTo( 'Individuals', From 1f7756934487a44e2363ef7747953da9d537db3e Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 17 Nov 2021 15:46:32 +0100 Subject: [PATCH 023/150] chg: [auditlog] log api authentication failures / successes --- src/Controller/AppController.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/Controller/AppController.php b/src/Controller/AppController.php index 3771796..9b44ec4 100644 --- a/src/Controller/AppController.php +++ b/src/Controller/AppController.php @@ -111,6 +111,7 @@ class AppController extends Controller } unset($user['password']); $this->ACL->setUser($user); + $this->request->getSession()->write('authUser', $user); $this->isAdmin = $user['role']['perm_admin']; $this->set('menu', $this->ACL->getMenu()); $this->set('loggedUser', $this->ACL->getUser()); @@ -147,13 +148,31 @@ class AppController extends Controller { if (!empty($_SERVER['HTTP_AUTHORIZATION']) && strlen($_SERVER['HTTP_AUTHORIZATION'])) { $this->loadModel('AuthKeys'); + $logModel = $this->Users->auditLogs(); $authKey = $this->AuthKeys->checkKey($_SERVER['HTTP_AUTHORIZATION']); if (!empty($authKey)) { $this->loadModel('Users'); $user = $this->Users->get($authKey['user_id']); + $user = $logModel->userInfo(); + $logModel->insert([ + 'action' => 'login', + 'model' => 'Users', + 'model_id' => $user['id'], + 'model_title' => $user['name'], + 'change' => [] + ]); if (!empty($user)) { $this->Authentication->setIdentity($user); } + } else { + $user = $logModel->userInfo(); + $logModel->insert([ + 'action' => 'login', + 'model' => 'Users', + 'model_id' => $user['id'], + 'model_title' => $user['name'], + 'change' => [] + ]); } } } From cc043733752a5e1e660a1df761267e4d008a8332 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 17 Nov 2021 15:47:32 +0100 Subject: [PATCH 024/150] new: [crud component] fixes - add hidden option - fix afterfind --- src/Controller/Component/CRUDComponent.php | 29 +++++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/Controller/Component/CRUDComponent.php b/src/Controller/Component/CRUDComponent.php index 64b5777..45df6d5 100644 --- a/src/Controller/Component/CRUDComponent.php +++ b/src/Controller/Component/CRUDComponent.php @@ -61,11 +61,25 @@ class CRUDComponent extends Component } if ($this->Controller->ParamHandler->isRest()) { $data = $query->all(); + if (isset($options['hidden'])) { + $data->each(function($value, $key) use ($options) { + $hidden = is_array($options['hidden']) ? $options['hidden'] : [$options['hidden']]; + $value->setHidden($hidden); + return $value; + }); + } if (isset($options['afterFind'])) { + $function = $options['afterFind']; if (is_callable($options['afterFind'])) { - $data = $options['afterFind']($data); + $function = $options['afterFind']; + $data->each(function($value, $key) use ($function) { + return $function($value); + }); } else { - $data = $this->Table->{$options['afterFind']}($data); + $t = $this->Table; + $data->each(function($value, $key) use ($t, $function) { + return $t->$function($value); + }); } } $this->Controller->restResponsePayload = $this->RestResponse->viewData($data, 'json'); @@ -73,10 +87,17 @@ class CRUDComponent extends Component $this->Controller->loadComponent('Paginator'); $data = $this->Controller->Paginator->paginate($query); if (isset($options['afterFind'])) { + $function = $options['afterFind']; if (is_callable($options['afterFind'])) { - $data = $options['afterFind']($data); + $function = $options['afterFind']; + $data->each(function($value, $key) use ($function) { + return $function($value); + }); } else { - $data = $this->Table->{$options['afterFind']}($data); + $t = $this->Table; + $data->each(function($value, $key) use ($t, $function) { + return $t->$function($value); + }); } } $this->setFilteringContext($options['contextFilters'] ?? [], $params); From bc2e2fa4887703f21ded8d8856c25b802adac304 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 17 Nov 2021 15:48:49 +0100 Subject: [PATCH 025/150] new: [open] individualscontroller fix - import badrequest exception --- src/Controller/Open/IndividualsController.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Controller/Open/IndividualsController.php b/src/Controller/Open/IndividualsController.php index fa75aaa..28cd51d 100644 --- a/src/Controller/Open/IndividualsController.php +++ b/src/Controller/Open/IndividualsController.php @@ -6,6 +6,7 @@ use App\Controller\AppController; use Cake\Utility\Hash; use Cake\Utility\Text; use \Cake\Database\Expression\QueryExpression; +use Cake\Http\Exception\BadRequestException; use Cake\Http\Exception\NotFoundException; use Cake\Http\Exception\MethodNotAllowedException; use Cake\Http\Exception\ForbiddenException; From 7b52d29320365b3df6b9ae6ebea6bba5e27849f3 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 17 Nov 2021 15:49:28 +0100 Subject: [PATCH 026/150] new: [login] log success/failure --- src/Controller/UsersController.php | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/Controller/UsersController.php b/src/Controller/UsersController.php index e5258d0..012655e 100644 --- a/src/Controller/UsersController.php +++ b/src/Controller/UsersController.php @@ -130,11 +130,27 @@ class UsersController extends AppController { $result = $this->Authentication->getResult(); // If the user is logged in send them away. + $logModel = $this->Users->auditLogs(); if ($result->isValid()) { + $user = $logModel->userInfo(); + $logModel->insert([ + 'request_action' => 'login', + 'model' => 'Users', + 'model_id' => $user['id'], + 'model_title' => $user['name'], + 'change' => [] + ]); $target = $this->Authentication->getLoginRedirect() ?? '/instance/home'; return $this->redirect($target); } if ($this->request->is('post') && !$result->isValid()) { + $logModel->insert([ + 'request_action' => 'login_fail', + 'model' => 'Users', + 'model_id' => 0, + 'model_title' => 'unknown_user', + 'change' => [] + ]); $this->Flash->error(__('Invalid username or password')); } $this->viewBuilder()->setLayout('login'); @@ -144,6 +160,15 @@ class UsersController extends AppController { $result = $this->Authentication->getResult(); if ($result->isValid()) { + $logModel = $this->Users->auditLogs(); + $user = $logModel->userInfo(); + $logModel->insert([ + 'request_action' => 'logout', + 'model' => 'Users', + 'model_id' => $user['id'], + 'model_title' => $user['name'], + 'change' => [] + ]); $this->Authentication->logout(); $this->Flash->success(__('Goodbye.')); return $this->redirect(\Cake\Routing\Router::url('/users/login')); From fe8e217d61d4cd480bcc0568d684fbf87dfe4e8f Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 17 Nov 2021 15:57:34 +0100 Subject: [PATCH 027/150] chg: [audit log naming] renamed action to request_action to avoid reserved keyword usage --- src/Model/Behavior/AuditLogBehavior.php | 4 ++-- src/Model/Entity/AuditLog.php | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Model/Behavior/AuditLogBehavior.php b/src/Model/Behavior/AuditLogBehavior.php index 3eacfe2..f7ef3df 100644 --- a/src/Model/Behavior/AuditLogBehavior.php +++ b/src/Model/Behavior/AuditLogBehavior.php @@ -106,7 +106,7 @@ class AuditLogBehavior extends Behavior $modelTitle = $this->old[$modelTitleField]; } $this->auditLogs()->insert([ - 'action' => $action, + 'request_action' => $action, 'model' => $entity->getSource(), 'model_id' => $id, 'model_title' => $modelTitle, @@ -130,7 +130,7 @@ class AuditLogBehavior extends Behavior } $logEntity = $this->auditLogs()->newEntity([ - 'action' => $entity->getConstant('ACTION_DELETE'), + 'request_action' => $entity->getConstant('ACTION_DELETE'), 'model' => $entity->getSource(), 'model_id' => $this->old->id, 'model_title' => $modelTitle, diff --git a/src/Model/Entity/AuditLog.php b/src/Model/Entity/AuditLog.php index beabeb8..74e2f41 100644 --- a/src/Model/Entity/AuditLog.php +++ b/src/Model/Entity/AuditLog.php @@ -47,9 +47,9 @@ class AuditLog extends AppModel */ public function generateUserFriendlyTitle($auditLog) { - if (in_array($auditLog['action'], [self::ACTION_TAG, self::ACTION_TAG_LOCAL, self::ACTION_REMOVE_TAG, self::ACTION_REMOVE_TAG_LOCAL], true)) { - $attached = ($auditLog['action'] === self::ACTION_TAG || $auditLog['action'] === self::ACTION_TAG_LOCAL); - $local = ($auditLog['action'] === self::ACTION_TAG_LOCAL || $auditLog['action'] === self::ACTION_REMOVE_TAG_LOCAL) ? __('local') : __('global'); + if (in_array($auditLog['request_action'], [self::ACTION_TAG, self::ACTION_TAG_LOCAL, self::ACTION_REMOVE_TAG, self::ACTION_REMOVE_TAG_LOCAL], true)) { + $attached = ($auditLog['request_action'] === self::ACTION_TAG || $auditLog['request_action'] === self::ACTION_TAG_LOCAL); + $local = ($auditLog['request_action'] === self::ACTION_TAG_LOCAL || $auditLog['request_action'] === self::ACTION_REMOVE_TAG_LOCAL) ? __('local') : __('global'); if ($attached) { return __('Attached %s tag "%s" to %s #%s', $local, $auditLog['model_title'], strtolower($auditLog['model']), $auditLog['model_id']); } else { From ff77af0a8edabf2d62bcc3237143a0c5b7555b1f Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 17 Nov 2021 15:58:06 +0100 Subject: [PATCH 028/150] new: [appmodel] moved constants related to the logging along with a getter to app model --- src/Model/Entity/AppModel.php | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/Model/Entity/AppModel.php b/src/Model/Entity/AppModel.php index 84c86b3..50bfe86 100644 --- a/src/Model/Entity/AppModel.php +++ b/src/Model/Entity/AppModel.php @@ -6,6 +6,28 @@ use Cake\ORM\Entity; class AppModel extends Entity { + const BROTLI_HEADER = "\xce\xb2\xcf\x81"; + const BROTLI_MIN_LENGTH = 200; + + const ACTION_ADD = 'add', + ACTION_EDIT = 'edit', + ACTION_SOFT_DELETE = 'soft_delete', + ACTION_DELETE = 'delete', + ACTION_UNDELETE = 'undelete', + ACTION_TAG = 'tag', + ACTION_TAG_LOCAL = 'tag_local', + ACTION_REMOVE_TAG = 'remove_tag', + ACTION_REMOVE_TAG_LOCAL = 'remove_local_tag', + ACTION_LOGIN = 'login', + ACTION_LOGIN_FAIL = 'login_fail', + ACTION_LOGOUT = 'logout'; + + + public function getConstant($name) + { + return constant('self::' . $name); + } + public function getAccessibleFieldForNew(): array { return $this->_accessibleOnNew ?? []; From 92ddd04ba0c4bfde46c69f3ecb996cd5f80a4b8c Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 17 Nov 2021 15:58:52 +0100 Subject: [PATCH 029/150] fix: [JSON fields] fixed escaping issues --- templates/element/genericElements/IndexTable/Fields/json.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/templates/element/genericElements/IndexTable/Fields/json.php b/templates/element/genericElements/IndexTable/Fields/json.php index 6c14248..2aca821 100644 --- a/templates/element/genericElements/IndexTable/Fields/json.php +++ b/templates/element/genericElements/IndexTable/Fields/json.php @@ -1,9 +1,12 @@ Hash->extract($row, $field['data_path'])); + $data = $this->Hash->extract($row, $field['data_path']); // I feed dirty for this... if (is_array($data) && count($data) === 1 && isset($data[0])) { $data = $data[0]; } + if (!is_array($data)) { + $data = json_decode($data, true); + } echo sprintf( '
', h($k) From 7f138325a8a5bd59ff83b653ce4e4a7ab63aa4c2 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 17 Nov 2021 16:04:02 +0100 Subject: [PATCH 030/150] fix: [log index] use the proper action column --- templates/AuditLogs/index.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/AuditLogs/index.php b/templates/AuditLogs/index.php index 46ec90b..a121e4f 100644 --- a/templates/AuditLogs/index.php +++ b/templates/AuditLogs/index.php @@ -45,8 +45,8 @@ echo $this->element('genericElements/IndexTable/index_table', [ ], [ 'name' => __('Action'), - 'sort' => 'action', - 'data_path' => 'action', + 'sort' => 'request_action', + 'data_path' => 'request_action', ], [ 'name' => __('Change'), From 18b78e8eecbac57db8030fa4e9b61d81dbfb0464 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 17 Nov 2021 16:04:57 +0100 Subject: [PATCH 031/150] fix: [audit log] filtering now uses request_action rather than the renamed action field --- src/Controller/AuditLogsController.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Controller/AuditLogsController.php b/src/Controller/AuditLogsController.php index 58a2133..9717416 100644 --- a/src/Controller/AuditLogsController.php +++ b/src/Controller/AuditLogsController.php @@ -11,8 +11,8 @@ use Cake\Core\Configure; class AuditLogsController extends AppController { - public $filterFields = ['model_id', 'model', 'action', 'user_id', 'title']; - public $quickFilterFields = ['model', 'action', 'title']; + public $filterFields = ['model_id', 'model', 'request_action', 'user_id', 'title']; + public $quickFilterFields = ['model', 'request_action', 'title']; public $containFields = ['Users']; public function index() From 9619989a94a2162d8a9c7f7e81a97b97e2db510a Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 24 Nov 2021 01:24:25 +0100 Subject: [PATCH 032/150] new: [migration] organisation_id added to users - also, grab the first org for a default --- config/Migrations/20211123152707_user_org.php | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 config/Migrations/20211123152707_user_org.php diff --git a/config/Migrations/20211123152707_user_org.php b/config/Migrations/20211123152707_user_org.php new file mode 100644 index 0000000..f615c98 --- /dev/null +++ b/config/Migrations/20211123152707_user_org.php @@ -0,0 +1,43 @@ +hasTable('users'); + if (!$exists) { + $alignments = $this->table('users') + ->addColumn('organisation_id', 'integer', [ + 'default' => null, + 'null' => true, + 'signed' => false, + 'length' => 10 + ]) + ->addIndex('org_id') + ->update(); + } + $q1 = $this->getQueryBuilder(); + $org_id = $q1->select(['min(id)'])->from('organisations')->execute()->fetchAll()[0][0]; + if (!empty($org_id)) { + $q2 = $this->getQueryBuilder(); + $q2->update('users') + ->set('organisation_id', $org_id) + ->where(['organisation_id IS NULL']) + ->execute(); + } + } +} From e5e4e74cae53bc988c047958d11b194b8dd3fc68 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 24 Nov 2021 01:25:32 +0100 Subject: [PATCH 033/150] chg: [users] associated with orgs --- src/Model/Table/UsersTable.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Model/Table/UsersTable.php b/src/Model/Table/UsersTable.php index 3a98b00..cb189aa 100644 --- a/src/Model/Table/UsersTable.php +++ b/src/Model/Table/UsersTable.php @@ -36,6 +36,13 @@ class UsersTable extends AppTable 'cascadeCallbacks' => false ] ); + $this->belongsTo( + 'Organisations', + [ + 'dependent' => false, + 'cascadeCallbacks' => false + ] + ); $this->hasMany( 'UserSettings', [ From 81ab20291773a01eabc031c1f9e5e5a44b8ff92d Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 24 Nov 2021 01:25:57 +0100 Subject: [PATCH 034/150] chg: [templates] org fields added to user templates --- templates/Users/add.php | 6 ++++++ templates/Users/index.php | 7 +++++++ templates/Users/view.php | 8 ++++++++ 3 files changed, 21 insertions(+) diff --git a/templates/Users/add.php b/templates/Users/add.php index 99bf366..f215277 100644 --- a/templates/Users/add.php +++ b/templates/Users/add.php @@ -14,6 +14,12 @@ 'field' => 'username', 'autocomplete' => 'off' ], + [ + 'field' => 'organisation_id', + 'type' => 'dropdown', + 'label' => __('Associated organisation'), + 'options' => $dropdownData['organisation'] + ], [ 'field' => 'password', 'label' => __('Password'), diff --git a/templates/Users/index.php b/templates/Users/index.php index 64561a1..1ff36bf 100644 --- a/templates/Users/index.php +++ b/templates/Users/index.php @@ -51,6 +51,13 @@ echo $this->element('genericElements/IndexTable/index_table', [ 'sort' => 'username', 'data_path' => 'username', ], + [ + 'name' => __('Organisation'), + 'sort' => 'organisation.name', + 'data_path' => 'organisation.name', + 'url' => '/organisations/view/{{0}}', + 'url_vars' => ['organisation.id'] + ], [ 'name' => __('Email'), 'sort' => 'individual.email', diff --git a/templates/Users/view.php b/templates/Users/view.php index 760ead4..26c3c25 100644 --- a/templates/Users/view.php +++ b/templates/Users/view.php @@ -21,6 +21,14 @@ echo $this->element( 'path' => 'individual.email' ], [ + 'type' => 'generic', + 'key' => __('Organisation'), + 'path' => 'organisation.name', + 'url' => '/organisations/view/{{0}}', + 'url_vars' => 'organisation.id' + ], + [ + 'type' => 'generic', 'key' => __('Role'), 'path' => 'role.name', 'url' => '/roles/view/{{0}}', From 061f3fc468dfed6a239582ea4b506b41627d3d71 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 24 Nov 2021 01:26:29 +0100 Subject: [PATCH 035/150] chg: [profile] added org to profile menu --- templates/element/layouts/header/header-profile.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/templates/element/layouts/header/header-profile.php b/templates/element/layouts/header/header-profile.php index a35e041..1f148d6 100644 --- a/templates/element/layouts/header/header-profile.php +++ b/templates/element/layouts/header/header-profile.php @@ -8,10 +8,10 @@ use Cake\Routing\Router; \ No newline at end of file + From ed848e9ceeaa7dc5eafc198fa64d59f0a1952c63 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 24 Nov 2021 01:26:55 +0100 Subject: [PATCH 036/150] chg: [sharing groups] show owner org on the index --- templates/SharingGroups/index.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/templates/SharingGroups/index.php b/templates/SharingGroups/index.php index 6076c6c..2aae744 100644 --- a/templates/SharingGroups/index.php +++ b/templates/SharingGroups/index.php @@ -35,6 +35,11 @@ echo $this->element('genericElements/IndexTable/index_table', [ 'class' => 'short', 'data_path' => 'name', ], + [ + 'name' => __('Owner'), + 'class' => 'short', + 'data_path' => 'organisation.name' + ], [ 'name' => __('UUID'), 'sort' => 'uuid', From e708730e9706782b69a457f2be1db69692044112 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 24 Nov 2021 01:27:14 +0100 Subject: [PATCH 037/150] chg: [roles] hide action buttons on the role index when they wouldn't be available anyway --- templates/Roles/index.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/templates/Roles/index.php b/templates/Roles/index.php index 2fc4c03..204d1d9 100644 --- a/templates/Roles/index.php +++ b/templates/Roles/index.php @@ -78,12 +78,14 @@ echo $this->element('genericElements/IndexTable/index_table', [ [ 'open_modal' => '/roles/edit/[onclick_params_data_path]', 'modal_params_data_path' => 'id', - 'icon' => 'edit' + 'icon' => 'edit', + 'requirement' => !empty($loggedUser['role']['perm_site_admin']) ], [ 'open_modal' => '/roles/delete/[onclick_params_data_path]', 'modal_params_data_path' => 'id', - 'icon' => 'trash' + 'icon' => 'trash', + 'requirement' => !empty($loggedUser['role']['perm_site_admin']) ], ] ] From 6d7a5553686b5ea6365039d15c2515cb8881a348 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 24 Nov 2021 01:28:01 +0100 Subject: [PATCH 038/150] chg: [index views] slight changes - hide inaccessible action buttons on org index - add owner to sharing group index --- templates/Organisations/index.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/templates/Organisations/index.php b/templates/Organisations/index.php index 0448c77..4b34aab 100644 --- a/templates/Organisations/index.php +++ b/templates/Organisations/index.php @@ -94,12 +94,14 @@ echo $this->element('genericElements/IndexTable/index_table', [ [ 'open_modal' => '/organisations/edit/[onclick_params_data_path]', 'modal_params_data_path' => 'id', - 'icon' => 'edit' + 'icon' => 'edit', + 'requirement' => $loggedUser['role']['perm_admin'] ], [ 'open_modal' => '/organisations/delete/[onclick_params_data_path]', 'modal_params_data_path' => 'id', - 'icon' => 'trash' + 'icon' => 'trash', + 'requirement' => $loggedUser['role']['perm_admin'] ], ] ] From dad310f4348646c3c46bf319fd556877820ab84b Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 24 Nov 2021 01:28:52 +0100 Subject: [PATCH 039/150] chg: [appcontroller] include user org in loaded user object during authentication - also log username as username rather than name --- src/Controller/AppController.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Controller/AppController.php b/src/Controller/AppController.php index 9b44ec4..46c5355 100644 --- a/src/Controller/AppController.php +++ b/src/Controller/AppController.php @@ -102,7 +102,7 @@ class AppController extends Controller $this->ACL->setPublicInterfaces(); if (!empty($this->request->getAttribute('identity'))) { $user = $this->Users->get($this->request->getAttribute('identity')->getIdentifier(), [ - 'contain' => ['Roles', 'Individuals' => 'Organisations', 'UserSettings'] + 'contain' => ['Roles', 'Individuals' => 'Organisations', 'UserSettings', 'Organisations'] ]); if (!empty($user['disabled'])) { $this->Authentication->logout(); @@ -158,7 +158,7 @@ class AppController extends Controller 'action' => 'login', 'model' => 'Users', 'model_id' => $user['id'], - 'model_title' => $user['name'], + 'model_title' => $user['username'], 'change' => [] ]); if (!empty($user)) { From 5483357e1cd98532a047f4d6fb2cbdf81c7e1ecf Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 24 Nov 2021 01:29:39 +0100 Subject: [PATCH 040/150] chg: [ACL] fix permissions for org admins - also, fix a bug with the simple permissions being ignored --- src/Controller/Component/ACLComponent.php | 24 ++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/Controller/Component/ACLComponent.php b/src/Controller/Component/ACLComponent.php index 5feae97..cd38752 100644 --- a/src/Controller/Component/ACLComponent.php +++ b/src/Controller/Component/ACLComponent.php @@ -145,24 +145,24 @@ class ACLComponent extends Component 'view' => ['*'] ], 'SharingGroups' => [ - 'add' => ['perm_admin'], - 'addOrg' => ['perm_admin'], - 'delete' => ['perm_admin'], - 'edit' => ['perm_admin'], + 'add' => ['perm_org_admin'], + 'addOrg' => ['perm_org_admin'], + 'delete' => ['perm_org_admin'], + 'edit' => ['perm_org_admin'], 'index' => ['*'], 'listOrgs' => ['*'], - 'removeOrg' => ['perm_admin'], + 'removeOrg' => ['perm_org_admin'], 'view' => ['*'] ], 'Users' => [ - 'add' => ['perm_admin'], - 'delete' => ['perm_admin'], + 'add' => ['perm_org_admin'], + 'delete' => ['perm_org_admin'], 'edit' => ['*'], - 'index' => ['perm_admin'], + 'index' => ['perm_org_admin'], 'login' => ['*'], 'logout' => ['*'], 'register' => ['*'], - 'toggle' => ['perm_admin'], + 'toggle' => ['perm_org_admin'], 'view' => ['*'] ] ); @@ -290,6 +290,12 @@ class ACLComponent extends Component if ($allConditionsMet) { return true; } + } else { + foreach ($this->aclList[$controller][$action] as $permission) { + if ($this->user['role'][$permission]) { + return true; + } + } } } return false; From 0fe7f4f9312c16df6b770ae8d866aff8444e8d41 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 24 Nov 2021 01:30:28 +0100 Subject: [PATCH 041/150] new: [CRUD] added additional features to the CRUD component - conditions passable to add/edit/index/delete - refactored get() requests internally to finds to accomodate for additional parameters - delete() now takes a params[] array as a second argument --- src/Controller/Component/CRUDComponent.php | 43 +++++++++++++++++++--- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/src/Controller/Component/CRUDComponent.php b/src/Controller/Component/CRUDComponent.php index 45df6d5..4ebabff 100644 --- a/src/Controller/Component/CRUDComponent.php +++ b/src/Controller/Component/CRUDComponent.php @@ -50,6 +50,9 @@ class CRUDComponent extends Component } $query = $this->setFilters($params, $query, $options); $query = $this->setQuickFilters($params, $query, empty($options['quickFilters']) ? [] : $options['quickFilters']); + if (!empty($options['conditions'])) { + $query->where($options['conditions']); + } if (!empty($options['contain'])) { $query->contain($options['contain']); } @@ -284,7 +287,14 @@ class CRUDComponent extends Component $params['contain'][] = 'Tags'; $this->setAllTags(); } - $data = $this->Table->get($id, isset($params['get']) ? $params['get'] : $params); + $data = $this->Table->find()->where(['id' => $id]); + if (!empty($params['conditions'])) { + $data->where($params['conditions']); + } + $data = $data->first(); + if (empty($data)) { + throw new NotFoundException(__('Invalid {0}.', $this->ObjectAlias)); + } $data = $this->getMetaFields($id, $data); if (!empty($params['fields'])) { $this->Controller->set('fields', $params['fields']); @@ -414,11 +424,21 @@ class CRUDComponent extends Component $this->Controller->set('entity', $data); } - public function delete($id=false): void + public function delete($id=false, $params=[]): void { if ($this->request->is('get')) { if(!empty($id)) { - $data = $this->Table->get($id); + $data = $this->Table->find()->where([$this->Table->getAlias() . '.id' => $id]); + if (!empty($params['conditions'])) { + $data->where($params['conditions']); + } + if (!empty($params['contain'])) { + $data->contain($params['contain']); + } + $data = $data->first(); + if (empty($data)) { + throw new NotFoundException(__('Invalid {0}.', $this->ObjectAlias)); + } $this->Controller->set('id', $data['id']); $this->Controller->set('data', $data); $this->Controller->set('bulkEnabled', false); @@ -430,9 +450,20 @@ class CRUDComponent extends Component $isBulk = count($ids) > 1; $bulkSuccesses = 0; foreach ($ids as $id) { - $data = $this->Table->get($id); - $success = $this->Table->delete($data); - $success = true; + $data = $this->Table->find()->where([$this->Table->getAlias() . '.id' => $id]); + if (!empty($params['conditions'])) { + $data->where($params['conditions']); + } + if (!empty($params['contain'])) { + $data->contain($params['contain']); + } + $data = $data->first(); + if (!empty($data)) { + $success = $this->Table->delete($data); + $success = true; + } else { + $success = false; + } if ($success) { $bulkSuccesses++; } From 22e4a90af0047dc2dfa8095c71dcef6f1e1341c3 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 24 Nov 2021 01:32:05 +0100 Subject: [PATCH 042/150] chg: [ACL] tightened ACL for several controllers - org admins now have access to new functionalities, added ACL for them - Affected controllers: - Authkeys, encryptionkeys, users, sharinggroups - sets defaults/restricts access accordingly --- src/Controller/AuthKeysController.php | 24 +++++++++-- src/Controller/EncryptionKeysController.php | 36 ++++++++++++++++- src/Controller/SharingGroupsController.php | 19 +++++++-- src/Controller/UsersController.php | 44 ++++++++++++++++++--- 4 files changed, 108 insertions(+), 15 deletions(-) diff --git a/src/Controller/AuthKeysController.php b/src/Controller/AuthKeysController.php index 5f75b4a..9e8ac03 100644 --- a/src/Controller/AuthKeysController.php +++ b/src/Controller/AuthKeysController.php @@ -16,15 +16,25 @@ class AuthKeysController extends AppController { public $filterFields = ['Users.username', 'authkey', 'comment', 'Users.id']; public $quickFilterFields = ['authkey', ['comment' => true]]; - public $containFields = ['Users']; + public $containFields = ['Users' => ['fields' => ['id', 'username']]]; public function index() { + $currentUser = $this->ACL->getUser(); + $conditions = []; + if (empty($currentUser['role']['perm_admin'])) { + $conditions['Users.organisation_id'] = $currentUser['organisation_id']; + if (empty($currentUser['role']['perm_org_admin'])) { + $conditions['Users.id'] = $currentUser['id']; + } + } $this->CRUD->index([ 'filters' => $this->filterFields, 'quickFilters' => $this->quickFilterFields, 'contain' => $this->containFields, - 'exclude_fields' => ['authkey'] + 'exclude_fields' => ['authkey'], + 'conditions' => $conditions, + 'hidden' => [] ]); $responsePayload = $this->CRUD->getResponsePayload(); if (!empty($responsePayload)) { @@ -35,7 +45,15 @@ class AuthKeysController extends AppController public function delete($id) { - $this->CRUD->delete($id); + $currentUser = $this->ACL->getUser(); + $conditions = []; + if (empty($currentUser['role']['perm_admin'])) { + $conditions['Users.organisation_id'] = $currentUser['organisation_id']; + if (empty($currentUser['role']['perm_org_admin'])) { + $conditions['Users.id'] = $currentUser['id']; + } + } + $this->CRUD->delete($id, ['conditions' => $conditions, 'contain' => 'Users']); $responsePayload = $this->CRUD->getResponsePayload(); if (!empty($responsePayload)) { return $responsePayload; diff --git a/src/Controller/EncryptionKeysController.php b/src/Controller/EncryptionKeysController.php index 65183cb..78bec89 100644 --- a/src/Controller/EncryptionKeysController.php +++ b/src/Controller/EncryptionKeysController.php @@ -49,7 +49,31 @@ class EncryptionKeysController extends AppController public function add() { - $this->CRUD->add(['redirect' => $this->referer()]); + $orgConditions = []; + $currentUser = $this->ACL->getUser(); + $params = ['redirect' => $this->referer()]; + if (empty($currentUser['role']['perm_admin'])) { + $params['beforeSave'] = function($entity) { + if ($entity['owner_model'] === 'organisation') { + $entity['owner_id'] = $currentUser['organisation_id']; + } else { + if ($currentUser['role']['perm_org_admin']) { + $validIndividuals = $this->Organisations->Alignments->find('list', [ + 'fields' => ['distinct(individual_id)'], + 'conditions' => ['organisation_id' => $currentUser['organisation_id']] + ]); + if (!in_array($entity['owner_id'], $validIndividuals)) { + throw new MethodNotAllowedException(__('Selected individual cannot be linked by the current user.')); + } + } else { + if ($entity['owner_id'] !== $currentUser['id']) { + throw new MethodNotAllowedException(__('Selected individual cannot be linked by the current user.')); + } + } + } + }; + } + $this->CRUD->add($params); $responsePayload = $this->CRUD->getResponsePayload(); if (!empty($responsePayload)) { return $responsePayload; @@ -58,7 +82,8 @@ class EncryptionKeysController extends AppController $this->loadModel('Individuals'); $dropdownData = [ 'organisation' => $this->Organisations->find('list', [ - 'sort' => ['name' => 'asc'] + 'sort' => ['name' => 'asc'], + 'conditions' => $orgConditions ]), 'individual' => $this->Individuals->find('list', [ 'sort' => ['email' => 'asc'] @@ -70,12 +95,19 @@ class EncryptionKeysController extends AppController public function edit($id = false) { + $conditions = []; + $currentUser = $this->ACL->getUser(); $params = [ 'fields' => [ 'type', 'encryption_key', 'revoked' ], 'redirect' => $this->referer() ]; + if (empty($currentUser['role']['perm_admin'])) { + if (empty($currentUser['role']['perm_org_admin'])) { + + } + } $this->CRUD->edit($id, $params); $responsePayload = $this->CRUD->getResponsePayload(); if (!empty($responsePayload)) { diff --git a/src/Controller/SharingGroupsController.php b/src/Controller/SharingGroupsController.php index c8f8f79..4e98df8 100644 --- a/src/Controller/SharingGroupsController.php +++ b/src/Controller/SharingGroupsController.php @@ -16,10 +16,16 @@ class SharingGroupsController extends AppController public function index() { + $currentUser = $this->ACL->getUser(); + $conditions = []; + if (empty($currentUser['role']['perm_admin'])) { + $conditions['SharingGroups.organisation_id'] = $currentUser['organisation_id']; + } $this->CRUD->index([ 'contain' => $this->containFields, 'filters' => $this->filterFields, - 'quickFilters' => $this->quickFilterFields + 'quickFilters' => $this->quickFilterFields, + 'conditions' => $conditions ]); $responsePayload = $this->CRUD->getResponsePayload(); if (!empty($responsePayload)) { @@ -60,7 +66,12 @@ class SharingGroupsController extends AppController public function edit($id = false) { - $this->CRUD->edit($id); + $params = []; + $currentUser = $this->ACL->getUser(); + if (empty($currentUser['role']['perm_admin'])) { + $params['conditions'] = ['organisation_id' => $currentUser['organisation_id']]; + } + $this->CRUD->edit($id, $params); $responsePayload = $this->CRUD->getResponsePayload(); if (!empty($responsePayload)) { return $responsePayload; @@ -206,11 +217,11 @@ class SharingGroupsController extends AppController $organisations = []; if (!empty($user['role']['perm_admin'])) { $organisations = $this->SharingGroups->Organisations->find('list')->order(['name' => 'ASC'])->toArray(); - } else if (!empty($user['individual']['organisations'])) { + } else { $organisations = $this->SharingGroups->Organisations->find('list', [ 'sort' => ['name' => 'asc'], 'conditions' => [ - 'id IN' => array_values(\Cake\Utility\Hash::extract($user, 'individual.organisations.{n}.id')) + 'id' => $user['organisation_id'] ] ]); } diff --git a/src/Controller/UsersController.php b/src/Controller/UsersController.php index 012655e..0f81751 100644 --- a/src/Controller/UsersController.php +++ b/src/Controller/UsersController.php @@ -11,16 +11,22 @@ use Cake\Core\Configure; class UsersController extends AppController { - public $filterFields = ['Individuals.uuid', 'username', 'Individuals.email', 'Individuals.first_name', 'Individuals.last_name']; + public $filterFields = ['Individuals.uuid', 'username', 'Individuals.email', 'Individuals.first_name', 'Individuals.last_name', 'Organisations.name']; public $quickFilterFields = ['Individuals.uuid', ['username' => true], ['Individuals.first_name' => true], ['Individuals.last_name' => true], 'Individuals.email']; - public $containFields = ['Individuals', 'Roles', 'UserSettings']; + public $containFields = ['Individuals', 'Roles', 'UserSettings', 'Organisations']; public function index() { + $currentUser = $this->ACL->getUser(); + $conditions = []; + if (empty($currentUser['role']['perm_admin'])) { + $conditions['organisation_id'] = $currentUser['organisation_id']; + } $this->CRUD->index([ 'contain' => $this->containFields, 'filters' => $this->filterFields, 'quickFilters' => $this->quickFilterFields, + 'conditions' => $conditions ]); $responsePayload = $this->CRUD->getResponsePayload(); if (!empty($responsePayload)) { @@ -31,8 +37,12 @@ class UsersController extends AppController public function add() { + $currentUser = $this->ACL->getUser(); $this->CRUD->add([ - 'beforeSave' => function($data) { + 'beforeSave' => function($data) use ($currentUser) { + if (!$currentUser['role']['perm_admin']) { + $data['organisation_id'] = $currentUser['organisation_id']; + } $this->Users->enrollUserRouter($data); return $data; } @@ -41,12 +51,28 @@ class UsersController extends AppController if (!empty($responsePayload)) { return $responsePayload; } + /* + $alignments = $this->Users->Individuals->Alignments->find('list', [ + //'keyField' => 'id', + 'valueField' => 'organisation_id', + 'groupField' => 'individual_id' + ])->toArray(); + $alignments = array_map(function($value) { return array_values($value); }, $alignments); + */ + $org_conditions = []; + if (empty($currentUser['role']['perm_admin'])) { + $org_conditions = ['id' => $currentUser['organisation_id']]; + } $dropdownData = [ 'role' => $this->Users->Roles->find('list', [ 'sort' => ['name' => 'asc'] ]), 'individual' => $this->Users->Individuals->find('list', [ 'sort' => ['email' => 'asc'] + ]), + 'organisation' => $this->Users->Organisations->find('list', [ + 'sort' => ['name' => 'asc'], + 'conditions' => $org_conditions ]) ]; $this->set(compact('dropdownData')); @@ -59,7 +85,7 @@ class UsersController extends AppController $id = $this->ACL->getUser()['id']; } $this->CRUD->view($id, [ - 'contain' => ['Individuals' => ['Alignments' => 'Organisations'], 'Roles'] + 'contain' => ['Individuals' => ['Alignments' => 'Organisations'], 'Roles', 'Organisations'] ]); $responsePayload = $this->CRUD->getResponsePayload(); if (!empty($responsePayload)) { @@ -70,9 +96,11 @@ class UsersController extends AppController public function edit($id = false) { - if (empty($id) || empty($this->ACL->getUser()['role']['perm_admin'])) { - $id = $this->ACL->getUser()['id']; + $currentUser = $this->ACL->getUser(); + if (empty($id) || (empty($currentUser['role']['perm_org_admin']) && empty($currentUser['role']['perm_site_admin']))) { + $id = $currentUser['id']; } + $params = [ 'get' => [ 'fields' => [ @@ -88,6 +116,7 @@ class UsersController extends AppController ]; if (!empty($this->ACL->getUser()['role']['perm_admin'])) { $params['fields'][] = 'role_id'; + $params['fields'][] = 'organisation_id'; } $this->CRUD->edit($id, $params); $responsePayload = $this->CRUD->getResponsePayload(); @@ -100,6 +129,9 @@ class UsersController extends AppController ]), 'individual' => $this->Users->Individuals->find('list', [ 'sort' => ['email' => 'asc'] + ]), + 'organisation' => $this->Users->Organisations->find('list', [ + 'sort' => ['name' => 'asc'] ]) ]; $this->set(compact('dropdownData')); From 3cc857c42f4225b130ab3a6fda5dd8c36b526fac Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 24 Nov 2021 01:33:26 +0100 Subject: [PATCH 043/150] fix: [auditlog] use insert() rather than save() as that is not available in the behavior - fixes exception on logging deletes, blocking any actual deletions --- src/Model/Behavior/AuditLogBehavior.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Model/Behavior/AuditLogBehavior.php b/src/Model/Behavior/AuditLogBehavior.php index f7ef3df..26d354c 100644 --- a/src/Model/Behavior/AuditLogBehavior.php +++ b/src/Model/Behavior/AuditLogBehavior.php @@ -129,14 +129,13 @@ class AuditLogBehavior extends Behavior $modelTitle = $entity[$modelTitleField]; } - $logEntity = $this->auditLogs()->newEntity([ + $this->auditLogs()->insert([ 'request_action' => $entity->getConstant('ACTION_DELETE'), 'model' => $entity->getSource(), 'model_id' => $this->old->id, 'model_title' => $modelTitle, 'change' => $this->changedFields($entity) ]); - $logEntity->save(); } /** From 92fee87a7f30d97c0c661fd283fa614317811681 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 24 Nov 2021 01:34:15 +0100 Subject: [PATCH 044/150] fix: [keycloak] when enrolling users in keycloak, use the user organisation_id instead of the individual's first alias --- src/Model/Behavior/AuthKeycloakBehavior.php | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/Model/Behavior/AuthKeycloakBehavior.php b/src/Model/Behavior/AuthKeycloakBehavior.php index d42a8c9..4b46a87 100644 --- a/src/Model/Behavior/AuthKeycloakBehavior.php +++ b/src/Model/Behavior/AuthKeycloakBehavior.php @@ -98,7 +98,7 @@ class AuthKeycloakBehavior extends Behavior { $individual = $this->_table->Individuals->find()->where( ['id' => $data['individual_id']] - )->contain(['Organisations'])->first(); + )->first(); $roleConditions = [ 'id' => $data['role_id'] ]; @@ -106,10 +106,9 @@ class AuthKeycloakBehavior extends Behavior $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']; - } + $org = $this->_table->Organisations->find()->where([ + ['id' => $data['organisation_id']] + ]); $token = $this->getAdminAccessToken(); $keyCloakUser = [ 'firstName' => $individual['first_name'], @@ -118,7 +117,7 @@ class AuthKeycloakBehavior extends Behavior 'email' => $individual['email'], 'attributes' => [ 'role_name' => empty($role['name']) ? Configure::read('keycloak.default_role_name') : $role['name'], - 'org_uuid' => empty($orgs[0]) ? '' : $orgs[0] + 'org_uuid' => $orgs['uuid'] ] ]; $keycloakConfig = Configure::read('keycloak'); From bacb3dc85e4e0b31fecb190f1f16815da05790b9 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 24 Nov 2021 01:50:55 +0100 Subject: [PATCH 045/150] fix: [API] fixed broken API - don't call functions specifically meant for the UI when in an ACL context - also fixed breaking issues with the logging --- src/Controller/AppController.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Controller/AppController.php b/src/Controller/AppController.php index 46c5355..cdd85e2 100644 --- a/src/Controller/AppController.php +++ b/src/Controller/AppController.php @@ -113,8 +113,10 @@ class AppController extends Controller $this->ACL->setUser($user); $this->request->getSession()->write('authUser', $user); $this->isAdmin = $user['role']['perm_admin']; - $this->set('menu', $this->ACL->getMenu()); - $this->set('loggedUser', $this->ACL->getUser()); + if (!$this->ParamHandler->isRest()) { + $this->set('menu', $this->ACL->getMenu()); + $this->set('loggedUser', $this->ACL->getUser()); + } } else if ($this->ParamHandler->isRest()) { throw new MethodNotAllowedException(__('Invalid user credentials.')); } @@ -153,9 +155,8 @@ class AppController extends Controller if (!empty($authKey)) { $this->loadModel('Users'); $user = $this->Users->get($authKey['user_id']); - $user = $logModel->userInfo(); $logModel->insert([ - 'action' => 'login', + 'request_action' => 'login', 'model' => 'Users', 'model_id' => $user['id'], 'model_title' => $user['username'], @@ -167,7 +168,7 @@ class AppController extends Controller } else { $user = $logModel->userInfo(); $logModel->insert([ - 'action' => 'login', + 'request_action' => 'login', 'model' => 'Users', 'model_id' => $user['id'], 'model_title' => $user['name'], From 4bcdf9534acf33ee66f2dff51d5ec5f8168fa23d Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 24 Nov 2021 01:52:03 +0100 Subject: [PATCH 046/150] chg: [cakephp] version bump --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 42a49b2..bc562a0 100644 --- a/composer.json +++ b/composer.json @@ -9,7 +9,7 @@ "admad/cakephp-social-auth": "^1.1", "cakephp/authentication": "^2.0", "cakephp/authorization": "^2.0", - "cakephp/cakephp": "^4.0", + "cakephp/cakephp": "^4.3", "cakephp/migrations": "^3.0", "cakephp/plugin-installer": "^1.2", "erusev/parsedown": "^1.7", From c647ae95ebd8a5881fb4173f4055f450d1295013 Mon Sep 17 00:00:00 2001 From: Andras Iklody Date: Wed, 24 Nov 2021 13:44:12 +0100 Subject: [PATCH 047/150] fix: typo in mysql.sql --- INSTALL/mysql.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/INSTALL/mysql.sql b/INSTALL/mysql.sql index 6bd956c..936df41 100644 --- a/INSTALL/mysql.sql +++ b/INSTALL/mysql.sql @@ -408,7 +408,7 @@ CREATE TABLE IF NOT EXISTS `audit_logs` ( KEY `ip` (`ip`), KEY `model` (`model`), KEY `action` (`action`), - KEY `model_id` (`model_id`) + KEY `model_id` (`model_id`), KEY `created` (`created`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; From 8ca22760e029a4da62156c1d32878dd81b767673 Mon Sep 17 00:00:00 2001 From: Andras Iklody Date: Wed, 24 Nov 2021 13:47:10 +0100 Subject: [PATCH 048/150] fix: [mysql] create if exists rather than drop + create - made sense early in development, however, it no longer does --- INSTALL/mysql.sql | 51 +++++++++++++++++------------------------------ 1 file changed, 18 insertions(+), 33 deletions(-) diff --git a/INSTALL/mysql.sql b/INSTALL/mysql.sql index 936df41..799cda8 100644 --- a/INSTALL/mysql.sql +++ b/INSTALL/mysql.sql @@ -19,10 +19,9 @@ -- Table structure for table `alignment_tags` -- -DROP TABLE IF EXISTS `alignment_tags`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; -CREATE TABLE `alignment_tags` ( +CREATE TABLE IF NOT EXISTS `alignment_tags` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `alignment_id` int(10) unsigned NOT NULL, `tag_id` int(10) unsigned NOT NULL, @@ -48,10 +47,9 @@ CREATE TABLE `alignment_tags` ( -- Table structure for table `alignments` -- -DROP TABLE IF EXISTS `alignments`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; -CREATE TABLE `alignments` ( +CREATE TABLE IF NOT EXISTS `alignments` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `individual_id` int(10) unsigned NOT NULL, `organisation_id` int(10) unsigned NOT NULL, @@ -68,10 +66,9 @@ CREATE TABLE `alignments` ( -- Table structure for table `auth_keys` -- -DROP TABLE IF EXISTS `auth_keys`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; -CREATE TABLE `auth_keys` ( +CREATE TABLE IF NOT EXISTS `auth_keys` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `uuid` varchar(40) COLLATE utf8mb4_unicode_ci NOT NULL, `authkey` varchar(72) CHARACTER SET ascii DEFAULT NULL, @@ -94,10 +91,9 @@ CREATE TABLE `auth_keys` ( -- Table structure for table `broods` -- -DROP TABLE IF EXISTS `broods`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; -CREATE TABLE `broods` ( +CREATE TABLE IF NOT EXISTS `broods` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `uuid` varchar(40) CHARACTER SET ascii DEFAULT NULL, `name` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, @@ -122,10 +118,9 @@ CREATE TABLE `broods` ( -- Table structure for table `encryption_keys` -- -DROP TABLE IF EXISTS `encryption_keys`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; -CREATE TABLE `encryption_keys` ( +CREATE TABLE IF NOT EXISTS `encryption_keys` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `uuid` varchar(40) CHARACTER SET ascii DEFAULT NULL, `type` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, @@ -145,10 +140,9 @@ CREATE TABLE `encryption_keys` ( -- Table structure for table `individual_encryption_keys` -- -DROP TABLE IF EXISTS `individual_encryption_keys`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; -CREATE TABLE `individual_encryption_keys` ( +CREATE TABLE IF NOT EXISTS `individual_encryption_keys` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `individual_id` int(10) unsigned NOT NULL, `encryption_key_id` int(10) unsigned NOT NULL, @@ -168,10 +162,9 @@ CREATE TABLE `individual_encryption_keys` ( -- Table structure for table `individuals` -- -DROP TABLE IF EXISTS `individuals`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; -CREATE TABLE `individuals` ( +CREATE TABLE IF NOT EXISTS `individuals` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `uuid` varchar(40) CHARACTER SET ascii DEFAULT NULL, `email` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, @@ -190,8 +183,7 @@ CREATE TABLE `individuals` ( -- Table structure for table `local_tools` -- -DROP TABLE IF EXISTS `local_tools`; -CREATE TABLE `local_tools` ( +CREATE TABLE IF NOT EXISTS `local_tools` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `name` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, `connector` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, @@ -207,10 +199,9 @@ CREATE TABLE `local_tools` ( -- Table structure for table `organisation_encryption_keys` -- -DROP TABLE IF EXISTS `organisation_encryption_keys`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; -CREATE TABLE `organisation_encryption_keys` ( +CREATE TABLE IF NOT EXISTS `organisation_encryption_keys` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `organisation_id` int(10) unsigned NOT NULL, `encryption_key_id` int(10) unsigned NOT NULL, @@ -226,10 +217,9 @@ CREATE TABLE `organisation_encryption_keys` ( -- Table structure for table `organisations` -- -DROP TABLE IF EXISTS `organisations`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; -CREATE TABLE `organisations` ( +CREATE TABLE IF NOT EXISTS `organisations` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `uuid` varchar(40) CHARACTER SET ascii DEFAULT NULL, `name` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, @@ -252,10 +242,9 @@ CREATE TABLE `organisations` ( -- Table structure for table `roles` -- -DROP TABLE IF EXISTS `roles`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; -CREATE TABLE `roles` ( +CREATE TABLE IF NOT EXISTS `roles` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `uuid` varchar(40) CHARACTER SET ascii DEFAULT NULL, `name` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, @@ -271,10 +260,9 @@ CREATE TABLE `roles` ( -- Table structure for table `tags` -- -DROP TABLE IF EXISTS `tags`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; -CREATE TABLE `tags` ( +CREATE TABLE IF NOT EXISTS `tags` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `name` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, `description` text COLLATE utf8mb4_unicode_ci, @@ -288,10 +276,7 @@ CREATE TABLE `tags` ( -- Table structure for table `users` -- -DROP TABLE IF EXISTS `users`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `users` ( +CREATE TABLE IF NOT EXISTS `users` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `uuid` varchar(40) CHARACTER SET ascii DEFAULT NULL, `username` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL, @@ -309,7 +294,7 @@ CREATE TABLE `users` ( /*!40101 SET character_set_client = @saved_cs_client */; /*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; -CREATE TABLE `sharing_groups` ( +CREATE TABLE IF NOT EXISTS `sharing_groups` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `uuid` varchar(40) CHARACTER SET ascii DEFAULT NULL, `name` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, @@ -326,7 +311,7 @@ CREATE TABLE `sharing_groups` ( KEY `name` (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -CREATE TABLE `sgo` ( +CREATE TABLE IF NOT EXISTS `sgo` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `sharing_group_id` int(10) unsigned NOT NULL, `organisation_id` int(10) unsigned NOT NULL, @@ -336,7 +321,7 @@ CREATE TABLE `sgo` ( KEY `organisation_id` (`organisation_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -CREATE TABLE `meta_fields` ( +CREATE TABLE IF NOT EXISTS `meta_fields` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `scope` varchar(191) NOT NULL, `parent_id` int(10) unsigned NOT NULL, @@ -356,7 +341,7 @@ CREATE TABLE `meta_fields` ( KEY `meta_template_field_id` (`meta_template_field_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -CREATE TABLE `meta_templates` ( +CREATE TABLE IF NOT EXISTS `meta_templates` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `scope` varchar(191) NOT NULL, `name` varchar(191) NOT NULL, @@ -376,7 +361,7 @@ CREATE TABLE `meta_templates` ( KEY `uuid` (`uuid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -CREATE TABLE `meta_template_fields` ( +CREATE TABLE IF NOT EXISTS `meta_template_fields` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `field` varchar(191) NOT NULL, `type` varchar(191) NOT NULL, From 19b98a0df4b03380f9e5b5397c2c711f346a84ed Mon Sep 17 00:00:00 2001 From: Andras Iklody Date: Wed, 24 Nov 2021 13:48:20 +0100 Subject: [PATCH 049/150] fix: [mysql] renamed field without renaming the associated index --- INSTALL/mysql.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/INSTALL/mysql.sql b/INSTALL/mysql.sql index 799cda8..627a3b6 100644 --- a/INSTALL/mysql.sql +++ b/INSTALL/mysql.sql @@ -390,7 +390,7 @@ CREATE TABLE IF NOT EXISTS `audit_logs` ( `change` blob, PRIMARY KEY (`id`), KEY `user_id` (`user_id`), - KEY `ip` (`ip`), + KEY `request_ip` (`request_ip`), KEY `model` (`model`), KEY `action` (`action`), KEY `model_id` (`model_id`), From 2ac32911bb22ffe8f929812d8c398d16cb5c18b7 Mon Sep 17 00:00:00 2001 From: Andras Iklody Date: Wed, 24 Nov 2021 13:50:20 +0100 Subject: [PATCH 050/150] fix: [mysql] action field renamed without renaming the index --- INSTALL/mysql.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/INSTALL/mysql.sql b/INSTALL/mysql.sql index 627a3b6..ed39991 100644 --- a/INSTALL/mysql.sql +++ b/INSTALL/mysql.sql @@ -392,7 +392,7 @@ CREATE TABLE IF NOT EXISTS `audit_logs` ( KEY `user_id` (`user_id`), KEY `request_ip` (`request_ip`), KEY `model` (`model`), - KEY `action` (`action`), + KEY `request_action` (`request_action`), KEY `model_id` (`model_id`), KEY `created` (`created`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; From eb0a67327ae96a2b0f33f2d2e0d92cf5506da843 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 24 Nov 2021 14:46:34 +0100 Subject: [PATCH 051/150] fix: [initial user] generation fixed - requires a default organisation + org link now --- src/Model/Table/UsersTable.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Model/Table/UsersTable.php b/src/Model/Table/UsersTable.php index cb189aa..1b5d09f 100644 --- a/src/Model/Table/UsersTable.php +++ b/src/Model/Table/UsersTable.php @@ -12,6 +12,7 @@ use \Cake\Http\Session; use Cake\Http\Client; use Cake\Utility\Security; use Cake\Core\Configure; +use Cake\Utility\Text; class UsersTable extends AppTable { @@ -105,6 +106,12 @@ class UsersTable extends AppTable 'perm_admin' => 1 ]); $this->Roles->save($role); + $this->Organisations = TableRegistry::get('Organisations'); + $organisation = $this->Organisations->newEntity([ + 'name' => 'default_organisation', + 'uuid' => Text::uuid() + ]); + $this->Organisations->save($organisation); $this->Individuals = TableRegistry::get('Individuals'); $individual = $this->Individuals->newEntity([ 'email' => 'admin@admin.test', @@ -116,6 +123,7 @@ class UsersTable extends AppTable 'username' => 'admin', 'password' => 'Password1234', 'individual_id' => $individual->id, + 'oganisation_id' => $organisation->id, 'role_id' => $role->id ]); $this->save($user); From e8e1a16673a8aba557502e95a16a98b07e31c565 Mon Sep 17 00:00:00 2001 From: Sami Mokaddem Date: Wed, 24 Nov 2021 22:38:39 +0100 Subject: [PATCH 052/150] chg: [search_all] Added drafty support of meta-fields --- src/Model/Table/InstanceTable.php | 12 ++++++++++++ templates/Instance/search_all.php | 27 +++++++++++++++++++-------- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/src/Model/Table/InstanceTable.php b/src/Model/Table/InstanceTable.php index a625e79..95b965a 100644 --- a/src/Model/Table/InstanceTable.php +++ b/src/Model/Table/InstanceTable.php @@ -71,6 +71,18 @@ class InstanceTable extends AppTable public function searchAll($value, $limit=5, $model=null) { $results = []; + + // search in metafields. FIXME: To be replaced by the meta-template system + $metaFieldTable = TableRegistry::get('MetaFields'); + $query = $metaFieldTable->find()->where([ + 'value LIKE' => '%' . $value . '%' + ]); + $results['MetaFields']['amount'] = $query->count(); + $result = $query->limit($limit)->all()->toList(); + if (!empty($result)) { + $results['MetaFields']['entries'] = $result; + } + $models = $this->seachAllTables; if (!is_null($model)) { if (in_array($model, $this->seachAllTables)) { diff --git a/templates/Instance/search_all.php b/templates/Instance/search_all.php index 76c46f6..bd6bb17 100644 --- a/templates/Instance/search_all.php +++ b/templates/Instance/search_all.php @@ -19,14 +19,25 @@ ', h($tableName), $tableResult['amount']); foreach ($tableResult['entries'] as $entry) { - $section .= sprintf('%s', - Cake\Routing\Router::URL([ - 'controller' => Cake\Utility\Inflector::pluralize($entry->getSource()), - 'action' => 'view', - h($entry['id']) - ]), - h($entry[$fieldPath]) - ); + if ($entry->getSource() == 'MetaFields') { + $section .= sprintf('%s', + Cake\Routing\Router::URL([ + 'controller' => Cake\Utility\Inflector::pluralize($entry->scope), + 'action' => 'view', + h($entry->parent_id) + ]), + sprintf('%s (%s::%s)', h($entry->value), h($entry->scope), h($entry->field)) + ); + } else { + $section .= sprintf('%s', + 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) { From 999f4c8539d8d4eed134cdbe346671f76ac23dc8 Mon Sep 17 00:00:00 2001 From: Sami Mokaddem Date: Wed, 24 Nov 2021 22:49:40 +0100 Subject: [PATCH 053/150] fix: [migration:user_org] Fixed if org_id column does not exist --- config/Migrations/20211123152707_user_org.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config/Migrations/20211123152707_user_org.php b/config/Migrations/20211123152707_user_org.php index f615c98..a0772ef 100644 --- a/config/Migrations/20211123152707_user_org.php +++ b/config/Migrations/20211123152707_user_org.php @@ -18,16 +18,16 @@ final class UserOrg extends AbstractMigration */ public function change(): void { - $exists = $this->hasTable('users'); + $exists = $this->table('users')->hasColumn('organisation_id'); if (!$exists) { - $alignments = $this->table('users') + $this->table('users') ->addColumn('organisation_id', 'integer', [ 'default' => null, 'null' => true, 'signed' => false, 'length' => 10 ]) - ->addIndex('org_id') + ->addIndex('organisation_id') ->update(); } $q1 = $this->getQueryBuilder(); From 716f6b114750422e2bb96c1817a44f69575897e0 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 24 Nov 2021 23:24:04 +0100 Subject: [PATCH 054/150] fix: [default user creation] explicitly create UUIDs --- src/Model/Table/UsersTable.php | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/Model/Table/UsersTable.php b/src/Model/Table/UsersTable.php index 1b5d09f..37a76e1 100644 --- a/src/Model/Table/UsersTable.php +++ b/src/Model/Table/UsersTable.php @@ -97,13 +97,28 @@ class UsersTable extends AppTable return $rules; } + public function test() + { + $this->Roles = TableRegistry::get('Roles'); + $role = $this->Roles->newEntity([ + 'name' => 'admin', + 'perm_admin' => 1, + 'perm_org_admin' => 1, + 'perm_sync' => 1 + ]); + $this->Roles->save($role); + } + public function checkForNewInstance(): bool { if (empty($this->find()->first())) { $this->Roles = TableRegistry::get('Roles'); $role = $this->Roles->newEntity([ 'name' => 'admin', - 'perm_admin' => 1 + 'uuid' => Text::uuid(), + 'perm_admin' => 1, + 'perm_org_admin' => 1, + 'perm_sync' => 1 ]); $this->Roles->save($role); $this->Organisations = TableRegistry::get('Organisations'); @@ -115,12 +130,14 @@ class UsersTable extends AppTable $this->Individuals = TableRegistry::get('Individuals'); $individual = $this->Individuals->newEntity([ 'email' => 'admin@admin.test', + 'uuid' => Text::uuid(), 'first_name' => 'admin', 'last_name' => 'admin' ]); $this->Individuals->save($individual); $user = $this->newEntity([ 'username' => 'admin', + 'uuid' => Text::uuid(), 'password' => 'Password1234', 'individual_id' => $individual->id, 'oganisation_id' => $organisation->id, From c7768921fb98d9a3027b9a0eecf353c219807f9e Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 24 Nov 2021 23:32:17 +0100 Subject: [PATCH 055/150] fix: [user init] explicit uuid creation removed - added behavior wherever it was missing --- src/Model/Table/OrganisationsTable.php | 1 + src/Model/Table/UsersTable.php | 6 +----- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/Model/Table/OrganisationsTable.php b/src/Model/Table/OrganisationsTable.php index d4a99b9..4e77c99 100644 --- a/src/Model/Table/OrganisationsTable.php +++ b/src/Model/Table/OrganisationsTable.php @@ -21,6 +21,7 @@ class OrganisationsTable extends AppTable $this->addBehavior('Timestamp'); $this->addBehavior('Tags.Tag'); $this->addBehavior('AuditLog'); + $this->addBehavior('UUID'); $this->hasMany( 'Alignments', [ diff --git a/src/Model/Table/UsersTable.php b/src/Model/Table/UsersTable.php index 37a76e1..6b92527 100644 --- a/src/Model/Table/UsersTable.php +++ b/src/Model/Table/UsersTable.php @@ -115,7 +115,6 @@ class UsersTable extends AppTable $this->Roles = TableRegistry::get('Roles'); $role = $this->Roles->newEntity([ 'name' => 'admin', - 'uuid' => Text::uuid(), 'perm_admin' => 1, 'perm_org_admin' => 1, 'perm_sync' => 1 @@ -123,21 +122,18 @@ class UsersTable extends AppTable $this->Roles->save($role); $this->Organisations = TableRegistry::get('Organisations'); $organisation = $this->Organisations->newEntity([ - 'name' => 'default_organisation', - 'uuid' => Text::uuid() + 'name' => 'default_organisation' ]); $this->Organisations->save($organisation); $this->Individuals = TableRegistry::get('Individuals'); $individual = $this->Individuals->newEntity([ 'email' => 'admin@admin.test', - 'uuid' => Text::uuid(), 'first_name' => 'admin', 'last_name' => 'admin' ]); $this->Individuals->save($individual); $user = $this->newEntity([ 'username' => 'admin', - 'uuid' => Text::uuid(), 'password' => 'Password1234', 'individual_id' => $individual->id, 'oganisation_id' => $organisation->id, From 94457d3b97b262e88513b4db8aedc01cc2a8c935 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 24 Nov 2021 23:36:24 +0100 Subject: [PATCH 056/150] fix: [migration] userorg migration fixed --- config/Migrations/20211123152707_user_org.php | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/config/Migrations/20211123152707_user_org.php b/config/Migrations/20211123152707_user_org.php index f615c98..5543e98 100644 --- a/config/Migrations/20211123152707_user_org.php +++ b/config/Migrations/20211123152707_user_org.php @@ -1,7 +1,7 @@ hasTable('users'); - if (!$exists) { - $alignments = $this->table('users') - ->addColumn('organisation_id', 'integer', [ - 'default' => null, - 'null' => true, - 'signed' => false, - 'length' => 10 - ]) - ->addIndex('org_id') - ->update(); - } + $alignments = $this->table('users') + ->addColumn('organisation_id', 'integer', [ + 'default' => null, + 'null' => true, + 'signed' => false, + 'length' => 10 + ]) + ->addIndex('org_id') + ->update(); $q1 = $this->getQueryBuilder(); $org_id = $q1->select(['min(id)'])->from('organisations')->execute()->fetchAll()[0][0]; if (!empty($org_id)) { From b009191aa68f0320a174c5f5a719f6db323ee4b1 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 24 Nov 2021 23:39:27 +0100 Subject: [PATCH 057/150] fix: [migrations] user org further fixes --- config/Migrations/20211123152707_user_org.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/Migrations/20211123152707_user_org.php b/config/Migrations/20211123152707_user_org.php index 5543e98..0c5bc7a 100644 --- a/config/Migrations/20211123152707_user_org.php +++ b/config/Migrations/20211123152707_user_org.php @@ -25,7 +25,7 @@ final class UserOrg extends AbstractMigration 'signed' => false, 'length' => 10 ]) - ->addIndex('org_id') + ->addIndex('organisation_id') ->update(); $q1 = $this->getQueryBuilder(); $org_id = $q1->select(['min(id)'])->from('organisations')->execute()->fetchAll()[0][0]; From c2cefb4311021ad24b280cc2d7784cb2fca82b81 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 24 Nov 2021 23:59:34 +0100 Subject: [PATCH 058/150] fix: [user init] generation fixed --- src/Model/Entity/Organisation.php | 5 ----- src/Model/Table/OrganisationsTable.php | 6 +----- src/Model/Table/UsersTable.php | 3 ++- 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/src/Model/Entity/Organisation.php b/src/Model/Entity/Organisation.php index bc78378..6766963 100644 --- a/src/Model/Entity/Organisation.php +++ b/src/Model/Entity/Organisation.php @@ -10,10 +10,5 @@ class Organisation extends AppModel protected $_accessible = [ '*' => true, 'id' => false, - 'uuid' => false, - ]; - - protected $_accessibleOnNew = [ - 'uuid' => true, ]; } diff --git a/src/Model/Table/OrganisationsTable.php b/src/Model/Table/OrganisationsTable.php index 4e77c99..c56720e 100644 --- a/src/Model/Table/OrganisationsTable.php +++ b/src/Model/Table/OrganisationsTable.php @@ -11,17 +11,13 @@ class OrganisationsTable extends AppTable { public $metaFields = 'organisation'; - protected $_accessible = [ - 'id' => false - ]; - public function initialize(array $config): void { parent::initialize($config); + $this->addBehavior('UUID'); $this->addBehavior('Timestamp'); $this->addBehavior('Tags.Tag'); $this->addBehavior('AuditLog'); - $this->addBehavior('UUID'); $this->hasMany( 'Alignments', [ diff --git a/src/Model/Table/UsersTable.php b/src/Model/Table/UsersTable.php index 6b92527..2eebd8c 100644 --- a/src/Model/Table/UsersTable.php +++ b/src/Model/Table/UsersTable.php @@ -122,7 +122,8 @@ class UsersTable extends AppTable $this->Roles->save($role); $this->Organisations = TableRegistry::get('Organisations'); $organisation = $this->Organisations->newEntity([ - 'name' => 'default_organisation' + 'name' => 'default_organisation', + 'uuid' => Text::uuid() ]); $this->Organisations->save($organisation); $this->Individuals = TableRegistry::get('Individuals'); From 033f6d7f97cefb5c7dd1aa3f1687172f90a50493 Mon Sep 17 00:00:00 2001 From: iglocska Date: Thu, 25 Nov 2021 00:02:16 +0100 Subject: [PATCH 059/150] fix: [typo] organisations != oganisations --- src/Model/Table/UsersTable.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Model/Table/UsersTable.php b/src/Model/Table/UsersTable.php index 2eebd8c..61f06b8 100644 --- a/src/Model/Table/UsersTable.php +++ b/src/Model/Table/UsersTable.php @@ -137,7 +137,7 @@ class UsersTable extends AppTable 'username' => 'admin', 'password' => 'Password1234', 'individual_id' => $individual->id, - 'oganisation_id' => $organisation->id, + 'organisation_id' => $organisation->id, 'role_id' => $role->id ]); $this->save($user); From b981b3f942c8d12bf6b25ebe237f52b657f1e95b Mon Sep 17 00:00:00 2001 From: iglocska Date: Thu, 25 Nov 2021 00:43:22 +0100 Subject: [PATCH 060/150] fix: [conflict] resolved in user_org update script --- config/Migrations/20211123152707_user_org.php | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/config/Migrations/20211123152707_user_org.php b/config/Migrations/20211123152707_user_org.php index d51fd1e..47d8a5b 100644 --- a/config/Migrations/20211123152707_user_org.php +++ b/config/Migrations/20211123152707_user_org.php @@ -18,7 +18,6 @@ final class UserOrg extends AbstractMigration */ public function change(): void { -<<<<<<< HEAD $exists = $this->table('users')->hasColumn('organisation_id'); if (!$exists) { $this->table('users') @@ -31,17 +30,6 @@ final class UserOrg extends AbstractMigration ->addIndex('organisation_id') ->update(); } -======= - $alignments = $this->table('users') - ->addColumn('organisation_id', 'integer', [ - 'default' => null, - 'null' => true, - 'signed' => false, - 'length' => 10 - ]) - ->addIndex('organisation_id') - ->update(); ->>>>>>> main $q1 = $this->getQueryBuilder(); $org_id = $q1->select(['min(id)'])->from('organisations')->execute()->fetchAll()[0][0]; if (!empty($org_id)) { From a4f6e06e7aff9bec638abf08211a81b7ba0e647d Mon Sep 17 00:00:00 2001 From: iglocska Date: Thu, 25 Nov 2021 00:55:36 +0100 Subject: [PATCH 061/150] fix: [roles index] correctly allow site admins to modify / remove roles --- templates/Roles/index.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/Roles/index.php b/templates/Roles/index.php index 204d1d9..e710fd6 100644 --- a/templates/Roles/index.php +++ b/templates/Roles/index.php @@ -79,13 +79,13 @@ echo $this->element('genericElements/IndexTable/index_table', [ 'open_modal' => '/roles/edit/[onclick_params_data_path]', 'modal_params_data_path' => 'id', 'icon' => 'edit', - 'requirement' => !empty($loggedUser['role']['perm_site_admin']) + 'requirement' => !empty($loggedUser['role']['perm_admin']) ], [ 'open_modal' => '/roles/delete/[onclick_params_data_path]', 'modal_params_data_path' => 'id', 'icon' => 'trash', - 'requirement' => !empty($loggedUser['role']['perm_site_admin']) + 'requirement' => !empty($loggedUser['role']['perm_admin']) ], ] ] From cc5c750de8d01374b0540a380fb48a6e0e52dcd6 Mon Sep 17 00:00:00 2001 From: iglocska Date: Thu, 25 Nov 2021 00:57:31 +0100 Subject: [PATCH 062/150] chg: [audit log] change field renamed to changed - change is a reserved keyword - this way quoting of field names is no longer needed in the cakePHP settings --- .../20211124234433_audit_changed.php | 28 +++++++++++++++++++ src/Controller/AppController.php | 4 +-- src/Controller/AuditLogsController.php | 2 +- .../Component/RestResponseComponent.php | 4 +-- src/Controller/UsersController.php | 6 ++-- src/Model/Behavior/AuditLogBehavior.php | 4 +-- src/Model/Table/AuditLogsTable.php | 10 +++---- templates/AuditLogs/index.php | 6 ++-- 8 files changed, 46 insertions(+), 18 deletions(-) create mode 100644 config/Migrations/20211124234433_audit_changed.php diff --git a/config/Migrations/20211124234433_audit_changed.php b/config/Migrations/20211124234433_audit_changed.php new file mode 100644 index 0000000..607c630 --- /dev/null +++ b/config/Migrations/20211124234433_audit_changed.php @@ -0,0 +1,28 @@ +table('audit_logs')->hasColumn('change'); + if ($exists) { + $this->table('audit_logs') + ->renameColumn('change', 'changed') + ->update(); + } + } +} diff --git a/src/Controller/AppController.php b/src/Controller/AppController.php index cdd85e2..7afab70 100644 --- a/src/Controller/AppController.php +++ b/src/Controller/AppController.php @@ -160,7 +160,7 @@ class AppController extends Controller 'model' => 'Users', 'model_id' => $user['id'], 'model_title' => $user['username'], - 'change' => [] + 'changed' => [] ]); if (!empty($user)) { $this->Authentication->setIdentity($user); @@ -172,7 +172,7 @@ class AppController extends Controller 'model' => 'Users', 'model_id' => $user['id'], 'model_title' => $user['name'], - 'change' => [] + 'changed' => [] ]); } } diff --git a/src/Controller/AuditLogsController.php b/src/Controller/AuditLogsController.php index 9717416..27bee73 100644 --- a/src/Controller/AuditLogsController.php +++ b/src/Controller/AuditLogsController.php @@ -23,7 +23,7 @@ class AuditLogsController extends AppController 'quickFilters' => $this->quickFilterFields, 'afterFind' => function($data) { $data['request_ip'] = inet_ntop(stream_get_contents($data['request_ip'])); - $data['change'] = stream_get_contents($data['change']); + $data['changed'] = stream_get_contents($data['changed']); return $data; } ]); diff --git a/src/Controller/Component/RestResponseComponent.php b/src/Controller/Component/RestResponseComponent.php index 4a7346f..50fb40c 100644 --- a/src/Controller/Component/RestResponseComponent.php +++ b/src/Controller/Component/RestResponseComponent.php @@ -718,11 +718,11 @@ class RestResponseComponent extends Component 'operators' => array('equal'), 'help' => __('A valid x509 certificate ') ), - 'change' => array( + 'changed' => array( 'input' => 'text', 'type' => 'string', 'operators' => array('equal'), - 'help' => __('The text contained in the change field') + 'help' => __('The text contained in the changed field') ), 'change_pw' => array( 'input' => 'radio', diff --git a/src/Controller/UsersController.php b/src/Controller/UsersController.php index 0f81751..6331d7b 100644 --- a/src/Controller/UsersController.php +++ b/src/Controller/UsersController.php @@ -170,7 +170,7 @@ class UsersController extends AppController 'model' => 'Users', 'model_id' => $user['id'], 'model_title' => $user['name'], - 'change' => [] + 'changed' => [] ]); $target = $this->Authentication->getLoginRedirect() ?? '/instance/home'; return $this->redirect($target); @@ -181,7 +181,7 @@ class UsersController extends AppController 'model' => 'Users', 'model_id' => 0, 'model_title' => 'unknown_user', - 'change' => [] + 'changed' => [] ]); $this->Flash->error(__('Invalid username or password')); } @@ -199,7 +199,7 @@ class UsersController extends AppController 'model' => 'Users', 'model_id' => $user['id'], 'model_title' => $user['name'], - 'change' => [] + 'changed' => [] ]); $this->Authentication->logout(); $this->Flash->success(__('Goodbye.')); diff --git a/src/Model/Behavior/AuditLogBehavior.php b/src/Model/Behavior/AuditLogBehavior.php index 26d354c..55df5e8 100644 --- a/src/Model/Behavior/AuditLogBehavior.php +++ b/src/Model/Behavior/AuditLogBehavior.php @@ -110,7 +110,7 @@ class AuditLogBehavior extends Behavior 'model' => $entity->getSource(), 'model_id' => $id, 'model_title' => $modelTitle, - 'change' => $changedFields + 'changed' => $changedFields ]); } @@ -134,7 +134,7 @@ class AuditLogBehavior extends Behavior 'model' => $entity->getSource(), 'model_id' => $this->old->id, 'model_title' => $modelTitle, - 'change' => $this->changedFields($entity) + 'changed' => $this->changedFields($entity) ]); } diff --git a/src/Model/Table/AuditLogsTable.php b/src/Model/Table/AuditLogsTable.php index b65b5b2..efd6339 100644 --- a/src/Model/Table/AuditLogsTable.php +++ b/src/Model/Table/AuditLogsTable.php @@ -91,12 +91,12 @@ class AuditLogsTable extends AppTable $data['model_title'] = mb_substr($data['model_title'], 0, 252) . '...'; } - if (isset($data['change'])) { - $change = json_encode($data['change'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - if ($this->compressionEnabled && strlen($change) >= self::BROTLI_MIN_LENGTH) { - $change = self::BROTLI_HEADER . brotli_compress($change, 4, BROTLI_TEXT); + if (isset($data['changed'])) { + $changed = json_encode($data['changed'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($this->compressionEnabled && strlen($changed) >= self::BROTLI_MIN_LENGTH) { + $changed = self::BROTLI_HEADER . brotli_compress($changed, 4, BROTLI_TEXT); } - $data['change'] = $change; + $data['changed'] = $changed; } } diff --git a/templates/AuditLogs/index.php b/templates/AuditLogs/index.php index a121e4f..c4657cb 100644 --- a/templates/AuditLogs/index.php +++ b/templates/AuditLogs/index.php @@ -49,9 +49,9 @@ echo $this->element('genericElements/IndexTable/index_table', [ 'data_path' => 'request_action', ], [ - 'name' => __('Change'), - 'sort' => 'change', - 'data_path' => 'change', + 'name' => __('Changed'), + 'sort' => 'changed', + 'data_path' => 'changed', 'element' => 'json' ], ], From 312229751bab0a58bc2e164e1116922c864bfd59 Mon Sep 17 00:00:00 2001 From: iglocska Date: Thu, 25 Nov 2021 11:55:51 +0100 Subject: [PATCH 063/150] fix: [keycloak] enrollment org_id issues fixed --- src/Model/Behavior/AuthKeycloakBehavior.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Model/Behavior/AuthKeycloakBehavior.php b/src/Model/Behavior/AuthKeycloakBehavior.php index 4b46a87..12bb3e2 100644 --- a/src/Model/Behavior/AuthKeycloakBehavior.php +++ b/src/Model/Behavior/AuthKeycloakBehavior.php @@ -108,7 +108,7 @@ class AuthKeycloakBehavior extends Behavior $role = $this->_table->Roles->find()->where($roleConditions)->first(); $org = $this->_table->Organisations->find()->where([ ['id' => $data['organisation_id']] - ]); + ])->first(); $token = $this->getAdminAccessToken(); $keyCloakUser = [ 'firstName' => $individual['first_name'], @@ -117,7 +117,7 @@ class AuthKeycloakBehavior extends Behavior 'email' => $individual['email'], 'attributes' => [ 'role_name' => empty($role['name']) ? Configure::read('keycloak.default_role_name') : $role['name'], - 'org_uuid' => $orgs['uuid'] + 'org_uuid' => $org['uuid'] ] ]; $keycloakConfig = Configure::read('keycloak'); From 15d738aa775b251941ac3209a42c98499ea3ae73 Mon Sep 17 00:00:00 2001 From: iglocska Date: Fri, 26 Nov 2021 10:51:58 +0100 Subject: [PATCH 064/150] fix: [forms] dropdowns overriding values from request --- templates/element/genericElements/Form/Fields/dropdownField.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/element/genericElements/Form/Fields/dropdownField.php b/templates/element/genericElements/Form/Fields/dropdownField.php index 46b4c81..0bfc0e5 100644 --- a/templates/element/genericElements/Form/Fields/dropdownField.php +++ b/templates/element/genericElements/Form/Fields/dropdownField.php @@ -2,7 +2,7 @@ $controlParams = [ 'options' => $fieldData['options'], 'empty' => $fieldData['empty'] ?? false, - 'value' => $fieldData['value'] ?? [], + 'value' => $fieldData['value'] ?? null, 'multiple' => $fieldData['multiple'] ?? false, 'disabled' => $fieldData['disabled'] ?? false, 'class' => ($fieldData['class'] ?? '') . ' formDropdown form-select' From 2eb2459936382d20f3b2fda70449e9d13cb57533 Mon Sep 17 00:00:00 2001 From: iglocska Date: Fri, 26 Nov 2021 10:52:44 +0100 Subject: [PATCH 065/150] fix: [forms] added missing password form field --- .../element/genericElements/Form/Fields/passwordField.php | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 templates/element/genericElements/Form/Fields/passwordField.php diff --git a/templates/element/genericElements/Form/Fields/passwordField.php b/templates/element/genericElements/Form/Fields/passwordField.php new file mode 100644 index 0000000..6831ce6 --- /dev/null +++ b/templates/element/genericElements/Form/Fields/passwordField.php @@ -0,0 +1,6 @@ +FormFieldMassage->prepareFormElement($this->Form, $params, $fieldData); +?> From 2406e31b729d3bf000dddc26f182e80b996fc8ff Mon Sep 17 00:00:00 2001 From: iglocska Date: Fri, 26 Nov 2021 10:53:24 +0100 Subject: [PATCH 066/150] fix: [user add] form fixes --- templates/Users/add.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/templates/Users/add.php b/templates/Users/add.php index f215277..a3c90a8 100644 --- a/templates/Users/add.php +++ b/templates/Users/add.php @@ -25,7 +25,8 @@ 'label' => __('Password'), 'type' => 'password', 'required' => $this->request->getParam('action') === 'add' ? 'required' : false, - 'autocomplete' => 'new-password' + 'autocomplete' => 'new-password', + 'value' => '' ], [ 'field' => 'confirm_password', From 7fa0537cfd0577648b75092eea43131589b2485f Mon Sep 17 00:00:00 2001 From: iglocska Date: Sat, 27 Nov 2021 23:51:32 +0100 Subject: [PATCH 067/150] fix: [encryption keys] only show valid options when creating keys as a user --- src/Controller/EncryptionKeysController.php | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/Controller/EncryptionKeysController.php b/src/Controller/EncryptionKeysController.php index 78bec89..ae2a55b 100644 --- a/src/Controller/EncryptionKeysController.php +++ b/src/Controller/EncryptionKeysController.php @@ -50,16 +50,25 @@ class EncryptionKeysController extends AppController public function add() { $orgConditions = []; + $individualConditions = []; $currentUser = $this->ACL->getUser(); $params = ['redirect' => $this->referer()]; if (empty($currentUser['role']['perm_admin'])) { + $orgConditions = [ + 'id' => $currentUser['organisation_id'] + ]; + if (empty($currentUser['role']['perm_org_admin'])) { + $individualConditions = [ + 'id' => $currentUser['individual_id'] + ]; + } $params['beforeSave'] = function($entity) { if ($entity['owner_model'] === 'organisation') { $entity['owner_id'] = $currentUser['organisation_id']; } else { if ($currentUser['role']['perm_org_admin']) { - $validIndividuals = $this->Organisations->Alignments->find('list', [ - 'fields' => ['distinct(individual_id)'], + $validIndividuals = $this->Organisations->find('list', [ + 'fields' => ['distinct(id)'], 'conditions' => ['organisation_id' => $currentUser['organisation_id']] ]); if (!in_array($entity['owner_id'], $validIndividuals)) { @@ -86,7 +95,8 @@ class EncryptionKeysController extends AppController 'conditions' => $orgConditions ]), 'individual' => $this->Individuals->find('list', [ - 'sort' => ['email' => 'asc'] + 'sort' => ['email' => 'asc'], + 'conditions' => $individualConditions ]) ]; $this->set(compact('dropdownData')); From 22be309dc253c94e2815243a34c14d8a6df97810 Mon Sep 17 00:00:00 2001 From: iglocska Date: Sun, 28 Nov 2021 23:42:22 +0100 Subject: [PATCH 068/150] fix: [ACL] fix wildcard controller checks failing --- src/Controller/Component/ACLComponent.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Controller/Component/ACLComponent.php b/src/Controller/Component/ACLComponent.php index cd38752..ea0378f 100644 --- a/src/Controller/Component/ACLComponent.php +++ b/src/Controller/Component/ACLComponent.php @@ -267,9 +267,19 @@ class ACLComponent extends Component return true; } //$this->__checkLoggedActions($user, $controller, $action); + if (isset($this->aclList['*'][$action])) { + if ($this->evaluateAccessLeaf('*', $action)) { + return true; + } + } if (!isset($this->aclList[$controller])) { return $this->__error(404, __('Invalid controller.'), $soft); } + return $this->evaluateAccessLeaf($controller, $action); + } + + private function evaluateAccessLeaf(string $controller, string $action): bool + { if (isset($this->aclList[$controller][$action]) && !empty($this->aclList[$controller][$action])) { if (in_array('*', $this->aclList[$controller][$action])) { return true; From c7d40d42c70728defa37b26ea4c9d1c1c7728092 Mon Sep 17 00:00:00 2001 From: iglocska Date: Mon, 29 Nov 2021 23:37:41 +0100 Subject: [PATCH 069/150] fix: [ACL] added missing entries --- src/Controller/Component/ACLComponent.php | 32 +++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/Controller/Component/ACLComponent.php b/src/Controller/Component/ACLComponent.php index ea0378f..83cd348 100644 --- a/src/Controller/Component/ACLComponent.php +++ b/src/Controller/Component/ACLComponent.php @@ -45,6 +45,9 @@ class ACLComponent extends Component 'index' => ['*'], 'view' => ['*'] ], + 'AuditLogs' => [ + 'index' => ['perm_admin'] + ], 'AuthKeys' => [ 'add' => ['*'], 'delete' => ['*'], @@ -82,19 +85,27 @@ class ACLComponent extends Component 'add' => ['perm_admin'], 'delete' => ['perm_admin'], 'edit' => ['perm_admin'], + 'filtering' => ['*'], 'index' => ['*'], - 'view' => ['*'] + 'tag' => ['perm_tagger'], + 'untag' => ['perm_tagger'], + 'view' => ['*'], + 'viewTags' => ['*'] ], 'Instance' => [ 'home' => ['*'], 'migrate' => ['perm_admin'], 'migrationIndex' => ['perm_admin'], 'rollback' => ['perm_admin'], + 'saveSetting' => ['perm_admin'], + 'searchAll' => ['*'], + 'settings' => ['perm_admin'], 'status' => ['*'] ], 'LocalTools' => [ 'action' => ['perm_admin'], 'add' => ['perm_admin'], + 'batchAction' => ['perm_admin'], 'broodTools' => ['perm_admin'], 'connectionRequest' => ['perm_admin'], 'connectLocal' => ['perm_admin'], @@ -123,7 +134,10 @@ class ACLComponent extends Component 'edit' => ['perm_admin'], 'filtering' => ['*'], 'index' => ['*'], - 'view' => ['*'] + 'tag' => ['perm_tagger'], + 'untag' => ['perm_tagger'], + 'view' => ['*'], + 'viewTags' => ['*'] ], 'Outbox' => [ 'createEntry' => ['perm_admin'], @@ -162,8 +176,22 @@ class ACLComponent extends Component 'login' => ['*'], 'logout' => ['*'], 'register' => ['*'], + 'settings' => ['*'], 'toggle' => ['perm_org_admin'], 'view' => ['*'] + ], + 'UserSettings' => [ + 'index' => ['*'], + 'view' => ['*'], + 'add' => ['*'], + 'edit' => ['*'], + 'delete' => ['*'], + 'getSettingByName' => ['*'], + 'setSetting' => ['*'], + 'saveSetting' => ['*'], + 'getBookmarks' => ['*'], + 'saveBookmark' => ['*'], + 'deleteBookmark' => ['*'] ] ); From 392faa60e48205319c873a11535f343f1d938193 Mon Sep 17 00:00:00 2001 From: iglocska Date: Tue, 30 Nov 2021 00:00:05 +0100 Subject: [PATCH 070/150] new: [ACL] getRoleAccess endpoint added - prints all valid URLs for the current user's role --- src/Controller/AppController.php | 5 ++ src/Controller/Component/ACLComponent.php | 70 ++++++++++++----------- 2 files changed, 43 insertions(+), 32 deletions(-) diff --git a/src/Controller/AppController.php b/src/Controller/AppController.php index 7afab70..2b0102a 100644 --- a/src/Controller/AppController.php +++ b/src/Controller/AppController.php @@ -188,4 +188,9 @@ class AppController extends Controller { return $this->RestResponse->viewData($this->ACL->findMissingFunctionNames()); } + + public function getRoleAccess() + { + return $this->RestResponse->viewData($this->ACL->getRoleAccess()); + } } diff --git a/src/Controller/Component/ACLComponent.php b/src/Controller/Component/ACLComponent.php index 83cd348..cac1bc0 100644 --- a/src/Controller/Component/ACLComponent.php +++ b/src/Controller/Component/ACLComponent.php @@ -435,13 +435,19 @@ class ACLComponent extends Component return $missing; } + public function getRoleAccess($role = false) + { + $urls = $this->__checkRoleAccess($role); + return $urls; + } + public function printRoleAccess($content = false) { $results = []; - $this->Role = TableRegistry::get('Role'); + $this->Role = TableRegistry::get('Roles'); $conditions = []; if (is_numeric($content)) { - $conditions = array('Role.id' => $content); + $conditions = array('id' => $content); } $roles = $this->Role->find('all', array( 'recursive' => -1, @@ -457,40 +463,40 @@ class ACLComponent extends Component return $results; } - private function __checkRoleAccess($role) + private function __checkRoleAccess($role = false) { $result = []; - foreach ($this->__aclList as $controller => $actions) { - $controllerNames = Inflector::variable($controller) == Inflector::underscore($controller) ? array(Inflector::variable($controller)) : array(Inflector::variable($controller), Inflector::underscore($controller)); - foreach ($controllerNames as $controllerName) { - foreach ($actions as $action => $permissions) { - if ($role['perm_site_admin']) { - $result[] = DS . $controllerName . DS . $action; - } elseif (in_array('*', $permissions)) { - $result[] = DS . $controllerName . DS . $action . DS . '*'; - } elseif (isset($permissions['OR'])) { - $access = false; - foreach ($permissions['OR'] as $permission) { - if ($role[$permission]) { - $access = true; - } + if ($role === false) { + $role = $this->getUser()['role']; + } + foreach ($this->aclList as $controller => $actions) { + foreach ($actions as $action => $permissions) { + if ($role['perm_admin']) { + $result[] = DS . $controller . DS . $action; + } elseif (in_array('*', $permissions)) { + $result[] = DS . $controller . DS . $action . DS . '*'; + } elseif (isset($permissions['OR'])) { + $access = false; + foreach ($permissions['OR'] as $permission) { + if ($role[$permission]) { + $access = true; } - if ($access) { - $result[] = DS . $controllerName . DS . $action . DS . '*'; - } - } elseif (isset($permissions['AND'])) { - $access = true; - foreach ($permissions['AND'] as $permission) { - if ($role[$permission]) { - $access = false; - } - } - if ($access) { - $result[] = DS . $controllerName . DS . $action . DS . '*'; - } - } elseif (isset($permissions[0]) && $role[$permissions[0]]) { - $result[] = DS . $controllerName . DS . $action . DS . '*'; } + if ($access) { + $result[] = DS . $controller . DS . $action . DS . '*'; + } + } elseif (isset($permissions['AND'])) { + $access = true; + foreach ($permissions['AND'] as $permission) { + if ($role[$permission]) { + $access = false; + } + } + if ($access) { + $result[] = DS . $controller . DS . $action . DS . '*'; + } + } elseif (isset($permissions[0]) && $role[$permissions[0]]) { + $result[] = DS . $controller . DS . $action . DS . '*'; } } } From fbb1a52724663eda3350d5ac3484b0c314647057 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 1 Dec 2021 14:22:02 +0100 Subject: [PATCH 071/150] new: [ACL component] new functionalities - getRoleAccess now returns either URLs or arrays - array format allows for easy checking of controller + action pairs --- src/Controller/Component/ACLComponent.php | 32 +++++++++++++++-------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/src/Controller/Component/ACLComponent.php b/src/Controller/Component/ACLComponent.php index cac1bc0..afa70ff 100644 --- a/src/Controller/Component/ACLComponent.php +++ b/src/Controller/Component/ACLComponent.php @@ -37,6 +37,7 @@ class ACLComponent extends Component '*' => [ 'checkPermission' => ['*'], 'generateUUID' => ['*'], + 'getRoleAccess' => ['*'], 'queryACL' => ['perm_admin'] ], 'Alignments' => [ @@ -435,10 +436,9 @@ class ACLComponent extends Component return $missing; } - public function getRoleAccess($role = false) + public function getRoleAccess($role = false, $url_mode = true) { - $urls = $this->__checkRoleAccess($role); - return $urls; + return $this->__checkRoleAccess($role, $url_mode); } public function printRoleAccess($content = false) @@ -463,18 +463,28 @@ class ACLComponent extends Component return $results; } - private function __checkRoleAccess($role = false) + private function __formatControllerAction(array $results, string $controller, string $action, $url_mode = true): array { - $result = []; + if ($url_mode) { + $results[] = DS . $controller . DS . $action . DS . '*'; + } else { + $results[$controller][] = $action; + } + return $results; + } + + private function __checkRoleAccess($role = false, $url_mode = true) + { + $results = []; if ($role === false) { $role = $this->getUser()['role']; } foreach ($this->aclList as $controller => $actions) { foreach ($actions as $action => $permissions) { if ($role['perm_admin']) { - $result[] = DS . $controller . DS . $action; + $results = $this->__formatControllerAction($results, $controller, $action, $url_mode); } elseif (in_array('*', $permissions)) { - $result[] = DS . $controller . DS . $action . DS . '*'; + $results = $this->__formatControllerAction($results, $controller, $action, $url_mode); } elseif (isset($permissions['OR'])) { $access = false; foreach ($permissions['OR'] as $permission) { @@ -483,7 +493,7 @@ class ACLComponent extends Component } } if ($access) { - $result[] = DS . $controller . DS . $action . DS . '*'; + $results = $this->__formatControllerAction($results, $controller, $action, $url_mode); } } elseif (isset($permissions['AND'])) { $access = true; @@ -493,14 +503,14 @@ class ACLComponent extends Component } } if ($access) { - $result[] = DS . $controller . DS . $action . DS . '*'; + $results = $this->__formatControllerAction($results, $controller, $action, $url_mode); } } elseif (isset($permissions[0]) && $role[$permissions[0]]) { - $result[] = DS . $controller . DS . $action . DS . '*'; + $results = $this->__formatControllerAction($results, $controller, $action, $url_mode); } } } - return $result; + return $results; } public function getMenu() From e408f29a0552e0f2fbda7d84e8253b941a512bc2 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 1 Dec 2021 14:23:27 +0100 Subject: [PATCH 072/150] chg: [appcontroller] minor changes - getRoleAccess now returns array format - moved setting of view variables behind a rest check, to avoid additional unused actions for API queries - current user's role access matrix passed to view via "roleAccess" --- src/Controller/AppController.php | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/src/Controller/AppController.php b/src/Controller/AppController.php index 2b0102a..4d41bac 100644 --- a/src/Controller/AppController.php +++ b/src/Controller/AppController.php @@ -116,6 +116,7 @@ class AppController extends Controller if (!$this->ParamHandler->isRest()) { $this->set('menu', $this->ACL->getMenu()); $this->set('loggedUser', $this->ACL->getUser()); + $this->set('roleAccess', $this->ACL->getRoleAccess(false, false)); } } else if ($this->ParamHandler->isRest()) { throw new MethodNotAllowedException(__('Invalid user credentials.')); @@ -131,18 +132,20 @@ class AppController extends Controller } $this->ACL->checkAccess(); - $this->set('breadcrumb', $this->Navigation->getBreadcrumb()); - $this->set('ajax', $this->request->is('ajax')); - $this->request->getParam('prefix'); - $this->set('baseurl', Configure::read('App.fullBaseUrl')); - if (!empty($user) && !empty($user->user_settings_by_name['ui.bsTheme']['value'])) { - $this->set('bsTheme', $user->user_settings_by_name['ui.bsTheme']['value']); - } else { - $this->set('bsTheme', Configure::check('ui.bsTheme') ? Configure::read('ui.bsTheme') : 'default'); - } + if (!$this->ParamHandler->isRest()) { + $this->set('breadcrumb', $this->Navigation->getBreadcrumb()); + $this->set('ajax', $this->request->is('ajax')); + $this->request->getParam('prefix'); + $this->set('baseurl', Configure::read('App.fullBaseUrl')); + if (!empty($user) && !empty($user->user_settings_by_name['ui.bsTheme']['value'])) { + $this->set('bsTheme', $user->user_settings_by_name['ui.bsTheme']['value']); + } else { + $this->set('bsTheme', Configure::check('ui.bsTheme') ? Configure::read('ui.bsTheme') : 'default'); + } - if ($this->modelClass == 'Tags.Tags') { - $this->set('metaGroup', !empty($this->isAdmin) ? 'Administration' : 'Cerebrate'); + if ($this->modelClass == 'Tags.Tags') { + $this->set('metaGroup', !empty($this->isAdmin) ? 'Administration' : 'Cerebrate'); + } } } @@ -191,6 +194,6 @@ class AppController extends Controller public function getRoleAccess() { - return $this->RestResponse->viewData($this->ACL->getRoleAccess()); + return $this->RestResponse->viewData($this->ACL->getRoleAccess(false, false)); } } From 1e31f4d1dd8cf22784421ca455b362bfe9c85314 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 1 Dec 2021 14:25:34 +0100 Subject: [PATCH 073/150] new: [ACL Helper] check access for controller / action pair for given user - accesible everywhere in the UI --- src/View/AppView.php | 1 + src/View/Helper/ACLHelper.php | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 src/View/Helper/ACLHelper.php diff --git a/src/View/AppView.php b/src/View/AppView.php index 7636a87..9018168 100644 --- a/src/View/AppView.php +++ b/src/View/AppView.php @@ -44,5 +44,6 @@ class AppView extends View $this->loadHelper('FormFieldMassage'); $this->loadHelper('Paginator', ['templates' => 'cerebrate-pagination-templates']); $this->loadHelper('Tags.Tag'); + $this->loadHelper('ACL'); } } diff --git a/src/View/Helper/ACLHelper.php b/src/View/Helper/ACLHelper.php new file mode 100644 index 0000000..e563e82 --- /dev/null +++ b/src/View/Helper/ACLHelper.php @@ -0,0 +1,25 @@ +roleAccess)) { + $this->roleAccess = $this->getView()->get('roleAccess'); + } + if ( + in_array($action, $this->roleAccess['*']) || + (isset($this->roleAccess[$controller]) && in_array($action, $this->roleAccess[$controller])) + ) { + return true; + } else { + return false; + } + } +} From 332f374e01965cc9c44389de381f8d15866535ca Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 1 Dec 2021 14:26:20 +0100 Subject: [PATCH 074/150] chg: [sharing group index] add button now has the new checkaccess conditions applied --- templates/SharingGroups/index.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/templates/SharingGroups/index.php b/templates/SharingGroups/index.php index 2aae744..96315e6 100644 --- a/templates/SharingGroups/index.php +++ b/templates/SharingGroups/index.php @@ -10,7 +10,8 @@ echo $this->element('genericElements/IndexTable/index_table', [ 'data' => [ 'type' => 'simple', 'text' => __('Add sharing group'), - 'popover_url' => '/SharingGroups/add' + 'popover_url' => '/SharingGroups/add', + 'requirement' => $this->ACL->checkAccess('SharingGroups', 'add') ] ] ], From 4c7dc85d0e8a67a3fe0fcff6f2a7d2ef629b7ebe Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 1 Dec 2021 15:24:08 +0100 Subject: [PATCH 075/150] fix: [encryptions] fixed adding encryption keys --- src/Controller/EncryptionKeysController.php | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/Controller/EncryptionKeysController.php b/src/Controller/EncryptionKeysController.php index ae2a55b..bafe8ce 100644 --- a/src/Controller/EncryptionKeysController.php +++ b/src/Controller/EncryptionKeysController.php @@ -62,16 +62,18 @@ class EncryptionKeysController extends AppController 'id' => $currentUser['individual_id'] ]; } - $params['beforeSave'] = function($entity) { + $params['beforeSave'] = function($entity) use($currentUser) { if ($entity['owner_model'] === 'organisation') { $entity['owner_id'] = $currentUser['organisation_id']; } else { if ($currentUser['role']['perm_org_admin']) { - $validIndividuals = $this->Organisations->find('list', [ - 'fields' => ['distinct(id)'], + $this->loadModel('Alignments'); + $validIndividuals = $this->Alignments->find('list', [ + 'keyField' => 'individual_id', + 'valueField' => 'id', 'conditions' => ['organisation_id' => $currentUser['organisation_id']] - ]); - if (!in_array($entity['owner_id'], $validIndividuals)) { + ])->toArray(); + if (!isset($validIndividuals[$entity['owner_id']])) { throw new MethodNotAllowedException(__('Selected individual cannot be linked by the current user.')); } } else { @@ -80,6 +82,7 @@ class EncryptionKeysController extends AppController } } } + return $entity; }; } $this->CRUD->add($params); From 5041a57e08dc8cfc41d0d680c19d5c8b1c9a1d5f Mon Sep 17 00:00:00 2001 From: iglocska Date: Sat, 4 Dec 2021 23:58:42 +0100 Subject: [PATCH 076/150] fix: [sharing groups] index members column fixed --- templates/SharingGroups/index.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/SharingGroups/index.php b/templates/SharingGroups/index.php index 96315e6..946d19a 100644 --- a/templates/SharingGroups/index.php +++ b/templates/SharingGroups/index.php @@ -49,7 +49,7 @@ echo $this->element('genericElements/IndexTable/index_table', [ ], [ 'name' => __('Members'), - 'data_path' => 'alignments', + 'data_path' => 'sharing_group_orgs', 'element' => 'count_summary', 'url' => '/sharingGroups/view/{{id}}', 'url_data_path' => 'id' From bb3b264cfbed3e6e5b941cc276860579bbe950d7 Mon Sep 17 00:00:00 2001 From: iglocska Date: Sun, 5 Dec 2021 00:02:33 +0100 Subject: [PATCH 077/150] fix: [sharing group index] fixed members link --- templates/SharingGroups/index.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/SharingGroups/index.php b/templates/SharingGroups/index.php index 946d19a..c930bed 100644 --- a/templates/SharingGroups/index.php +++ b/templates/SharingGroups/index.php @@ -51,7 +51,7 @@ echo $this->element('genericElements/IndexTable/index_table', [ 'name' => __('Members'), 'data_path' => 'sharing_group_orgs', 'element' => 'count_summary', - 'url' => '/sharingGroups/view/{{id}}', + 'url' => '/sharingGroups/view/{{url_data}}', 'url_data_path' => 'id' ] ], From b9da6195386ce73c7675520eaa44c671db4f4c46 Mon Sep 17 00:00:00 2001 From: Sami Mokaddem Date: Thu, 16 Dec 2021 11:53:12 +0100 Subject: [PATCH 078/150] chg: [themes:packages] Replaced node-sass by dart-sass --- webroot/theme/package-lock.json | 1867 ++++++------------------------- webroot/theme/package.json | 7 +- 2 files changed, 349 insertions(+), 1525 deletions(-) diff --git a/webroot/theme/package-lock.json b/webroot/theme/package-lock.json index c19ec32..70d2d66 100644 --- a/webroot/theme/package-lock.json +++ b/webroot/theme/package-lock.json @@ -1,1555 +1,378 @@ { "name": "theme", "version": "1.0.0", - "lockfileVersion": 1, + "lockfileVersion": 2, "requires": true, - "dependencies": { - "@babel/code-frame": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.14.5.tgz", - "integrity": "sha512-9pzDqyc6OLDaqe+zbACgFkb6fKMNG6CObKpnYXChRsvYGyEdc7CA2BaqeOM+vOtCS5ndmJicPJhKAwYRI6UfFw==", - "requires": { - "@babel/highlight": "^7.14.5" - } - }, - "@babel/helper-validator-identifier": { - "version": "7.15.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.15.7.tgz", - "integrity": "sha512-K4JvCtQqad9OY2+yTU8w+E82ywk/fe+ELNlt1G8z3bVGlZfn/hOcQQsUhGhW/N+tb3fxK800wLtKOE/aM0m72w==" - }, - "@babel/highlight": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.14.5.tgz", - "integrity": "sha512-qf9u2WFWVV0MppaL877j2dBtQIDgmidgjGk5VIMw3OadXvYaXn66U1BFlH2t4+t3i+8PhedppRv+i40ABzd+gg==", - "requires": { - "@babel/helper-validator-identifier": "^7.14.5", - "chalk": "^2.0.0", - "js-tokens": "^4.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "requires": { - "color-convert": "^1.9.0" - } - }, - "chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - } - }, - "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "requires": { - "has-flag": "^3.0.0" - } - } - } - }, - "@types/minimist": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.2.tgz", - "integrity": "sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==" - }, - "@types/normalize-package-data": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz", - "integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==" - }, - "abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" - }, - "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "requires": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, - "amdefine": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", - "integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU=" - }, - "ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=" - }, - "ansi-styles": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", - "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=" - }, - "aproba": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", - "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==" - }, - "are-we-there-yet": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz", - "integrity": "sha512-nxwy40TuMiUGqMyRHgCSWZ9FM4VAoRP4xUYSTv5ImRog+h9yISPbVH7H8fASCIzYn9wlEv4zvFL7uKDMCFQm3g==", - "requires": { - "delegates": "^1.0.0", - "readable-stream": "^2.0.6" - } - }, - "arrify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", - "integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=" - }, - "asn1": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", - "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", - "requires": { - "safer-buffer": "~2.1.0" - } - }, - "assert-plus": { + "packages": { + "": { + "name": "theme", "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" - }, - "async-foreach": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/async-foreach/-/async-foreach-0.1.3.tgz", - "integrity": "sha1-NhIfhFwFeBct5Bmpfb6x0W7DRUI=" - }, - "asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" - }, - "aws-sign2": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" - }, - "aws4": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz", - "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==" - }, - "balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" - }, - "bcrypt-pbkdf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", - "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", - "requires": { - "tweetnacl": "^0.14.3" + "license": "ISC", + "dependencies": { + "bootstrap": "^5.1.1", + "sass": "^1.45.0" } }, + "node_modules/@popperjs/core": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.0.tgz", + "integrity": "sha512-zrsUxjLOKAzdewIDRWy9nsV1GQsKBCWaGwsZQlCgr6/q+vjyZhFgqedLfFBuI9anTPEUT4APq9Mu0SZBTzIcGQ==", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/anymatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", + "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "engines": { + "node": ">=8" + } + }, + "node_modules/bootstrap": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.1.3.tgz", + "integrity": "sha512-fcQztozJ8jToQWXxVuEyXWW+dSo8AiXWKwiSSrKWsRB/Qt+Ewwza+JWoLKiTuQLaEPhdNAJ7+Dosc9DOIqNy7Q==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + }, + "peerDependencies": { + "@popperjs/core": "^2.10.2" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.2.tgz", + "integrity": "sha512-ekGhOnNVPgT77r4K/U3GDhu+FQ2S8TnK/s2KbIGXi0SZWuwkZ2QNyfWdZW+TVfn84DpEP7rLeCt2UI6bJ8GwbQ==", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/immutable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.0.0.tgz", + "integrity": "sha512-zIE9hX70qew5qTUjSS7wi1iwj/l7+m54KWU247nhM3v806UdGj1yDndXj+IOYxxtW9zyLI+xqFNZjTuDaLUqFw==" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz", + "integrity": "sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/sass": { + "version": "1.45.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.45.0.tgz", + "integrity": "sha512-ONy5bjppoohtNkFJRqdz1gscXamMzN3wQy1YH9qO2FiNpgjLhpz/IPRGg0PpCjyz/pWfCOaNEaiEGCcjOFAjqw==", + "dependencies": { + "chokidar": ">=3.0.0 <4.0.0", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/source-map-js": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.1.tgz", + "integrity": "sha512-4+TN2b3tqOCd/kaGRJ/sTYA0tR0mdXx26ipdolxcwtJVqEnqNYvlCAt1q3ypy4QMlYus+Zh34RNtYLoq2oQ4IA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + } + }, + "dependencies": { + "@popperjs/core": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.0.tgz", + "integrity": "sha512-zrsUxjLOKAzdewIDRWy9nsV1GQsKBCWaGwsZQlCgr6/q+vjyZhFgqedLfFBuI9anTPEUT4APq9Mu0SZBTzIcGQ==", + "peer": true + }, + "anymatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", + "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", + "requires": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + } + }, + "binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==" + }, "bootstrap": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.1.1.tgz", - "integrity": "sha512-/jUa4sSuDZWlDLQ1gwQQR8uoYSvLJzDd8m5o6bPKh3asLAMYVZKdRCjb1joUd5WXf0WwCNzd2EjwQQhupou0dA==" + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.1.3.tgz", + "integrity": "sha512-fcQztozJ8jToQWXxVuEyXWW+dSo8AiXWKwiSSrKWsRB/Qt+Ewwza+JWoLKiTuQLaEPhdNAJ7+Dosc9DOIqNy7Q==", + "requires": {} }, - "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" - }, - "camelcase-keys": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-6.2.2.tgz", - "integrity": "sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==", - "requires": { - "camelcase": "^5.3.1", - "map-obj": "^4.0.0", - "quick-lru": "^4.0.1" - } - }, - "caseless": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" - }, - "chalk": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", - "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", - "requires": { - "ansi-styles": "^2.2.1", - "escape-string-regexp": "^1.0.2", - "has-ansi": "^2.0.0", - "strip-ansi": "^3.0.0", - "supports-color": "^2.0.0" - } - }, - "chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==" - }, - "cliui": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", - "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", - "requires": { - "string-width": "^3.1.0", - "strip-ansi": "^5.2.0", - "wrap-ansi": "^5.1.0" - }, - "dependencies": { - "ansi-regex": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", - "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==" - }, - "is-fullwidth-code-point": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=" - }, - "string-width": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", - "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", - "requires": { - "emoji-regex": "^7.0.1", - "is-fullwidth-code-point": "^2.0.0", - "strip-ansi": "^5.1.0" - } - }, - "strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", - "requires": { - "ansi-regex": "^4.1.0" - } - } - } - }, - "code-point-at": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", - "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=" - }, - "color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "requires": { - "color-name": "1.1.3" - } - }, - "color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" - }, - "combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "requires": { - "delayed-stream": "~1.0.0" - } - }, - "concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" - }, - "console-control-strings": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=" - }, - "core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" - }, - "cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "requires": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - } - }, - "dashdash": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", - "requires": { - "assert-plus": "^1.0.0" - } - }, - "decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=" - }, - "decamelize-keys": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.0.tgz", - "integrity": "sha1-0XGoeTMlKAfrPLYdwcFEXQeN8tk=", - "requires": { - "decamelize": "^1.1.0", - "map-obj": "^1.0.0" - }, - "dependencies": { - "map-obj": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", - "integrity": "sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=" - } - } - }, - "delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" - }, - "delegates": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=" - }, - "ecc-jsbn": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", - "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", - "requires": { - "jsbn": "~0.1.0", - "safer-buffer": "^2.1.0" - } - }, - "emoji-regex": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", - "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==" - }, - "env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==" - }, - "error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "requires": { - "is-arrayish": "^0.2.1" - } - }, - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" - }, - "extend": { + "braces": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" - }, - "extsprintf": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" - }, - "fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" - }, - "fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" - }, - "find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", "requires": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" + "fill-range": "^7.0.1" } }, - "forever-agent": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" - }, - "form-data": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", - "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "chokidar": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.2.tgz", + "integrity": "sha512-ekGhOnNVPgT77r4K/U3GDhu+FQ2S8TnK/s2KbIGXi0SZWuwkZ2QNyfWdZW+TVfn84DpEP7rLeCt2UI6bJ8GwbQ==", "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.6", - "mime-types": "^2.1.12" + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "fsevents": "~2.3.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" } }, - "fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "requires": { - "minipass": "^3.0.0" - } - }, - "fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" - }, - "function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" - }, - "gauge": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", - "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=", - "requires": { - "aproba": "^1.0.3", - "console-control-strings": "^1.0.0", - "has-unicode": "^2.0.0", - "object-assign": "^4.1.0", - "signal-exit": "^3.0.0", - "string-width": "^1.0.1", - "strip-ansi": "^3.0.1", - "wide-align": "^1.1.0" - } - }, - "gaze": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/gaze/-/gaze-1.1.3.tgz", - "integrity": "sha512-BRdNm8hbWzFzWHERTrejLqwHDfS4GibPoq5wjTPIoJHoBtKGPg3xAFfxmM+9ztbXelxcf2hwQcaz1PtmFeue8g==", - "requires": { - "globule": "^1.0.0" - } - }, - "get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" - }, - "get-stdin": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz", - "integrity": "sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4=" - }, - "getpass": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", - "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", - "requires": { - "assert-plus": "^1.0.0" - } - }, - "glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "globule": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/globule/-/globule-1.3.3.tgz", - "integrity": "sha512-mb1aYtDbIjTu4ShMB85m3UzjX9BVKe9WCzsnfMSZk+K5GpIbBOexgg4PPCt5eHDEG5/ZQAUX2Kct02zfiPLsKg==", - "requires": { - "glob": "~7.1.1", - "lodash": "~4.17.10", - "minimatch": "~3.0.2" - }, - "dependencies": { - "glob": { - "version": "7.1.7", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", - "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - } - } - }, - "graceful-fs": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.8.tgz", - "integrity": "sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg==" - }, - "har-schema": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", - "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=" - }, - "har-validator": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", - "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", - "requires": { - "ajv": "^6.12.3", - "har-schema": "^2.0.0" - } - }, - "hard-rejection": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", - "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==" - }, - "has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "requires": { - "function-bind": "^1.1.1" - } - }, - "has-ansi": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", - "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", - "requires": { - "ansi-regex": "^2.0.0" - } - }, - "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" - }, - "has-unicode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=" - }, - "hosted-git-info": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.0.2.tgz", - "integrity": "sha512-c9OGXbZ3guC/xOlCg1Ci/VgWlwsqDv1yMQL1CWqXDL0hDjXuNcq0zuR4xqPSuasI3kqFDhqSyTjREz5gzq0fXg==", - "requires": { - "lru-cache": "^6.0.0" - } - }, - "http-signature": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", - "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", - "requires": { - "assert-plus": "^1.0.0", - "jsprim": "^1.2.2", - "sshpk": "^1.7.0" - } - }, - "indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==" - }, - "inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", - "requires": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=" - }, - "is-core-module": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.7.0.tgz", - "integrity": "sha512-ByY+tjCciCr+9nLryBYcSD50EOGWt95c7tIsKTG1J2ixKKXPvF7Ej3AVd+UfDydAJom3biBGDBALaO79ktwgEQ==", - "requires": { - "has": "^1.0.3" - } - }, - "is-fullwidth-code-point": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", - "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", - "requires": { - "number-is-nan": "^1.0.0" - } - }, - "is-plain-obj": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", - "integrity": "sha1-caUMhCnfync8kqOQpKA7OfzVHT4=" - }, - "is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" - }, - "isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" - }, - "isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" - }, - "isstream": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" - }, - "js-base64": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.6.4.tgz", - "integrity": "sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ==" - }, - "js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" - }, - "jsbn": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=" - }, - "json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" - }, - "json-schema": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", - "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=" - }, - "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" - }, - "json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" - }, - "jsprim": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", - "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", - "requires": { - "assert-plus": "1.0.0", - "extsprintf": "1.3.0", - "json-schema": "0.2.3", - "verror": "1.10.0" - } - }, - "kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==" - }, - "lines-and-columns": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz", - "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=" - }, - "locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "requires": { - "p-locate": "^4.1.0" - } - }, - "lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" - }, - "lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "requires": { - "yallist": "^4.0.0" - } - }, - "map-obj": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", - "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==" - }, - "meow": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/meow/-/meow-9.0.0.tgz", - "integrity": "sha512-+obSblOQmRhcyBt62furQqRAQpNyWXo8BuQ5bN7dG8wmwQ+vwHKp/rCFD4CrTP8CsDQD1sjoZ94K417XEUk8IQ==", - "requires": { - "@types/minimist": "^1.2.0", - "camelcase-keys": "^6.2.2", - "decamelize": "^1.2.0", - "decamelize-keys": "^1.1.0", - "hard-rejection": "^2.1.0", - "minimist-options": "4.1.0", - "normalize-package-data": "^3.0.0", - "read-pkg-up": "^7.0.1", - "redent": "^3.0.0", - "trim-newlines": "^3.0.0", - "type-fest": "^0.18.0", - "yargs-parser": "^20.2.3" - } - }, - "mime-db": { - "version": "1.50.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.50.0.tgz", - "integrity": "sha512-9tMZCDlYHqeERXEHO9f/hKfNXhre5dK2eE/krIvUjZbS2KPcqGDfNShIWS1uW9XOTKQKqK6qbeOci18rbfW77A==" - }, - "mime-types": { - "version": "2.1.33", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.33.tgz", - "integrity": "sha512-plLElXp7pRDd0bNZHw+nMd52vRYjLwQjygaNg7ddJ2uJtTlmnTCjWuPKxVu6//AdaRuME84SvLW91sIkBqGT0g==", - "requires": { - "mime-db": "1.50.0" - } - }, - "min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==" - }, - "minimatch": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", - "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "minimist-options": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", - "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", - "requires": { - "arrify": "^1.0.1", - "is-plain-obj": "^1.1.0", - "kind-of": "^6.0.3" - } - }, - "minipass": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.1.5.tgz", - "integrity": "sha512-+8NzxD82XQoNKNrl1d/FSi+X8wAEWR+sbYAfIvub4Nz0d22plFG72CEVVaufV8PNf4qSslFTD8VMOxNVhHCjTw==", - "requires": { - "yallist": "^4.0.0" - } - }, - "minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "requires": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - } - }, - "mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" - }, - "nan": { - "version": "2.15.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.15.0.tgz", - "integrity": "sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==" - }, - "node-gyp": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-7.1.2.tgz", - "integrity": "sha512-CbpcIo7C3eMu3dL1c3d0xw449fHIGALIJsRP4DDPHpyiW8vcriNY7ubh9TE4zEKfSxscY7PjeFnshE7h75ynjQ==", - "requires": { - "env-paths": "^2.2.0", - "glob": "^7.1.4", - "graceful-fs": "^4.2.3", - "nopt": "^5.0.0", - "npmlog": "^4.1.2", - "request": "^2.88.2", - "rimraf": "^3.0.2", - "semver": "^7.3.2", - "tar": "^6.0.2", - "which": "^2.0.2" - } - }, - "node-sass": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/node-sass/-/node-sass-6.0.1.tgz", - "integrity": "sha512-f+Rbqt92Ful9gX0cGtdYwjTrWAaGURgaK5rZCWOgCNyGWusFYHhbqCCBoFBeat+HKETOU02AyTxNhJV0YZf2jQ==", - "requires": { - "async-foreach": "^0.1.3", - "chalk": "^1.1.1", - "cross-spawn": "^7.0.3", - "gaze": "^1.0.0", - "get-stdin": "^4.0.1", - "glob": "^7.0.3", - "lodash": "^4.17.15", - "meow": "^9.0.0", - "nan": "^2.13.2", - "node-gyp": "^7.1.0", - "npmlog": "^4.0.0", - "request": "^2.88.0", - "sass-graph": "2.2.5", - "stdout-stream": "^1.4.0", - "true-case-path": "^1.0.2" - } - }, - "nopt": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", - "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", - "requires": { - "abbrev": "1" - } - }, - "normalize-package-data": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", - "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==", - "requires": { - "hosted-git-info": "^4.0.1", - "is-core-module": "^2.5.0", - "semver": "^7.3.4", - "validate-npm-package-license": "^3.0.1" - } - }, - "npmlog": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", - "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", - "requires": { - "are-we-there-yet": "~1.1.2", - "console-control-strings": "~1.1.0", - "gauge": "~2.7.3", - "set-blocking": "~2.0.0" - } - }, - "number-is-nan": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", - "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=" - }, - "oauth-sign": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", - "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==" - }, - "object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" - }, - "once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "requires": { - "wrappy": "1" - } - }, - "p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "requires": { - "p-try": "^2.0.0" - } - }, - "p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "requires": { - "p-limit": "^2.2.0" - } - }, - "p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==" - }, - "parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "requires": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - } - }, - "path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" - }, - "path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" - }, - "path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" - }, - "path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" - }, - "performance-now": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" - }, - "process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" - }, - "psl": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", - "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==" - }, - "punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" - }, - "qs": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", - "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" - }, - "quick-lru": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", - "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==" - }, - "read-pkg": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", - "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", - "requires": { - "@types/normalize-package-data": "^2.4.0", - "normalize-package-data": "^2.5.0", - "parse-json": "^5.0.0", - "type-fest": "^0.6.0" - }, - "dependencies": { - "hosted-git-info": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", - "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==" - }, - "normalize-package-data": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", - "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", - "requires": { - "hosted-git-info": "^2.1.4", - "resolve": "^1.10.0", - "semver": "2 || 3 || 4 || 5", - "validate-npm-package-license": "^3.0.1" - } - }, - "semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" - }, - "type-fest": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", - "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==" - } - } - }, - "read-pkg-up": { + "fill-range": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", - "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", "requires": { - "find-up": "^4.1.0", - "read-pkg": "^5.2.0", - "type-fest": "^0.8.1" - }, - "dependencies": { - "type-fest": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", - "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==" - } + "to-regex-range": "^5.0.1" } }, - "readable-stream": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } + "fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "optional": true }, - "redent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", - "requires": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" - } - }, - "request": { - "version": "2.88.2", - "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", - "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", - "requires": { - "aws-sign2": "~0.7.0", - "aws4": "^1.8.0", - "caseless": "~0.12.0", - "combined-stream": "~1.0.6", - "extend": "~3.0.2", - "forever-agent": "~0.6.1", - "form-data": "~2.3.2", - "har-validator": "~5.1.3", - "http-signature": "~1.2.0", - "is-typedarray": "~1.0.0", - "isstream": "~0.1.2", - "json-stringify-safe": "~5.0.1", - "mime-types": "~2.1.19", - "oauth-sign": "~0.9.0", - "performance-now": "^2.1.0", - "qs": "~6.5.2", - "safe-buffer": "^5.1.2", - "tough-cookie": "~2.5.0", - "tunnel-agent": "^0.6.0", - "uuid": "^3.3.2" - } - }, - "require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=" - }, - "require-main-filename": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", - "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" - }, - "resolve": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz", - "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==", - "requires": { - "is-core-module": "^2.2.0", - "path-parse": "^1.0.6" - } - }, - "rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "requires": { - "glob": "^7.1.3" - } - }, - "safe-buffer": { + "glob-parent": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - }, - "safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, - "sass-graph": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/sass-graph/-/sass-graph-2.2.5.tgz", - "integrity": "sha512-VFWDAHOe6mRuT4mZRd4eKE+d8Uedrk6Xnh7Sh9b4NGufQLQjOrvf/MQoOdx+0s92L89FeyUUNfU597j/3uNpag==", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "requires": { - "glob": "^7.0.0", - "lodash": "^4.0.0", - "scss-tokenizer": "^0.2.3", - "yargs": "^13.3.2" + "is-glob": "^4.0.1" } }, - "scss-tokenizer": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/scss-tokenizer/-/scss-tokenizer-0.2.3.tgz", - "integrity": "sha1-jrBtualyMzOCTT9VMGQRSYR85dE=", - "requires": { - "js-base64": "^2.1.8", - "source-map": "^0.4.2" - } - }, - "semver": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", - "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", - "requires": { - "lru-cache": "^6.0.0" - } - }, - "set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=" - }, - "shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "requires": { - "shebang-regex": "^3.0.0" - } - }, - "shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" - }, - "signal-exit": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.5.tgz", - "integrity": "sha512-KWcOiKeQj6ZyXx7zq4YxSMgHRlod4czeBQZrPb8OKcohcqAXShm7E20kEMle9WBt26hFcAf0qLOcp5zmY7kOqQ==" - }, - "source-map": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz", - "integrity": "sha1-66T12pwNyZneaAMti092FzZSA2s=", - "requires": { - "amdefine": ">=0.0.4" - } - }, - "spdx-correct": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz", - "integrity": "sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==", - "requires": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" - } - }, - "spdx-exceptions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", - "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==" - }, - "spdx-expression-parse": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", - "requires": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "spdx-license-ids": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.10.tgz", - "integrity": "sha512-oie3/+gKf7QtpitB0LYLETe+k8SifzsX4KixvpOsbI6S0kRiRQ5MKOio8eMSAKQ17N06+wdEOXRiId+zOxo0hA==" - }, - "sshpk": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", - "integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==", - "requires": { - "asn1": "~0.2.3", - "assert-plus": "^1.0.0", - "bcrypt-pbkdf": "^1.0.0", - "dashdash": "^1.12.0", - "ecc-jsbn": "~0.1.1", - "getpass": "^0.1.1", - "jsbn": "~0.1.0", - "safer-buffer": "^2.0.2", - "tweetnacl": "~0.14.0" - } - }, - "stdout-stream": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/stdout-stream/-/stdout-stream-1.4.1.tgz", - "integrity": "sha512-j4emi03KXqJWcIeF8eIXkjMFN1Cmb8gUlDYGeBALLPo5qdyTfA9bOtl8m33lRoC+vFMkP3gl0WsDr6+gzxbbTA==", - "requires": { - "readable-stream": "^2.0.1" - } - }, - "string-width": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", - "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", - "requires": { - "code-point-at": "^1.0.0", - "is-fullwidth-code-point": "^1.0.0", - "strip-ansi": "^3.0.0" - } - }, - "string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "requires": { - "safe-buffer": "~5.1.0" - } - }, - "strip-ansi": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", - "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", - "requires": { - "ansi-regex": "^2.0.0" - } - }, - "strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "requires": { - "min-indent": "^1.0.0" - } - }, - "supports-color": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", - "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=" - }, - "tar": { - "version": "6.1.11", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz", - "integrity": "sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==", - "requires": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^3.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - } - }, - "tough-cookie": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", - "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", - "requires": { - "psl": "^1.1.28", - "punycode": "^2.1.1" - } - }, - "trim-newlines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", - "integrity": "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==" - }, - "true-case-path": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/true-case-path/-/true-case-path-1.0.3.tgz", - "integrity": "sha512-m6s2OdQe5wgpFMC+pAJ+q9djG82O2jcHPOI6RNg1yy9rCYR+WD6Nbpl32fDpfC56nirdRy+opFa/Vk7HYhqaew==", - "requires": { - "glob": "^7.1.2" - } - }, - "tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", - "requires": { - "safe-buffer": "^5.0.1" - } - }, - "tweetnacl": { - "version": "0.14.5", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" - }, - "type-fest": { - "version": "0.18.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz", - "integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==" - }, - "uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "requires": { - "punycode": "^2.1.0" - } - }, - "util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" - }, - "uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" - }, - "validate-npm-package-license": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", - "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", - "requires": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" - } - }, - "verror": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", - "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", - "requires": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" - }, - "dependencies": { - "core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" - } - } - }, - "which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "requires": { - "isexe": "^2.0.0" - } - }, - "which-module": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", - "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=" - }, - "wide-align": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", - "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", - "requires": { - "string-width": "^1.0.2 || 2" - } - }, - "wrap-ansi": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", - "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", - "requires": { - "ansi-styles": "^3.2.0", - "string-width": "^3.0.0", - "strip-ansi": "^5.0.0" - }, - "dependencies": { - "ansi-regex": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", - "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==" - }, - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "requires": { - "color-convert": "^1.9.0" - } - }, - "is-fullwidth-code-point": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=" - }, - "string-width": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", - "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", - "requires": { - "emoji-regex": "^7.0.1", - "is-fullwidth-code-point": "^2.0.0", - "strip-ansi": "^5.1.0" - } - }, - "strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", - "requires": { - "ansi-regex": "^4.1.0" - } - } - } - }, - "wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" - }, - "y18n": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==" - }, - "yallist": { + "immutable": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.0.0.tgz", + "integrity": "sha512-zIE9hX70qew5qTUjSS7wi1iwj/l7+m54KWU247nhM3v806UdGj1yDndXj+IOYxxtW9zyLI+xqFNZjTuDaLUqFw==" }, - "yargs": { - "version": "13.3.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", - "integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==", + "is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", "requires": { - "cliui": "^5.0.0", - "find-up": "^3.0.0", - "get-caller-file": "^2.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^3.0.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^13.1.2" - }, - "dependencies": { - "ansi-regex": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", - "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==" - }, - "find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", - "requires": { - "locate-path": "^3.0.0" - } - }, - "is-fullwidth-code-point": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=" - }, - "locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "requires": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" - } - }, - "p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", - "requires": { - "p-limit": "^2.0.0" - } - }, - "path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=" - }, - "string-width": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", - "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", - "requires": { - "emoji-regex": "^7.0.1", - "is-fullwidth-code-point": "^2.0.0", - "strip-ansi": "^5.1.0" - } - }, - "strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", - "requires": { - "ansi-regex": "^4.1.0" - } - }, - "yargs-parser": { - "version": "13.1.2", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz", - "integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==", - "requires": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" - } - } + "binary-extensions": "^2.0.0" } }, - "yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==" + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=" + }, + "is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==" + }, + "picomatch": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz", + "integrity": "sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==" + }, + "readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "requires": { + "picomatch": "^2.2.1" + } + }, + "sass": { + "version": "1.45.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.45.0.tgz", + "integrity": "sha512-ONy5bjppoohtNkFJRqdz1gscXamMzN3wQy1YH9qO2FiNpgjLhpz/IPRGg0PpCjyz/pWfCOaNEaiEGCcjOFAjqw==", + "requires": { + "chokidar": ">=3.0.0 <4.0.0", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" + } + }, + "source-map-js": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.1.tgz", + "integrity": "sha512-4+TN2b3tqOCd/kaGRJ/sTYA0tR0mdXx26ipdolxcwtJVqEnqNYvlCAt1q3ypy4QMlYus+Zh34RNtYLoq2oQ4IA==" + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "requires": { + "is-number": "^7.0.0" + } } } } diff --git a/webroot/theme/package.json b/webroot/theme/package.json index 476def3..69bfdc1 100644 --- a/webroot/theme/package.json +++ b/webroot/theme/package.json @@ -5,13 +5,14 @@ "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "autobuild": "node-sass --watch scss -o ../css/themes", - "build": "node-sass scss -o ../css/themes" + "autobuild": "sass --no-source-map --watch scss:../css/themes", + "build": "sass --no-source-map scss:../css/themes", + "build-with-map": "sass scss:../css/themes" }, "author": "", "license": "ISC", "dependencies": { "bootstrap": "^5.1.1", - "node-sass": "^6.0.1" + "sass": "^1.45.0" } } From e22068ec90cce7f80570f3fd328397967a332641 Mon Sep 17 00:00:00 2001 From: Sami Mokaddem Date: Thu, 16 Dec 2021 11:53:56 +0100 Subject: [PATCH 079/150] chg: [themes] Recompiled themes using dart-sass --- .../additional/bootstrap-additional.css | 315 +- webroot/css/themes/theme-darkly.css | 389 ++- webroot/css/themes/theme-default.css | 389 ++- webroot/css/themes/theme-flatly.css | 389 ++- webroot/css/themes/theme-minty.css | 389 ++- webroot/css/themes/theme-quartz.css | 1156 +++---- webroot/css/themes/theme-slate.css | 2698 ++++++++++------- webroot/css/themes/theme-vapor.css | 386 ++- 8 files changed, 3493 insertions(+), 2618 deletions(-) diff --git a/webroot/css/themes/additional/bootstrap-additional.css b/webroot/css/themes/additional/bootstrap-additional.css index 4a7c905..a0fc59f 100644 --- a/webroot/css/themes/additional/bootstrap-additional.css +++ b/webroot/css/themes/additional/bootstrap-additional.css @@ -1,284 +1,329 @@ /* Callout */ .callout { border: 1px solid #e9ecef; - border-radius: .25rem; + border-radius: 0.25rem; background-color: #fff; - box-shadow: 0 0 35px 0 rgba(154, 161, 171, 0.25); } + box-shadow: 0 0 35px 0 rgba(154, 161, 171, 0.25); +} .callout-primary { border-left-color: #0d6efd; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-secondary { border-left-color: #6c757d; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-success { border-left-color: #198754; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-info { border-left-color: #0dcaf0; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-warning { border-left-color: #ffc107; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-danger { border-left-color: #dc3545; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-light { border-left-color: #f8f9fa; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-dark { border-left-color: #212529; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} /* Toasts */ .toast { - min-width: 250px; } + min-width: 250px; +} .toast-primary { color: #04214c; background-color: #b6d4fe; - border-color: #9ec5fe; } - .toast-primary strong { - border-top-color: #85b6fe; } + border-color: #9ec5fe; +} +.toast-primary strong { + border-top-color: #85b6fe; +} .toast-secondary { color: #202326; background-color: #d3d6d8; - border-color: #c4c8cb; } - .toast-secondary strong { - border-top-color: #b6bbbf; } + border-color: #c4c8cb; +} +.toast-secondary strong { + border-top-color: #b6bbbf; +} .toast-success { color: #082919; background-color: #badbcc; - border-color: #a3cfbb; } - .toast-success strong { - border-top-color: #92c6af; } + border-color: #a3cfbb; +} +.toast-success strong { + border-top-color: #92c6af; +} .toast-info { color: #043d48; background-color: #b6effb; - border-color: #9eeaf9; } - .toast-info strong { - border-top-color: #86e5f8; } + border-color: #9eeaf9; +} +.toast-info strong { + border-top-color: #86e5f8; +} .toast-warning { color: #4d3a02; background-color: #ffecb5; - border-color: #ffe69c; } - .toast-warning strong { - border-top-color: #ffe083; } + border-color: #ffe69c; +} +.toast-warning strong { + border-top-color: #ffe083; +} .toast-danger { color: #421015; background-color: #f5c2c7; - border-color: #f1aeb5; } - .toast-danger strong { - border-top-color: #ed98a1; } + border-color: #f1aeb5; +} +.toast-danger strong { + border-top-color: #ed98a1; +} .toast-light { color: #4a4b4b; background-color: #fdfdfe; - border-color: #fcfdfd; } - .toast-light strong { - border-top-color: #edf3f3; } + border-color: #fcfdfd; +} +.toast-light strong { + border-top-color: #edf3f3; +} .toast-dark { color: #0a0b0c; background-color: #bcbebf; - border-color: #a6a8a9; } - .toast-dark strong { - border-top-color: #999b9c; } + border-color: #a6a8a9; +} +.toast-dark strong { + border-top-color: #999b9c; +} /* Dropdown-item */ .dropdown-item.dropdown-item-primary { color: #fff; text-decoration: none; - background-color: #0d6efd; } - + background-color: #0d6efd; +} .dropdown-item.dropdown-item-outline-primary:hover { color: #fff; - background-color: #0d6efd; } - + background-color: #0d6efd; +} .dropdown-item.dropdown-item-secondary { color: #fff; text-decoration: none; - background-color: #6c757d; } - + background-color: #6c757d; +} .dropdown-item.dropdown-item-outline-secondary:hover { color: #fff; - background-color: #6c757d; } - + background-color: #6c757d; +} .dropdown-item.dropdown-item-success { color: #fff; text-decoration: none; - background-color: #198754; } - + background-color: #198754; +} .dropdown-item.dropdown-item-outline-success:hover { color: #fff; - background-color: #198754; } - + background-color: #198754; +} .dropdown-item.dropdown-item-info { color: #000; text-decoration: none; - background-color: #0dcaf0; } - + background-color: #0dcaf0; +} .dropdown-item.dropdown-item-outline-info:hover { color: #000; - background-color: #0dcaf0; } - + background-color: #0dcaf0; +} .dropdown-item.dropdown-item-warning { color: #000; text-decoration: none; - background-color: #ffc107; } - + background-color: #ffc107; +} .dropdown-item.dropdown-item-outline-warning:hover { color: #000; - background-color: #ffc107; } - + background-color: #ffc107; +} .dropdown-item.dropdown-item-danger { color: #fff; text-decoration: none; - background-color: #dc3545; } - + background-color: #dc3545; +} .dropdown-item.dropdown-item-outline-danger:hover { color: #fff; - background-color: #dc3545; } - + background-color: #dc3545; +} .dropdown-item.dropdown-item-light { color: #000; text-decoration: none; - background-color: #f8f9fa; } - + background-color: #f8f9fa; +} .dropdown-item.dropdown-item-outline-light:hover { color: #000; - background-color: #f8f9fa; } - + background-color: #f8f9fa; +} .dropdown-item.dropdown-item-dark { color: #fff; text-decoration: none; - background-color: #212529; } - + background-color: #212529; +} .dropdown-item.dropdown-item-outline-dark:hover { color: #fff; - background-color: #212529; } + background-color: #212529; +} /* Progress Timeline */ .progress-timeline { - padding: 0.2em 0.2em 0.5em 0.2em; } - .progress-timeline ul { - position: relative; - padding: 0; } - .progress-timeline li { - list-style-type: none; - position: relative; } - .progress-timeline li.progress-inactive { - opacity: 0.5; } - .progress-timeline .progress-line { - height: 2px; } - .progress-timeline .progress-line.progress-inactive { - opacity: 0.5; } + padding: 0.2em 0.2em 0.5em 0.2em; +} +.progress-timeline ul { + position: relative; + padding: 0; +} +.progress-timeline li { + list-style-type: none; + position: relative; +} +.progress-timeline li.progress-inactive { + opacity: 0.5; +} +.progress-timeline .progress-line { + height: 2px; +} +.progress-timeline .progress-line.progress-inactive { + opacity: 0.5; +} /* Forms severity */ .form-control.is-invalid.info { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%230dcaf0' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%230dcaf0' stroke='none'/%3e%3c/svg%3e"); } - .form-control.is-invalid.info:focus { - border-color: #0dcaf0; - box-shadow: 0 0 0 0.25rem rgba(13, 202, 240, 0.25); } - + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%230dcaf0' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%230dcaf0' stroke='none'/%3e%3c/svg%3e"); +} +.form-control.is-invalid.info:focus { + border-color: #0dcaf0; + box-shadow: 0 0 0 0.25rem rgba(13, 202, 240, 0.25); +} .form-control.is-invalid.warning { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23ffc107' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23ffc107' stroke='none'/%3e%3c/svg%3e"); } - .form-control.is-invalid.warning:focus { - border-color: #ffc107; - box-shadow: 0 0 0 0.25rem rgba(255, 193, 7, 0.25); } + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23ffc107' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23ffc107' stroke='none'/%3e%3c/svg%3e"); +} +.form-control.is-invalid.warning:focus { + border-color: #ffc107; + box-shadow: 0 0 0 0.25rem rgba(255, 193, 7, 0.25); +} .form-select.is-invalid:not([multiple]):not([size]).info, -.form-select.is-invalid:not([multiple])[size="1"] -.form-select.is-invalid.info { +.form-select.is-invalid:not([multiple])[size="1"] .form-select.is-invalid.info { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%230dcaf0'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%230dcaf0' stroke='none'/%3e%3c/svg%3e"); - background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); } - .form-select.is-invalid:not([multiple]):not([size]).info:focus, - .form-select.is-invalid:not([multiple])[size="1"] -.form-select.is-invalid.info:focus { - box-shadow: 0 0 0 0.25rem rgba(13, 202, 240, 0.25); } - + background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); +} +.form-select.is-invalid:not([multiple]):not([size]).info:focus, +.form-select.is-invalid:not([multiple])[size="1"] .form-select.is-invalid.info:focus { + box-shadow: 0 0 0 0.25rem rgba(13, 202, 240, 0.25); +} .form-select.is-invalid:not([multiple]):not([size]).warning, -.form-select.is-invalid:not([multiple])[size="1"] -.form-select.is-invalid.warning { +.form-select.is-invalid:not([multiple])[size="1"] .form-select.is-invalid.warning { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23ffc107'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23ffc107' stroke='none'/%3e%3c/svg%3e"); - background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); } - .form-select.is-invalid:not([multiple]):not([size]).warning:focus, - .form-select.is-invalid:not([multiple])[size="1"] -.form-select.is-invalid.warning:focus { - box-shadow: 0 0 0 0.25rem rgba(255, 193, 7, 0.25); } + background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); +} +.form-select.is-invalid:not([multiple]):not([size]).warning:focus, +.form-select.is-invalid:not([multiple])[size="1"] .form-select.is-invalid.warning:focus { + box-shadow: 0 0 0 0.25rem rgba(255, 193, 7, 0.25); +} .form-check-input.is-invalid.info { - border-color: #0dcaf0; } - + border-color: #0dcaf0; +} .form-check-input.is-invalid.info:checked { - background-color: #0dcaf0; } - + background-color: #0dcaf0; +} .form-check-input.is-invalid.info ~ .form-check-label { - color: unset; } - + color: unset; +} .form-check-input.is-invalid.info:focus { - box-shadow: 0 0 0 0.2rem rgba(13, 202, 240, 0.25); } - + box-shadow: 0 0 0 0.2rem rgba(13, 202, 240, 0.25); +} .form-check-input.is-invalid.warning { - border-color: #ffc107; } - + border-color: #ffc107; +} .form-check-input.is-invalid.warning:checked { - background-color: #ffc107; } - + background-color: #ffc107; +} .form-check-input.is-invalid.warning ~ .form-check-label { - color: unset; } - + color: unset; +} .form-check-input.is-invalid.warning:focus { - box-shadow: 0 0 0 0.2rem rgba(255, 193, 7, 0.25); } + box-shadow: 0 0 0 0.2rem rgba(255, 193, 7, 0.25); +} /* Utilities */ .mw-75 { - max-width: 75% !important; } + max-width: 75% !important; +} .mw-50 { - max-width: 50% !important; } + max-width: 50% !important; +} .mw-25 { - max-width: 25% !important; } + max-width: 25% !important; +} .mh-75 { - max-height: 75% !important; } + max-height: 75% !important; +} .mh-50 { - max-height: 50% !important; } + max-height: 50% !important; +} .mh-25 { - max-height: 25% !important; } + max-height: 25% !important; +} .p-abs-center-y { top: 50%; - transform: translateY(-50%); } + transform: translateY(-50%); +} .p-abs-center-x { left: 50%; - transform: translateX(-50%); } + transform: translateX(-50%); +} .p-abs-center-both { top: 50%; left: 50%; - transform: translateX(-50%) translateY(-50%); } + transform: translateX(-50%) translateY(-50%); +} diff --git a/webroot/css/themes/theme-darkly.css b/webroot/css/themes/theme-darkly.css index 9c4511a..eb1924f 100644 --- a/webroot/css/themes/theme-darkly.css +++ b/webroot/css/themes/theme-darkly.css @@ -1,287 +1,332 @@ /* Callout */ .callout { border: 1px solid 1px solid none; - border-radius: .25rem; + border-radius: 0.25rem; background-color: #363636; - box-shadow: none; } + box-shadow: none; +} .callout-primary { border-left-color: #375a7f; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-secondary { border-left-color: #444; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-success { border-left-color: #00bc8c; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-info { border-left-color: #3498db; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-warning { border-left-color: #f39c12; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-danger { border-left-color: #e74c3c; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-light { border-left-color: #adb5bd; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-dark { border-left-color: #303030; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} /* Toasts */ .toast { - min-width: 250px; } + min-width: 250px; +} .toast-primary { color: #111b26; background-color: #c3ced9; - border-color: #afbdcc; } - .toast-primary strong { - border-top-color: #9fb0c2; } + border-color: #afbdcc; +} +.toast-primary strong { + border-top-color: #9fb0c2; +} .toast-secondary { color: #141414; background-color: #c7c7c7; - border-color: #b4b4b4; } - .toast-secondary strong { - border-top-color: #a7a7a7; } + border-color: #b4b4b4; +} +.toast-secondary strong { + border-top-color: #a7a7a7; +} .toast-success { color: #00382a; background-color: #b3ebdd; - border-color: #99e4d1; } - .toast-success strong { - border-top-color: #85dfc8; } + border-color: #99e4d1; +} +.toast-success strong { + border-top-color: #85dfc8; +} .toast-info { color: #102e42; background-color: #c2e0f4; - border-color: #aed6f1; } - .toast-info strong { - border-top-color: #98cbed; } + border-color: #aed6f1; +} +.toast-info strong { + border-top-color: #98cbed; +} .toast-warning { color: #492f05; background-color: #fbe1b8; - border-color: #fad7a0; } - .toast-warning strong { - border-top-color: #f9cd88; } + border-color: #fad7a0; +} +.toast-warning strong { + border-top-color: #f9cd88; +} .toast-danger { color: #451712; background-color: #f8c9c5; - border-color: #f5b7b1; } - .toast-danger strong { - border-top-color: #f2a29a; } + border-color: #f5b7b1; +} +.toast-danger strong { + border-top-color: #f2a29a; +} .toast-light { color: #343639; background-color: #e6e9eb; - border-color: #dee1e5; } - .toast-light strong { - border-top-color: #d0d4da; } + border-color: #dee1e5; +} +.toast-light strong { + border-top-color: #d0d4da; +} .toast-dark { color: #0e0e0e; background-color: #c1c1c1; - border-color: #acacac; } - .toast-dark strong { - border-top-color: #9f9f9f; } + border-color: #acacac; +} +.toast-dark strong { + border-top-color: #9f9f9f; +} /* Dropdown-item */ .dropdown-item.dropdown-item-primary { color: #fff; text-decoration: none; - background-color: #375a7f; } - + background-color: #375a7f; +} .dropdown-item.dropdown-item-outline-primary:hover { color: #fff; - background-color: #375a7f; } - + background-color: #375a7f; +} .dropdown-item.dropdown-item-secondary { color: #fff; text-decoration: none; - background-color: #444; } - + background-color: #444; +} .dropdown-item.dropdown-item-outline-secondary:hover { color: #fff; - background-color: #444; } - + background-color: #444; +} .dropdown-item.dropdown-item-success { color: #fff; text-decoration: none; - background-color: #00bc8c; } - + background-color: #00bc8c; +} .dropdown-item.dropdown-item-outline-success:hover { color: #fff; - background-color: #00bc8c; } - + background-color: #00bc8c; +} .dropdown-item.dropdown-item-info { color: #fff; text-decoration: none; - background-color: #3498db; } - + background-color: #3498db; +} .dropdown-item.dropdown-item-outline-info:hover { color: #fff; - background-color: #3498db; } - + background-color: #3498db; +} .dropdown-item.dropdown-item-warning { color: #fff; text-decoration: none; - background-color: #f39c12; } - + background-color: #f39c12; +} .dropdown-item.dropdown-item-outline-warning:hover { color: #fff; - background-color: #f39c12; } - + background-color: #f39c12; +} .dropdown-item.dropdown-item-danger { color: #fff; text-decoration: none; - background-color: #e74c3c; } - + background-color: #e74c3c; +} .dropdown-item.dropdown-item-outline-danger:hover { color: #fff; - background-color: #e74c3c; } - + background-color: #e74c3c; +} .dropdown-item.dropdown-item-light { color: #fff; text-decoration: none; - background-color: #adb5bd; } - + background-color: #adb5bd; +} .dropdown-item.dropdown-item-outline-light:hover { color: #fff; - background-color: #adb5bd; } - + background-color: #adb5bd; +} .dropdown-item.dropdown-item-dark { color: #fff; text-decoration: none; - background-color: #303030; } - + background-color: #303030; +} .dropdown-item.dropdown-item-outline-dark:hover { color: #fff; - background-color: #303030; } + background-color: #303030; +} /* Progress Timeline */ .progress-timeline { - padding: 0.2em 0.2em 0.5em 0.2em; } - .progress-timeline ul { - position: relative; - padding: 0; } - .progress-timeline li { - list-style-type: none; - position: relative; } - .progress-timeline li.progress-inactive { - opacity: 0.5; } - .progress-timeline .progress-line { - height: 2px; } - .progress-timeline .progress-line.progress-inactive { - opacity: 0.5; } + padding: 0.2em 0.2em 0.5em 0.2em; +} +.progress-timeline ul { + position: relative; + padding: 0; +} +.progress-timeline li { + list-style-type: none; + position: relative; +} +.progress-timeline li.progress-inactive { + opacity: 0.5; +} +.progress-timeline .progress-line { + height: 2px; +} +.progress-timeline .progress-line.progress-inactive { + opacity: 0.5; +} /* Forms severity */ .form-control.is-invalid.info { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%233498db' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%233498db' stroke='none'/%3e%3c/svg%3e"); } - .form-control.is-invalid.info:focus { - border-color: #3498db; - box-shadow: 0 0 0 0.25rem rgba(52, 152, 219, 0.25); } - + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%233498db' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%233498db' stroke='none'/%3e%3c/svg%3e"); +} +.form-control.is-invalid.info:focus { + border-color: #3498db; + box-shadow: 0 0 0 0.25rem rgba(52, 152, 219, 0.25); +} .form-control.is-invalid.warning { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23f39c12' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23f39c12' stroke='none'/%3e%3c/svg%3e"); } - .form-control.is-invalid.warning:focus { - border-color: #f39c12; - box-shadow: 0 0 0 0.25rem rgba(243, 156, 18, 0.25); } + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23f39c12' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23f39c12' stroke='none'/%3e%3c/svg%3e"); +} +.form-control.is-invalid.warning:focus { + border-color: #f39c12; + box-shadow: 0 0 0 0.25rem rgba(243, 156, 18, 0.25); +} .form-select.is-invalid:not([multiple]):not([size]).info, -.form-select.is-invalid:not([multiple])[size="1"] -.form-select.is-invalid.info { +.form-select.is-invalid:not([multiple])[size="1"] .form-select.is-invalid.info { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%233498db'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%233498db' stroke='none'/%3e%3c/svg%3e"); - background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); } - .form-select.is-invalid:not([multiple]):not([size]).info:focus, - .form-select.is-invalid:not([multiple])[size="1"] -.form-select.is-invalid.info:focus { - box-shadow: 0 0 0 0.25rem rgba(52, 152, 219, 0.25); } - + background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); +} +.form-select.is-invalid:not([multiple]):not([size]).info:focus, +.form-select.is-invalid:not([multiple])[size="1"] .form-select.is-invalid.info:focus { + box-shadow: 0 0 0 0.25rem rgba(52, 152, 219, 0.25); +} .form-select.is-invalid:not([multiple]):not([size]).warning, -.form-select.is-invalid:not([multiple])[size="1"] -.form-select.is-invalid.warning { +.form-select.is-invalid:not([multiple])[size="1"] .form-select.is-invalid.warning { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23f39c12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23f39c12' stroke='none'/%3e%3c/svg%3e"); - background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); } - .form-select.is-invalid:not([multiple]):not([size]).warning:focus, - .form-select.is-invalid:not([multiple])[size="1"] -.form-select.is-invalid.warning:focus { - box-shadow: 0 0 0 0.25rem rgba(243, 156, 18, 0.25); } + background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); +} +.form-select.is-invalid:not([multiple]):not([size]).warning:focus, +.form-select.is-invalid:not([multiple])[size="1"] .form-select.is-invalid.warning:focus { + box-shadow: 0 0 0 0.25rem rgba(243, 156, 18, 0.25); +} .form-check-input.is-invalid.info { - border-color: #3498db; } - + border-color: #3498db; +} .form-check-input.is-invalid.info:checked { - background-color: #3498db; } - + background-color: #3498db; +} .form-check-input.is-invalid.info ~ .form-check-label { - color: unset; } - + color: unset; +} .form-check-input.is-invalid.info:focus { - box-shadow: 0 0 0 0.2rem rgba(52, 152, 219, 0.25); } - + box-shadow: 0 0 0 0.2rem rgba(52, 152, 219, 0.25); +} .form-check-input.is-invalid.warning { - border-color: #f39c12; } - + border-color: #f39c12; +} .form-check-input.is-invalid.warning:checked { - background-color: #f39c12; } - + background-color: #f39c12; +} .form-check-input.is-invalid.warning ~ .form-check-label { - color: unset; } - + color: unset; +} .form-check-input.is-invalid.warning:focus { - box-shadow: 0 0 0 0.2rem rgba(243, 156, 18, 0.25); } + box-shadow: 0 0 0 0.2rem rgba(243, 156, 18, 0.25); +} /* Utilities */ .mw-75 { - max-width: 75% !important; } + max-width: 75% !important; +} .mw-50 { - max-width: 50% !important; } + max-width: 50% !important; +} .mw-25 { - max-width: 25% !important; } + max-width: 25% !important; +} .mh-75 { - max-height: 75% !important; } + max-height: 75% !important; +} .mh-50 { - max-height: 50% !important; } + max-height: 50% !important; +} .mh-25 { - max-height: 25% !important; } + max-height: 25% !important; +} .p-abs-center-y { top: 50%; - transform: translateY(-50%); } + transform: translateY(-50%); +} .p-abs-center-x { left: 50%; - transform: translateX(-50%); } + transform: translateX(-50%); +} .p-abs-center-both { top: 50%; left: 50%; - transform: translateX(-50%) translateY(-50%); } + transform: translateX(-50%) translateY(-50%); +} /* Body */ body { @@ -290,87 +335,111 @@ body { /* background by SVGBackgrounds.com */ background-attachment: fixed; background-size: cover; - background-blend-mode: soft-light; } + background-blend-mode: soft-light; +} .panel { background-color: #363636; border: 1px solid #454545; - box-shadow: none; } + box-shadow: none; +} .loading-overlay { background-color: #222; - opacity: 0.65; } + opacity: 0.65; +} /* Top navbar */ .top-navbar { - background-color: #375a7f; } + background-color: #375a7f; +} .center-navbar nav.header-breadcrumb { - color: #fff; } + color: #fff; +} header.top-navbar .header-menu > a:hover, header.top-navbar .header-breadcrumb .header-breadcrumb-item > a:hover { - color: #d6d6d6 !important; } + color: #d6d6d6 !important; +} .top-navbar .center-navbar nav.header-breadcrumb li.header-breadcrumb-item a { - color: #fff; } + color: #fff; +} .top-navbar .right-navbar .header-menu a.nav-link { - color: #fff; } + color: #fff; +} .top-navbar .left-navbar .navbar-brand img { - filter: invert(1); } + filter: invert(1); +} .top-navbar .left-navbar .navbar-brand:hover img { - filter: invert(1) drop-shadow(0px 0px 3px #fff); } + filter: invert(1) drop-shadow(0px 0px 3px #fff); +} .top-navbar .composed-app-icon-container > .app-icon { - background-color: #fff; } + background-color: #fff; +} .breadcrumb-link-container { box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.16), 0 2px 6px 0 rgba(0, 0, 0, 0.12); - background-color: #adb5bd; } + background-color: #adb5bd; +} /* Sidebar */ .sidebar { - transition: width .08s linear; + transition: width 0.08s linear; box-shadow: none; - background-color: #444; } + background-color: #444; +} .sidebar ~ main.content:after { - background: #000; } + background: #000; +} .sidebar .sidebar-wrapper { - border-right: 1px solid none; } + border-right: 1px solid none; +} .sidebar .sidebar-wrapper { - border-right: 1px solid rgba(0, 0, 0, 0.125); } + border-right: 1px solid rgba(0, 0, 0, 0.125); +} .sidebar ul.sidebar-elements li > a.sidebar-link { - color: #fff; } + color: #fff; +} .sidebar ul.sidebar-elements li > a.sidebar-link.active { background-color: #595f64; - color: #fff; } + color: #fff; +} .sidebar ul.sidebar-elements li > a.sidebar-link.have-active-child { background-color: #595f64; - color: #fff; } + color: #fff; +} .sidebar ul.sidebar-elements li > a.sidebar-link:hover { background-color: #60676c; - color: #fff; } + color: #fff; +} .sidebar.expanded ul.sidebar-elements li > a.sidebar-link.have-active-child, .sidebar:hover ul.sidebar-elements li > a.sidebar-link.have-active-child { - background-color: unset; } + background-color: unset; +} .sidebar.expanded ul.sidebar-elements li > a.sidebar-link.have-active-child:hover, .sidebar:hover ul.sidebar-elements li > a.sidebar-link.have-active-child:hover { - background-color: #60676c; } + background-color: #60676c; +} ul.sidebar-elements li > a.sidebar-link.active::after { - background-color: var(--cerebrate-color); } + background-color: var(--cerebrate-color); +} .lock-sidebar > a.btn { - background-color: unset; } + background-color: unset; +} diff --git a/webroot/css/themes/theme-default.css b/webroot/css/themes/theme-default.css index d826d86..a43c429 100644 --- a/webroot/css/themes/theme-default.css +++ b/webroot/css/themes/theme-default.css @@ -1,287 +1,332 @@ /* Callout */ .callout { border: 1px solid #e9ecef; - border-radius: .25rem; + border-radius: 0.25rem; background-color: #fff; - box-shadow: 0 0 35px 0 rgba(154, 161, 171, 0.25); } + box-shadow: 0 0 35px 0 rgba(154, 161, 171, 0.25); +} .callout-primary { border-left-color: #0d6efd; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-secondary { border-left-color: #6c757d; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-success { border-left-color: #198754; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-info { border-left-color: #0dcaf0; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-warning { border-left-color: #ffc107; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-danger { border-left-color: #dc3545; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-light { border-left-color: #f8f9fa; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-dark { border-left-color: #212529; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} /* Toasts */ .toast { - min-width: 250px; } + min-width: 250px; +} .toast-primary { color: #04214c; background-color: #b6d4fe; - border-color: #9ec5fe; } - .toast-primary strong { - border-top-color: #85b6fe; } + border-color: #9ec5fe; +} +.toast-primary strong { + border-top-color: #85b6fe; +} .toast-secondary { color: #202326; background-color: #d3d6d8; - border-color: #c4c8cb; } - .toast-secondary strong { - border-top-color: #b6bbbf; } + border-color: #c4c8cb; +} +.toast-secondary strong { + border-top-color: #b6bbbf; +} .toast-success { color: #082919; background-color: #badbcc; - border-color: #a3cfbb; } - .toast-success strong { - border-top-color: #92c6af; } + border-color: #a3cfbb; +} +.toast-success strong { + border-top-color: #92c6af; +} .toast-info { color: #043d48; background-color: #b6effb; - border-color: #9eeaf9; } - .toast-info strong { - border-top-color: #86e5f8; } + border-color: #9eeaf9; +} +.toast-info strong { + border-top-color: #86e5f8; +} .toast-warning { color: #4d3a02; background-color: #ffecb5; - border-color: #ffe69c; } - .toast-warning strong { - border-top-color: #ffe083; } + border-color: #ffe69c; +} +.toast-warning strong { + border-top-color: #ffe083; +} .toast-danger { color: #421015; background-color: #f5c2c7; - border-color: #f1aeb5; } - .toast-danger strong { - border-top-color: #ed98a1; } + border-color: #f1aeb5; +} +.toast-danger strong { + border-top-color: #ed98a1; +} .toast-light { color: #4a4b4b; background-color: #fdfdfe; - border-color: #fcfdfd; } - .toast-light strong { - border-top-color: #edf3f3; } + border-color: #fcfdfd; +} +.toast-light strong { + border-top-color: #edf3f3; +} .toast-dark { color: #0a0b0c; background-color: #bcbebf; - border-color: #a6a8a9; } - .toast-dark strong { - border-top-color: #999b9c; } + border-color: #a6a8a9; +} +.toast-dark strong { + border-top-color: #999b9c; +} /* Dropdown-item */ .dropdown-item.dropdown-item-primary { color: #fff; text-decoration: none; - background-color: #0d6efd; } - + background-color: #0d6efd; +} .dropdown-item.dropdown-item-outline-primary:hover { color: #fff; - background-color: #0d6efd; } - + background-color: #0d6efd; +} .dropdown-item.dropdown-item-secondary { color: #fff; text-decoration: none; - background-color: #6c757d; } - + background-color: #6c757d; +} .dropdown-item.dropdown-item-outline-secondary:hover { color: #fff; - background-color: #6c757d; } - + background-color: #6c757d; +} .dropdown-item.dropdown-item-success { color: #fff; text-decoration: none; - background-color: #198754; } - + background-color: #198754; +} .dropdown-item.dropdown-item-outline-success:hover { color: #fff; - background-color: #198754; } - + background-color: #198754; +} .dropdown-item.dropdown-item-info { color: #000; text-decoration: none; - background-color: #0dcaf0; } - + background-color: #0dcaf0; +} .dropdown-item.dropdown-item-outline-info:hover { color: #000; - background-color: #0dcaf0; } - + background-color: #0dcaf0; +} .dropdown-item.dropdown-item-warning { color: #000; text-decoration: none; - background-color: #ffc107; } - + background-color: #ffc107; +} .dropdown-item.dropdown-item-outline-warning:hover { color: #000; - background-color: #ffc107; } - + background-color: #ffc107; +} .dropdown-item.dropdown-item-danger { color: #fff; text-decoration: none; - background-color: #dc3545; } - + background-color: #dc3545; +} .dropdown-item.dropdown-item-outline-danger:hover { color: #fff; - background-color: #dc3545; } - + background-color: #dc3545; +} .dropdown-item.dropdown-item-light { color: #000; text-decoration: none; - background-color: #f8f9fa; } - + background-color: #f8f9fa; +} .dropdown-item.dropdown-item-outline-light:hover { color: #000; - background-color: #f8f9fa; } - + background-color: #f8f9fa; +} .dropdown-item.dropdown-item-dark { color: #fff; text-decoration: none; - background-color: #212529; } - + background-color: #212529; +} .dropdown-item.dropdown-item-outline-dark:hover { color: #fff; - background-color: #212529; } + background-color: #212529; +} /* Progress Timeline */ .progress-timeline { - padding: 0.2em 0.2em 0.5em 0.2em; } - .progress-timeline ul { - position: relative; - padding: 0; } - .progress-timeline li { - list-style-type: none; - position: relative; } - .progress-timeline li.progress-inactive { - opacity: 0.5; } - .progress-timeline .progress-line { - height: 2px; } - .progress-timeline .progress-line.progress-inactive { - opacity: 0.5; } + padding: 0.2em 0.2em 0.5em 0.2em; +} +.progress-timeline ul { + position: relative; + padding: 0; +} +.progress-timeline li { + list-style-type: none; + position: relative; +} +.progress-timeline li.progress-inactive { + opacity: 0.5; +} +.progress-timeline .progress-line { + height: 2px; +} +.progress-timeline .progress-line.progress-inactive { + opacity: 0.5; +} /* Forms severity */ .form-control.is-invalid.info { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%230dcaf0' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%230dcaf0' stroke='none'/%3e%3c/svg%3e"); } - .form-control.is-invalid.info:focus { - border-color: #0dcaf0; - box-shadow: 0 0 0 0.25rem rgba(13, 202, 240, 0.25); } - + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%230dcaf0' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%230dcaf0' stroke='none'/%3e%3c/svg%3e"); +} +.form-control.is-invalid.info:focus { + border-color: #0dcaf0; + box-shadow: 0 0 0 0.25rem rgba(13, 202, 240, 0.25); +} .form-control.is-invalid.warning { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23ffc107' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23ffc107' stroke='none'/%3e%3c/svg%3e"); } - .form-control.is-invalid.warning:focus { - border-color: #ffc107; - box-shadow: 0 0 0 0.25rem rgba(255, 193, 7, 0.25); } + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23ffc107' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23ffc107' stroke='none'/%3e%3c/svg%3e"); +} +.form-control.is-invalid.warning:focus { + border-color: #ffc107; + box-shadow: 0 0 0 0.25rem rgba(255, 193, 7, 0.25); +} .form-select.is-invalid:not([multiple]):not([size]).info, -.form-select.is-invalid:not([multiple])[size="1"] -.form-select.is-invalid.info { +.form-select.is-invalid:not([multiple])[size="1"] .form-select.is-invalid.info { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%230dcaf0'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%230dcaf0' stroke='none'/%3e%3c/svg%3e"); - background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); } - .form-select.is-invalid:not([multiple]):not([size]).info:focus, - .form-select.is-invalid:not([multiple])[size="1"] -.form-select.is-invalid.info:focus { - box-shadow: 0 0 0 0.25rem rgba(13, 202, 240, 0.25); } - + background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); +} +.form-select.is-invalid:not([multiple]):not([size]).info:focus, +.form-select.is-invalid:not([multiple])[size="1"] .form-select.is-invalid.info:focus { + box-shadow: 0 0 0 0.25rem rgba(13, 202, 240, 0.25); +} .form-select.is-invalid:not([multiple]):not([size]).warning, -.form-select.is-invalid:not([multiple])[size="1"] -.form-select.is-invalid.warning { +.form-select.is-invalid:not([multiple])[size="1"] .form-select.is-invalid.warning { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23ffc107'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23ffc107' stroke='none'/%3e%3c/svg%3e"); - background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); } - .form-select.is-invalid:not([multiple]):not([size]).warning:focus, - .form-select.is-invalid:not([multiple])[size="1"] -.form-select.is-invalid.warning:focus { - box-shadow: 0 0 0 0.25rem rgba(255, 193, 7, 0.25); } + background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); +} +.form-select.is-invalid:not([multiple]):not([size]).warning:focus, +.form-select.is-invalid:not([multiple])[size="1"] .form-select.is-invalid.warning:focus { + box-shadow: 0 0 0 0.25rem rgba(255, 193, 7, 0.25); +} .form-check-input.is-invalid.info { - border-color: #0dcaf0; } - + border-color: #0dcaf0; +} .form-check-input.is-invalid.info:checked { - background-color: #0dcaf0; } - + background-color: #0dcaf0; +} .form-check-input.is-invalid.info ~ .form-check-label { - color: unset; } - + color: unset; +} .form-check-input.is-invalid.info:focus { - box-shadow: 0 0 0 0.2rem rgba(13, 202, 240, 0.25); } - + box-shadow: 0 0 0 0.2rem rgba(13, 202, 240, 0.25); +} .form-check-input.is-invalid.warning { - border-color: #ffc107; } - + border-color: #ffc107; +} .form-check-input.is-invalid.warning:checked { - background-color: #ffc107; } - + background-color: #ffc107; +} .form-check-input.is-invalid.warning ~ .form-check-label { - color: unset; } - + color: unset; +} .form-check-input.is-invalid.warning:focus { - box-shadow: 0 0 0 0.2rem rgba(255, 193, 7, 0.25); } + box-shadow: 0 0 0 0.2rem rgba(255, 193, 7, 0.25); +} /* Utilities */ .mw-75 { - max-width: 75% !important; } + max-width: 75% !important; +} .mw-50 { - max-width: 50% !important; } + max-width: 50% !important; +} .mw-25 { - max-width: 25% !important; } + max-width: 25% !important; +} .mh-75 { - max-height: 75% !important; } + max-height: 75% !important; +} .mh-50 { - max-height: 50% !important; } + max-height: 50% !important; +} .mh-25 { - max-height: 25% !important; } + max-height: 25% !important; +} .p-abs-center-y { top: 50%; - transform: translateY(-50%); } + transform: translateY(-50%); +} .p-abs-center-x { left: 50%; - transform: translateX(-50%); } + transform: translateX(-50%); +} .p-abs-center-both { top: 50%; left: 50%; - transform: translateX(-50%) translateY(-50%); } + transform: translateX(-50%) translateY(-50%); +} /* Body */ body { @@ -290,87 +335,111 @@ body { /* background by SVGBackgrounds.com */ background-attachment: fixed; background-size: cover; - background-blend-mode: normal; } + background-blend-mode: normal; +} .panel { background-color: #fff; border: none; - box-shadow: 0 0 35px 0 rgba(154, 161, 171, 0.25); } + box-shadow: 0 0 35px 0 rgba(154, 161, 171, 0.25); +} .loading-overlay { background-color: #f8f9fa; - opacity: 0.75; } + opacity: 0.75; +} /* Top navbar */ .top-navbar { - background-color: #212529; } + background-color: #212529; +} .center-navbar nav.header-breadcrumb { - color: #fff; } + color: #fff; +} header.top-navbar .header-menu > a:hover, header.top-navbar .header-breadcrumb .header-breadcrumb-item > a:hover { - color: #d6d6d6 !important; } + color: #d6d6d6 !important; +} .top-navbar .center-navbar nav.header-breadcrumb li.header-breadcrumb-item a { - color: #fff; } + color: #fff; +} .top-navbar .right-navbar .header-menu a.nav-link { - color: #fff; } + color: #fff; +} .top-navbar .left-navbar .navbar-brand img { - filter: invert(1); } + filter: invert(1); +} .top-navbar .left-navbar .navbar-brand:hover img { - filter: invert(1) drop-shadow(0px 0px 3px #fff); } + filter: invert(1) drop-shadow(0px 0px 3px #fff); +} .top-navbar .composed-app-icon-container > .app-icon { - background-color: #fff; } + background-color: #fff; +} .breadcrumb-link-container { box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.16), 0 2px 6px 0 rgba(0, 0, 0, 0.12); - background-color: #f8f9fa; } + background-color: #f8f9fa; +} /* Sidebar */ .sidebar { - transition: width .08s linear; + transition: width 0.08s linear; box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.16), 0 2px 6px 0 rgba(0, 0, 0, 0.12); - background-color: #f8f9fa; } + background-color: #f8f9fa; +} .sidebar ~ main.content:after { - background: #000; } + background: #000; +} .sidebar .sidebar-wrapper { - border-right: 1px solid #ddd; } + border-right: 1px solid #ddd; +} .sidebar .sidebar-wrapper { - border-right: 1px solid rgba(0, 0, 0, 0.125); } + border-right: 1px solid rgba(0, 0, 0, 0.125); +} .sidebar ul.sidebar-elements li > a.sidebar-link { - color: #000; } + color: #000; +} .sidebar ul.sidebar-elements li > a.sidebar-link.active { background-color: #dbdbdb; - color: #000; } + color: #000; +} .sidebar ul.sidebar-elements li > a.sidebar-link.have-active-child { background-color: #dbdbdb; - color: #000; } + color: #000; +} .sidebar ul.sidebar-elements li > a.sidebar-link:hover { background-color: #ebebeb; - color: #000; } + color: #000; +} .sidebar.expanded ul.sidebar-elements li > a.sidebar-link.have-active-child, .sidebar:hover ul.sidebar-elements li > a.sidebar-link.have-active-child { - background-color: unset; } + background-color: unset; +} .sidebar.expanded ul.sidebar-elements li > a.sidebar-link.have-active-child:hover, .sidebar:hover ul.sidebar-elements li > a.sidebar-link.have-active-child:hover { - background-color: #ebebeb; } + background-color: #ebebeb; +} ul.sidebar-elements li > a.sidebar-link.active::after { - background-color: var(--cerebrate-color); } + background-color: var(--cerebrate-color); +} .lock-sidebar > a.btn { - background-color: #f8f9fa; } + background-color: #f8f9fa; +} diff --git a/webroot/css/themes/theme-flatly.css b/webroot/css/themes/theme-flatly.css index c94d649..50fbfd9 100644 --- a/webroot/css/themes/theme-flatly.css +++ b/webroot/css/themes/theme-flatly.css @@ -1,287 +1,332 @@ /* Callout */ .callout { border: 1px solid #ecf0f1; - border-radius: .25rem; + border-radius: 0.25rem; background-color: #fff; - box-shadow: 0 0 35px 0 rgba(154, 161, 171, 0.25); } + box-shadow: 0 0 35px 0 rgba(154, 161, 171, 0.25); +} .callout-primary { border-left-color: #2c3e50; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-secondary { border-left-color: #95a5a6; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-success { border-left-color: #18bc9c; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-info { border-left-color: #3498db; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-warning { border-left-color: #f39c12; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-danger { border-left-color: #e74c3c; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-light { border-left-color: #ecf0f1; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-dark { border-left-color: #7b8a8b; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} /* Toasts */ .toast { - min-width: 250px; } + min-width: 250px; +} .toast-primary { color: #0d1318; background-color: #c0c5cb; - border-color: #abb2b9; } - .toast-primary strong { - border-top-color: #9da5ad; } + border-color: #abb2b9; +} +.toast-primary strong { + border-top-color: #9da5ad; +} .toast-secondary { color: #2d3232; background-color: #dfe4e4; - border-color: #d5dbdb; } - .toast-secondary strong { - border-top-color: #c7cfcf; } + border-color: #d5dbdb; +} +.toast-secondary strong { + border-top-color: #c7cfcf; +} .toast-success { color: #07382f; background-color: #baebe1; - border-color: #a3e4d7; } - .toast-success strong { - border-top-color: #8fdece; } + border-color: #a3e4d7; +} +.toast-success strong { + border-top-color: #8fdece; +} .toast-info { color: #102e42; background-color: #c2e0f4; - border-color: #aed6f1; } - .toast-info strong { - border-top-color: #98cbed; } + border-color: #aed6f1; +} +.toast-info strong { + border-top-color: #98cbed; +} .toast-warning { color: #492f05; background-color: #fbe1b8; - border-color: #fad7a0; } - .toast-warning strong { - border-top-color: #f9cd88; } + border-color: #fad7a0; +} +.toast-warning strong { + border-top-color: #f9cd88; +} .toast-danger { color: #451712; background-color: #f8c9c5; - border-color: #f5b7b1; } - .toast-danger strong { - border-top-color: #f2a29a; } + border-color: #f5b7b1; +} +.toast-danger strong { + border-top-color: #f2a29a; +} .toast-light { color: #474848; background-color: #f9fbfb; - border-color: #f7f9f9; } - .toast-light strong { - border-top-color: #e8eeee; } + border-color: #f7f9f9; +} +.toast-light strong { + border-top-color: #e8eeee; +} .toast-dark { color: #25292a; background-color: #d7dcdc; - border-color: #cad0d1; } - .toast-dark strong { - border-top-color: #bcc4c5; } + border-color: #cad0d1; +} +.toast-dark strong { + border-top-color: #bcc4c5; +} /* Dropdown-item */ .dropdown-item.dropdown-item-primary { color: #fff; text-decoration: none; - background-color: #2c3e50; } - + background-color: #2c3e50; +} .dropdown-item.dropdown-item-outline-primary:hover { color: #fff; - background-color: #2c3e50; } - + background-color: #2c3e50; +} .dropdown-item.dropdown-item-secondary { color: #fff; text-decoration: none; - background-color: #95a5a6; } - + background-color: #95a5a6; +} .dropdown-item.dropdown-item-outline-secondary:hover { color: #fff; - background-color: #95a5a6; } - + background-color: #95a5a6; +} .dropdown-item.dropdown-item-success { color: #fff; text-decoration: none; - background-color: #18bc9c; } - + background-color: #18bc9c; +} .dropdown-item.dropdown-item-outline-success:hover { color: #fff; - background-color: #18bc9c; } - + background-color: #18bc9c; +} .dropdown-item.dropdown-item-info { color: #fff; text-decoration: none; - background-color: #3498db; } - + background-color: #3498db; +} .dropdown-item.dropdown-item-outline-info:hover { color: #fff; - background-color: #3498db; } - + background-color: #3498db; +} .dropdown-item.dropdown-item-warning { color: #fff; text-decoration: none; - background-color: #f39c12; } - + background-color: #f39c12; +} .dropdown-item.dropdown-item-outline-warning:hover { color: #fff; - background-color: #f39c12; } - + background-color: #f39c12; +} .dropdown-item.dropdown-item-danger { color: #fff; text-decoration: none; - background-color: #e74c3c; } - + background-color: #e74c3c; +} .dropdown-item.dropdown-item-outline-danger:hover { color: #fff; - background-color: #e74c3c; } - + background-color: #e74c3c; +} .dropdown-item.dropdown-item-light { color: #000; text-decoration: none; - background-color: #ecf0f1; } - + background-color: #ecf0f1; +} .dropdown-item.dropdown-item-outline-light:hover { color: #000; - background-color: #ecf0f1; } - + background-color: #ecf0f1; +} .dropdown-item.dropdown-item-dark { color: #fff; text-decoration: none; - background-color: #7b8a8b; } - + background-color: #7b8a8b; +} .dropdown-item.dropdown-item-outline-dark:hover { color: #fff; - background-color: #7b8a8b; } + background-color: #7b8a8b; +} /* Progress Timeline */ .progress-timeline { - padding: 0.2em 0.2em 0.5em 0.2em; } - .progress-timeline ul { - position: relative; - padding: 0; } - .progress-timeline li { - list-style-type: none; - position: relative; } - .progress-timeline li.progress-inactive { - opacity: 0.5; } - .progress-timeline .progress-line { - height: 2px; } - .progress-timeline .progress-line.progress-inactive { - opacity: 0.5; } + padding: 0.2em 0.2em 0.5em 0.2em; +} +.progress-timeline ul { + position: relative; + padding: 0; +} +.progress-timeline li { + list-style-type: none; + position: relative; +} +.progress-timeline li.progress-inactive { + opacity: 0.5; +} +.progress-timeline .progress-line { + height: 2px; +} +.progress-timeline .progress-line.progress-inactive { + opacity: 0.5; +} /* Forms severity */ .form-control.is-invalid.info { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%233498db' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%233498db' stroke='none'/%3e%3c/svg%3e"); } - .form-control.is-invalid.info:focus { - border-color: #3498db; - box-shadow: 0 0 0 0.25rem rgba(52, 152, 219, 0.25); } - + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%233498db' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%233498db' stroke='none'/%3e%3c/svg%3e"); +} +.form-control.is-invalid.info:focus { + border-color: #3498db; + box-shadow: 0 0 0 0.25rem rgba(52, 152, 219, 0.25); +} .form-control.is-invalid.warning { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23f39c12' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23f39c12' stroke='none'/%3e%3c/svg%3e"); } - .form-control.is-invalid.warning:focus { - border-color: #f39c12; - box-shadow: 0 0 0 0.25rem rgba(243, 156, 18, 0.25); } + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23f39c12' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23f39c12' stroke='none'/%3e%3c/svg%3e"); +} +.form-control.is-invalid.warning:focus { + border-color: #f39c12; + box-shadow: 0 0 0 0.25rem rgba(243, 156, 18, 0.25); +} .form-select.is-invalid:not([multiple]):not([size]).info, -.form-select.is-invalid:not([multiple])[size="1"] -.form-select.is-invalid.info { +.form-select.is-invalid:not([multiple])[size="1"] .form-select.is-invalid.info { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%233498db'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%233498db' stroke='none'/%3e%3c/svg%3e"); - background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); } - .form-select.is-invalid:not([multiple]):not([size]).info:focus, - .form-select.is-invalid:not([multiple])[size="1"] -.form-select.is-invalid.info:focus { - box-shadow: 0 0 0 0.25rem rgba(52, 152, 219, 0.25); } - + background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); +} +.form-select.is-invalid:not([multiple]):not([size]).info:focus, +.form-select.is-invalid:not([multiple])[size="1"] .form-select.is-invalid.info:focus { + box-shadow: 0 0 0 0.25rem rgba(52, 152, 219, 0.25); +} .form-select.is-invalid:not([multiple]):not([size]).warning, -.form-select.is-invalid:not([multiple])[size="1"] -.form-select.is-invalid.warning { +.form-select.is-invalid:not([multiple])[size="1"] .form-select.is-invalid.warning { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23f39c12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23f39c12' stroke='none'/%3e%3c/svg%3e"); - background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); } - .form-select.is-invalid:not([multiple]):not([size]).warning:focus, - .form-select.is-invalid:not([multiple])[size="1"] -.form-select.is-invalid.warning:focus { - box-shadow: 0 0 0 0.25rem rgba(243, 156, 18, 0.25); } + background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); +} +.form-select.is-invalid:not([multiple]):not([size]).warning:focus, +.form-select.is-invalid:not([multiple])[size="1"] .form-select.is-invalid.warning:focus { + box-shadow: 0 0 0 0.25rem rgba(243, 156, 18, 0.25); +} .form-check-input.is-invalid.info { - border-color: #3498db; } - + border-color: #3498db; +} .form-check-input.is-invalid.info:checked { - background-color: #3498db; } - + background-color: #3498db; +} .form-check-input.is-invalid.info ~ .form-check-label { - color: unset; } - + color: unset; +} .form-check-input.is-invalid.info:focus { - box-shadow: 0 0 0 0.2rem rgba(52, 152, 219, 0.25); } - + box-shadow: 0 0 0 0.2rem rgba(52, 152, 219, 0.25); +} .form-check-input.is-invalid.warning { - border-color: #f39c12; } - + border-color: #f39c12; +} .form-check-input.is-invalid.warning:checked { - background-color: #f39c12; } - + background-color: #f39c12; +} .form-check-input.is-invalid.warning ~ .form-check-label { - color: unset; } - + color: unset; +} .form-check-input.is-invalid.warning:focus { - box-shadow: 0 0 0 0.2rem rgba(243, 156, 18, 0.25); } + box-shadow: 0 0 0 0.2rem rgba(243, 156, 18, 0.25); +} /* Utilities */ .mw-75 { - max-width: 75% !important; } + max-width: 75% !important; +} .mw-50 { - max-width: 50% !important; } + max-width: 50% !important; +} .mw-25 { - max-width: 25% !important; } + max-width: 25% !important; +} .mh-75 { - max-height: 75% !important; } + max-height: 75% !important; +} .mh-50 { - max-height: 50% !important; } + max-height: 50% !important; +} .mh-25 { - max-height: 25% !important; } + max-height: 25% !important; +} .p-abs-center-y { top: 50%; - transform: translateY(-50%); } + transform: translateY(-50%); +} .p-abs-center-x { left: 50%; - transform: translateX(-50%); } + transform: translateX(-50%); +} .p-abs-center-both { top: 50%; left: 50%; - transform: translateX(-50%) translateY(-50%); } + transform: translateX(-50%) translateY(-50%); +} /* Body */ body { @@ -290,87 +335,111 @@ body { /* background by SVGBackgrounds.com */ background-attachment: fixed; background-size: cover; - background-blend-mode: normal; } + background-blend-mode: normal; +} .panel { background-color: #fff; border: none; - box-shadow: 0 0 35px 0 rgba(154, 161, 171, 0.25); } + box-shadow: 0 0 35px 0 rgba(154, 161, 171, 0.25); +} .loading-overlay { background-color: #ecf0f1; - opacity: 0.75; } + opacity: 0.75; +} /* Top navbar */ .top-navbar { - background-color: #2c3e50; } + background-color: #2c3e50; +} .center-navbar nav.header-breadcrumb { - color: #fff; } + color: #fff; +} header.top-navbar .header-menu > a:hover, header.top-navbar .header-breadcrumb .header-breadcrumb-item > a:hover { - color: #d6d6d6 !important; } + color: #d6d6d6 !important; +} .top-navbar .center-navbar nav.header-breadcrumb li.header-breadcrumb-item a { - color: #fff; } + color: #fff; +} .top-navbar .right-navbar .header-menu a.nav-link { - color: #fff; } + color: #fff; +} .top-navbar .left-navbar .navbar-brand img { - filter: invert(1); } + filter: invert(1); +} .top-navbar .left-navbar .navbar-brand:hover img { - filter: invert(1) drop-shadow(0px 0px 3px #fff); } + filter: invert(1) drop-shadow(0px 0px 3px #fff); +} .top-navbar .composed-app-icon-container > .app-icon { - background-color: #fff; } + background-color: #fff; +} .breadcrumb-link-container { box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.16), 0 2px 6px 0 rgba(0, 0, 0, 0.12); - background-color: #ecf0f1; } + background-color: #ecf0f1; +} /* Sidebar */ .sidebar { - transition: width .08s linear; + transition: width 0.08s linear; box-shadow: none; - background-color: #ecf0f1; } + background-color: #ecf0f1; +} .sidebar ~ main.content:after { - background: #000; } + background: #000; +} .sidebar .sidebar-wrapper { - border-right: 1px solid none; } + border-right: 1px solid none; +} .sidebar .sidebar-wrapper { - border-right: 1px solid rgba(0, 0, 0, 0.125); } + border-right: 1px solid rgba(0, 0, 0, 0.125); +} .sidebar ul.sidebar-elements li > a.sidebar-link { - color: #000; } + color: #000; +} .sidebar ul.sidebar-elements li > a.sidebar-link.active { background-color: #dbdbdb; - color: #18bc9c; } + color: #18bc9c; +} .sidebar ul.sidebar-elements li > a.sidebar-link.have-active-child { background-color: #dbdbdb; - color: #18bc9c; } + color: #18bc9c; +} .sidebar ul.sidebar-elements li > a.sidebar-link:hover { background-color: #ebebeb; - color: #18bc9c; } + color: #18bc9c; +} .sidebar.expanded ul.sidebar-elements li > a.sidebar-link.have-active-child, .sidebar:hover ul.sidebar-elements li > a.sidebar-link.have-active-child { - background-color: unset; } + background-color: unset; +} .sidebar.expanded ul.sidebar-elements li > a.sidebar-link.have-active-child:hover, .sidebar:hover ul.sidebar-elements li > a.sidebar-link.have-active-child:hover { - background-color: #ebebeb; } + background-color: #ebebeb; +} ul.sidebar-elements li > a.sidebar-link.active::after { - background-color: #18bc9c; } + background-color: #18bc9c; +} .lock-sidebar > a.btn { - background-color: unset; } + background-color: unset; +} diff --git a/webroot/css/themes/theme-minty.css b/webroot/css/themes/theme-minty.css index 6948503..c003c4b 100644 --- a/webroot/css/themes/theme-minty.css +++ b/webroot/css/themes/theme-minty.css @@ -1,287 +1,332 @@ /* Callout */ .callout { border: 1px solid #ecf0f1; - border-radius: .25rem; + border-radius: 0.25rem; background-color: #fff; - box-shadow: 0 0 35px 0 rgba(154, 161, 171, 0.25); } + box-shadow: 0 0 35px 0 rgba(154, 161, 171, 0.25); +} .callout-primary { border-left-color: #2c3e50; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-secondary { border-left-color: #95a5a6; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-success { border-left-color: #18bc9c; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-info { border-left-color: #3498db; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-warning { border-left-color: #f39c12; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-danger { border-left-color: #e74c3c; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-light { border-left-color: #ecf0f1; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-dark { border-left-color: #7b8a8b; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} /* Toasts */ .toast { - min-width: 250px; } + min-width: 250px; +} .toast-primary { color: #0d1318; background-color: #c0c5cb; - border-color: #abb2b9; } - .toast-primary strong { - border-top-color: #9da5ad; } + border-color: #abb2b9; +} +.toast-primary strong { + border-top-color: #9da5ad; +} .toast-secondary { color: #2d3232; background-color: #dfe4e4; - border-color: #d5dbdb; } - .toast-secondary strong { - border-top-color: #c7cfcf; } + border-color: #d5dbdb; +} +.toast-secondary strong { + border-top-color: #c7cfcf; +} .toast-success { color: #07382f; background-color: #baebe1; - border-color: #a3e4d7; } - .toast-success strong { - border-top-color: #8fdece; } + border-color: #a3e4d7; +} +.toast-success strong { + border-top-color: #8fdece; +} .toast-info { color: #102e42; background-color: #c2e0f4; - border-color: #aed6f1; } - .toast-info strong { - border-top-color: #98cbed; } + border-color: #aed6f1; +} +.toast-info strong { + border-top-color: #98cbed; +} .toast-warning { color: #492f05; background-color: #fbe1b8; - border-color: #fad7a0; } - .toast-warning strong { - border-top-color: #f9cd88; } + border-color: #fad7a0; +} +.toast-warning strong { + border-top-color: #f9cd88; +} .toast-danger { color: #451712; background-color: #f8c9c5; - border-color: #f5b7b1; } - .toast-danger strong { - border-top-color: #f2a29a; } + border-color: #f5b7b1; +} +.toast-danger strong { + border-top-color: #f2a29a; +} .toast-light { color: #474848; background-color: #f9fbfb; - border-color: #f7f9f9; } - .toast-light strong { - border-top-color: #e8eeee; } + border-color: #f7f9f9; +} +.toast-light strong { + border-top-color: #e8eeee; +} .toast-dark { color: #25292a; background-color: #d7dcdc; - border-color: #cad0d1; } - .toast-dark strong { - border-top-color: #bcc4c5; } + border-color: #cad0d1; +} +.toast-dark strong { + border-top-color: #bcc4c5; +} /* Dropdown-item */ .dropdown-item.dropdown-item-primary { color: #fff; text-decoration: none; - background-color: #2c3e50; } - + background-color: #2c3e50; +} .dropdown-item.dropdown-item-outline-primary:hover { color: #fff; - background-color: #2c3e50; } - + background-color: #2c3e50; +} .dropdown-item.dropdown-item-secondary { color: #fff; text-decoration: none; - background-color: #95a5a6; } - + background-color: #95a5a6; +} .dropdown-item.dropdown-item-outline-secondary:hover { color: #fff; - background-color: #95a5a6; } - + background-color: #95a5a6; +} .dropdown-item.dropdown-item-success { color: #fff; text-decoration: none; - background-color: #18bc9c; } - + background-color: #18bc9c; +} .dropdown-item.dropdown-item-outline-success:hover { color: #fff; - background-color: #18bc9c; } - + background-color: #18bc9c; +} .dropdown-item.dropdown-item-info { color: #fff; text-decoration: none; - background-color: #3498db; } - + background-color: #3498db; +} .dropdown-item.dropdown-item-outline-info:hover { color: #fff; - background-color: #3498db; } - + background-color: #3498db; +} .dropdown-item.dropdown-item-warning { color: #fff; text-decoration: none; - background-color: #f39c12; } - + background-color: #f39c12; +} .dropdown-item.dropdown-item-outline-warning:hover { color: #fff; - background-color: #f39c12; } - + background-color: #f39c12; +} .dropdown-item.dropdown-item-danger { color: #fff; text-decoration: none; - background-color: #e74c3c; } - + background-color: #e74c3c; +} .dropdown-item.dropdown-item-outline-danger:hover { color: #fff; - background-color: #e74c3c; } - + background-color: #e74c3c; +} .dropdown-item.dropdown-item-light { color: #000; text-decoration: none; - background-color: #ecf0f1; } - + background-color: #ecf0f1; +} .dropdown-item.dropdown-item-outline-light:hover { color: #000; - background-color: #ecf0f1; } - + background-color: #ecf0f1; +} .dropdown-item.dropdown-item-dark { color: #fff; text-decoration: none; - background-color: #7b8a8b; } - + background-color: #7b8a8b; +} .dropdown-item.dropdown-item-outline-dark:hover { color: #fff; - background-color: #7b8a8b; } + background-color: #7b8a8b; +} /* Progress Timeline */ .progress-timeline { - padding: 0.2em 0.2em 0.5em 0.2em; } - .progress-timeline ul { - position: relative; - padding: 0; } - .progress-timeline li { - list-style-type: none; - position: relative; } - .progress-timeline li.progress-inactive { - opacity: 0.5; } - .progress-timeline .progress-line { - height: 2px; } - .progress-timeline .progress-line.progress-inactive { - opacity: 0.5; } + padding: 0.2em 0.2em 0.5em 0.2em; +} +.progress-timeline ul { + position: relative; + padding: 0; +} +.progress-timeline li { + list-style-type: none; + position: relative; +} +.progress-timeline li.progress-inactive { + opacity: 0.5; +} +.progress-timeline .progress-line { + height: 2px; +} +.progress-timeline .progress-line.progress-inactive { + opacity: 0.5; +} /* Forms severity */ .form-control.is-invalid.info { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%233498db' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%233498db' stroke='none'/%3e%3c/svg%3e"); } - .form-control.is-invalid.info:focus { - border-color: #3498db; - box-shadow: 0 0 0 0.25rem rgba(52, 152, 219, 0.25); } - + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%233498db' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%233498db' stroke='none'/%3e%3c/svg%3e"); +} +.form-control.is-invalid.info:focus { + border-color: #3498db; + box-shadow: 0 0 0 0.25rem rgba(52, 152, 219, 0.25); +} .form-control.is-invalid.warning { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23f39c12' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23f39c12' stroke='none'/%3e%3c/svg%3e"); } - .form-control.is-invalid.warning:focus { - border-color: #f39c12; - box-shadow: 0 0 0 0.25rem rgba(243, 156, 18, 0.25); } + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23f39c12' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23f39c12' stroke='none'/%3e%3c/svg%3e"); +} +.form-control.is-invalid.warning:focus { + border-color: #f39c12; + box-shadow: 0 0 0 0.25rem rgba(243, 156, 18, 0.25); +} .form-select.is-invalid:not([multiple]):not([size]).info, -.form-select.is-invalid:not([multiple])[size="1"] -.form-select.is-invalid.info { +.form-select.is-invalid:not([multiple])[size="1"] .form-select.is-invalid.info { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%233498db'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%233498db' stroke='none'/%3e%3c/svg%3e"); - background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); } - .form-select.is-invalid:not([multiple]):not([size]).info:focus, - .form-select.is-invalid:not([multiple])[size="1"] -.form-select.is-invalid.info:focus { - box-shadow: 0 0 0 0.25rem rgba(52, 152, 219, 0.25); } - + background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); +} +.form-select.is-invalid:not([multiple]):not([size]).info:focus, +.form-select.is-invalid:not([multiple])[size="1"] .form-select.is-invalid.info:focus { + box-shadow: 0 0 0 0.25rem rgba(52, 152, 219, 0.25); +} .form-select.is-invalid:not([multiple]):not([size]).warning, -.form-select.is-invalid:not([multiple])[size="1"] -.form-select.is-invalid.warning { +.form-select.is-invalid:not([multiple])[size="1"] .form-select.is-invalid.warning { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23f39c12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23f39c12' stroke='none'/%3e%3c/svg%3e"); - background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); } - .form-select.is-invalid:not([multiple]):not([size]).warning:focus, - .form-select.is-invalid:not([multiple])[size="1"] -.form-select.is-invalid.warning:focus { - box-shadow: 0 0 0 0.25rem rgba(243, 156, 18, 0.25); } + background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); +} +.form-select.is-invalid:not([multiple]):not([size]).warning:focus, +.form-select.is-invalid:not([multiple])[size="1"] .form-select.is-invalid.warning:focus { + box-shadow: 0 0 0 0.25rem rgba(243, 156, 18, 0.25); +} .form-check-input.is-invalid.info { - border-color: #3498db; } - + border-color: #3498db; +} .form-check-input.is-invalid.info:checked { - background-color: #3498db; } - + background-color: #3498db; +} .form-check-input.is-invalid.info ~ .form-check-label { - color: unset; } - + color: unset; +} .form-check-input.is-invalid.info:focus { - box-shadow: 0 0 0 0.2rem rgba(52, 152, 219, 0.25); } - + box-shadow: 0 0 0 0.2rem rgba(52, 152, 219, 0.25); +} .form-check-input.is-invalid.warning { - border-color: #f39c12; } - + border-color: #f39c12; +} .form-check-input.is-invalid.warning:checked { - background-color: #f39c12; } - + background-color: #f39c12; +} .form-check-input.is-invalid.warning ~ .form-check-label { - color: unset; } - + color: unset; +} .form-check-input.is-invalid.warning:focus { - box-shadow: 0 0 0 0.2rem rgba(243, 156, 18, 0.25); } + box-shadow: 0 0 0 0.2rem rgba(243, 156, 18, 0.25); +} /* Utilities */ .mw-75 { - max-width: 75% !important; } + max-width: 75% !important; +} .mw-50 { - max-width: 50% !important; } + max-width: 50% !important; +} .mw-25 { - max-width: 25% !important; } + max-width: 25% !important; +} .mh-75 { - max-height: 75% !important; } + max-height: 75% !important; +} .mh-50 { - max-height: 50% !important; } + max-height: 50% !important; +} .mh-25 { - max-height: 25% !important; } + max-height: 25% !important; +} .p-abs-center-y { top: 50%; - transform: translateY(-50%); } + transform: translateY(-50%); +} .p-abs-center-x { left: 50%; - transform: translateX(-50%); } + transform: translateX(-50%); +} .p-abs-center-both { top: 50%; left: 50%; - transform: translateX(-50%) translateY(-50%); } + transform: translateX(-50%) translateY(-50%); +} /* Body */ body { @@ -290,87 +335,111 @@ body { /* background by SVGBackgrounds.com */ background-attachment: fixed; background-size: cover; - background-blend-mode: normal; } + background-blend-mode: normal; +} .panel { background-color: #fff; border: none; - box-shadow: 0 0 35px 0 rgba(154, 161, 171, 0.25); } + box-shadow: 0 0 35px 0 rgba(154, 161, 171, 0.25); +} .loading-overlay { background-color: #ecf0f1; - opacity: 0.65; } + opacity: 0.65; +} /* Top navbar */ .top-navbar { - background-color: #2c3e50; } + background-color: #2c3e50; +} .center-navbar nav.header-breadcrumb { - color: #fff; } + color: #fff; +} header.top-navbar .header-menu > a:hover, header.top-navbar .header-breadcrumb .header-breadcrumb-item > a:hover { - color: #d6d6d6 !important; } + color: #d6d6d6 !important; +} .top-navbar .center-navbar nav.header-breadcrumb li.header-breadcrumb-item a { - color: #fff; } + color: #fff; +} .top-navbar .right-navbar .header-menu a.nav-link { - color: #fff; } + color: #fff; +} .top-navbar .left-navbar .navbar-brand img { - filter: invert(1); } + filter: invert(1); +} .top-navbar .left-navbar .navbar-brand:hover img { - filter: invert(1) drop-shadow(0px 0px 3px #fff); } + filter: invert(1) drop-shadow(0px 0px 3px #fff); +} .top-navbar .composed-app-icon-container > .app-icon { - background-color: #fff; } + background-color: #fff; +} .breadcrumb-link-container { box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.16), 0 2px 6px 0 rgba(0, 0, 0, 0.12); - background-color: #ecf0f1; } + background-color: #ecf0f1; +} /* Sidebar */ .sidebar { - transition: width .08s linear; + transition: width 0.08s linear; box-shadow: none; - background-color: #ecf0f1; } + background-color: #ecf0f1; +} .sidebar ~ main.content:after { - background: #000; } + background: #000; +} .sidebar .sidebar-wrapper { - border-right: 1px solid none; } + border-right: 1px solid none; +} .sidebar .sidebar-wrapper { - border-right: 1px solid rgba(0, 0, 0, 0.125); } + border-right: 1px solid rgba(0, 0, 0, 0.125); +} .sidebar ul.sidebar-elements li > a.sidebar-link { - color: #343a40; } + color: #343a40; +} .sidebar ul.sidebar-elements li > a.sidebar-link.active { background-color: #dbdbdb; - color: #18bc9c; } + color: #18bc9c; +} .sidebar ul.sidebar-elements li > a.sidebar-link.have-active-child { background-color: #dbdbdb; - color: #18bc9c; } + color: #18bc9c; +} .sidebar ul.sidebar-elements li > a.sidebar-link:hover { background-color: #ebebeb; - color: #18bc9c; } + color: #18bc9c; +} .sidebar.expanded ul.sidebar-elements li > a.sidebar-link.have-active-child, .sidebar:hover ul.sidebar-elements li > a.sidebar-link.have-active-child { - background-color: unset; } + background-color: unset; +} .sidebar.expanded ul.sidebar-elements li > a.sidebar-link.have-active-child:hover, .sidebar:hover ul.sidebar-elements li > a.sidebar-link.have-active-child:hover { - background-color: #ebebeb; } + background-color: #ebebeb; +} ul.sidebar-elements li > a.sidebar-link.active::after { - background-color: #18bc9c; } + background-color: #18bc9c; +} .lock-sidebar > a.btn { - background-color: unset; } + background-color: unset; +} diff --git a/webroot/css/themes/theme-quartz.css b/webroot/css/themes/theme-quartz.css index 71469f2..83280db 100644 --- a/webroot/css/themes/theme-quartz.css +++ b/webroot/css/themes/theme-quartz.css @@ -1,371 +1,439 @@ /* Callout */ .callout { border: 1px solid #e9e9e8; - border-radius: .25rem; + border-radius: 0.25rem; background-color: transparent; - box-shadow: 0 0 35px 0 rgba(154, 161, 171, 0.25); } + box-shadow: 0 0 35px 0 rgba(154, 161, 171, 0.25); +} .callout-primary { border-left-color: #e83283; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-secondary { border-left-color: rgba(255, 255, 255, 0.4); - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-success { border-left-color: #41d7a7; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-info { border-left-color: #39cbfb; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-warning { border-left-color: #ffc107; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-danger { border-left-color: #fd7e14; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-light { border-left-color: #e9e9e8; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-dark { border-left-color: #212529; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} /* Toasts */ .toast { - min-width: 250px; } + min-width: 250px; +} .toast-primary { color: #460f27; background-color: #f8c2da; - border-color: #f6adcd; } - .toast-primary strong { - border-top-color: #f396bf; } + border-color: #f6adcd; +} +.toast-primary strong { + border-top-color: #f396bf; +} .toast-secondary { color: rgba(25, 25, 25, 0.82); background-color: rgba(255, 255, 255, 0.82); - border-color: rgba(255, 255, 255, 0.76); } - .toast-secondary strong { - border-top-color: rgba(242, 242, 242, 0.76); } + border-color: rgba(255, 255, 255, 0.76); +} +.toast-secondary strong { + border-top-color: rgba(242, 242, 242, 0.76); +} .toast-success { color: #144132; background-color: #c6f3e5; - border-color: #b3efdc; } - .toast-success strong { - border-top-color: #9eebd2; } + border-color: #b3efdc; +} +.toast-success strong { + border-top-color: #9eebd2; +} .toast-info { color: #113d4b; background-color: #c4effe; - border-color: #b0eafd; } - .toast-info strong { - border-top-color: #97e3fc; } + border-color: #b0eafd; +} +.toast-info strong { + border-top-color: #97e3fc; +} .toast-warning { color: #4d3a02; background-color: #ffecb5; - border-color: #ffe69c; } - .toast-warning strong { - border-top-color: #ffe083; } + border-color: #ffe69c; +} +.toast-warning strong { + border-top-color: #ffe083; +} .toast-danger { color: #4c2606; background-color: #fed8b9; - border-color: #fecba1; } - .toast-danger strong { - border-top-color: #febd88; } + border-color: #fecba1; +} +.toast-danger strong { + border-top-color: #febd88; +} .toast-light { color: #464646; background-color: #f8f8f8; - border-color: #f6f6f6; } - .toast-light strong { - border-top-color: #e9e9e9; } + border-color: #f6f6f6; +} +.toast-light strong { + border-top-color: #e9e9e9; +} .toast-dark { color: #0a0b0c; background-color: #bcbebf; - border-color: #a6a8a9; } - .toast-dark strong { - border-top-color: #999b9c; } + border-color: #a6a8a9; +} +.toast-dark strong { + border-top-color: #999b9c; +} /* Dropdown-item */ .dropdown-item.dropdown-item-primary { color: #fff; text-decoration: none; - background-color: #e83283; } - + background-color: #e83283; +} .dropdown-item.dropdown-item-outline-primary:hover { color: #fff; - background-color: #e83283; } - + background-color: #e83283; +} .dropdown-item.dropdown-item-secondary { color: #000; text-decoration: none; - background-color: rgba(255, 255, 255, 0.4); } - + background-color: rgba(255, 255, 255, 0.4); +} .dropdown-item.dropdown-item-outline-secondary:hover { color: #000; - background-color: rgba(255, 255, 255, 0.4); } - + background-color: rgba(255, 255, 255, 0.4); +} .dropdown-item.dropdown-item-success { color: #fff; text-decoration: none; - background-color: #41d7a7; } - + background-color: #41d7a7; +} .dropdown-item.dropdown-item-outline-success:hover { color: #fff; - background-color: #41d7a7; } - + background-color: #41d7a7; +} .dropdown-item.dropdown-item-info { color: #fff; text-decoration: none; - background-color: #39cbfb; } - + background-color: #39cbfb; +} .dropdown-item.dropdown-item-outline-info:hover { color: #fff; - background-color: #39cbfb; } - + background-color: #39cbfb; +} .dropdown-item.dropdown-item-warning { color: #fff; text-decoration: none; - background-color: #ffc107; } - + background-color: #ffc107; +} .dropdown-item.dropdown-item-outline-warning:hover { color: #fff; - background-color: #ffc107; } - + background-color: #ffc107; +} .dropdown-item.dropdown-item-danger { color: #fff; text-decoration: none; - background-color: #fd7e14; } - + background-color: #fd7e14; +} .dropdown-item.dropdown-item-outline-danger:hover { color: #fff; - background-color: #fd7e14; } - + background-color: #fd7e14; +} .dropdown-item.dropdown-item-light { color: #000; text-decoration: none; - background-color: #e9e9e8; } - + background-color: #e9e9e8; +} .dropdown-item.dropdown-item-outline-light:hover { color: #000; - background-color: #e9e9e8; } - + background-color: #e9e9e8; +} .dropdown-item.dropdown-item-dark { color: #fff; text-decoration: none; - background-color: #212529; } - + background-color: #212529; +} .dropdown-item.dropdown-item-outline-dark:hover { color: #fff; - background-color: #212529; } + background-color: #212529; +} /* Progress Timeline */ .progress-timeline { - padding: 0.2em 0.2em 0.5em 0.2em; } - .progress-timeline ul { - position: relative; - padding: 0; } - .progress-timeline li { - list-style-type: none; - position: relative; } - .progress-timeline li.progress-inactive { - opacity: 0.5; } - .progress-timeline .progress-line { - height: 2px; } - .progress-timeline .progress-line.progress-inactive { - opacity: 0.5; } + padding: 0.2em 0.2em 0.5em 0.2em; +} +.progress-timeline ul { + position: relative; + padding: 0; +} +.progress-timeline li { + list-style-type: none; + position: relative; +} +.progress-timeline li.progress-inactive { + opacity: 0.5; +} +.progress-timeline .progress-line { + height: 2px; +} +.progress-timeline .progress-line.progress-inactive { + opacity: 0.5; +} /* Forms severity */ .form-control.is-invalid.info { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%2339cbfb' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%2339cbfb' stroke='none'/%3e%3c/svg%3e"); } - .form-control.is-invalid.info:focus { - border-color: #39cbfb; - box-shadow: 0 0 0 0.25rem rgba(57, 203, 251, 0.25); } - + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%2339cbfb' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%2339cbfb' stroke='none'/%3e%3c/svg%3e"); +} +.form-control.is-invalid.info:focus { + border-color: #39cbfb; + box-shadow: 0 0 0 0.25rem rgba(57, 203, 251, 0.25); +} .form-control.is-invalid.warning { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23ffc107' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23ffc107' stroke='none'/%3e%3c/svg%3e"); } - .form-control.is-invalid.warning:focus { - border-color: #ffc107; - box-shadow: 0 0 0 0.25rem rgba(255, 193, 7, 0.25); } + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23ffc107' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23ffc107' stroke='none'/%3e%3c/svg%3e"); +} +.form-control.is-invalid.warning:focus { + border-color: #ffc107; + box-shadow: 0 0 0 0.25rem rgba(255, 193, 7, 0.25); +} .form-select.is-invalid:not([multiple]):not([size]).info, -.form-select.is-invalid:not([multiple])[size="1"] -.form-select.is-invalid.info { +.form-select.is-invalid:not([multiple])[size="1"] .form-select.is-invalid.info { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%2339cbfb'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%2339cbfb' stroke='none'/%3e%3c/svg%3e"); - background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); } - .form-select.is-invalid:not([multiple]):not([size]).info:focus, - .form-select.is-invalid:not([multiple])[size="1"] -.form-select.is-invalid.info:focus { - box-shadow: 0 0 0 0.25rem rgba(57, 203, 251, 0.25); } - + background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); +} +.form-select.is-invalid:not([multiple]):not([size]).info:focus, +.form-select.is-invalid:not([multiple])[size="1"] .form-select.is-invalid.info:focus { + box-shadow: 0 0 0 0.25rem rgba(57, 203, 251, 0.25); +} .form-select.is-invalid:not([multiple]):not([size]).warning, -.form-select.is-invalid:not([multiple])[size="1"] -.form-select.is-invalid.warning { +.form-select.is-invalid:not([multiple])[size="1"] .form-select.is-invalid.warning { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23ffc107'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23ffc107' stroke='none'/%3e%3c/svg%3e"); - background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); } - .form-select.is-invalid:not([multiple]):not([size]).warning:focus, - .form-select.is-invalid:not([multiple])[size="1"] -.form-select.is-invalid.warning:focus { - box-shadow: 0 0 0 0.25rem rgba(255, 193, 7, 0.25); } + background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); +} +.form-select.is-invalid:not([multiple]):not([size]).warning:focus, +.form-select.is-invalid:not([multiple])[size="1"] .form-select.is-invalid.warning:focus { + box-shadow: 0 0 0 0.25rem rgba(255, 193, 7, 0.25); +} .form-check-input.is-invalid.info { - border-color: #39cbfb; } - + border-color: #39cbfb; +} .form-check-input.is-invalid.info:checked { - background-color: #39cbfb; } - + background-color: #39cbfb; +} .form-check-input.is-invalid.info ~ .form-check-label { - color: unset; } - + color: unset; +} .form-check-input.is-invalid.info:focus { - box-shadow: 0 0 0 0.2rem rgba(57, 203, 251, 0.25); } - + box-shadow: 0 0 0 0.2rem rgba(57, 203, 251, 0.25); +} .form-check-input.is-invalid.warning { - border-color: #ffc107; } - + border-color: #ffc107; +} .form-check-input.is-invalid.warning:checked { - background-color: #ffc107; } - + background-color: #ffc107; +} .form-check-input.is-invalid.warning ~ .form-check-label { - color: unset; } - + color: unset; +} .form-check-input.is-invalid.warning:focus { - box-shadow: 0 0 0 0.2rem rgba(255, 193, 7, 0.25); } + box-shadow: 0 0 0 0.2rem rgba(255, 193, 7, 0.25); +} /* Utilities */ .mw-75 { - max-width: 75% !important; } + max-width: 75% !important; +} .mw-50 { - max-width: 50% !important; } + max-width: 50% !important; +} .mw-25 { - max-width: 25% !important; } + max-width: 25% !important; +} .mh-75 { - max-height: 75% !important; } + max-height: 75% !important; +} .mh-50 { - max-height: 50% !important; } + max-height: 50% !important; +} .mh-25 { - max-height: 25% !important; } + max-height: 25% !important; +} .p-abs-center-y { top: 50%; - transform: translateY(-50%); } + transform: translateY(-50%); +} .p-abs-center-x { left: 50%; - transform: translateX(-50%); } + transform: translateX(-50%); +} .p-abs-center-both { top: 50%; left: 50%; - transform: translateX(-50%) translateY(-50%); } + transform: translateX(-50%) translateY(-50%); +} /* Body */ .panel { background-color: transparent; border: none; - box-shadow: 0 0 35px 0 rgba(154, 161, 171, 0.25); } + box-shadow: 0 0 35px 0 rgba(154, 161, 171, 0.25); +} .loading-overlay { background-color: #e9e9e8; - opacity: 0.35; } + opacity: 0.35; +} /* Top navbar */ .top-navbar { - background-color: #e83283; } + background-color: #e83283; +} .center-navbar nav.header-breadcrumb { - color: #fff; } + color: #fff; +} header.top-navbar .header-menu > a:hover, header.top-navbar .header-breadcrumb .header-breadcrumb-item > a:hover { - color: #d6d6d6 !important; } + color: #d6d6d6 !important; +} .top-navbar .center-navbar nav.header-breadcrumb li.header-breadcrumb-item a { - color: #fff; } + color: #fff; +} .top-navbar .right-navbar .header-menu a.nav-link { - color: #fff; } + color: #fff; +} .top-navbar .left-navbar .navbar-brand img { - filter: invert(1); } + filter: invert(1); +} .top-navbar .left-navbar .navbar-brand:hover img { - filter: invert(1) drop-shadow(0px 0px 3px #fff); } + filter: invert(1) drop-shadow(0px 0px 3px #fff); +} .top-navbar .composed-app-icon-container > .app-icon { - background-color: #fff; } + background-color: #fff; +} .breadcrumb-link-container { box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.16), 0 2px 6px 0 rgba(0, 0, 0, 0.12); - background-color: rgba(255, 255, 255, 0.4); } + background-color: rgba(255, 255, 255, 0.4); +} /* Sidebar */ .sidebar { - transition: width .08s linear; + transition: width 0.08s linear; box-shadow: none; - background-color: rgba(255, 255, 255, 0.4); } + background-color: rgba(255, 255, 255, 0.4); +} .sidebar ~ main.content:after { - background: #000; } + background: #000; +} .sidebar .sidebar-wrapper { - border-right: 1px solid none; } + border-right: 1px solid none; +} .sidebar .sidebar-wrapper { - border-right: 1px solid rgba(0, 0, 0, 0.125); } + border-right: 1px solid rgba(0, 0, 0, 0.125); +} .sidebar ul.sidebar-elements li > a.sidebar-link { - color: #fff; } + color: #fff; +} .sidebar ul.sidebar-elements li > a.sidebar-link.active { background-color: #fff; - color: #343a40; } + color: #343a40; +} .sidebar ul.sidebar-elements li > a.sidebar-link.have-active-child { background-color: #fff; - color: #343a40; } + color: #343a40; +} .sidebar ul.sidebar-elements li > a.sidebar-link:hover { background-color: #fff; - color: #343a40; } + color: #343a40; +} .sidebar.expanded ul.sidebar-elements li > a.sidebar-link.have-active-child, .sidebar:hover ul.sidebar-elements li > a.sidebar-link.have-active-child { - background-color: unset; } + background-color: unset; +} .sidebar.expanded ul.sidebar-elements li > a.sidebar-link.have-active-child:hover, .sidebar:hover ul.sidebar-elements li > a.sidebar-link.have-active-child:hover { - background-color: #fff; } + background-color: #fff; +} ul.sidebar-elements li > a.sidebar-link.active::after { - background-color: #e83283; } + background-color: #e83283; +} .lock-sidebar > a.btn { - background-color: rgba(255, 255, 255, 0.4); } + background-color: rgba(255, 255, 255, 0.4); +} .btn { display: inline-block; @@ -382,427 +450,485 @@ ul.sidebar-elements li > a.sidebar-link.active::after { padding: 0.75rem 1.5rem; font-size: 1rem; border-radius: 0.5rem; - transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; } - @media (prefers-reduced-motion: reduce) { - .btn { - transition: none; } } - .btn:hover { - color: #fff; } - .btn-check:focus + .btn, .btn:focus { - outline: 0; - box-shadow: 0 0 0 0.25rem rgba(232, 50, 131, 0.25); } - .btn:disabled, .btn.disabled, - fieldset:disabled .btn { - pointer-events: none; - opacity: 0.65; } + transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +} +@media (prefers-reduced-motion: reduce) { + .btn { + transition: none; + } +} +.btn:hover { + color: #fff; +} +.btn-check:focus + .btn, .btn:focus { + outline: 0; + box-shadow: 0 0 0 0.25rem rgba(232, 50, 131, 0.25); +} +.btn:disabled, .btn.disabled, fieldset:disabled .btn { + pointer-events: none; + opacity: 0.65; +} .btn-primary { color: #fff; background-color: #e83283; - border-color: #e83283; } - .btn-primary:hover { - color: #fff; - background-color: #c52b6f; - border-color: #ba2869; } - .btn-check:focus + .btn-primary, .btn-primary:focus { - color: #fff; - background-color: #c52b6f; - border-color: #ba2869; - box-shadow: 0 0 0 0.25rem rgba(235, 81, 150, 0.5); } - .btn-check:checked + .btn-primary, - .btn-check:active + .btn-primary, .btn-primary:active, .btn-primary.active, - .show > .btn-primary.dropdown-toggle { - color: #fff; - background-color: #ba2869; - border-color: #ae2662; } - .btn-check:checked + .btn-primary:focus, - .btn-check:active + .btn-primary:focus, .btn-primary:active:focus, .btn-primary.active:focus, - .show > .btn-primary.dropdown-toggle:focus { - box-shadow: 0 0 0 0.25rem rgba(235, 81, 150, 0.5); } - .btn-primary:disabled, .btn-primary.disabled { - color: #fff; - background-color: #e83283; - border-color: #e83283; } + border-color: #e83283; +} +.btn-primary:hover { + color: #fff; + background-color: #c52b6f; + border-color: #ba2869; +} +.btn-check:focus + .btn-primary, .btn-primary:focus { + color: #fff; + background-color: #c52b6f; + border-color: #ba2869; + box-shadow: 0 0 0 0.25rem rgba(235, 81, 150, 0.5); +} +.btn-check:checked + .btn-primary, .btn-check:active + .btn-primary, .btn-primary:active, .btn-primary.active, .show > .btn-primary.dropdown-toggle { + color: #fff; + background-color: #ba2869; + border-color: #ae2662; +} +.btn-check:checked + .btn-primary:focus, .btn-check:active + .btn-primary:focus, .btn-primary:active:focus, .btn-primary.active:focus, .show > .btn-primary.dropdown-toggle:focus { + box-shadow: 0 0 0 0.25rem rgba(235, 81, 150, 0.5); +} +.btn-primary:disabled, .btn-primary.disabled { + color: #fff; + background-color: #e83283; + border-color: #e83283; +} .btn-secondary { color: #000; background-color: rgba(255, 255, 255, 0.4); - border-color: rgba(255, 255, 255, 0.4); } - .btn-secondary:hover { - color: #000; - background-color: rgba(255, 255, 255, 0.49); - border-color: rgba(255, 255, 255, 0.46); } - .btn-check:focus + .btn-secondary, .btn-secondary:focus { - color: #000; - background-color: rgba(255, 255, 255, 0.49); - border-color: rgba(255, 255, 255, 0.46); - box-shadow: 0 0 0 0.25rem rgba(149, 149, 149, 0.5); } - .btn-check:checked + .btn-secondary, - .btn-check:active + .btn-secondary, .btn-secondary:active, .btn-secondary.active, - .show > .btn-secondary.dropdown-toggle { - color: #000; - background-color: rgba(255, 255, 255, 0.52); - border-color: rgba(255, 255, 255, 0.46); } - .btn-check:checked + .btn-secondary:focus, - .btn-check:active + .btn-secondary:focus, .btn-secondary:active:focus, .btn-secondary.active:focus, - .show > .btn-secondary.dropdown-toggle:focus { - box-shadow: 0 0 0 0.25rem rgba(149, 149, 149, 0.5); } - .btn-secondary:disabled, .btn-secondary.disabled { - color: #000; - background-color: rgba(255, 255, 255, 0.4); - border-color: rgba(255, 255, 255, 0.4); } + border-color: rgba(255, 255, 255, 0.4); +} +.btn-secondary:hover { + color: #000; + background-color: rgba(255, 255, 255, 0.49); + border-color: rgba(255, 255, 255, 0.46); +} +.btn-check:focus + .btn-secondary, .btn-secondary:focus { + color: #000; + background-color: rgba(255, 255, 255, 0.49); + border-color: rgba(255, 255, 255, 0.46); + box-shadow: 0 0 0 0.25rem rgba(149, 149, 149, 0.5); +} +.btn-check:checked + .btn-secondary, .btn-check:active + .btn-secondary, .btn-secondary:active, .btn-secondary.active, .show > .btn-secondary.dropdown-toggle { + color: #000; + background-color: rgba(255, 255, 255, 0.52); + border-color: rgba(255, 255, 255, 0.46); +} +.btn-check:checked + .btn-secondary:focus, .btn-check:active + .btn-secondary:focus, .btn-secondary:active:focus, .btn-secondary.active:focus, .show > .btn-secondary.dropdown-toggle:focus { + box-shadow: 0 0 0 0.25rem rgba(149, 149, 149, 0.5); +} +.btn-secondary:disabled, .btn-secondary.disabled { + color: #000; + background-color: rgba(255, 255, 255, 0.4); + border-color: rgba(255, 255, 255, 0.4); +} .btn-success { color: #fff; background-color: #41d7a7; - border-color: #41d7a7; } - .btn-success:hover { - color: #fff; - background-color: #37b78e; - border-color: #34ac86; } - .btn-check:focus + .btn-success, .btn-success:focus { - color: #fff; - background-color: #37b78e; - border-color: #34ac86; - box-shadow: 0 0 0 0.25rem rgba(94, 221, 180, 0.5); } - .btn-check:checked + .btn-success, - .btn-check:active + .btn-success, .btn-success:active, .btn-success.active, - .show > .btn-success.dropdown-toggle { - color: #fff; - background-color: #34ac86; - border-color: #31a17d; } - .btn-check:checked + .btn-success:focus, - .btn-check:active + .btn-success:focus, .btn-success:active:focus, .btn-success.active:focus, - .show > .btn-success.dropdown-toggle:focus { - box-shadow: 0 0 0 0.25rem rgba(94, 221, 180, 0.5); } - .btn-success:disabled, .btn-success.disabled { - color: #fff; - background-color: #41d7a7; - border-color: #41d7a7; } + border-color: #41d7a7; +} +.btn-success:hover { + color: #fff; + background-color: #37b78e; + border-color: #34ac86; +} +.btn-check:focus + .btn-success, .btn-success:focus { + color: #fff; + background-color: #37b78e; + border-color: #34ac86; + box-shadow: 0 0 0 0.25rem rgba(94, 221, 180, 0.5); +} +.btn-check:checked + .btn-success, .btn-check:active + .btn-success, .btn-success:active, .btn-success.active, .show > .btn-success.dropdown-toggle { + color: #fff; + background-color: #34ac86; + border-color: #31a17d; +} +.btn-check:checked + .btn-success:focus, .btn-check:active + .btn-success:focus, .btn-success:active:focus, .btn-success.active:focus, .show > .btn-success.dropdown-toggle:focus { + box-shadow: 0 0 0 0.25rem rgba(94, 221, 180, 0.5); +} +.btn-success:disabled, .btn-success.disabled { + color: #fff; + background-color: #41d7a7; + border-color: #41d7a7; +} .btn-info { color: #fff; background-color: #39cbfb; - border-color: #39cbfb; } - .btn-info:hover { - color: #fff; - background-color: #30add5; - border-color: #2ea2c9; } - .btn-check:focus + .btn-info, .btn-info:focus { - color: #fff; - background-color: #30add5; - border-color: #2ea2c9; - box-shadow: 0 0 0 0.25rem rgba(87, 211, 252, 0.5); } - .btn-check:checked + .btn-info, - .btn-check:active + .btn-info, .btn-info:active, .btn-info.active, - .show > .btn-info.dropdown-toggle { - color: #fff; - background-color: #2ea2c9; - border-color: #2b98bc; } - .btn-check:checked + .btn-info:focus, - .btn-check:active + .btn-info:focus, .btn-info:active:focus, .btn-info.active:focus, - .show > .btn-info.dropdown-toggle:focus { - box-shadow: 0 0 0 0.25rem rgba(87, 211, 252, 0.5); } - .btn-info:disabled, .btn-info.disabled { - color: #fff; - background-color: #39cbfb; - border-color: #39cbfb; } + border-color: #39cbfb; +} +.btn-info:hover { + color: #fff; + background-color: #30add5; + border-color: #2ea2c9; +} +.btn-check:focus + .btn-info, .btn-info:focus { + color: #fff; + background-color: #30add5; + border-color: #2ea2c9; + box-shadow: 0 0 0 0.25rem rgba(87, 211, 252, 0.5); +} +.btn-check:checked + .btn-info, .btn-check:active + .btn-info, .btn-info:active, .btn-info.active, .show > .btn-info.dropdown-toggle { + color: #fff; + background-color: #2ea2c9; + border-color: #2b98bc; +} +.btn-check:checked + .btn-info:focus, .btn-check:active + .btn-info:focus, .btn-info:active:focus, .btn-info.active:focus, .show > .btn-info.dropdown-toggle:focus { + box-shadow: 0 0 0 0.25rem rgba(87, 211, 252, 0.5); +} +.btn-info:disabled, .btn-info.disabled { + color: #fff; + background-color: #39cbfb; + border-color: #39cbfb; +} .btn-warning { color: #fff; background-color: #ffc107; - border-color: #ffc107; } - .btn-warning:hover { - color: #fff; - background-color: #d9a406; - border-color: #cc9a06; } - .btn-check:focus + .btn-warning, .btn-warning:focus { - color: #fff; - background-color: #d9a406; - border-color: #cc9a06; - box-shadow: 0 0 0 0.25rem rgba(255, 202, 44, 0.5); } - .btn-check:checked + .btn-warning, - .btn-check:active + .btn-warning, .btn-warning:active, .btn-warning.active, - .show > .btn-warning.dropdown-toggle { - color: #fff; - background-color: #cc9a06; - border-color: #bf9105; } - .btn-check:checked + .btn-warning:focus, - .btn-check:active + .btn-warning:focus, .btn-warning:active:focus, .btn-warning.active:focus, - .show > .btn-warning.dropdown-toggle:focus { - box-shadow: 0 0 0 0.25rem rgba(255, 202, 44, 0.5); } - .btn-warning:disabled, .btn-warning.disabled { - color: #fff; - background-color: #ffc107; - border-color: #ffc107; } + border-color: #ffc107; +} +.btn-warning:hover { + color: #fff; + background-color: #d9a406; + border-color: #cc9a06; +} +.btn-check:focus + .btn-warning, .btn-warning:focus { + color: #fff; + background-color: #d9a406; + border-color: #cc9a06; + box-shadow: 0 0 0 0.25rem rgba(255, 202, 44, 0.5); +} +.btn-check:checked + .btn-warning, .btn-check:active + .btn-warning, .btn-warning:active, .btn-warning.active, .show > .btn-warning.dropdown-toggle { + color: #fff; + background-color: #cc9a06; + border-color: #bf9105; +} +.btn-check:checked + .btn-warning:focus, .btn-check:active + .btn-warning:focus, .btn-warning:active:focus, .btn-warning.active:focus, .show > .btn-warning.dropdown-toggle:focus { + box-shadow: 0 0 0 0.25rem rgba(255, 202, 44, 0.5); +} +.btn-warning:disabled, .btn-warning.disabled { + color: #fff; + background-color: #ffc107; + border-color: #ffc107; +} .btn-danger { color: #fff; background-color: #fd7e14; - border-color: #fd7e14; } - .btn-danger:hover { - color: #fff; - background-color: #d76b11; - border-color: #ca6510; } - .btn-check:focus + .btn-danger, .btn-danger:focus { - color: #fff; - background-color: #d76b11; - border-color: #ca6510; - box-shadow: 0 0 0 0.25rem rgba(253, 145, 55, 0.5); } - .btn-check:checked + .btn-danger, - .btn-check:active + .btn-danger, .btn-danger:active, .btn-danger.active, - .show > .btn-danger.dropdown-toggle { - color: #fff; - background-color: #ca6510; - border-color: #be5f0f; } - .btn-check:checked + .btn-danger:focus, - .btn-check:active + .btn-danger:focus, .btn-danger:active:focus, .btn-danger.active:focus, - .show > .btn-danger.dropdown-toggle:focus { - box-shadow: 0 0 0 0.25rem rgba(253, 145, 55, 0.5); } - .btn-danger:disabled, .btn-danger.disabled { - color: #fff; - background-color: #fd7e14; - border-color: #fd7e14; } + border-color: #fd7e14; +} +.btn-danger:hover { + color: #fff; + background-color: #d76b11; + border-color: #ca6510; +} +.btn-check:focus + .btn-danger, .btn-danger:focus { + color: #fff; + background-color: #d76b11; + border-color: #ca6510; + box-shadow: 0 0 0 0.25rem rgba(253, 145, 55, 0.5); +} +.btn-check:checked + .btn-danger, .btn-check:active + .btn-danger, .btn-danger:active, .btn-danger.active, .show > .btn-danger.dropdown-toggle { + color: #fff; + background-color: #ca6510; + border-color: #be5f0f; +} +.btn-check:checked + .btn-danger:focus, .btn-check:active + .btn-danger:focus, .btn-danger:active:focus, .btn-danger.active:focus, .show > .btn-danger.dropdown-toggle:focus { + box-shadow: 0 0 0 0.25rem rgba(253, 145, 55, 0.5); +} +.btn-danger:disabled, .btn-danger.disabled { + color: #fff; + background-color: #fd7e14; + border-color: #fd7e14; +} .btn-light { color: #000; background-color: #e9e9e8; - border-color: #e9e9e8; } - .btn-light:hover { - color: #000; - background-color: #ececeb; - border-color: #ebebea; } - .btn-check:focus + .btn-light, .btn-light:focus { - color: #000; - background-color: #ececeb; - border-color: #ebebea; - box-shadow: 0 0 0 0.25rem rgba(198, 198, 197, 0.5); } - .btn-check:checked + .btn-light, - .btn-check:active + .btn-light, .btn-light:active, .btn-light.active, - .show > .btn-light.dropdown-toggle { - color: #000; - background-color: #ededed; - border-color: #ebebea; } - .btn-check:checked + .btn-light:focus, - .btn-check:active + .btn-light:focus, .btn-light:active:focus, .btn-light.active:focus, - .show > .btn-light.dropdown-toggle:focus { - box-shadow: 0 0 0 0.25rem rgba(198, 198, 197, 0.5); } - .btn-light:disabled, .btn-light.disabled { - color: #000; - background-color: #e9e9e8; - border-color: #e9e9e8; } + border-color: #e9e9e8; +} +.btn-light:hover { + color: #000; + background-color: #ececeb; + border-color: #ebebea; +} +.btn-check:focus + .btn-light, .btn-light:focus { + color: #000; + background-color: #ececeb; + border-color: #ebebea; + box-shadow: 0 0 0 0.25rem rgba(198, 198, 197, 0.5); +} +.btn-check:checked + .btn-light, .btn-check:active + .btn-light, .btn-light:active, .btn-light.active, .show > .btn-light.dropdown-toggle { + color: #000; + background-color: #ededed; + border-color: #ebebea; +} +.btn-check:checked + .btn-light:focus, .btn-check:active + .btn-light:focus, .btn-light:active:focus, .btn-light.active:focus, .show > .btn-light.dropdown-toggle:focus { + box-shadow: 0 0 0 0.25rem rgba(198, 198, 197, 0.5); +} +.btn-light:disabled, .btn-light.disabled { + color: #000; + background-color: #e9e9e8; + border-color: #e9e9e8; +} .btn-dark { color: #fff; background-color: #212529; - border-color: #212529; } - .btn-dark:hover { - color: #fff; - background-color: #1c1f23; - border-color: #1a1e21; } - .btn-check:focus + .btn-dark, .btn-dark:focus { - color: #fff; - background-color: #1c1f23; - border-color: #1a1e21; - box-shadow: 0 0 0 0.25rem rgba(66, 70, 73, 0.5); } - .btn-check:checked + .btn-dark, - .btn-check:active + .btn-dark, .btn-dark:active, .btn-dark.active, - .show > .btn-dark.dropdown-toggle { - color: #fff; - background-color: #1a1e21; - border-color: #191c1f; } - .btn-check:checked + .btn-dark:focus, - .btn-check:active + .btn-dark:focus, .btn-dark:active:focus, .btn-dark.active:focus, - .show > .btn-dark.dropdown-toggle:focus { - box-shadow: 0 0 0 0.25rem rgba(66, 70, 73, 0.5); } - .btn-dark:disabled, .btn-dark.disabled { - color: #fff; - background-color: #212529; - border-color: #212529; } + border-color: #212529; +} +.btn-dark:hover { + color: #fff; + background-color: #1c1f23; + border-color: #1a1e21; +} +.btn-check:focus + .btn-dark, .btn-dark:focus { + color: #fff; + background-color: #1c1f23; + border-color: #1a1e21; + box-shadow: 0 0 0 0.25rem rgba(66, 70, 73, 0.5); +} +.btn-check:checked + .btn-dark, .btn-check:active + .btn-dark, .btn-dark:active, .btn-dark.active, .show > .btn-dark.dropdown-toggle { + color: #fff; + background-color: #1a1e21; + border-color: #191c1f; +} +.btn-check:checked + .btn-dark:focus, .btn-check:active + .btn-dark:focus, .btn-dark:active:focus, .btn-dark.active:focus, .show > .btn-dark.dropdown-toggle:focus { + box-shadow: 0 0 0 0.25rem rgba(66, 70, 73, 0.5); +} +.btn-dark:disabled, .btn-dark.disabled { + color: #fff; + background-color: #212529; + border-color: #212529; +} .btn-outline-primary { color: #e83283; - border-color: #e83283; } - .btn-outline-primary:hover { - color: #fff; - background-color: #e83283; - border-color: #e83283; } - .btn-check:focus + .btn-outline-primary, .btn-outline-primary:focus { - box-shadow: 0 0 0 0.25rem rgba(232, 50, 131, 0.5); } - .btn-check:checked + .btn-outline-primary, - .btn-check:active + .btn-outline-primary, .btn-outline-primary:active, .btn-outline-primary.active, .btn-outline-primary.dropdown-toggle.show { - color: #fff; - background-color: #e83283; - border-color: #e83283; } - .btn-check:checked + .btn-outline-primary:focus, - .btn-check:active + .btn-outline-primary:focus, .btn-outline-primary:active:focus, .btn-outline-primary.active:focus, .btn-outline-primary.dropdown-toggle.show:focus { - box-shadow: 0 0 0 0.25rem rgba(232, 50, 131, 0.5); } - .btn-outline-primary:disabled, .btn-outline-primary.disabled { - color: #e83283; - background-color: transparent; } + border-color: #e83283; +} +.btn-outline-primary:hover { + color: #fff; + background-color: #e83283; + border-color: #e83283; +} +.btn-check:focus + .btn-outline-primary, .btn-outline-primary:focus { + box-shadow: 0 0 0 0.25rem rgba(232, 50, 131, 0.5); +} +.btn-check:checked + .btn-outline-primary, .btn-check:active + .btn-outline-primary, .btn-outline-primary:active, .btn-outline-primary.active, .btn-outline-primary.dropdown-toggle.show { + color: #fff; + background-color: #e83283; + border-color: #e83283; +} +.btn-check:checked + .btn-outline-primary:focus, .btn-check:active + .btn-outline-primary:focus, .btn-outline-primary:active:focus, .btn-outline-primary.active:focus, .btn-outline-primary.dropdown-toggle.show:focus { + box-shadow: 0 0 0 0.25rem rgba(232, 50, 131, 0.5); +} +.btn-outline-primary:disabled, .btn-outline-primary.disabled { + color: #e83283; + background-color: transparent; +} .btn-outline-secondary { color: rgba(255, 255, 255, 0.4); - border-color: rgba(255, 255, 255, 0.4); } - .btn-outline-secondary:hover { - color: #000; - background-color: rgba(255, 255, 255, 0.4); - border-color: rgba(255, 255, 255, 0.4); } - .btn-check:focus + .btn-outline-secondary, .btn-outline-secondary:focus { - box-shadow: 0 0 0 0.25rem rgba(255, 255, 255, 0.5); } - .btn-check:checked + .btn-outline-secondary, - .btn-check:active + .btn-outline-secondary, .btn-outline-secondary:active, .btn-outline-secondary.active, .btn-outline-secondary.dropdown-toggle.show { - color: #000; - background-color: rgba(255, 255, 255, 0.4); - border-color: rgba(255, 255, 255, 0.4); } - .btn-check:checked + .btn-outline-secondary:focus, - .btn-check:active + .btn-outline-secondary:focus, .btn-outline-secondary:active:focus, .btn-outline-secondary.active:focus, .btn-outline-secondary.dropdown-toggle.show:focus { - box-shadow: 0 0 0 0.25rem rgba(255, 255, 255, 0.5); } - .btn-outline-secondary:disabled, .btn-outline-secondary.disabled { - color: rgba(255, 255, 255, 0.4); - background-color: transparent; } + border-color: rgba(255, 255, 255, 0.4); +} +.btn-outline-secondary:hover { + color: #000; + background-color: rgba(255, 255, 255, 0.4); + border-color: rgba(255, 255, 255, 0.4); +} +.btn-check:focus + .btn-outline-secondary, .btn-outline-secondary:focus { + box-shadow: 0 0 0 0.25rem rgba(255, 255, 255, 0.5); +} +.btn-check:checked + .btn-outline-secondary, .btn-check:active + .btn-outline-secondary, .btn-outline-secondary:active, .btn-outline-secondary.active, .btn-outline-secondary.dropdown-toggle.show { + color: #000; + background-color: rgba(255, 255, 255, 0.4); + border-color: rgba(255, 255, 255, 0.4); +} +.btn-check:checked + .btn-outline-secondary:focus, .btn-check:active + .btn-outline-secondary:focus, .btn-outline-secondary:active:focus, .btn-outline-secondary.active:focus, .btn-outline-secondary.dropdown-toggle.show:focus { + box-shadow: 0 0 0 0.25rem rgba(255, 255, 255, 0.5); +} +.btn-outline-secondary:disabled, .btn-outline-secondary.disabled { + color: rgba(255, 255, 255, 0.4); + background-color: transparent; +} .btn-outline-success { color: #41d7a7; - border-color: #41d7a7; } - .btn-outline-success:hover { - color: #fff; - background-color: #41d7a7; - border-color: #41d7a7; } - .btn-check:focus + .btn-outline-success, .btn-outline-success:focus { - box-shadow: 0 0 0 0.25rem rgba(65, 215, 167, 0.5); } - .btn-check:checked + .btn-outline-success, - .btn-check:active + .btn-outline-success, .btn-outline-success:active, .btn-outline-success.active, .btn-outline-success.dropdown-toggle.show { - color: #fff; - background-color: #41d7a7; - border-color: #41d7a7; } - .btn-check:checked + .btn-outline-success:focus, - .btn-check:active + .btn-outline-success:focus, .btn-outline-success:active:focus, .btn-outline-success.active:focus, .btn-outline-success.dropdown-toggle.show:focus { - box-shadow: 0 0 0 0.25rem rgba(65, 215, 167, 0.5); } - .btn-outline-success:disabled, .btn-outline-success.disabled { - color: #41d7a7; - background-color: transparent; } + border-color: #41d7a7; +} +.btn-outline-success:hover { + color: #fff; + background-color: #41d7a7; + border-color: #41d7a7; +} +.btn-check:focus + .btn-outline-success, .btn-outline-success:focus { + box-shadow: 0 0 0 0.25rem rgba(65, 215, 167, 0.5); +} +.btn-check:checked + .btn-outline-success, .btn-check:active + .btn-outline-success, .btn-outline-success:active, .btn-outline-success.active, .btn-outline-success.dropdown-toggle.show { + color: #fff; + background-color: #41d7a7; + border-color: #41d7a7; +} +.btn-check:checked + .btn-outline-success:focus, .btn-check:active + .btn-outline-success:focus, .btn-outline-success:active:focus, .btn-outline-success.active:focus, .btn-outline-success.dropdown-toggle.show:focus { + box-shadow: 0 0 0 0.25rem rgba(65, 215, 167, 0.5); +} +.btn-outline-success:disabled, .btn-outline-success.disabled { + color: #41d7a7; + background-color: transparent; +} .btn-outline-info { color: #39cbfb; - border-color: #39cbfb; } - .btn-outline-info:hover { - color: #fff; - background-color: #39cbfb; - border-color: #39cbfb; } - .btn-check:focus + .btn-outline-info, .btn-outline-info:focus { - box-shadow: 0 0 0 0.25rem rgba(57, 203, 251, 0.5); } - .btn-check:checked + .btn-outline-info, - .btn-check:active + .btn-outline-info, .btn-outline-info:active, .btn-outline-info.active, .btn-outline-info.dropdown-toggle.show { - color: #fff; - background-color: #39cbfb; - border-color: #39cbfb; } - .btn-check:checked + .btn-outline-info:focus, - .btn-check:active + .btn-outline-info:focus, .btn-outline-info:active:focus, .btn-outline-info.active:focus, .btn-outline-info.dropdown-toggle.show:focus { - box-shadow: 0 0 0 0.25rem rgba(57, 203, 251, 0.5); } - .btn-outline-info:disabled, .btn-outline-info.disabled { - color: #39cbfb; - background-color: transparent; } + border-color: #39cbfb; +} +.btn-outline-info:hover { + color: #fff; + background-color: #39cbfb; + border-color: #39cbfb; +} +.btn-check:focus + .btn-outline-info, .btn-outline-info:focus { + box-shadow: 0 0 0 0.25rem rgba(57, 203, 251, 0.5); +} +.btn-check:checked + .btn-outline-info, .btn-check:active + .btn-outline-info, .btn-outline-info:active, .btn-outline-info.active, .btn-outline-info.dropdown-toggle.show { + color: #fff; + background-color: #39cbfb; + border-color: #39cbfb; +} +.btn-check:checked + .btn-outline-info:focus, .btn-check:active + .btn-outline-info:focus, .btn-outline-info:active:focus, .btn-outline-info.active:focus, .btn-outline-info.dropdown-toggle.show:focus { + box-shadow: 0 0 0 0.25rem rgba(57, 203, 251, 0.5); +} +.btn-outline-info:disabled, .btn-outline-info.disabled { + color: #39cbfb; + background-color: transparent; +} .btn-outline-warning { color: #ffc107; - border-color: #ffc107; } - .btn-outline-warning:hover { - color: #fff; - background-color: #ffc107; - border-color: #ffc107; } - .btn-check:focus + .btn-outline-warning, .btn-outline-warning:focus { - box-shadow: 0 0 0 0.25rem rgba(255, 193, 7, 0.5); } - .btn-check:checked + .btn-outline-warning, - .btn-check:active + .btn-outline-warning, .btn-outline-warning:active, .btn-outline-warning.active, .btn-outline-warning.dropdown-toggle.show { - color: #fff; - background-color: #ffc107; - border-color: #ffc107; } - .btn-check:checked + .btn-outline-warning:focus, - .btn-check:active + .btn-outline-warning:focus, .btn-outline-warning:active:focus, .btn-outline-warning.active:focus, .btn-outline-warning.dropdown-toggle.show:focus { - box-shadow: 0 0 0 0.25rem rgba(255, 193, 7, 0.5); } - .btn-outline-warning:disabled, .btn-outline-warning.disabled { - color: #ffc107; - background-color: transparent; } + border-color: #ffc107; +} +.btn-outline-warning:hover { + color: #fff; + background-color: #ffc107; + border-color: #ffc107; +} +.btn-check:focus + .btn-outline-warning, .btn-outline-warning:focus { + box-shadow: 0 0 0 0.25rem rgba(255, 193, 7, 0.5); +} +.btn-check:checked + .btn-outline-warning, .btn-check:active + .btn-outline-warning, .btn-outline-warning:active, .btn-outline-warning.active, .btn-outline-warning.dropdown-toggle.show { + color: #fff; + background-color: #ffc107; + border-color: #ffc107; +} +.btn-check:checked + .btn-outline-warning:focus, .btn-check:active + .btn-outline-warning:focus, .btn-outline-warning:active:focus, .btn-outline-warning.active:focus, .btn-outline-warning.dropdown-toggle.show:focus { + box-shadow: 0 0 0 0.25rem rgba(255, 193, 7, 0.5); +} +.btn-outline-warning:disabled, .btn-outline-warning.disabled { + color: #ffc107; + background-color: transparent; +} .btn-outline-danger { color: #fd7e14; - border-color: #fd7e14; } - .btn-outline-danger:hover { - color: #fff; - background-color: #fd7e14; - border-color: #fd7e14; } - .btn-check:focus + .btn-outline-danger, .btn-outline-danger:focus { - box-shadow: 0 0 0 0.25rem rgba(253, 126, 20, 0.5); } - .btn-check:checked + .btn-outline-danger, - .btn-check:active + .btn-outline-danger, .btn-outline-danger:active, .btn-outline-danger.active, .btn-outline-danger.dropdown-toggle.show { - color: #fff; - background-color: #fd7e14; - border-color: #fd7e14; } - .btn-check:checked + .btn-outline-danger:focus, - .btn-check:active + .btn-outline-danger:focus, .btn-outline-danger:active:focus, .btn-outline-danger.active:focus, .btn-outline-danger.dropdown-toggle.show:focus { - box-shadow: 0 0 0 0.25rem rgba(253, 126, 20, 0.5); } - .btn-outline-danger:disabled, .btn-outline-danger.disabled { - color: #fd7e14; - background-color: transparent; } + border-color: #fd7e14; +} +.btn-outline-danger:hover { + color: #fff; + background-color: #fd7e14; + border-color: #fd7e14; +} +.btn-check:focus + .btn-outline-danger, .btn-outline-danger:focus { + box-shadow: 0 0 0 0.25rem rgba(253, 126, 20, 0.5); +} +.btn-check:checked + .btn-outline-danger, .btn-check:active + .btn-outline-danger, .btn-outline-danger:active, .btn-outline-danger.active, .btn-outline-danger.dropdown-toggle.show { + color: #fff; + background-color: #fd7e14; + border-color: #fd7e14; +} +.btn-check:checked + .btn-outline-danger:focus, .btn-check:active + .btn-outline-danger:focus, .btn-outline-danger:active:focus, .btn-outline-danger.active:focus, .btn-outline-danger.dropdown-toggle.show:focus { + box-shadow: 0 0 0 0.25rem rgba(253, 126, 20, 0.5); +} +.btn-outline-danger:disabled, .btn-outline-danger.disabled { + color: #fd7e14; + background-color: transparent; +} .btn-outline-light { color: #e9e9e8; - border-color: #e9e9e8; } - .btn-outline-light:hover { - color: #000; - background-color: #e9e9e8; - border-color: #e9e9e8; } - .btn-check:focus + .btn-outline-light, .btn-outline-light:focus { - box-shadow: 0 0 0 0.25rem rgba(233, 233, 232, 0.5); } - .btn-check:checked + .btn-outline-light, - .btn-check:active + .btn-outline-light, .btn-outline-light:active, .btn-outline-light.active, .btn-outline-light.dropdown-toggle.show { - color: #000; - background-color: #e9e9e8; - border-color: #e9e9e8; } - .btn-check:checked + .btn-outline-light:focus, - .btn-check:active + .btn-outline-light:focus, .btn-outline-light:active:focus, .btn-outline-light.active:focus, .btn-outline-light.dropdown-toggle.show:focus { - box-shadow: 0 0 0 0.25rem rgba(233, 233, 232, 0.5); } - .btn-outline-light:disabled, .btn-outline-light.disabled { - color: #e9e9e8; - background-color: transparent; } + border-color: #e9e9e8; +} +.btn-outline-light:hover { + color: #000; + background-color: #e9e9e8; + border-color: #e9e9e8; +} +.btn-check:focus + .btn-outline-light, .btn-outline-light:focus { + box-shadow: 0 0 0 0.25rem rgba(233, 233, 232, 0.5); +} +.btn-check:checked + .btn-outline-light, .btn-check:active + .btn-outline-light, .btn-outline-light:active, .btn-outline-light.active, .btn-outline-light.dropdown-toggle.show { + color: #000; + background-color: #e9e9e8; + border-color: #e9e9e8; +} +.btn-check:checked + .btn-outline-light:focus, .btn-check:active + .btn-outline-light:focus, .btn-outline-light:active:focus, .btn-outline-light.active:focus, .btn-outline-light.dropdown-toggle.show:focus { + box-shadow: 0 0 0 0.25rem rgba(233, 233, 232, 0.5); +} +.btn-outline-light:disabled, .btn-outline-light.disabled { + color: #e9e9e8; + background-color: transparent; +} .btn-outline-dark { color: #212529; - border-color: #212529; } - .btn-outline-dark:hover { - color: #fff; - background-color: #212529; - border-color: #212529; } - .btn-check:focus + .btn-outline-dark, .btn-outline-dark:focus { - box-shadow: 0 0 0 0.25rem rgba(33, 37, 41, 0.5); } - .btn-check:checked + .btn-outline-dark, - .btn-check:active + .btn-outline-dark, .btn-outline-dark:active, .btn-outline-dark.active, .btn-outline-dark.dropdown-toggle.show { - color: #fff; - background-color: #212529; - border-color: #212529; } - .btn-check:checked + .btn-outline-dark:focus, - .btn-check:active + .btn-outline-dark:focus, .btn-outline-dark:active:focus, .btn-outline-dark.active:focus, .btn-outline-dark.dropdown-toggle.show:focus { - box-shadow: 0 0 0 0.25rem rgba(33, 37, 41, 0.5); } - .btn-outline-dark:disabled, .btn-outline-dark.disabled { - color: #212529; - background-color: transparent; } + border-color: #212529; +} +.btn-outline-dark:hover { + color: #fff; + background-color: #212529; + border-color: #212529; +} +.btn-check:focus + .btn-outline-dark, .btn-outline-dark:focus { + box-shadow: 0 0 0 0.25rem rgba(33, 37, 41, 0.5); +} +.btn-check:checked + .btn-outline-dark, .btn-check:active + .btn-outline-dark, .btn-outline-dark:active, .btn-outline-dark.active, .btn-outline-dark.dropdown-toggle.show { + color: #fff; + background-color: #212529; + border-color: #212529; +} +.btn-check:checked + .btn-outline-dark:focus, .btn-check:active + .btn-outline-dark:focus, .btn-outline-dark:active:focus, .btn-outline-dark.active:focus, .btn-outline-dark.dropdown-toggle.show:focus { + box-shadow: 0 0 0 0.25rem rgba(33, 37, 41, 0.5); +} +.btn-outline-dark:disabled, .btn-outline-dark.disabled { + color: #212529; + background-color: transparent; +} .btn-link { font-weight: 400; color: #fff; - text-decoration: underline; } - .btn-link:hover { - color: #cccccc; } - .btn-link:disabled, .btn-link.disabled { - color: #6c757d; } + text-decoration: underline; +} +.btn-link:hover { + color: #cccccc; +} +.btn-link:disabled, .btn-link.disabled { + color: #6c757d; +} .btn-lg { padding: 0.5rem 1rem; font-size: 1.25rem; - border-radius: 0.7rem; } + border-radius: 0.7rem; +} .btn-sm { padding: 0.25rem 0.5rem; font-size: 0.875rem; - border-radius: 0.6rem; } + border-radius: 0.6rem; +} diff --git a/webroot/css/themes/theme-slate.css b/webroot/css/themes/theme-slate.css index d8b5ba6..0d722d6 100644 --- a/webroot/css/themes/theme-slate.css +++ b/webroot/css/themes/theme-slate.css @@ -1,283 +1,332 @@ /* Callout */ .callout { border: 1px solid #e9ecef; - border-radius: .25rem; + border-radius: 0.25rem; background-color: #363636; - box-shadow: none; } + box-shadow: none; +} .callout-primary { border-left-color: #3a3f44; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-secondary { border-left-color: #7a8288; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-success { border-left-color: #62c462; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-info { border-left-color: #5bc0de; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-warning { border-left-color: #f89406; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-danger { border-left-color: #ee5f5b; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-light { border-left-color: #e9ecef; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-dark { border-left-color: #272b30; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} /* Toasts */ .toast { - min-width: 250px; } + min-width: 250px; +} .toast-primary { color: #111314; background-color: #c4c5c7; - border-color: #b0b2b4; } - .toast-primary strong { - border-top-color: #a3a5a8; } + border-color: #b0b2b4; +} +.toast-primary strong { + border-top-color: #a3a5a8; +} .toast-secondary { color: #252729; background-color: #d7dadb; - border-color: #cacdcf; } - .toast-secondary strong { - border-top-color: #bdc0c3; } + border-color: #cacdcf; +} +.toast-secondary strong { + border-top-color: #bdc0c3; +} .toast-success { color: #1d3b1d; background-color: #d0edd0; - border-color: #c0e7c0; } - .toast-success strong { - border-top-color: #aee0ae; } + border-color: #c0e7c0; +} +.toast-success strong { + border-top-color: #aee0ae; +} .toast-info { color: #1b3a43; background-color: #ceecf5; - border-color: #bde6f2; } - .toast-info strong { - border-top-color: #a8deee; } + border-color: #bde6f2; +} +.toast-info strong { + border-top-color: #a8deee; +} .toast-warning { color: #4a2c02; background-color: #fddfb4; - border-color: #fcd49b; } - .toast-warning strong { - border-top-color: #fbc982; } + border-color: #fcd49b; +} +.toast-warning strong { + border-top-color: #fbc982; +} .toast-danger { color: #471d1b; background-color: #facfce; - border-color: #f8bfbd; } - .toast-danger strong { - border-top-color: #f6a9a6; } + border-color: #f8bfbd; +} +.toast-danger strong { + border-top-color: #f6a9a6; +} .toast-light { color: #464748; background-color: #f8f9fa; - border-color: #f6f7f9; } - .toast-light strong { - border-top-color: #e7e9ef; } + border-color: #f6f7f9; +} +.toast-light strong { + border-top-color: #e7e9ef; +} .toast-dark { color: #0c0d0e; background-color: #bebfc1; - border-color: #a9aaac; } - .toast-dark strong { - border-top-color: #9c9d9f; } + border-color: #a9aaac; +} +.toast-dark strong { + border-top-color: #9c9d9f; +} /* Dropdown-item */ .dropdown-item.dropdown-item-primary { color: #fff; text-decoration: none; - background-color: #3a3f44; } - + background-color: #3a3f44; +} .dropdown-item.dropdown-item-outline-primary:hover { color: #fff; - background-color: #3a3f44; } - + background-color: #3a3f44; +} .dropdown-item.dropdown-item-secondary { color: #fff; text-decoration: none; - background-color: #7a8288; } - + background-color: #7a8288; +} .dropdown-item.dropdown-item-outline-secondary:hover { color: #fff; - background-color: #7a8288; } - + background-color: #7a8288; +} .dropdown-item.dropdown-item-success { color: #fff; text-decoration: none; - background-color: #62c462; } - + background-color: #62c462; +} .dropdown-item.dropdown-item-outline-success:hover { color: #fff; - background-color: #62c462; } - + background-color: #62c462; +} .dropdown-item.dropdown-item-info { color: #fff; text-decoration: none; - background-color: #5bc0de; } - + background-color: #5bc0de; +} .dropdown-item.dropdown-item-outline-info:hover { color: #fff; - background-color: #5bc0de; } - + background-color: #5bc0de; +} .dropdown-item.dropdown-item-warning { color: #fff; text-decoration: none; - background-color: #f89406; } - + background-color: #f89406; +} .dropdown-item.dropdown-item-outline-warning:hover { color: #fff; - background-color: #f89406; } - + background-color: #f89406; +} .dropdown-item.dropdown-item-danger { color: #fff; text-decoration: none; - background-color: #ee5f5b; } - + background-color: #ee5f5b; +} .dropdown-item.dropdown-item-outline-danger:hover { color: #fff; - background-color: #ee5f5b; } - + background-color: #ee5f5b; +} .dropdown-item.dropdown-item-light { color: #000; text-decoration: none; - background-color: #e9ecef; } - + background-color: #e9ecef; +} .dropdown-item.dropdown-item-outline-light:hover { color: #000; - background-color: #e9ecef; } - + background-color: #e9ecef; +} .dropdown-item.dropdown-item-dark { color: #fff; text-decoration: none; - background-color: #272b30; } - + background-color: #272b30; +} .dropdown-item.dropdown-item-outline-dark:hover { color: #fff; - background-color: #272b30; } + background-color: #272b30; +} /* Progress Timeline */ .progress-timeline { - padding: 0.2em 0.2em 0.5em 0.2em; } - .progress-timeline ul { - position: relative; - padding: 0; } - .progress-timeline li { - list-style-type: none; - position: relative; } - .progress-timeline li.progress-inactive { - opacity: 0.5; } - .progress-timeline .progress-line { - height: 2px; } - .progress-timeline .progress-line.progress-inactive { - opacity: 0.5; } + padding: 0.2em 0.2em 0.5em 0.2em; +} +.progress-timeline ul { + position: relative; + padding: 0; +} +.progress-timeline li { + list-style-type: none; + position: relative; +} +.progress-timeline li.progress-inactive { + opacity: 0.5; +} +.progress-timeline .progress-line { + height: 2px; +} +.progress-timeline .progress-line.progress-inactive { + opacity: 0.5; +} /* Forms severity */ .form-control.is-invalid.info { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%235bc0de' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%235bc0de' stroke='none'/%3e%3c/svg%3e"); } - .form-control.is-invalid.info:focus { - border-color: #5bc0de; - box-shadow: 0 0 0 0.25rem rgba(91, 192, 222, 0.25); } - + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%235bc0de' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%235bc0de' stroke='none'/%3e%3c/svg%3e"); +} +.form-control.is-invalid.info:focus { + border-color: #5bc0de; + box-shadow: 0 0 0 0.25rem rgba(91, 192, 222, 0.25); +} .form-control.is-invalid.warning { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23f89406' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23f89406' stroke='none'/%3e%3c/svg%3e"); } - .form-control.is-invalid.warning:focus { - border-color: #f89406; - box-shadow: 0 0 0 0.25rem rgba(248, 148, 6, 0.25); } + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23f89406' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23f89406' stroke='none'/%3e%3c/svg%3e"); +} +.form-control.is-invalid.warning:focus { + border-color: #f89406; + box-shadow: 0 0 0 0.25rem rgba(248, 148, 6, 0.25); +} .form-select.is-invalid:not([multiple]):not([size]).info, .form-select.is-invalid:not([multiple])[size="1"] .form-select.is-invalid.info { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%235bc0de'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%235bc0de' stroke='none'/%3e%3c/svg%3e"); - background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); } - .form-select.is-invalid:not([multiple]):not([size]).info:focus, - .form-select.is-invalid:not([multiple])[size="1"] .form-select.is-invalid.info:focus { - box-shadow: 0 0 0 0.25rem rgba(91, 192, 222, 0.25); } - + background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); +} +.form-select.is-invalid:not([multiple]):not([size]).info:focus, +.form-select.is-invalid:not([multiple])[size="1"] .form-select.is-invalid.info:focus { + box-shadow: 0 0 0 0.25rem rgba(91, 192, 222, 0.25); +} .form-select.is-invalid:not([multiple]):not([size]).warning, .form-select.is-invalid:not([multiple])[size="1"] .form-select.is-invalid.warning { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23f89406'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23f89406' stroke='none'/%3e%3c/svg%3e"); - background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); } - .form-select.is-invalid:not([multiple]):not([size]).warning:focus, - .form-select.is-invalid:not([multiple])[size="1"] .form-select.is-invalid.warning:focus { - box-shadow: 0 0 0 0.25rem rgba(248, 148, 6, 0.25); } + background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); +} +.form-select.is-invalid:not([multiple]):not([size]).warning:focus, +.form-select.is-invalid:not([multiple])[size="1"] .form-select.is-invalid.warning:focus { + box-shadow: 0 0 0 0.25rem rgba(248, 148, 6, 0.25); +} .form-check-input.is-invalid.info { - border-color: #5bc0de; } - + border-color: #5bc0de; +} .form-check-input.is-invalid.info:checked { - background-color: #5bc0de; } - + background-color: #5bc0de; +} .form-check-input.is-invalid.info ~ .form-check-label { - color: unset; } - + color: unset; +} .form-check-input.is-invalid.info:focus { - box-shadow: 0 0 0 0.2rem rgba(91, 192, 222, 0.25); } - + box-shadow: 0 0 0 0.2rem rgba(91, 192, 222, 0.25); +} .form-check-input.is-invalid.warning { - border-color: #f89406; } - + border-color: #f89406; +} .form-check-input.is-invalid.warning:checked { - background-color: #f89406; } - + background-color: #f89406; +} .form-check-input.is-invalid.warning ~ .form-check-label { - color: unset; } - + color: unset; +} .form-check-input.is-invalid.warning:focus { - box-shadow: 0 0 0 0.2rem rgba(248, 148, 6, 0.25); } + box-shadow: 0 0 0 0.2rem rgba(248, 148, 6, 0.25); +} /* Utilities */ .mw-75 { - max-width: 75% !important; } + max-width: 75% !important; +} .mw-50 { - max-width: 50% !important; } + max-width: 50% !important; +} .mw-25 { - max-width: 25% !important; } + max-width: 25% !important; +} .mh-75 { - max-height: 75% !important; } + max-height: 75% !important; +} .mh-50 { - max-height: 50% !important; } + max-height: 50% !important; +} .mh-25 { - max-height: 25% !important; } + max-height: 25% !important; +} .p-abs-center-y { top: 50%; - transform: translateY(-50%); } + transform: translateY(-50%); +} .p-abs-center-x { left: 50%; - transform: translateX(-50%); } + transform: translateX(-50%); +} .p-abs-center-both { top: 50%; left: 50%; - transform: translateX(-50%) translateY(-50%); } + transform: translateX(-50%) translateY(-50%); +} /* Body */ body { @@ -286,90 +335,114 @@ body { /* background by SVGBackgrounds.com */ background-attachment: fixed; background-size: cover; - background-blend-mode: normal; } + background-blend-mode: normal; +} .panel { background-color: #363636; border: 1px solid #454545; - box-shadow: none; } + box-shadow: none; +} .loading-overlay { background-color: #272b30; - opacity: 0.65; } + opacity: 0.65; +} /* Top navbar */ .top-navbar { - background-color: #3a3f44; } + background-color: #3a3f44; +} .center-navbar nav.header-breadcrumb { - color: #fff; } + color: #fff; +} header.top-navbar .header-menu > a:hover, header.top-navbar .header-breadcrumb .header-breadcrumb-item > a:hover { - color: #d6d6d6 !important; } + color: #d6d6d6 !important; +} .top-navbar .center-navbar nav.header-breadcrumb li.header-breadcrumb-item a { - color: #fff; } + color: #fff; +} .top-navbar .right-navbar .header-menu a.nav-link { - color: #fff; } + color: #fff; +} .top-navbar .left-navbar .navbar-brand img { - filter: invert(1); } + filter: invert(1); +} .top-navbar .left-navbar .navbar-brand:hover img { - filter: invert(1) drop-shadow(0px 0px 3px #fff); } + filter: invert(1) drop-shadow(0px 0px 3px #fff); +} .top-navbar .composed-app-icon-container > .app-icon { - background-color: #fff; } + background-color: #fff; +} .breadcrumb-link-container { box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.16), 0 2px 6px 0 rgba(0, 0, 0, 0.12); - background-color: #e9ecef; } + background-color: #e9ecef; +} /* Sidebar */ .sidebar { - transition: width .08s linear; + transition: width 0.08s linear; box-shadow: none; - background-color: #7a8288; } + background-color: #7a8288; +} .sidebar ~ main.content:after { - background: #000; } + background: #000; +} .sidebar .sidebar-wrapper { - border-right: 1px solid none; } + border-right: 1px solid none; +} .sidebar .sidebar-wrapper { - border-right: 1px solid rgba(0, 0, 0, 0.125); } + border-right: 1px solid rgba(0, 0, 0, 0.125); +} .sidebar ul.sidebar-elements li > a.sidebar-link { - color: #fff; } + color: #fff; +} .sidebar ul.sidebar-elements li > a.sidebar-link.active { background-color: #595f64; - color: #fff; } + color: #fff; +} .sidebar ul.sidebar-elements li > a.sidebar-link.have-active-child { background-color: #595f64; - color: #fff; } + color: #fff; +} .sidebar ul.sidebar-elements li > a.sidebar-link:hover { background-color: #60676c; - color: #fff; } + color: #fff; +} .sidebar.expanded ul.sidebar-elements li > a.sidebar-link.have-active-child, .sidebar:hover ul.sidebar-elements li > a.sidebar-link.have-active-child { - background-color: unset; } + background-color: unset; +} .sidebar.expanded ul.sidebar-elements li > a.sidebar-link.have-active-child:hover, .sidebar:hover ul.sidebar-elements li > a.sidebar-link.have-active-child:hover { - background-color: #60676c; } + background-color: #60676c; +} ul.sidebar-elements li > a.sidebar-link.active::after { - background-color: var(--cerebrate-color); } + background-color: var(--cerebrate-color); +} .lock-sidebar > a.btn { - background-color: #7a8288; } + background-color: #7a8288; +} .btn { display: inline-block; @@ -386,455 +459,518 @@ ul.sidebar-elements li > a.sidebar-link.active::after { padding: 0.375rem 0.75rem; font-size: 1rem; border-radius: 0.25rem; - transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; } - @media (prefers-reduced-motion: reduce) { - .btn { - transition: none; } } - .btn:hover { - color: #aaa; } - .btn-check:focus + .btn, .btn:focus { - outline: 0; - box-shadow: 0 0 0 0.25rem rgba(58, 63, 68, 0.25); } - .btn:disabled, .btn.disabled, - fieldset:disabled .btn { - pointer-events: none; - opacity: 0.65; } + transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +} +@media (prefers-reduced-motion: reduce) { + .btn { + transition: none; + } +} +.btn:hover { + color: #aaa; +} +.btn-check:focus + .btn, .btn:focus { + outline: 0; + box-shadow: 0 0 0 0.25rem rgba(58, 63, 68, 0.25); +} +.btn:disabled, .btn.disabled, fieldset:disabled .btn { + pointer-events: none; + opacity: 0.65; +} .btn-primary { color: #fff; background-color: #3a3f44; - border-color: #3a3f44; } - .btn-primary:hover { - color: #fff; - background-color: #31363a; - border-color: #2e3236; } - .btn-check:focus + .btn-primary, .btn-primary:focus { - color: #fff; - background-color: #31363a; - border-color: #2e3236; - box-shadow: 0 0 0 0.25rem rgba(88, 92, 96, 0.5); } - .btn-check:checked + .btn-primary, - .btn-check:active + .btn-primary, .btn-primary:active, .btn-primary.active, - .show > .btn-primary.dropdown-toggle { - color: #fff; - background-color: #2e3236; - border-color: #2c2f33; } - .btn-check:checked + .btn-primary:focus, - .btn-check:active + .btn-primary:focus, .btn-primary:active:focus, .btn-primary.active:focus, - .show > .btn-primary.dropdown-toggle:focus { - box-shadow: 0 0 0 0.25rem rgba(88, 92, 96, 0.5); } - .btn-primary:disabled, .btn-primary.disabled { - color: #fff; - background-color: #3a3f44; - border-color: #3a3f44; } + border-color: #3a3f44; +} +.btn-primary:hover { + color: #fff; + background-color: #31363a; + border-color: #2e3236; +} +.btn-check:focus + .btn-primary, .btn-primary:focus { + color: #fff; + background-color: #31363a; + border-color: #2e3236; + box-shadow: 0 0 0 0.25rem rgba(88, 92, 96, 0.5); +} +.btn-check:checked + .btn-primary, .btn-check:active + .btn-primary, .btn-primary:active, .btn-primary.active, .show > .btn-primary.dropdown-toggle { + color: #fff; + background-color: #2e3236; + border-color: #2c2f33; +} +.btn-check:checked + .btn-primary:focus, .btn-check:active + .btn-primary:focus, .btn-primary:active:focus, .btn-primary.active:focus, .show > .btn-primary.dropdown-toggle:focus { + box-shadow: 0 0 0 0.25rem rgba(88, 92, 96, 0.5); +} +.btn-primary:disabled, .btn-primary.disabled { + color: #fff; + background-color: #3a3f44; + border-color: #3a3f44; +} .btn-secondary { color: #fff; background-color: #7a8288; - border-color: #7a8288; } - .btn-secondary:hover { - color: #fff; - background-color: #686f74; - border-color: #62686d; } - .btn-check:focus + .btn-secondary, .btn-secondary:focus { - color: #fff; - background-color: #686f74; - border-color: #62686d; - box-shadow: 0 0 0 0.25rem rgba(142, 149, 154, 0.5); } - .btn-check:checked + .btn-secondary, - .btn-check:active + .btn-secondary, .btn-secondary:active, .btn-secondary.active, - .show > .btn-secondary.dropdown-toggle { - color: #fff; - background-color: #62686d; - border-color: #5c6266; } - .btn-check:checked + .btn-secondary:focus, - .btn-check:active + .btn-secondary:focus, .btn-secondary:active:focus, .btn-secondary.active:focus, - .show > .btn-secondary.dropdown-toggle:focus { - box-shadow: 0 0 0 0.25rem rgba(142, 149, 154, 0.5); } - .btn-secondary:disabled, .btn-secondary.disabled { - color: #fff; - background-color: #7a8288; - border-color: #7a8288; } + border-color: #7a8288; +} +.btn-secondary:hover { + color: #fff; + background-color: #686f74; + border-color: #62686d; +} +.btn-check:focus + .btn-secondary, .btn-secondary:focus { + color: #fff; + background-color: #686f74; + border-color: #62686d; + box-shadow: 0 0 0 0.25rem rgba(142, 149, 154, 0.5); +} +.btn-check:checked + .btn-secondary, .btn-check:active + .btn-secondary, .btn-secondary:active, .btn-secondary.active, .show > .btn-secondary.dropdown-toggle { + color: #fff; + background-color: #62686d; + border-color: #5c6266; +} +.btn-check:checked + .btn-secondary:focus, .btn-check:active + .btn-secondary:focus, .btn-secondary:active:focus, .btn-secondary.active:focus, .show > .btn-secondary.dropdown-toggle:focus { + box-shadow: 0 0 0 0.25rem rgba(142, 149, 154, 0.5); +} +.btn-secondary:disabled, .btn-secondary.disabled { + color: #fff; + background-color: #7a8288; + border-color: #7a8288; +} .btn-success { color: #fff; background-color: #62c462; - border-color: #62c462; } - .btn-success:hover { - color: #fff; - background-color: #53a753; - border-color: #4e9d4e; } - .btn-check:focus + .btn-success, .btn-success:focus { - color: #fff; - background-color: #53a753; - border-color: #4e9d4e; - box-shadow: 0 0 0 0.25rem rgba(122, 205, 122, 0.5); } - .btn-check:checked + .btn-success, - .btn-check:active + .btn-success, .btn-success:active, .btn-success.active, - .show > .btn-success.dropdown-toggle { - color: #fff; - background-color: #4e9d4e; - border-color: #4a934a; } - .btn-check:checked + .btn-success:focus, - .btn-check:active + .btn-success:focus, .btn-success:active:focus, .btn-success.active:focus, - .show > .btn-success.dropdown-toggle:focus { - box-shadow: 0 0 0 0.25rem rgba(122, 205, 122, 0.5); } - .btn-success:disabled, .btn-success.disabled { - color: #fff; - background-color: #62c462; - border-color: #62c462; } + border-color: #62c462; +} +.btn-success:hover { + color: #fff; + background-color: #53a753; + border-color: #4e9d4e; +} +.btn-check:focus + .btn-success, .btn-success:focus { + color: #fff; + background-color: #53a753; + border-color: #4e9d4e; + box-shadow: 0 0 0 0.25rem rgba(122, 205, 122, 0.5); +} +.btn-check:checked + .btn-success, .btn-check:active + .btn-success, .btn-success:active, .btn-success.active, .show > .btn-success.dropdown-toggle { + color: #fff; + background-color: #4e9d4e; + border-color: #4a934a; +} +.btn-check:checked + .btn-success:focus, .btn-check:active + .btn-success:focus, .btn-success:active:focus, .btn-success.active:focus, .show > .btn-success.dropdown-toggle:focus { + box-shadow: 0 0 0 0.25rem rgba(122, 205, 122, 0.5); +} +.btn-success:disabled, .btn-success.disabled { + color: #fff; + background-color: #62c462; + border-color: #62c462; +} .btn-info { color: #fff; background-color: #5bc0de; - border-color: #5bc0de; } - .btn-info:hover { - color: #fff; - background-color: #4da3bd; - border-color: #499ab2; } - .btn-check:focus + .btn-info, .btn-info:focus { - color: #fff; - background-color: #4da3bd; - border-color: #499ab2; - box-shadow: 0 0 0 0.25rem rgba(116, 201, 227, 0.5); } - .btn-check:checked + .btn-info, - .btn-check:active + .btn-info, .btn-info:active, .btn-info.active, - .show > .btn-info.dropdown-toggle { - color: #fff; - background-color: #499ab2; - border-color: #4490a7; } - .btn-check:checked + .btn-info:focus, - .btn-check:active + .btn-info:focus, .btn-info:active:focus, .btn-info.active:focus, - .show > .btn-info.dropdown-toggle:focus { - box-shadow: 0 0 0 0.25rem rgba(116, 201, 227, 0.5); } - .btn-info:disabled, .btn-info.disabled { - color: #fff; - background-color: #5bc0de; - border-color: #5bc0de; } + border-color: #5bc0de; +} +.btn-info:hover { + color: #fff; + background-color: #4da3bd; + border-color: #499ab2; +} +.btn-check:focus + .btn-info, .btn-info:focus { + color: #fff; + background-color: #4da3bd; + border-color: #499ab2; + box-shadow: 0 0 0 0.25rem rgba(116, 201, 227, 0.5); +} +.btn-check:checked + .btn-info, .btn-check:active + .btn-info, .btn-info:active, .btn-info.active, .show > .btn-info.dropdown-toggle { + color: #fff; + background-color: #499ab2; + border-color: #4490a7; +} +.btn-check:checked + .btn-info:focus, .btn-check:active + .btn-info:focus, .btn-info:active:focus, .btn-info.active:focus, .show > .btn-info.dropdown-toggle:focus { + box-shadow: 0 0 0 0.25rem rgba(116, 201, 227, 0.5); +} +.btn-info:disabled, .btn-info.disabled { + color: #fff; + background-color: #5bc0de; + border-color: #5bc0de; +} .btn-warning { color: #fff; background-color: #f89406; - border-color: #f89406; } - .btn-warning:hover { - color: #fff; - background-color: #d37e05; - border-color: #c67605; } - .btn-check:focus + .btn-warning, .btn-warning:focus { - color: #fff; - background-color: #d37e05; - border-color: #c67605; - box-shadow: 0 0 0 0.25rem rgba(249, 164, 43, 0.5); } - .btn-check:checked + .btn-warning, - .btn-check:active + .btn-warning, .btn-warning:active, .btn-warning.active, - .show > .btn-warning.dropdown-toggle { - color: #fff; - background-color: #c67605; - border-color: #ba6f05; } - .btn-check:checked + .btn-warning:focus, - .btn-check:active + .btn-warning:focus, .btn-warning:active:focus, .btn-warning.active:focus, - .show > .btn-warning.dropdown-toggle:focus { - box-shadow: 0 0 0 0.25rem rgba(249, 164, 43, 0.5); } - .btn-warning:disabled, .btn-warning.disabled { - color: #fff; - background-color: #f89406; - border-color: #f89406; } + border-color: #f89406; +} +.btn-warning:hover { + color: #fff; + background-color: #d37e05; + border-color: #c67605; +} +.btn-check:focus + .btn-warning, .btn-warning:focus { + color: #fff; + background-color: #d37e05; + border-color: #c67605; + box-shadow: 0 0 0 0.25rem rgba(249, 164, 43, 0.5); +} +.btn-check:checked + .btn-warning, .btn-check:active + .btn-warning, .btn-warning:active, .btn-warning.active, .show > .btn-warning.dropdown-toggle { + color: #fff; + background-color: #c67605; + border-color: #ba6f05; +} +.btn-check:checked + .btn-warning:focus, .btn-check:active + .btn-warning:focus, .btn-warning:active:focus, .btn-warning.active:focus, .show > .btn-warning.dropdown-toggle:focus { + box-shadow: 0 0 0 0.25rem rgba(249, 164, 43, 0.5); +} +.btn-warning:disabled, .btn-warning.disabled { + color: #fff; + background-color: #f89406; + border-color: #f89406; +} .btn-danger { color: #fff; background-color: #ee5f5b; - border-color: #ee5f5b; } - .btn-danger:hover { - color: #fff; - background-color: #ca514d; - border-color: #be4c49; } - .btn-check:focus + .btn-danger, .btn-danger:focus { - color: #fff; - background-color: #ca514d; - border-color: #be4c49; - box-shadow: 0 0 0 0.25rem rgba(241, 119, 116, 0.5); } - .btn-check:checked + .btn-danger, - .btn-check:active + .btn-danger, .btn-danger:active, .btn-danger.active, - .show > .btn-danger.dropdown-toggle { - color: #fff; - background-color: #be4c49; - border-color: #b34744; } - .btn-check:checked + .btn-danger:focus, - .btn-check:active + .btn-danger:focus, .btn-danger:active:focus, .btn-danger.active:focus, - .show > .btn-danger.dropdown-toggle:focus { - box-shadow: 0 0 0 0.25rem rgba(241, 119, 116, 0.5); } - .btn-danger:disabled, .btn-danger.disabled { - color: #fff; - background-color: #ee5f5b; - border-color: #ee5f5b; } + border-color: #ee5f5b; +} +.btn-danger:hover { + color: #fff; + background-color: #ca514d; + border-color: #be4c49; +} +.btn-check:focus + .btn-danger, .btn-danger:focus { + color: #fff; + background-color: #ca514d; + border-color: #be4c49; + box-shadow: 0 0 0 0.25rem rgba(241, 119, 116, 0.5); +} +.btn-check:checked + .btn-danger, .btn-check:active + .btn-danger, .btn-danger:active, .btn-danger.active, .show > .btn-danger.dropdown-toggle { + color: #fff; + background-color: #be4c49; + border-color: #b34744; +} +.btn-check:checked + .btn-danger:focus, .btn-check:active + .btn-danger:focus, .btn-danger:active:focus, .btn-danger.active:focus, .show > .btn-danger.dropdown-toggle:focus { + box-shadow: 0 0 0 0.25rem rgba(241, 119, 116, 0.5); +} +.btn-danger:disabled, .btn-danger.disabled { + color: #fff; + background-color: #ee5f5b; + border-color: #ee5f5b; +} .btn-light { color: #000; background-color: #e9ecef; - border-color: #e9ecef; } - .btn-light:hover { - color: #000; - background-color: #eceff1; - border-color: #ebeef1; } - .btn-check:focus + .btn-light, .btn-light:focus { - color: #000; - background-color: #eceff1; - border-color: #ebeef1; - box-shadow: 0 0 0 0.25rem rgba(198, 201, 203, 0.5); } - .btn-check:checked + .btn-light, - .btn-check:active + .btn-light, .btn-light:active, .btn-light.active, - .show > .btn-light.dropdown-toggle { - color: #000; - background-color: #edf0f2; - border-color: #ebeef1; } - .btn-check:checked + .btn-light:focus, - .btn-check:active + .btn-light:focus, .btn-light:active:focus, .btn-light.active:focus, - .show > .btn-light.dropdown-toggle:focus { - box-shadow: 0 0 0 0.25rem rgba(198, 201, 203, 0.5); } - .btn-light:disabled, .btn-light.disabled { - color: #000; - background-color: #e9ecef; - border-color: #e9ecef; } + border-color: #e9ecef; +} +.btn-light:hover { + color: #000; + background-color: #eceff1; + border-color: #ebeef1; +} +.btn-check:focus + .btn-light, .btn-light:focus { + color: #000; + background-color: #eceff1; + border-color: #ebeef1; + box-shadow: 0 0 0 0.25rem rgba(198, 201, 203, 0.5); +} +.btn-check:checked + .btn-light, .btn-check:active + .btn-light, .btn-light:active, .btn-light.active, .show > .btn-light.dropdown-toggle { + color: #000; + background-color: #edf0f2; + border-color: #ebeef1; +} +.btn-check:checked + .btn-light:focus, .btn-check:active + .btn-light:focus, .btn-light:active:focus, .btn-light.active:focus, .show > .btn-light.dropdown-toggle:focus { + box-shadow: 0 0 0 0.25rem rgba(198, 201, 203, 0.5); +} +.btn-light:disabled, .btn-light.disabled { + color: #000; + background-color: #e9ecef; + border-color: #e9ecef; +} .btn-dark { color: #fff; background-color: #272b30; - border-color: #272b30; } - .btn-dark:hover { - color: #fff; - background-color: #212529; - border-color: #1f2226; } - .btn-check:focus + .btn-dark, .btn-dark:focus { - color: #fff; - background-color: #212529; - border-color: #1f2226; - box-shadow: 0 0 0 0.25rem rgba(71, 75, 79, 0.5); } - .btn-check:checked + .btn-dark, - .btn-check:active + .btn-dark, .btn-dark:active, .btn-dark.active, - .show > .btn-dark.dropdown-toggle { - color: #fff; - background-color: #1f2226; - border-color: #1d2024; } - .btn-check:checked + .btn-dark:focus, - .btn-check:active + .btn-dark:focus, .btn-dark:active:focus, .btn-dark.active:focus, - .show > .btn-dark.dropdown-toggle:focus { - box-shadow: 0 0 0 0.25rem rgba(71, 75, 79, 0.5); } - .btn-dark:disabled, .btn-dark.disabled { - color: #fff; - background-color: #272b30; - border-color: #272b30; } + border-color: #272b30; +} +.btn-dark:hover { + color: #fff; + background-color: #212529; + border-color: #1f2226; +} +.btn-check:focus + .btn-dark, .btn-dark:focus { + color: #fff; + background-color: #212529; + border-color: #1f2226; + box-shadow: 0 0 0 0.25rem rgba(71, 75, 79, 0.5); +} +.btn-check:checked + .btn-dark, .btn-check:active + .btn-dark, .btn-dark:active, .btn-dark.active, .show > .btn-dark.dropdown-toggle { + color: #fff; + background-color: #1f2226; + border-color: #1d2024; +} +.btn-check:checked + .btn-dark:focus, .btn-check:active + .btn-dark:focus, .btn-dark:active:focus, .btn-dark.active:focus, .show > .btn-dark.dropdown-toggle:focus { + box-shadow: 0 0 0 0.25rem rgba(71, 75, 79, 0.5); +} +.btn-dark:disabled, .btn-dark.disabled { + color: #fff; + background-color: #272b30; + border-color: #272b30; +} .btn-outline-primary { color: #3a3f44; - border-color: #3a3f44; } - .btn-outline-primary:hover { - color: #fff; - background-color: #3a3f44; - border-color: #3a3f44; } - .btn-check:focus + .btn-outline-primary, .btn-outline-primary:focus { - box-shadow: 0 0 0 0.25rem rgba(58, 63, 68, 0.5); } - .btn-check:checked + .btn-outline-primary, - .btn-check:active + .btn-outline-primary, .btn-outline-primary:active, .btn-outline-primary.active, .btn-outline-primary.dropdown-toggle.show { - color: #fff; - background-color: #3a3f44; - border-color: #3a3f44; } - .btn-check:checked + .btn-outline-primary:focus, - .btn-check:active + .btn-outline-primary:focus, .btn-outline-primary:active:focus, .btn-outline-primary.active:focus, .btn-outline-primary.dropdown-toggle.show:focus { - box-shadow: 0 0 0 0.25rem rgba(58, 63, 68, 0.5); } - .btn-outline-primary:disabled, .btn-outline-primary.disabled { - color: #3a3f44; - background-color: transparent; } + border-color: #3a3f44; +} +.btn-outline-primary:hover { + color: #fff; + background-color: #3a3f44; + border-color: #3a3f44; +} +.btn-check:focus + .btn-outline-primary, .btn-outline-primary:focus { + box-shadow: 0 0 0 0.25rem rgba(58, 63, 68, 0.5); +} +.btn-check:checked + .btn-outline-primary, .btn-check:active + .btn-outline-primary, .btn-outline-primary:active, .btn-outline-primary.active, .btn-outline-primary.dropdown-toggle.show { + color: #fff; + background-color: #3a3f44; + border-color: #3a3f44; +} +.btn-check:checked + .btn-outline-primary:focus, .btn-check:active + .btn-outline-primary:focus, .btn-outline-primary:active:focus, .btn-outline-primary.active:focus, .btn-outline-primary.dropdown-toggle.show:focus { + box-shadow: 0 0 0 0.25rem rgba(58, 63, 68, 0.5); +} +.btn-outline-primary:disabled, .btn-outline-primary.disabled { + color: #3a3f44; + background-color: transparent; +} .btn-outline-secondary { color: #7a8288; - border-color: #7a8288; } - .btn-outline-secondary:hover { - color: #fff; - background-color: #7a8288; - border-color: #7a8288; } - .btn-check:focus + .btn-outline-secondary, .btn-outline-secondary:focus { - box-shadow: 0 0 0 0.25rem rgba(122, 130, 136, 0.5); } - .btn-check:checked + .btn-outline-secondary, - .btn-check:active + .btn-outline-secondary, .btn-outline-secondary:active, .btn-outline-secondary.active, .btn-outline-secondary.dropdown-toggle.show { - color: #fff; - background-color: #7a8288; - border-color: #7a8288; } - .btn-check:checked + .btn-outline-secondary:focus, - .btn-check:active + .btn-outline-secondary:focus, .btn-outline-secondary:active:focus, .btn-outline-secondary.active:focus, .btn-outline-secondary.dropdown-toggle.show:focus { - box-shadow: 0 0 0 0.25rem rgba(122, 130, 136, 0.5); } - .btn-outline-secondary:disabled, .btn-outline-secondary.disabled { - color: #7a8288; - background-color: transparent; } + border-color: #7a8288; +} +.btn-outline-secondary:hover { + color: #fff; + background-color: #7a8288; + border-color: #7a8288; +} +.btn-check:focus + .btn-outline-secondary, .btn-outline-secondary:focus { + box-shadow: 0 0 0 0.25rem rgba(122, 130, 136, 0.5); +} +.btn-check:checked + .btn-outline-secondary, .btn-check:active + .btn-outline-secondary, .btn-outline-secondary:active, .btn-outline-secondary.active, .btn-outline-secondary.dropdown-toggle.show { + color: #fff; + background-color: #7a8288; + border-color: #7a8288; +} +.btn-check:checked + .btn-outline-secondary:focus, .btn-check:active + .btn-outline-secondary:focus, .btn-outline-secondary:active:focus, .btn-outline-secondary.active:focus, .btn-outline-secondary.dropdown-toggle.show:focus { + box-shadow: 0 0 0 0.25rem rgba(122, 130, 136, 0.5); +} +.btn-outline-secondary:disabled, .btn-outline-secondary.disabled { + color: #7a8288; + background-color: transparent; +} .btn-outline-success { color: #62c462; - border-color: #62c462; } - .btn-outline-success:hover { - color: #fff; - background-color: #62c462; - border-color: #62c462; } - .btn-check:focus + .btn-outline-success, .btn-outline-success:focus { - box-shadow: 0 0 0 0.25rem rgba(98, 196, 98, 0.5); } - .btn-check:checked + .btn-outline-success, - .btn-check:active + .btn-outline-success, .btn-outline-success:active, .btn-outline-success.active, .btn-outline-success.dropdown-toggle.show { - color: #fff; - background-color: #62c462; - border-color: #62c462; } - .btn-check:checked + .btn-outline-success:focus, - .btn-check:active + .btn-outline-success:focus, .btn-outline-success:active:focus, .btn-outline-success.active:focus, .btn-outline-success.dropdown-toggle.show:focus { - box-shadow: 0 0 0 0.25rem rgba(98, 196, 98, 0.5); } - .btn-outline-success:disabled, .btn-outline-success.disabled { - color: #62c462; - background-color: transparent; } + border-color: #62c462; +} +.btn-outline-success:hover { + color: #fff; + background-color: #62c462; + border-color: #62c462; +} +.btn-check:focus + .btn-outline-success, .btn-outline-success:focus { + box-shadow: 0 0 0 0.25rem rgba(98, 196, 98, 0.5); +} +.btn-check:checked + .btn-outline-success, .btn-check:active + .btn-outline-success, .btn-outline-success:active, .btn-outline-success.active, .btn-outline-success.dropdown-toggle.show { + color: #fff; + background-color: #62c462; + border-color: #62c462; +} +.btn-check:checked + .btn-outline-success:focus, .btn-check:active + .btn-outline-success:focus, .btn-outline-success:active:focus, .btn-outline-success.active:focus, .btn-outline-success.dropdown-toggle.show:focus { + box-shadow: 0 0 0 0.25rem rgba(98, 196, 98, 0.5); +} +.btn-outline-success:disabled, .btn-outline-success.disabled { + color: #62c462; + background-color: transparent; +} .btn-outline-info { color: #5bc0de; - border-color: #5bc0de; } - .btn-outline-info:hover { - color: #fff; - background-color: #5bc0de; - border-color: #5bc0de; } - .btn-check:focus + .btn-outline-info, .btn-outline-info:focus { - box-shadow: 0 0 0 0.25rem rgba(91, 192, 222, 0.5); } - .btn-check:checked + .btn-outline-info, - .btn-check:active + .btn-outline-info, .btn-outline-info:active, .btn-outline-info.active, .btn-outline-info.dropdown-toggle.show { - color: #fff; - background-color: #5bc0de; - border-color: #5bc0de; } - .btn-check:checked + .btn-outline-info:focus, - .btn-check:active + .btn-outline-info:focus, .btn-outline-info:active:focus, .btn-outline-info.active:focus, .btn-outline-info.dropdown-toggle.show:focus { - box-shadow: 0 0 0 0.25rem rgba(91, 192, 222, 0.5); } - .btn-outline-info:disabled, .btn-outline-info.disabled { - color: #5bc0de; - background-color: transparent; } + border-color: #5bc0de; +} +.btn-outline-info:hover { + color: #fff; + background-color: #5bc0de; + border-color: #5bc0de; +} +.btn-check:focus + .btn-outline-info, .btn-outline-info:focus { + box-shadow: 0 0 0 0.25rem rgba(91, 192, 222, 0.5); +} +.btn-check:checked + .btn-outline-info, .btn-check:active + .btn-outline-info, .btn-outline-info:active, .btn-outline-info.active, .btn-outline-info.dropdown-toggle.show { + color: #fff; + background-color: #5bc0de; + border-color: #5bc0de; +} +.btn-check:checked + .btn-outline-info:focus, .btn-check:active + .btn-outline-info:focus, .btn-outline-info:active:focus, .btn-outline-info.active:focus, .btn-outline-info.dropdown-toggle.show:focus { + box-shadow: 0 0 0 0.25rem rgba(91, 192, 222, 0.5); +} +.btn-outline-info:disabled, .btn-outline-info.disabled { + color: #5bc0de; + background-color: transparent; +} .btn-outline-warning { color: #f89406; - border-color: #f89406; } - .btn-outline-warning:hover { - color: #fff; - background-color: #f89406; - border-color: #f89406; } - .btn-check:focus + .btn-outline-warning, .btn-outline-warning:focus { - box-shadow: 0 0 0 0.25rem rgba(248, 148, 6, 0.5); } - .btn-check:checked + .btn-outline-warning, - .btn-check:active + .btn-outline-warning, .btn-outline-warning:active, .btn-outline-warning.active, .btn-outline-warning.dropdown-toggle.show { - color: #fff; - background-color: #f89406; - border-color: #f89406; } - .btn-check:checked + .btn-outline-warning:focus, - .btn-check:active + .btn-outline-warning:focus, .btn-outline-warning:active:focus, .btn-outline-warning.active:focus, .btn-outline-warning.dropdown-toggle.show:focus { - box-shadow: 0 0 0 0.25rem rgba(248, 148, 6, 0.5); } - .btn-outline-warning:disabled, .btn-outline-warning.disabled { - color: #f89406; - background-color: transparent; } + border-color: #f89406; +} +.btn-outline-warning:hover { + color: #fff; + background-color: #f89406; + border-color: #f89406; +} +.btn-check:focus + .btn-outline-warning, .btn-outline-warning:focus { + box-shadow: 0 0 0 0.25rem rgba(248, 148, 6, 0.5); +} +.btn-check:checked + .btn-outline-warning, .btn-check:active + .btn-outline-warning, .btn-outline-warning:active, .btn-outline-warning.active, .btn-outline-warning.dropdown-toggle.show { + color: #fff; + background-color: #f89406; + border-color: #f89406; +} +.btn-check:checked + .btn-outline-warning:focus, .btn-check:active + .btn-outline-warning:focus, .btn-outline-warning:active:focus, .btn-outline-warning.active:focus, .btn-outline-warning.dropdown-toggle.show:focus { + box-shadow: 0 0 0 0.25rem rgba(248, 148, 6, 0.5); +} +.btn-outline-warning:disabled, .btn-outline-warning.disabled { + color: #f89406; + background-color: transparent; +} .btn-outline-danger { color: #ee5f5b; - border-color: #ee5f5b; } - .btn-outline-danger:hover { - color: #fff; - background-color: #ee5f5b; - border-color: #ee5f5b; } - .btn-check:focus + .btn-outline-danger, .btn-outline-danger:focus { - box-shadow: 0 0 0 0.25rem rgba(238, 95, 91, 0.5); } - .btn-check:checked + .btn-outline-danger, - .btn-check:active + .btn-outline-danger, .btn-outline-danger:active, .btn-outline-danger.active, .btn-outline-danger.dropdown-toggle.show { - color: #fff; - background-color: #ee5f5b; - border-color: #ee5f5b; } - .btn-check:checked + .btn-outline-danger:focus, - .btn-check:active + .btn-outline-danger:focus, .btn-outline-danger:active:focus, .btn-outline-danger.active:focus, .btn-outline-danger.dropdown-toggle.show:focus { - box-shadow: 0 0 0 0.25rem rgba(238, 95, 91, 0.5); } - .btn-outline-danger:disabled, .btn-outline-danger.disabled { - color: #ee5f5b; - background-color: transparent; } + border-color: #ee5f5b; +} +.btn-outline-danger:hover { + color: #fff; + background-color: #ee5f5b; + border-color: #ee5f5b; +} +.btn-check:focus + .btn-outline-danger, .btn-outline-danger:focus { + box-shadow: 0 0 0 0.25rem rgba(238, 95, 91, 0.5); +} +.btn-check:checked + .btn-outline-danger, .btn-check:active + .btn-outline-danger, .btn-outline-danger:active, .btn-outline-danger.active, .btn-outline-danger.dropdown-toggle.show { + color: #fff; + background-color: #ee5f5b; + border-color: #ee5f5b; +} +.btn-check:checked + .btn-outline-danger:focus, .btn-check:active + .btn-outline-danger:focus, .btn-outline-danger:active:focus, .btn-outline-danger.active:focus, .btn-outline-danger.dropdown-toggle.show:focus { + box-shadow: 0 0 0 0.25rem rgba(238, 95, 91, 0.5); +} +.btn-outline-danger:disabled, .btn-outline-danger.disabled { + color: #ee5f5b; + background-color: transparent; +} .btn-outline-light { color: #e9ecef; - border-color: #e9ecef; } - .btn-outline-light:hover { - color: #000; - background-color: #e9ecef; - border-color: #e9ecef; } - .btn-check:focus + .btn-outline-light, .btn-outline-light:focus { - box-shadow: 0 0 0 0.25rem rgba(233, 236, 239, 0.5); } - .btn-check:checked + .btn-outline-light, - .btn-check:active + .btn-outline-light, .btn-outline-light:active, .btn-outline-light.active, .btn-outline-light.dropdown-toggle.show { - color: #000; - background-color: #e9ecef; - border-color: #e9ecef; } - .btn-check:checked + .btn-outline-light:focus, - .btn-check:active + .btn-outline-light:focus, .btn-outline-light:active:focus, .btn-outline-light.active:focus, .btn-outline-light.dropdown-toggle.show:focus { - box-shadow: 0 0 0 0.25rem rgba(233, 236, 239, 0.5); } - .btn-outline-light:disabled, .btn-outline-light.disabled { - color: #e9ecef; - background-color: transparent; } + border-color: #e9ecef; +} +.btn-outline-light:hover { + color: #000; + background-color: #e9ecef; + border-color: #e9ecef; +} +.btn-check:focus + .btn-outline-light, .btn-outline-light:focus { + box-shadow: 0 0 0 0.25rem rgba(233, 236, 239, 0.5); +} +.btn-check:checked + .btn-outline-light, .btn-check:active + .btn-outline-light, .btn-outline-light:active, .btn-outline-light.active, .btn-outline-light.dropdown-toggle.show { + color: #000; + background-color: #e9ecef; + border-color: #e9ecef; +} +.btn-check:checked + .btn-outline-light:focus, .btn-check:active + .btn-outline-light:focus, .btn-outline-light:active:focus, .btn-outline-light.active:focus, .btn-outline-light.dropdown-toggle.show:focus { + box-shadow: 0 0 0 0.25rem rgba(233, 236, 239, 0.5); +} +.btn-outline-light:disabled, .btn-outline-light.disabled { + color: #e9ecef; + background-color: transparent; +} .btn-outline-dark { color: #272b30; - border-color: #272b30; } - .btn-outline-dark:hover { - color: #fff; - background-color: #272b30; - border-color: #272b30; } - .btn-check:focus + .btn-outline-dark, .btn-outline-dark:focus { - box-shadow: 0 0 0 0.25rem rgba(39, 43, 48, 0.5); } - .btn-check:checked + .btn-outline-dark, - .btn-check:active + .btn-outline-dark, .btn-outline-dark:active, .btn-outline-dark.active, .btn-outline-dark.dropdown-toggle.show { - color: #fff; - background-color: #272b30; - border-color: #272b30; } - .btn-check:checked + .btn-outline-dark:focus, - .btn-check:active + .btn-outline-dark:focus, .btn-outline-dark:active:focus, .btn-outline-dark.active:focus, .btn-outline-dark.dropdown-toggle.show:focus { - box-shadow: 0 0 0 0.25rem rgba(39, 43, 48, 0.5); } - .btn-outline-dark:disabled, .btn-outline-dark.disabled { - color: #272b30; - background-color: transparent; } + border-color: #272b30; +} +.btn-outline-dark:hover { + color: #fff; + background-color: #272b30; + border-color: #272b30; +} +.btn-check:focus + .btn-outline-dark, .btn-outline-dark:focus { + box-shadow: 0 0 0 0.25rem rgba(39, 43, 48, 0.5); +} +.btn-check:checked + .btn-outline-dark, .btn-check:active + .btn-outline-dark, .btn-outline-dark:active, .btn-outline-dark.active, .btn-outline-dark.dropdown-toggle.show { + color: #fff; + background-color: #272b30; + border-color: #272b30; +} +.btn-check:checked + .btn-outline-dark:focus, .btn-check:active + .btn-outline-dark:focus, .btn-outline-dark:active:focus, .btn-outline-dark.active:focus, .btn-outline-dark.dropdown-toggle.show:focus { + box-shadow: 0 0 0 0.25rem rgba(39, 43, 48, 0.5); +} +.btn-outline-dark:disabled, .btn-outline-dark.disabled { + color: #272b30; + background-color: transparent; +} .btn-link { font-weight: 400; color: #fff; - text-decoration: underline; } - .btn-link:hover { - color: #cccccc; } - .btn-link:disabled, .btn-link.disabled { - color: #7a8288; } + text-decoration: underline; +} +.btn-link:hover { + color: #cccccc; +} +.btn-link:disabled, .btn-link.disabled { + color: #7a8288; +} .btn-lg { padding: 0.5rem 1rem; font-size: 1.25rem; - border-radius: 0.3rem; } + border-radius: 0.3rem; +} .btn-sm { padding: 0.25rem 0.5rem; font-size: 0.875rem; - border-radius: 0.2rem; } + border-radius: 0.2rem; +} .form-label { - margin-bottom: 0.5rem; } + margin-bottom: 0.5rem; +} .col-form-label { padding-top: calc(0.375rem + 1px); padding-bottom: calc(0.375rem + 1px); margin-bottom: 0; font-size: inherit; - line-height: 1.5; } + line-height: 1.5; +} .col-form-label-lg { padding-top: calc(0.5rem + 1px); padding-bottom: calc(0.5rem + 1px); - font-size: 1.25rem; } + font-size: 1.25rem; +} .col-form-label-sm { padding-top: calc(0.25rem + 1px); padding-bottom: calc(0.25rem + 1px); - font-size: 0.875rem; } + font-size: 0.875rem; +} .form-text { margin-top: 0.25rem; font-size: 0.875em; - color: #7a8288; } + color: #7a8288; +} .form-control { display: block; @@ -849,64 +985,81 @@ ul.sidebar-elements li > a.sidebar-link.active::after { border: 1px solid #ced4da; appearance: none; border-radius: 0.25rem; - transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; } - @media (prefers-reduced-motion: reduce) { - .form-control { - transition: none; } } - .form-control[type="file"] { - overflow: hidden; } - .form-control[type="file"]:not(:disabled):not([readonly]) { - cursor: pointer; } - .form-control:focus { - color: #272b30; - background-color: #fff; - border-color: #9d9fa2; - outline: 0; - box-shadow: 0 0 0 0.25rem rgba(58, 63, 68, 0.25); } - .form-control::-webkit-date-and-time-value { - height: 1.5em; } - .form-control::placeholder { - color: #7a8288; - opacity: 1; } - .form-control:disabled, .form-control[readonly] { - background-color: #ccc; - opacity: 1; } + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +} +@media (prefers-reduced-motion: reduce) { + .form-control { + transition: none; + } +} +.form-control[type=file] { + overflow: hidden; +} +.form-control[type=file]:not(:disabled):not([readonly]) { + cursor: pointer; +} +.form-control:focus { + color: #272b30; + background-color: #fff; + border-color: #9d9fa2; + outline: 0; + box-shadow: 0 0 0 0.25rem rgba(58, 63, 68, 0.25); +} +.form-control::-webkit-date-and-time-value { + height: 1.5em; +} +.form-control::placeholder { + color: #7a8288; + opacity: 1; +} +.form-control:disabled, .form-control[readonly] { + background-color: #ccc; + opacity: 1; +} +.form-control::file-selector-button { + padding: 0.375rem 0.75rem; + margin: -0.375rem -0.75rem; + margin-inline-end: 0.75rem; + color: #272b30; + background-color: #e9ecef; + pointer-events: none; + border-color: inherit; + border-style: solid; + border-width: 0; + border-inline-end-width: 1px; + border-radius: 0; + transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +} +@media (prefers-reduced-motion: reduce) { .form-control::file-selector-button { - padding: 0.375rem 0.75rem; - margin: -0.375rem -0.75rem; - margin-inline-end: 0.75rem; - color: #272b30; - background-color: #e9ecef; - pointer-events: none; - border-color: inherit; - border-style: solid; - border-width: 0; - border-inline-end-width: 1px; - border-radius: 0; - transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; } - @media (prefers-reduced-motion: reduce) { - .form-control::file-selector-button { - transition: none; } } - .form-control:hover:not(:disabled):not([readonly])::file-selector-button { - background-color: #dde0e3; } + transition: none; + } +} +.form-control:hover:not(:disabled):not([readonly])::file-selector-button { + background-color: #dde0e3; +} +.form-control::-webkit-file-upload-button { + padding: 0.375rem 0.75rem; + margin: -0.375rem -0.75rem; + margin-inline-end: 0.75rem; + color: #272b30; + background-color: #e9ecef; + pointer-events: none; + border-color: inherit; + border-style: solid; + border-width: 0; + border-inline-end-width: 1px; + border-radius: 0; + transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +} +@media (prefers-reduced-motion: reduce) { .form-control::-webkit-file-upload-button { - padding: 0.375rem 0.75rem; - margin: -0.375rem -0.75rem; - margin-inline-end: 0.75rem; - color: #272b30; - background-color: #e9ecef; - pointer-events: none; - border-color: inherit; - border-style: solid; - border-width: 0; - border-inline-end-width: 1px; - border-radius: 0; - transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; } - @media (prefers-reduced-motion: reduce) { - .form-control::-webkit-file-upload-button { - transition: none; } } - .form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button { - background-color: #dde0e3; } + transition: none; + } +} +.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button { + background-color: #dde0e3; +} .form-control-plaintext { display: block; @@ -917,60 +1070,73 @@ ul.sidebar-elements li > a.sidebar-link.active::after { color: #aaa; background-color: transparent; border: solid transparent; - border-width: 1px 0; } - .form-control-plaintext.form-control-sm, .form-control-plaintext.form-control-lg { - padding-right: 0; - padding-left: 0; } + border-width: 1px 0; +} +.form-control-plaintext.form-control-sm, .form-control-plaintext.form-control-lg { + padding-right: 0; + padding-left: 0; +} .form-control-sm { min-height: calc(1.5em + 0.5rem + 2px); padding: 0.25rem 0.5rem; font-size: 0.875rem; - border-radius: 0.2rem; } - .form-control-sm::file-selector-button { - padding: 0.25rem 0.5rem; - margin: -0.25rem -0.5rem; - margin-inline-end: 0.5rem; } - .form-control-sm::-webkit-file-upload-button { - padding: 0.25rem 0.5rem; - margin: -0.25rem -0.5rem; - margin-inline-end: 0.5rem; } + border-radius: 0.2rem; +} +.form-control-sm::file-selector-button { + padding: 0.25rem 0.5rem; + margin: -0.25rem -0.5rem; + margin-inline-end: 0.5rem; +} +.form-control-sm::-webkit-file-upload-button { + padding: 0.25rem 0.5rem; + margin: -0.25rem -0.5rem; + margin-inline-end: 0.5rem; +} .form-control-lg { min-height: calc(1.5em + 1rem + 2px); padding: 0.5rem 1rem; font-size: 1.25rem; - border-radius: 0.3rem; } - .form-control-lg::file-selector-button { - padding: 0.5rem 1rem; - margin: -0.5rem -1rem; - margin-inline-end: 1rem; } - .form-control-lg::-webkit-file-upload-button { - padding: 0.5rem 1rem; - margin: -0.5rem -1rem; - margin-inline-end: 1rem; } + border-radius: 0.3rem; +} +.form-control-lg::file-selector-button { + padding: 0.5rem 1rem; + margin: -0.5rem -1rem; + margin-inline-end: 1rem; +} +.form-control-lg::-webkit-file-upload-button { + padding: 0.5rem 1rem; + margin: -0.5rem -1rem; + margin-inline-end: 1rem; +} textarea.form-control { - min-height: calc(1.5em + 0.75rem + 2px); } - + min-height: calc(1.5em + 0.75rem + 2px); +} textarea.form-control-sm { - min-height: calc(1.5em + 0.5rem + 2px); } - + min-height: calc(1.5em + 0.5rem + 2px); +} textarea.form-control-lg { - min-height: calc(1.5em + 1rem + 2px); } + min-height: calc(1.5em + 1rem + 2px); +} .form-control-color { width: 3rem; height: auto; - padding: 0.375rem; } - .form-control-color:not(:disabled):not([readonly]) { - cursor: pointer; } - .form-control-color::-moz-color-swatch { - height: 1.5em; - border-radius: 0.25rem; } - .form-control-color::-webkit-color-swatch { - height: 1.5em; - border-radius: 0.25rem; } + padding: 0.375rem; +} +.form-control-color:not(:disabled):not([readonly]) { + cursor: pointer; +} +.form-control-color::-moz-color-swatch { + height: 1.5em; + border-radius: 0.25rem; +} +.form-control-color::-webkit-color-swatch { + height: 1.5em; + border-radius: 0.25rem; +} .form-select { display: block; @@ -989,43 +1155,56 @@ textarea.form-control-lg { border: 1px solid #ced4da; border-radius: 0.25rem; transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; - appearance: none; } - @media (prefers-reduced-motion: reduce) { - .form-select { - transition: none; } } - .form-select:focus { - border-color: #9d9fa2; - outline: 0; - box-shadow: 0 0 0 0.25rem rgba(58, 63, 68, 0.25); } - .form-select[multiple], .form-select[size]:not([size="1"]) { - padding-right: 0.75rem; - background-image: none; } - .form-select:disabled { - background-color: #e9ecef; } - .form-select:-moz-focusring { - color: transparent; - text-shadow: 0 0 0 #272b30; } + appearance: none; +} +@media (prefers-reduced-motion: reduce) { + .form-select { + transition: none; + } +} +.form-select:focus { + border-color: #9d9fa2; + outline: 0; + box-shadow: 0 0 0 0.25rem rgba(58, 63, 68, 0.25); +} +.form-select[multiple], .form-select[size]:not([size="1"]) { + padding-right: 0.75rem; + background-image: none; +} +.form-select:disabled { + background-color: #e9ecef; +} +.form-select:-moz-focusring { + color: transparent; + text-shadow: 0 0 0 #272b30; +} .form-select-sm { padding-top: 0.25rem; padding-bottom: 0.25rem; padding-left: 0.5rem; - font-size: 0.875rem; } + font-size: 0.875rem; + border-radius: 0.2rem; +} .form-select-lg { padding-top: 0.5rem; padding-bottom: 0.5rem; padding-left: 1rem; - font-size: 1.25rem; } + font-size: 1.25rem; + border-radius: 0.3rem; +} .form-check { display: block; min-height: 1.5rem; padding-left: 1.5em; - margin-bottom: 0.125rem; } - .form-check .form-check-input { - float: left; - margin-left: -1.5em; } + margin-bottom: 0.125rem; +} +.form-check .form-check-input { + float: left; + margin-left: -1.5em; +} .form-check-input { width: 1em; @@ -1038,191 +1217,246 @@ textarea.form-control-lg { background-size: contain; border: 1px solid rgba(0, 0, 0, 0.25); appearance: none; - color-adjust: exact; } - .form-check-input[type="checkbox"] { - border-radius: 0.25em; } - .form-check-input[type="radio"] { - border-radius: 50%; } - .form-check-input:active { - filter: brightness(90%); } - .form-check-input:focus { - border-color: #9d9fa2; - outline: 0; - box-shadow: 0 0 0 0.25rem rgba(58, 63, 68, 0.25); } - .form-check-input:checked { - background-color: #3a3f44; - border-color: #3a3f44; } - .form-check-input:checked[type="checkbox"] { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10l3 3l6-6'/%3e%3c/svg%3e"); } - .form-check-input:checked[type="radio"] { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e"); } - .form-check-input[type="checkbox"]:indeterminate { - background-color: #3a3f44; - border-color: #3a3f44; - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e"); } - .form-check-input:disabled { - pointer-events: none; - filter: none; - opacity: 0.5; } - .form-check-input[disabled] ~ .form-check-label, .form-check-input:disabled ~ .form-check-label { - opacity: 0.5; } + color-adjust: exact; +} +.form-check-input[type=checkbox] { + border-radius: 0.25em; +} +.form-check-input[type=radio] { + border-radius: 50%; +} +.form-check-input:active { + filter: brightness(90%); +} +.form-check-input:focus { + border-color: #9d9fa2; + outline: 0; + box-shadow: 0 0 0 0.25rem rgba(58, 63, 68, 0.25); +} +.form-check-input:checked { + background-color: #3a3f44; + border-color: #3a3f44; +} +.form-check-input:checked[type=checkbox] { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10l3 3l6-6'/%3e%3c/svg%3e"); +} +.form-check-input:checked[type=radio] { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e"); +} +.form-check-input[type=checkbox]:indeterminate { + background-color: #3a3f44; + border-color: #3a3f44; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e"); +} +.form-check-input:disabled { + pointer-events: none; + filter: none; + opacity: 0.5; +} +.form-check-input[disabled] ~ .form-check-label, .form-check-input:disabled ~ .form-check-label { + opacity: 0.5; +} .form-switch { - padding-left: 2.5em; } + padding-left: 2.5em; +} +.form-switch .form-check-input { + width: 2em; + margin-left: -2.5em; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e"); + background-position: left center; + border-radius: 2em; + transition: background-position 0.15s ease-in-out; +} +@media (prefers-reduced-motion: reduce) { .form-switch .form-check-input { - width: 2em; - margin-left: -2.5em; - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e"); - background-position: left center; - border-radius: 2em; - transition: background-position 0.15s ease-in-out; } - @media (prefers-reduced-motion: reduce) { - .form-switch .form-check-input { - transition: none; } } - .form-switch .form-check-input:focus { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%239d9fa2'/%3e%3c/svg%3e"); } - .form-switch .form-check-input:checked { - background-position: right center; - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e"); } + transition: none; + } +} +.form-switch .form-check-input:focus { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%239d9fa2'/%3e%3c/svg%3e"); +} +.form-switch .form-check-input:checked { + background-position: right center; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e"); +} .form-check-inline { display: inline-block; - margin-right: 1rem; } + margin-right: 1rem; +} .btn-check { position: absolute; clip: rect(0, 0, 0, 0); - pointer-events: none; } - .btn-check[disabled] + .btn, .btn-check:disabled + .btn { - pointer-events: none; - filter: none; - opacity: 0.65; } + pointer-events: none; +} +.btn-check[disabled] + .btn, .btn-check:disabled + .btn { + pointer-events: none; + filter: none; + opacity: 0.65; +} .form-range { width: 100%; height: 1.5rem; padding: 0; background-color: transparent; - appearance: none; } - .form-range:focus { - outline: 0; } - .form-range:focus::-webkit-slider-thumb { - box-shadow: 0 0 0 1px #272b30, 0 0 0 0.25rem rgba(58, 63, 68, 0.25); } - .form-range:focus::-moz-range-thumb { - box-shadow: 0 0 0 1px #272b30, 0 0 0 0.25rem rgba(58, 63, 68, 0.25); } - .form-range::-moz-focus-outer { - border: 0; } + appearance: none; +} +.form-range:focus { + outline: 0; +} +.form-range:focus::-webkit-slider-thumb { + box-shadow: 0 0 0 1px #272b30, 0 0 0 0.25rem rgba(58, 63, 68, 0.25); +} +.form-range:focus::-moz-range-thumb { + box-shadow: 0 0 0 1px #272b30, 0 0 0 0.25rem rgba(58, 63, 68, 0.25); +} +.form-range::-moz-focus-outer { + border: 0; +} +.form-range::-webkit-slider-thumb { + width: 1rem; + height: 1rem; + margin-top: -0.25rem; + background-color: #3a3f44; + border: 0; + border-radius: 1rem; + transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + appearance: none; +} +@media (prefers-reduced-motion: reduce) { .form-range::-webkit-slider-thumb { - width: 1rem; - height: 1rem; - margin-top: -0.25rem; - background-color: #3a3f44; - border: 0; - border-radius: 1rem; - transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; - appearance: none; } - @media (prefers-reduced-motion: reduce) { - .form-range::-webkit-slider-thumb { - transition: none; } } - .form-range::-webkit-slider-thumb:active { - background-color: #c4c5c7; } - .form-range::-webkit-slider-runnable-track { - width: 100%; - height: 0.5rem; - color: transparent; - cursor: pointer; - background-color: #dee2e6; - border-color: transparent; - border-radius: 1rem; } + transition: none; + } +} +.form-range::-webkit-slider-thumb:active { + background-color: #c4c5c7; +} +.form-range::-webkit-slider-runnable-track { + width: 100%; + height: 0.5rem; + color: transparent; + cursor: pointer; + background-color: #dee2e6; + border-color: transparent; + border-radius: 1rem; +} +.form-range::-moz-range-thumb { + width: 1rem; + height: 1rem; + background-color: #3a3f44; + border: 0; + border-radius: 1rem; + transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + appearance: none; +} +@media (prefers-reduced-motion: reduce) { .form-range::-moz-range-thumb { - width: 1rem; - height: 1rem; - background-color: #3a3f44; - border: 0; - border-radius: 1rem; - transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; - appearance: none; } - @media (prefers-reduced-motion: reduce) { - .form-range::-moz-range-thumb { - transition: none; } } - .form-range::-moz-range-thumb:active { - background-color: #c4c5c7; } - .form-range::-moz-range-track { - width: 100%; - height: 0.5rem; - color: transparent; - cursor: pointer; - background-color: #dee2e6; - border-color: transparent; - border-radius: 1rem; } - .form-range:disabled { - pointer-events: none; } - .form-range:disabled::-webkit-slider-thumb { - background-color: #999; } - .form-range:disabled::-moz-range-thumb { - background-color: #999; } + transition: none; + } +} +.form-range::-moz-range-thumb:active { + background-color: #c4c5c7; +} +.form-range::-moz-range-track { + width: 100%; + height: 0.5rem; + color: transparent; + cursor: pointer; + background-color: #dee2e6; + border-color: transparent; + border-radius: 1rem; +} +.form-range:disabled { + pointer-events: none; +} +.form-range:disabled::-webkit-slider-thumb { + background-color: #999; +} +.form-range:disabled::-moz-range-thumb { + background-color: #999; +} .form-floating { - position: relative; } - .form-floating > .form-control, - .form-floating > .form-select { - height: calc(3.5rem + 2px); - line-height: 1.25; } + position: relative; +} +.form-floating > .form-control, +.form-floating > .form-select { + height: calc(3.5rem + 2px); + line-height: 1.25; +} +.form-floating > label { + position: absolute; + top: 0; + left: 0; + height: 100%; + padding: 1rem 0.75rem; + pointer-events: none; + border: 1px solid transparent; + transform-origin: 0 0; + transition: opacity 0.1s ease-in-out, transform 0.1s ease-in-out; +} +@media (prefers-reduced-motion: reduce) { .form-floating > label { - position: absolute; - top: 0; - left: 0; - height: 100%; - padding: 1rem 0.75rem; - pointer-events: none; - border: 1px solid transparent; - transform-origin: 0 0; - transition: opacity 0.1s ease-in-out, transform 0.1s ease-in-out; } - @media (prefers-reduced-motion: reduce) { - .form-floating > label { - transition: none; } } - .form-floating > .form-control { - padding: 1rem 0.75rem; } - .form-floating > .form-control::placeholder { - color: transparent; } - .form-floating > .form-control:focus, .form-floating > .form-control:not(:placeholder-shown) { - padding-top: 1.625rem; - padding-bottom: 0.625rem; } - .form-floating > .form-control:-webkit-autofill { - padding-top: 1.625rem; - padding-bottom: 0.625rem; } - .form-floating > .form-select { - padding-top: 1.625rem; - padding-bottom: 0.625rem; } - .form-floating > .form-control:focus ~ label, - .form-floating > .form-control:not(:placeholder-shown) ~ label, - .form-floating > .form-select ~ label { - opacity: 0.65; - transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem); } - .form-floating > .form-control:-webkit-autofill ~ label { - opacity: 0.65; - transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem); } + transition: none; + } +} +.form-floating > .form-control { + padding: 1rem 0.75rem; +} +.form-floating > .form-control::placeholder { + color: transparent; +} +.form-floating > .form-control:focus, .form-floating > .form-control:not(:placeholder-shown) { + padding-top: 1.625rem; + padding-bottom: 0.625rem; +} +.form-floating > .form-control:-webkit-autofill { + padding-top: 1.625rem; + padding-bottom: 0.625rem; +} +.form-floating > .form-select { + padding-top: 1.625rem; + padding-bottom: 0.625rem; +} +.form-floating > .form-control:focus ~ label, +.form-floating > .form-control:not(:placeholder-shown) ~ label, +.form-floating > .form-select ~ label { + opacity: 0.65; + transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem); +} +.form-floating > .form-control:-webkit-autofill ~ label { + opacity: 0.65; + transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem); +} .input-group { position: relative; display: flex; flex-wrap: wrap; align-items: stretch; - width: 100%; } - .input-group > .form-control, - .input-group > .form-select { - position: relative; - flex: 1 1 auto; - width: 1%; - min-width: 0; } - .input-group > .form-control:focus, - .input-group > .form-select:focus { - z-index: 3; } - .input-group .btn { - position: relative; - z-index: 2; } - .input-group .btn:focus { - z-index: 3; } + width: 100%; +} +.input-group > .form-control, +.input-group > .form-select { + position: relative; + flex: 1 1 auto; + width: 1%; + min-width: 0; +} +.input-group > .form-control:focus, +.input-group > .form-select:focus { + z-index: 3; +} +.input-group .btn { + position: relative; + z-index: 2; +} +.input-group .btn:focus { + z-index: 3; +} .input-group-text { display: flex; @@ -1236,7 +1470,8 @@ textarea.form-control-lg { white-space: nowrap; background-color: #e9ecef; border: 1px solid #ced4da; - border-radius: 0.25rem; } + border-radius: 0.25rem; +} .input-group-lg > .form-control, .input-group-lg > .form-select, @@ -1244,7 +1479,8 @@ textarea.form-control-lg { .input-group-lg > .btn { padding: 0.5rem 1rem; font-size: 1.25rem; - border-radius: 0.3rem; } + border-radius: 0.3rem; +} .input-group-sm > .form-control, .input-group-sm > .form-select, @@ -1252,33 +1488,37 @@ textarea.form-control-lg { .input-group-sm > .btn { padding: 0.25rem 0.5rem; font-size: 0.875rem; - border-radius: 0.2rem; } + border-radius: 0.2rem; +} .input-group-lg > .form-select, .input-group-sm > .form-select { - padding-right: 3rem; } + padding-right: 3rem; +} .input-group:not(.has-validation) > :not(:last-child):not(.dropdown-toggle):not(.dropdown-menu), -.input-group:not(.has-validation) > .dropdown-toggle:nth-last-child(n + 3) { +.input-group:not(.has-validation) > .dropdown-toggle:nth-last-child(n+3) { border-top-right-radius: 0; - border-bottom-right-radius: 0; } - -.input-group.has-validation > :nth-last-child(n + 3):not(.dropdown-toggle):not(.dropdown-menu), -.input-group.has-validation > .dropdown-toggle:nth-last-child(n + 4) { + border-bottom-right-radius: 0; +} +.input-group.has-validation > :nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu), +.input-group.has-validation > .dropdown-toggle:nth-last-child(n+4) { border-top-right-radius: 0; - border-bottom-right-radius: 0; } - + border-bottom-right-radius: 0; +} .input-group > :not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback) { margin-left: -1px; border-top-left-radius: 0; - border-bottom-left-radius: 0; } + border-bottom-left-radius: 0; +} .valid-feedback { display: none; width: 100%; margin-top: 0.25rem; font-size: 0.875em; - color: #62c462; } + color: #62c462; +} .valid-tooltip { position: absolute; @@ -1287,17 +1527,19 @@ textarea.form-control-lg { display: none; max-width: 100%; padding: 0.25rem 0.5rem; - margin-top: .1rem; + margin-top: 0.1rem; font-size: 0.875rem; color: #fff; background-color: rgba(98, 196, 98, 0.9); - border-radius: 0.25rem; } + border-radius: 0.25rem; +} .was-validated :valid ~ .valid-feedback, .was-validated :valid ~ .valid-tooltip, .is-valid ~ .valid-feedback, .is-valid ~ .valid-tooltip { - display: block; } + display: block; +} .was-validated .form-control:valid, .form-control.is-valid { border-color: #62c462; @@ -1305,53 +1547,67 @@ textarea.form-control-lg { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%2362c462' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e"); background-repeat: no-repeat; background-position: right calc(0.375em + 0.1875rem) center; - background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); } - .was-validated .form-control:valid:focus, .form-control.is-valid:focus { - border-color: #62c462; - box-shadow: 0 0 0 0.25rem rgba(98, 196, 98, 0.25); } + background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); +} +.was-validated .form-control:valid:focus, .form-control.is-valid:focus { + border-color: #62c462; + box-shadow: 0 0 0 0.25rem rgba(98, 196, 98, 0.25); +} .was-validated textarea.form-control:valid, textarea.form-control.is-valid { padding-right: calc(1.5em + 0.75rem); - background-position: top calc(0.375em + 0.1875rem) right calc(0.375em + 0.1875rem); } + background-position: top calc(0.375em + 0.1875rem) right calc(0.375em + 0.1875rem); +} .was-validated .form-select:valid, .form-select.is-valid { - border-color: #62c462; } - .was-validated .form-select:valid:not([multiple]):not([size]), .was-validated .form-select:valid:not([multiple])[size="1"], .form-select.is-valid:not([multiple]):not([size]), .form-select.is-valid:not([multiple])[size="1"] { - padding-right: 4.125rem; - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%233a3f44' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"), url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%2362c462' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e"); - background-position: right 0.75rem center, center right 2.25rem; - background-size: 16px 12px, calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); } - .was-validated .form-select:valid:focus, .form-select.is-valid:focus { - border-color: #62c462; - box-shadow: 0 0 0 0.25rem rgba(98, 196, 98, 0.25); } + border-color: #62c462; +} +.was-validated .form-select:valid:not([multiple]):not([size]), .was-validated .form-select:valid:not([multiple])[size="1"], .form-select.is-valid:not([multiple]):not([size]), .form-select.is-valid:not([multiple])[size="1"] { + padding-right: 4.125rem; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%233a3f44' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"), url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%2362c462' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e"); + background-position: right 0.75rem center, center right 2.25rem; + background-size: 16px 12px, calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); +} +.was-validated .form-select:valid:focus, .form-select.is-valid:focus { + border-color: #62c462; + box-shadow: 0 0 0 0.25rem rgba(98, 196, 98, 0.25); +} .was-validated .form-check-input:valid, .form-check-input.is-valid { - border-color: #62c462; } - .was-validated .form-check-input:valid:checked, .form-check-input.is-valid:checked { - background-color: #62c462; } - .was-validated .form-check-input:valid:focus, .form-check-input.is-valid:focus { - box-shadow: 0 0 0 0.25rem rgba(98, 196, 98, 0.25); } - .was-validated .form-check-input:valid ~ .form-check-label, .form-check-input.is-valid ~ .form-check-label { - color: #62c462; } + border-color: #62c462; +} +.was-validated .form-check-input:valid:checked, .form-check-input.is-valid:checked { + background-color: #62c462; +} +.was-validated .form-check-input:valid:focus, .form-check-input.is-valid:focus { + box-shadow: 0 0 0 0.25rem rgba(98, 196, 98, 0.25); +} +.was-validated .form-check-input:valid ~ .form-check-label, .form-check-input.is-valid ~ .form-check-label { + color: #62c462; +} .form-check-inline .form-check-input ~ .valid-feedback { - margin-left: .5em; } + margin-left: 0.5em; +} -.was-validated .input-group .form-control:valid, .input-group .form-control.is-valid, .was-validated -.input-group .form-select:valid, +.was-validated .input-group .form-control:valid, .input-group .form-control.is-valid, +.was-validated .input-group .form-select:valid, .input-group .form-select.is-valid { - z-index: 1; } - .was-validated .input-group .form-control:valid:focus, .input-group .form-control.is-valid:focus, .was-validated - .input-group .form-select:valid:focus, - .input-group .form-select.is-valid:focus { - z-index: 3; } + z-index: 1; +} +.was-validated .input-group .form-control:valid:focus, .input-group .form-control.is-valid:focus, +.was-validated .input-group .form-select:valid:focus, +.input-group .form-select.is-valid:focus { + z-index: 3; +} .invalid-feedback { display: none; width: 100%; margin-top: 0.25rem; font-size: 0.875em; - color: #ee5f5b; } + color: #ee5f5b; +} .invalid-tooltip { position: absolute; @@ -1360,17 +1616,19 @@ textarea.form-control-lg { display: none; max-width: 100%; padding: 0.25rem 0.5rem; - margin-top: .1rem; + margin-top: 0.1rem; font-size: 0.875rem; color: #fff; background-color: rgba(238, 95, 91, 0.9); - border-radius: 0.25rem; } + border-radius: 0.25rem; +} .was-validated :invalid ~ .invalid-feedback, .was-validated :invalid ~ .invalid-tooltip, .is-invalid ~ .invalid-feedback, .is-invalid ~ .invalid-tooltip { - display: block; } + display: block; +} .was-validated .form-control:invalid, .form-control.is-invalid { border-color: #ee5f5b; @@ -1378,46 +1636,59 @@ textarea.form-control-lg { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23ee5f5b'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23ee5f5b' stroke='none'/%3e%3c/svg%3e"); background-repeat: no-repeat; background-position: right calc(0.375em + 0.1875rem) center; - background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); } - .was-validated .form-control:invalid:focus, .form-control.is-invalid:focus { - border-color: #ee5f5b; - box-shadow: 0 0 0 0.25rem rgba(238, 95, 91, 0.25); } + background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); +} +.was-validated .form-control:invalid:focus, .form-control.is-invalid:focus { + border-color: #ee5f5b; + box-shadow: 0 0 0 0.25rem rgba(238, 95, 91, 0.25); +} .was-validated textarea.form-control:invalid, textarea.form-control.is-invalid { padding-right: calc(1.5em + 0.75rem); - background-position: top calc(0.375em + 0.1875rem) right calc(0.375em + 0.1875rem); } + background-position: top calc(0.375em + 0.1875rem) right calc(0.375em + 0.1875rem); +} .was-validated .form-select:invalid, .form-select.is-invalid { - border-color: #ee5f5b; } - .was-validated .form-select:invalid:not([multiple]):not([size]), .was-validated .form-select:invalid:not([multiple])[size="1"], .form-select.is-invalid:not([multiple]):not([size]), .form-select.is-invalid:not([multiple])[size="1"] { - padding-right: 4.125rem; - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%233a3f44' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"), url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23ee5f5b'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23ee5f5b' stroke='none'/%3e%3c/svg%3e"); - background-position: right 0.75rem center, center right 2.25rem; - background-size: 16px 12px, calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); } - .was-validated .form-select:invalid:focus, .form-select.is-invalid:focus { - border-color: #ee5f5b; - box-shadow: 0 0 0 0.25rem rgba(238, 95, 91, 0.25); } + border-color: #ee5f5b; +} +.was-validated .form-select:invalid:not([multiple]):not([size]), .was-validated .form-select:invalid:not([multiple])[size="1"], .form-select.is-invalid:not([multiple]):not([size]), .form-select.is-invalid:not([multiple])[size="1"] { + padding-right: 4.125rem; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%233a3f44' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"), url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23ee5f5b'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23ee5f5b' stroke='none'/%3e%3c/svg%3e"); + background-position: right 0.75rem center, center right 2.25rem; + background-size: 16px 12px, calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); +} +.was-validated .form-select:invalid:focus, .form-select.is-invalid:focus { + border-color: #ee5f5b; + box-shadow: 0 0 0 0.25rem rgba(238, 95, 91, 0.25); +} .was-validated .form-check-input:invalid, .form-check-input.is-invalid { - border-color: #ee5f5b; } - .was-validated .form-check-input:invalid:checked, .form-check-input.is-invalid:checked { - background-color: #ee5f5b; } - .was-validated .form-check-input:invalid:focus, .form-check-input.is-invalid:focus { - box-shadow: 0 0 0 0.25rem rgba(238, 95, 91, 0.25); } - .was-validated .form-check-input:invalid ~ .form-check-label, .form-check-input.is-invalid ~ .form-check-label { - color: #ee5f5b; } + border-color: #ee5f5b; +} +.was-validated .form-check-input:invalid:checked, .form-check-input.is-invalid:checked { + background-color: #ee5f5b; +} +.was-validated .form-check-input:invalid:focus, .form-check-input.is-invalid:focus { + box-shadow: 0 0 0 0.25rem rgba(238, 95, 91, 0.25); +} +.was-validated .form-check-input:invalid ~ .form-check-label, .form-check-input.is-invalid ~ .form-check-label { + color: #ee5f5b; +} .form-check-inline .form-check-input ~ .invalid-feedback { - margin-left: .5em; } + margin-left: 0.5em; +} -.was-validated .input-group .form-control:invalid, .input-group .form-control.is-invalid, .was-validated -.input-group .form-select:invalid, +.was-validated .input-group .form-control:invalid, .input-group .form-control.is-invalid, +.was-validated .input-group .form-select:invalid, .input-group .form-select.is-invalid { - z-index: 2; } - .was-validated .input-group .form-control:invalid:focus, .input-group .form-control.is-invalid:focus, .was-validated - .input-group .form-select:invalid:focus, - .input-group .form-select.is-invalid:focus { - z-index: 3; } + z-index: 2; +} +.was-validated .input-group .form-control:invalid:focus, .input-group .form-control.is-invalid:focus, +.was-validated .input-group .form-select:invalid:focus, +.input-group .form-select.is-invalid:focus { + z-index: 3; +} .navbar { position: relative; @@ -1426,42 +1697,48 @@ textarea.form-control-lg { align-items: center; justify-content: space-between; padding-top: 0; - padding-bottom: 0; } - .navbar > .container, - .navbar > .container-fluid, .navbar > .container-sm, .navbar > .container-md, .navbar > .container-lg, .navbar > .container-xl, .navbar > .container-xxl { - display: flex; - flex-wrap: inherit; - align-items: center; - justify-content: space-between; } - + padding-bottom: 0; +} +.navbar > .container-xxl, .navbar > .container-xl, .navbar > .container-lg, .navbar > .container-md, .navbar > .container-sm, .navbar > .container, +.navbar > .container-fluid { + display: flex; + flex-wrap: inherit; + align-items: center; + justify-content: space-between; +} .navbar-brand { padding-top: 0.3125rem; padding-bottom: 0.3125rem; margin-right: 1rem; font-size: 1.25rem; text-decoration: none; - white-space: nowrap; } - + white-space: nowrap; +} .navbar-nav { display: flex; flex-direction: column; padding-left: 0; margin-bottom: 0; - list-style: none; } - .navbar-nav .nav-link { - padding-right: 0; - padding-left: 0; } - .navbar-nav .dropdown-menu { - position: static; } + list-style: none; +} +.navbar-nav .nav-link { + padding-right: 0; + padding-left: 0; +} +.navbar-nav .dropdown-menu { + position: static; +} .navbar-text { padding-top: 0.5rem; - padding-bottom: 0.5rem; } + padding-bottom: 0.5rem; +} .navbar-collapse { flex-basis: 100%; flex-grow: 1; - align-items: center; } + align-items: center; +} .navbar-toggler { padding: 0.25rem 0.75rem; @@ -1470,16 +1747,21 @@ textarea.form-control-lg { background-color: transparent; border: 1px solid transparent; border-radius: 0.25rem; - transition: box-shadow 0.15s ease-in-out; } - @media (prefers-reduced-motion: reduce) { - .navbar-toggler { - transition: none; } } - .navbar-toggler:hover { - text-decoration: none; } - .navbar-toggler:focus { - text-decoration: none; - outline: 0; - box-shadow: 0 0 0 0.25rem; } + transition: box-shadow 0.15s ease-in-out; +} +@media (prefers-reduced-motion: reduce) { + .navbar-toggler { + transition: none; + } +} +.navbar-toggler:hover { + text-decoration: none; +} +.navbar-toggler:focus { + text-decoration: none; + outline: 0; + box-shadow: 0 0 0 0.25rem; +} .navbar-toggler-icon { display: inline-block; @@ -1488,242 +1770,43 @@ textarea.form-control-lg { vertical-align: middle; background-repeat: no-repeat; background-position: center; - background-size: 100%; } + background-size: 100%; +} .navbar-nav-scroll { max-height: var(--bs-scroll-height, 75vh); - overflow-y: auto; } + overflow-y: auto; +} @media (min-width: 576px) { .navbar-expand-sm { flex-wrap: nowrap; - justify-content: flex-start; } - .navbar-expand-sm .navbar-nav { - flex-direction: row; } - .navbar-expand-sm .navbar-nav .dropdown-menu { - position: absolute; } - .navbar-expand-sm .navbar-nav .nav-link { - padding-right: 0.5rem; - padding-left: 0.5rem; } - .navbar-expand-sm .navbar-nav-scroll { - overflow: visible; } - .navbar-expand-sm .navbar-collapse { - display: flex !important; - flex-basis: auto; } - .navbar-expand-sm .navbar-toggler { - display: none; } - .navbar-expand-sm .offcanvas-header { - display: none; } - .navbar-expand-sm .offcanvas { - position: inherit; - bottom: 0; - z-index: 1000; - flex-grow: 1; - visibility: visible !important; - background-color: transparent; - border-right: 0; - border-left: 0; - transition: none; - transform: none; } - .navbar-expand-sm .offcanvas-top, - .navbar-expand-sm .offcanvas-bottom { - height: auto; - border-top: 0; - border-bottom: 0; } - .navbar-expand-sm .offcanvas-body { - display: flex; - flex-grow: 0; - padding: 0; - overflow-y: visible; } } - -@media (min-width: 768px) { - .navbar-expand-md { - flex-wrap: nowrap; - justify-content: flex-start; } - .navbar-expand-md .navbar-nav { - flex-direction: row; } - .navbar-expand-md .navbar-nav .dropdown-menu { - position: absolute; } - .navbar-expand-md .navbar-nav .nav-link { - padding-right: 0.5rem; - padding-left: 0.5rem; } - .navbar-expand-md .navbar-nav-scroll { - overflow: visible; } - .navbar-expand-md .navbar-collapse { - display: flex !important; - flex-basis: auto; } - .navbar-expand-md .navbar-toggler { - display: none; } - .navbar-expand-md .offcanvas-header { - display: none; } - .navbar-expand-md .offcanvas { - position: inherit; - bottom: 0; - z-index: 1000; - flex-grow: 1; - visibility: visible !important; - background-color: transparent; - border-right: 0; - border-left: 0; - transition: none; - transform: none; } - .navbar-expand-md .offcanvas-top, - .navbar-expand-md .offcanvas-bottom { - height: auto; - border-top: 0; - border-bottom: 0; } - .navbar-expand-md .offcanvas-body { - display: flex; - flex-grow: 0; - padding: 0; - overflow-y: visible; } } - -@media (min-width: 992px) { - .navbar-expand-lg { - flex-wrap: nowrap; - justify-content: flex-start; } - .navbar-expand-lg .navbar-nav { - flex-direction: row; } - .navbar-expand-lg .navbar-nav .dropdown-menu { - position: absolute; } - .navbar-expand-lg .navbar-nav .nav-link { - padding-right: 0.5rem; - padding-left: 0.5rem; } - .navbar-expand-lg .navbar-nav-scroll { - overflow: visible; } - .navbar-expand-lg .navbar-collapse { - display: flex !important; - flex-basis: auto; } - .navbar-expand-lg .navbar-toggler { - display: none; } - .navbar-expand-lg .offcanvas-header { - display: none; } - .navbar-expand-lg .offcanvas { - position: inherit; - bottom: 0; - z-index: 1000; - flex-grow: 1; - visibility: visible !important; - background-color: transparent; - border-right: 0; - border-left: 0; - transition: none; - transform: none; } - .navbar-expand-lg .offcanvas-top, - .navbar-expand-lg .offcanvas-bottom { - height: auto; - border-top: 0; - border-bottom: 0; } - .navbar-expand-lg .offcanvas-body { - display: flex; - flex-grow: 0; - padding: 0; - overflow-y: visible; } } - -@media (min-width: 1200px) { - .navbar-expand-xl { - flex-wrap: nowrap; - justify-content: flex-start; } - .navbar-expand-xl .navbar-nav { - flex-direction: row; } - .navbar-expand-xl .navbar-nav .dropdown-menu { - position: absolute; } - .navbar-expand-xl .navbar-nav .nav-link { - padding-right: 0.5rem; - padding-left: 0.5rem; } - .navbar-expand-xl .navbar-nav-scroll { - overflow: visible; } - .navbar-expand-xl .navbar-collapse { - display: flex !important; - flex-basis: auto; } - .navbar-expand-xl .navbar-toggler { - display: none; } - .navbar-expand-xl .offcanvas-header { - display: none; } - .navbar-expand-xl .offcanvas { - position: inherit; - bottom: 0; - z-index: 1000; - flex-grow: 1; - visibility: visible !important; - background-color: transparent; - border-right: 0; - border-left: 0; - transition: none; - transform: none; } - .navbar-expand-xl .offcanvas-top, - .navbar-expand-xl .offcanvas-bottom { - height: auto; - border-top: 0; - border-bottom: 0; } - .navbar-expand-xl .offcanvas-body { - display: flex; - flex-grow: 0; - padding: 0; - overflow-y: visible; } } - -@media (min-width: 1400px) { - .navbar-expand-xxl { - flex-wrap: nowrap; - justify-content: flex-start; } - .navbar-expand-xxl .navbar-nav { - flex-direction: row; } - .navbar-expand-xxl .navbar-nav .dropdown-menu { - position: absolute; } - .navbar-expand-xxl .navbar-nav .nav-link { - padding-right: 0.5rem; - padding-left: 0.5rem; } - .navbar-expand-xxl .navbar-nav-scroll { - overflow: visible; } - .navbar-expand-xxl .navbar-collapse { - display: flex !important; - flex-basis: auto; } - .navbar-expand-xxl .navbar-toggler { - display: none; } - .navbar-expand-xxl .offcanvas-header { - display: none; } - .navbar-expand-xxl .offcanvas { - position: inherit; - bottom: 0; - z-index: 1000; - flex-grow: 1; - visibility: visible !important; - background-color: transparent; - border-right: 0; - border-left: 0; - transition: none; - transform: none; } - .navbar-expand-xxl .offcanvas-top, - .navbar-expand-xxl .offcanvas-bottom { - height: auto; - border-top: 0; - border-bottom: 0; } - .navbar-expand-xxl .offcanvas-body { - display: flex; - flex-grow: 0; - padding: 0; - overflow-y: visible; } } - -.navbar-expand { - flex-wrap: nowrap; - justify-content: flex-start; } - .navbar-expand .navbar-nav { - flex-direction: row; } - .navbar-expand .navbar-nav .dropdown-menu { - position: absolute; } - .navbar-expand .navbar-nav .nav-link { - padding-right: 0.5rem; - padding-left: 0.5rem; } - .navbar-expand .navbar-nav-scroll { - overflow: visible; } - .navbar-expand .navbar-collapse { + justify-content: flex-start; + } + .navbar-expand-sm .navbar-nav { + flex-direction: row; + } + .navbar-expand-sm .navbar-nav .dropdown-menu { + position: absolute; + } + .navbar-expand-sm .navbar-nav .nav-link { + padding-right: 0.5rem; + padding-left: 0.5rem; + } + .navbar-expand-sm .navbar-nav-scroll { + overflow: visible; + } + .navbar-expand-sm .navbar-collapse { display: flex !important; - flex-basis: auto; } - .navbar-expand .navbar-toggler { - display: none; } - .navbar-expand .offcanvas-header { - display: none; } - .navbar-expand .offcanvas { + flex-basis: auto; + } + .navbar-expand-sm .navbar-toggler { + display: none; + } + .navbar-expand-sm .offcanvas-header { + display: none; + } + .navbar-expand-sm .offcanvas { position: inherit; bottom: 0; z-index: 1000; @@ -1733,74 +1816,351 @@ textarea.form-control-lg { border-right: 0; border-left: 0; transition: none; - transform: none; } - .navbar-expand .offcanvas-top, - .navbar-expand .offcanvas-bottom { + transform: none; + } + .navbar-expand-sm .offcanvas-top, +.navbar-expand-sm .offcanvas-bottom { height: auto; border-top: 0; - border-bottom: 0; } - .navbar-expand .offcanvas-body { + border-bottom: 0; + } + .navbar-expand-sm .offcanvas-body { display: flex; flex-grow: 0; padding: 0; - overflow-y: visible; } + overflow-y: visible; + } +} +@media (min-width: 768px) { + .navbar-expand-md { + flex-wrap: nowrap; + justify-content: flex-start; + } + .navbar-expand-md .navbar-nav { + flex-direction: row; + } + .navbar-expand-md .navbar-nav .dropdown-menu { + position: absolute; + } + .navbar-expand-md .navbar-nav .nav-link { + padding-right: 0.5rem; + padding-left: 0.5rem; + } + .navbar-expand-md .navbar-nav-scroll { + overflow: visible; + } + .navbar-expand-md .navbar-collapse { + display: flex !important; + flex-basis: auto; + } + .navbar-expand-md .navbar-toggler { + display: none; + } + .navbar-expand-md .offcanvas-header { + display: none; + } + .navbar-expand-md .offcanvas { + position: inherit; + bottom: 0; + z-index: 1000; + flex-grow: 1; + visibility: visible !important; + background-color: transparent; + border-right: 0; + border-left: 0; + transition: none; + transform: none; + } + .navbar-expand-md .offcanvas-top, +.navbar-expand-md .offcanvas-bottom { + height: auto; + border-top: 0; + border-bottom: 0; + } + .navbar-expand-md .offcanvas-body { + display: flex; + flex-grow: 0; + padding: 0; + overflow-y: visible; + } +} +@media (min-width: 992px) { + .navbar-expand-lg { + flex-wrap: nowrap; + justify-content: flex-start; + } + .navbar-expand-lg .navbar-nav { + flex-direction: row; + } + .navbar-expand-lg .navbar-nav .dropdown-menu { + position: absolute; + } + .navbar-expand-lg .navbar-nav .nav-link { + padding-right: 0.5rem; + padding-left: 0.5rem; + } + .navbar-expand-lg .navbar-nav-scroll { + overflow: visible; + } + .navbar-expand-lg .navbar-collapse { + display: flex !important; + flex-basis: auto; + } + .navbar-expand-lg .navbar-toggler { + display: none; + } + .navbar-expand-lg .offcanvas-header { + display: none; + } + .navbar-expand-lg .offcanvas { + position: inherit; + bottom: 0; + z-index: 1000; + flex-grow: 1; + visibility: visible !important; + background-color: transparent; + border-right: 0; + border-left: 0; + transition: none; + transform: none; + } + .navbar-expand-lg .offcanvas-top, +.navbar-expand-lg .offcanvas-bottom { + height: auto; + border-top: 0; + border-bottom: 0; + } + .navbar-expand-lg .offcanvas-body { + display: flex; + flex-grow: 0; + padding: 0; + overflow-y: visible; + } +} +@media (min-width: 1200px) { + .navbar-expand-xl { + flex-wrap: nowrap; + justify-content: flex-start; + } + .navbar-expand-xl .navbar-nav { + flex-direction: row; + } + .navbar-expand-xl .navbar-nav .dropdown-menu { + position: absolute; + } + .navbar-expand-xl .navbar-nav .nav-link { + padding-right: 0.5rem; + padding-left: 0.5rem; + } + .navbar-expand-xl .navbar-nav-scroll { + overflow: visible; + } + .navbar-expand-xl .navbar-collapse { + display: flex !important; + flex-basis: auto; + } + .navbar-expand-xl .navbar-toggler { + display: none; + } + .navbar-expand-xl .offcanvas-header { + display: none; + } + .navbar-expand-xl .offcanvas { + position: inherit; + bottom: 0; + z-index: 1000; + flex-grow: 1; + visibility: visible !important; + background-color: transparent; + border-right: 0; + border-left: 0; + transition: none; + transform: none; + } + .navbar-expand-xl .offcanvas-top, +.navbar-expand-xl .offcanvas-bottom { + height: auto; + border-top: 0; + border-bottom: 0; + } + .navbar-expand-xl .offcanvas-body { + display: flex; + flex-grow: 0; + padding: 0; + overflow-y: visible; + } +} +@media (min-width: 1400px) { + .navbar-expand-xxl { + flex-wrap: nowrap; + justify-content: flex-start; + } + .navbar-expand-xxl .navbar-nav { + flex-direction: row; + } + .navbar-expand-xxl .navbar-nav .dropdown-menu { + position: absolute; + } + .navbar-expand-xxl .navbar-nav .nav-link { + padding-right: 0.5rem; + padding-left: 0.5rem; + } + .navbar-expand-xxl .navbar-nav-scroll { + overflow: visible; + } + .navbar-expand-xxl .navbar-collapse { + display: flex !important; + flex-basis: auto; + } + .navbar-expand-xxl .navbar-toggler { + display: none; + } + .navbar-expand-xxl .offcanvas-header { + display: none; + } + .navbar-expand-xxl .offcanvas { + position: inherit; + bottom: 0; + z-index: 1000; + flex-grow: 1; + visibility: visible !important; + background-color: transparent; + border-right: 0; + border-left: 0; + transition: none; + transform: none; + } + .navbar-expand-xxl .offcanvas-top, +.navbar-expand-xxl .offcanvas-bottom { + height: auto; + border-top: 0; + border-bottom: 0; + } + .navbar-expand-xxl .offcanvas-body { + display: flex; + flex-grow: 0; + padding: 0; + overflow-y: visible; + } +} +.navbar-expand { + flex-wrap: nowrap; + justify-content: flex-start; +} +.navbar-expand .navbar-nav { + flex-direction: row; +} +.navbar-expand .navbar-nav .dropdown-menu { + position: absolute; +} +.navbar-expand .navbar-nav .nav-link { + padding-right: 0.5rem; + padding-left: 0.5rem; +} +.navbar-expand .navbar-nav-scroll { + overflow: visible; +} +.navbar-expand .navbar-collapse { + display: flex !important; + flex-basis: auto; +} +.navbar-expand .navbar-toggler { + display: none; +} +.navbar-expand .offcanvas-header { + display: none; +} +.navbar-expand .offcanvas { + position: inherit; + bottom: 0; + z-index: 1000; + flex-grow: 1; + visibility: visible !important; + background-color: transparent; + border-right: 0; + border-left: 0; + transition: none; + transform: none; +} +.navbar-expand .offcanvas-top, +.navbar-expand .offcanvas-bottom { + height: auto; + border-top: 0; + border-bottom: 0; +} +.navbar-expand .offcanvas-body { + display: flex; + flex-grow: 0; + padding: 0; + overflow-y: visible; +} .navbar-light .navbar-brand { - color: #3a3f44; } - .navbar-light .navbar-brand:hover, .navbar-light .navbar-brand:focus { - color: #3a3f44; } - + color: #3a3f44; +} +.navbar-light .navbar-brand:hover, .navbar-light .navbar-brand:focus { + color: #3a3f44; +} .navbar-light .navbar-nav .nav-link { - color: rgba(0, 0, 0, 0.55); } - .navbar-light .navbar-nav .nav-link:hover, .navbar-light .navbar-nav .nav-link:focus { - color: #3a3f44; } - .navbar-light .navbar-nav .nav-link.disabled { - color: rgba(0, 0, 0, 0.3); } - + color: rgba(0, 0, 0, 0.55); +} +.navbar-light .navbar-nav .nav-link:hover, .navbar-light .navbar-nav .nav-link:focus { + color: #3a3f44; +} +.navbar-light .navbar-nav .nav-link.disabled { + color: rgba(0, 0, 0, 0.3); +} .navbar-light .navbar-nav .show > .nav-link, .navbar-light .navbar-nav .nav-link.active { - color: #3a3f44; } - + color: #3a3f44; +} .navbar-light .navbar-toggler { color: rgba(0, 0, 0, 0.55); - border-color: rgba(0, 0, 0, 0.1); } - + border-color: rgba(0, 0, 0, 0.1); +} .navbar-light .navbar-toggler-icon { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%280, 0, 0, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e"); } - + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%280, 0, 0, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e"); +} .navbar-light .navbar-text { - color: rgba(0, 0, 0, 0.55); } - .navbar-light .navbar-text a, - .navbar-light .navbar-text a:hover, - .navbar-light .navbar-text a:focus { - color: #3a3f44; } + color: rgba(0, 0, 0, 0.55); +} +.navbar-light .navbar-text a, +.navbar-light .navbar-text a:hover, +.navbar-light .navbar-text a:focus { + color: #3a3f44; +} .navbar-dark .navbar-brand { - color: #fff; } - .navbar-dark .navbar-brand:hover, .navbar-dark .navbar-brand:focus { - color: #fff; } - + color: #fff; +} +.navbar-dark .navbar-brand:hover, .navbar-dark .navbar-brand:focus { + color: #fff; +} .navbar-dark .navbar-nav .nav-link { - color: rgba(255, 255, 255, 0.55); } - .navbar-dark .navbar-nav .nav-link:hover, .navbar-dark .navbar-nav .nav-link:focus { - color: #fff; } - .navbar-dark .navbar-nav .nav-link.disabled { - color: rgba(255, 255, 255, 0.25); } - + color: rgba(255, 255, 255, 0.55); +} +.navbar-dark .navbar-nav .nav-link:hover, .navbar-dark .navbar-nav .nav-link:focus { + color: #fff; +} +.navbar-dark .navbar-nav .nav-link.disabled { + color: rgba(255, 255, 255, 0.25); +} .navbar-dark .navbar-nav .show > .nav-link, .navbar-dark .navbar-nav .nav-link.active { - color: #fff; } - + color: #fff; +} .navbar-dark .navbar-toggler { color: rgba(255, 255, 255, 0.55); - border-color: rgba(255, 255, 255, 0.1); } - + border-color: rgba(255, 255, 255, 0.1); +} .navbar-dark .navbar-toggler-icon { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e"); } - + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e"); +} .navbar-dark .navbar-text { - color: rgba(255, 255, 255, 0.55); } - .navbar-dark .navbar-text a, - .navbar-dark .navbar-text a:hover, - .navbar-dark .navbar-text a:focus { - color: #fff; } + color: rgba(255, 255, 255, 0.55); +} +.navbar-dark .navbar-text a, +.navbar-dark .navbar-text a:hover, +.navbar-dark .navbar-text a:focus { + color: #fff; +} diff --git a/webroot/css/themes/theme-vapor.css b/webroot/css/themes/theme-vapor.css index ebab0cb..907bfbf 100644 --- a/webroot/css/themes/theme-vapor.css +++ b/webroot/css/themes/theme-vapor.css @@ -1,368 +1,436 @@ /* Callout */ .callout { border: 1px solid #e9ecef; - border-radius: .25rem; + border-radius: 0.25rem; background-color: #363636; - box-shadow: none; } + box-shadow: none; +} .callout-primary { border-left-color: #6f42c1; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-secondary { border-left-color: #ea39b8; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-success { border-left-color: #3cf281; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-info { border-left-color: #1ba2f6; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-warning { border-left-color: #ffc107; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-danger { border-left-color: #e44c55; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-light { border-left-color: #44d9e8; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} .callout-dark { border-left-color: #170229; - border-left-width: .25rem; - border-left-style: solid; } + border-left-width: 0.25rem; + border-left-style: solid; +} /* Toasts */ .toast { - min-width: 250px; } + min-width: 250px; +} .toast-primary { color: #21143a; background-color: #d4c6ec; - border-color: #c5b3e6; } - .toast-primary strong { - border-top-color: #b6a0e0; } + border-color: #c5b3e6; +} +.toast-primary strong { + border-top-color: #b6a0e0; +} .toast-secondary { color: #461137; background-color: #f9c4ea; - border-color: #f7b0e3; } - .toast-secondary strong { - border-top-color: #f599db; } + border-color: #f7b0e3; +} +.toast-secondary strong { + border-top-color: #f599db; +} .toast-success { color: #124927; background-color: #c5fbd9; - border-color: #b1facd; } - .toast-success strong { - border-top-color: #99f8be; } + border-color: #b1facd; +} +.toast-success strong { + border-top-color: #99f8be; +} .toast-info { color: #08314a; background-color: #bbe3fc; - border-color: #a4dafb; } - .toast-info strong { - border-top-color: #8cd0fa; } + border-color: #a4dafb; +} +.toast-info strong { + border-top-color: #8cd0fa; +} .toast-warning { color: #4d3a02; background-color: #ffecb5; - border-color: #ffe69c; } - .toast-warning strong { - border-top-color: #ffe083; } + border-color: #ffe69c; +} +.toast-warning strong { + border-top-color: #ffe083; +} .toast-danger { color: #44171a; background-color: #f7c9cc; - border-color: #f4b7bb; } - .toast-danger strong { - border-top-color: #f1a1a6; } + border-color: #f4b7bb; +} +.toast-danger strong { + border-top-color: #f1a1a6; +} .toast-light { color: #144146; background-color: #c7f4f8; - border-color: #b4f0f6; } - .toast-light strong { - border-top-color: #9debf3; } + border-color: #b4f0f6; +} +.toast-light strong { + border-top-color: #9debf3; +} .toast-dark { color: #07010c; background-color: #b9b3bf; - border-color: #a29aa9; } - .toast-dark strong { - border-top-color: #958c9d; } + border-color: #a29aa9; +} +.toast-dark strong { + border-top-color: #958c9d; +} /* Dropdown-item */ .dropdown-item.dropdown-item-primary { color: #fff; text-decoration: none; - background-color: #6f42c1; } - + background-color: #6f42c1; +} .dropdown-item.dropdown-item-outline-primary:hover { color: #fff; - background-color: #6f42c1; } - + background-color: #6f42c1; +} .dropdown-item.dropdown-item-secondary { color: #fff; text-decoration: none; - background-color: #ea39b8; } - + background-color: #ea39b8; +} .dropdown-item.dropdown-item-outline-secondary:hover { color: #fff; - background-color: #ea39b8; } - + background-color: #ea39b8; +} .dropdown-item.dropdown-item-success { color: #fff; text-decoration: none; - background-color: #3cf281; } - + background-color: #3cf281; +} .dropdown-item.dropdown-item-outline-success:hover { color: #fff; - background-color: #3cf281; } - + background-color: #3cf281; +} .dropdown-item.dropdown-item-info { color: #fff; text-decoration: none; - background-color: #1ba2f6; } - + background-color: #1ba2f6; +} .dropdown-item.dropdown-item-outline-info:hover { color: #fff; - background-color: #1ba2f6; } - + background-color: #1ba2f6; +} .dropdown-item.dropdown-item-warning { color: #fff; text-decoration: none; - background-color: #ffc107; } - + background-color: #ffc107; +} .dropdown-item.dropdown-item-outline-warning:hover { color: #fff; - background-color: #ffc107; } - + background-color: #ffc107; +} .dropdown-item.dropdown-item-danger { color: #fff; text-decoration: none; - background-color: #e44c55; } - + background-color: #e44c55; +} .dropdown-item.dropdown-item-outline-danger:hover { color: #fff; - background-color: #e44c55; } - + background-color: #e44c55; +} .dropdown-item.dropdown-item-light { color: #fff; text-decoration: none; - background-color: #44d9e8; } - + background-color: #44d9e8; +} .dropdown-item.dropdown-item-outline-light:hover { color: #fff; - background-color: #44d9e8; } - + background-color: #44d9e8; +} .dropdown-item.dropdown-item-dark { color: #fff; text-decoration: none; - background-color: #170229; } - + background-color: #170229; +} .dropdown-item.dropdown-item-outline-dark:hover { color: #fff; - background-color: #170229; } + background-color: #170229; +} /* Progress Timeline */ .progress-timeline { - padding: 0.2em 0.2em 0.5em 0.2em; } - .progress-timeline ul { - position: relative; - padding: 0; } - .progress-timeline li { - list-style-type: none; - position: relative; } - .progress-timeline li.progress-inactive { - opacity: 0.5; } - .progress-timeline .progress-line { - height: 2px; } - .progress-timeline .progress-line.progress-inactive { - opacity: 0.5; } + padding: 0.2em 0.2em 0.5em 0.2em; +} +.progress-timeline ul { + position: relative; + padding: 0; +} +.progress-timeline li { + list-style-type: none; + position: relative; +} +.progress-timeline li.progress-inactive { + opacity: 0.5; +} +.progress-timeline .progress-line { + height: 2px; +} +.progress-timeline .progress-line.progress-inactive { + opacity: 0.5; +} /* Forms severity */ .form-control.is-invalid.info { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%231ba2f6' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%231ba2f6' stroke='none'/%3e%3c/svg%3e"); } - .form-control.is-invalid.info:focus { - border-color: #1ba2f6; - box-shadow: 0 0 0 0.25rem rgba(27, 162, 246, 0.25); } - + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%231ba2f6' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%231ba2f6' stroke='none'/%3e%3c/svg%3e"); +} +.form-control.is-invalid.info:focus { + border-color: #1ba2f6; + box-shadow: 0 0 0 0.25rem rgba(27, 162, 246, 0.25); +} .form-control.is-invalid.warning { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23ffc107' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23ffc107' stroke='none'/%3e%3c/svg%3e"); } - .form-control.is-invalid.warning:focus { - border-color: #ffc107; - box-shadow: 0 0 0 0.25rem rgba(255, 193, 7, 0.25); } + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23ffc107' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23ffc107' stroke='none'/%3e%3c/svg%3e"); +} +.form-control.is-invalid.warning:focus { + border-color: #ffc107; + box-shadow: 0 0 0 0.25rem rgba(255, 193, 7, 0.25); +} .form-select.is-invalid:not([multiple]):not([size]).info, -.form-select.is-invalid:not([multiple])[size="1"] -.form-select.is-invalid.info { +.form-select.is-invalid:not([multiple])[size="1"] .form-select.is-invalid.info { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%231ba2f6'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%231ba2f6' stroke='none'/%3e%3c/svg%3e"); - background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); } - .form-select.is-invalid:not([multiple]):not([size]).info:focus, - .form-select.is-invalid:not([multiple])[size="1"] -.form-select.is-invalid.info:focus { - box-shadow: 0 0 0 0.25rem rgba(27, 162, 246, 0.25); } - + background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); +} +.form-select.is-invalid:not([multiple]):not([size]).info:focus, +.form-select.is-invalid:not([multiple])[size="1"] .form-select.is-invalid.info:focus { + box-shadow: 0 0 0 0.25rem rgba(27, 162, 246, 0.25); +} .form-select.is-invalid:not([multiple]):not([size]).warning, -.form-select.is-invalid:not([multiple])[size="1"] -.form-select.is-invalid.warning { +.form-select.is-invalid:not([multiple])[size="1"] .form-select.is-invalid.warning { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23ffc107'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23ffc107' stroke='none'/%3e%3c/svg%3e"); - background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); } - .form-select.is-invalid:not([multiple]):not([size]).warning:focus, - .form-select.is-invalid:not([multiple])[size="1"] -.form-select.is-invalid.warning:focus { - box-shadow: 0 0 0 0.25rem rgba(255, 193, 7, 0.25); } + background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); +} +.form-select.is-invalid:not([multiple]):not([size]).warning:focus, +.form-select.is-invalid:not([multiple])[size="1"] .form-select.is-invalid.warning:focus { + box-shadow: 0 0 0 0.25rem rgba(255, 193, 7, 0.25); +} .form-check-input.is-invalid.info { - border-color: #1ba2f6; } - + border-color: #1ba2f6; +} .form-check-input.is-invalid.info:checked { - background-color: #1ba2f6; } - + background-color: #1ba2f6; +} .form-check-input.is-invalid.info ~ .form-check-label { - color: unset; } - + color: unset; +} .form-check-input.is-invalid.info:focus { - box-shadow: 0 0 0 0.2rem rgba(27, 162, 246, 0.25); } - + box-shadow: 0 0 0 0.2rem rgba(27, 162, 246, 0.25); +} .form-check-input.is-invalid.warning { - border-color: #ffc107; } - + border-color: #ffc107; +} .form-check-input.is-invalid.warning:checked { - background-color: #ffc107; } - + background-color: #ffc107; +} .form-check-input.is-invalid.warning ~ .form-check-label { - color: unset; } - + color: unset; +} .form-check-input.is-invalid.warning:focus { - box-shadow: 0 0 0 0.2rem rgba(255, 193, 7, 0.25); } + box-shadow: 0 0 0 0.2rem rgba(255, 193, 7, 0.25); +} /* Utilities */ .mw-75 { - max-width: 75% !important; } + max-width: 75% !important; +} .mw-50 { - max-width: 50% !important; } + max-width: 50% !important; +} .mw-25 { - max-width: 25% !important; } + max-width: 25% !important; +} .mh-75 { - max-height: 75% !important; } + max-height: 75% !important; +} .mh-50 { - max-height: 50% !important; } + max-height: 50% !important; +} .mh-25 { - max-height: 25% !important; } + max-height: 25% !important; +} .p-abs-center-y { top: 50%; - transform: translateY(-50%); } + transform: translateY(-50%); +} .p-abs-center-x { left: 50%; - transform: translateX(-50%); } + transform: translateX(-50%); +} .p-abs-center-both { top: 50%; left: 50%; - transform: translateX(-50%) translateY(-50%); } + transform: translateX(-50%) translateY(-50%); +} /* Body */ .panel { background-color: #363636; border: 1px solid #454545; - box-shadow: none; } + box-shadow: none; +} .loading-overlay { background-color: #170229; - opacity: 0.65; } + opacity: 0.65; +} /* Top navbar */ .top-navbar { - background-color: #6f42c1; } + background-color: #6f42c1; +} .center-navbar nav.header-breadcrumb { - color: #fff; } + color: #fff; +} header.top-navbar .header-menu > a:hover, header.top-navbar .header-breadcrumb .header-breadcrumb-item > a:hover { - color: #d6d6d6 !important; } + color: #d6d6d6 !important; +} .top-navbar .center-navbar nav.header-breadcrumb li.header-breadcrumb-item a { - color: #fff; } + color: #fff; +} .top-navbar .right-navbar .header-menu a.nav-link { - color: #fff; } + color: #fff; +} .top-navbar .left-navbar .navbar-brand img { - filter: invert(1); } + filter: invert(1); +} .top-navbar .left-navbar .navbar-brand:hover img { - filter: invert(1) drop-shadow(0px 0px 3px #fff); } + filter: invert(1) drop-shadow(0px 0px 3px #fff); +} .top-navbar .composed-app-icon-container > .app-icon { - background-color: #fff; } + background-color: #fff; +} .breadcrumb-link-container { box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.16), 0 2px 6px 0 rgba(0, 0, 0, 0.12); - background-color: #44d9e8; } + background-color: #44d9e8; +} /* Sidebar */ .sidebar { - transition: width .08s linear; + transition: width 0.08s linear; box-shadow: none; - background-color: #170229; } + background-color: #170229; +} .sidebar ~ main.content:after { - background: #000; } + background: #000; +} .sidebar .sidebar-wrapper { - border-right: 1px solid none; } + border-right: 1px solid none; +} .sidebar .sidebar-wrapper { - border-right: 1px solid rgba(0, 0, 0, 0.125); } + border-right: 1px solid rgba(0, 0, 0, 0.125); +} .sidebar ul.sidebar-elements li > a.sidebar-link { - color: #fff; } + color: #fff; +} .sidebar ul.sidebar-elements li > a.sidebar-link.active { background-color: #343a40; - color: #3cf281; } + color: #3cf281; +} .sidebar ul.sidebar-elements li > a.sidebar-link.have-active-child { background-color: #343a40; - color: #3cf281; } + color: #3cf281; +} .sidebar ul.sidebar-elements li > a.sidebar-link:hover { background-color: #495057; - color: #3cf281; } + color: #3cf281; +} .sidebar.expanded ul.sidebar-elements li > a.sidebar-link.have-active-child, .sidebar:hover ul.sidebar-elements li > a.sidebar-link.have-active-child { - background-color: unset; } + background-color: unset; +} .sidebar.expanded ul.sidebar-elements li > a.sidebar-link.have-active-child:hover, .sidebar:hover ul.sidebar-elements li > a.sidebar-link.have-active-child:hover { - background-color: #495057; } + background-color: #495057; +} ul.sidebar-elements li > a.sidebar-link.active::after { - background-color: #6f42c1; } + background-color: #6f42c1; +} .lock-sidebar > a.btn { - background-color: unset; } + background-color: unset; +} From f9b1c150c41bab1531e52a4a126aecc05a3594db Mon Sep 17 00:00:00 2001 From: Andras Iklody Date: Fri, 17 Dec 2021 10:56:21 +0100 Subject: [PATCH 080/150] Don't ignore platform reqs in dockerfile --- docker/Dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 7e2035e..6e0cb03 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -24,7 +24,6 @@ RUN curl -sL https://getcomposer.org/installer | \ USER www-data RUN composer install \ - --ignore-platform-reqs \ --no-interaction \ --no-plugins \ --no-scripts \ From 16201369de0aa6292b914c97d4b209c41018f04f Mon Sep 17 00:00:00 2001 From: Luciano Righetti Date: Fri, 17 Dec 2021 12:39:18 +0100 Subject: [PATCH 081/150] chg: wait for db before running migrations --- docker/docker-compose.yml | 2 + docker/wait-for-it.sh | 182 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100755 docker/wait-for-it.sh diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index c99ee69..6aa0b73 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -17,6 +17,8 @@ services: - "8080:80" volumes: - ./run/logs:/var/www/html/logs + - ./wait-for-it.sh:/usr/local/bin/wait-for-it.sh:ro + entrypoint: wait-for-it.sh -t 0 -h database -p 3306 -- /entrypoint.sh environment: DEBUG: "true" CEREBRATE_DB_USERNAME: "cerebrate" diff --git a/docker/wait-for-it.sh b/docker/wait-for-it.sh new file mode 100755 index 0000000..d990e0d --- /dev/null +++ b/docker/wait-for-it.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +# Use this script to test if a given TCP host/port are available + +WAITFORIT_cmdname=${0##*/} + +echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } + +usage() +{ + cat << USAGE >&2 +Usage: + $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] + -h HOST | --host=HOST Host or IP under test + -p PORT | --port=PORT TCP port under test + Alternatively, you specify the host and port as host:port + -s | --strict Only execute subcommand if the test succeeds + -q | --quiet Don't output any status messages + -t TIMEOUT | --timeout=TIMEOUT + Timeout in seconds, zero for no timeout + -- COMMAND ARGS Execute command with args after the test finishes +USAGE + exit 1 +} + +wait_for() +{ + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + else + echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" + fi + WAITFORIT_start_ts=$(date +%s) + while : + do + if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then + nc -z $WAITFORIT_HOST $WAITFORIT_PORT + WAITFORIT_result=$? + else + (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 + WAITFORIT_result=$? + fi + if [[ $WAITFORIT_result -eq 0 ]]; then + WAITFORIT_end_ts=$(date +%s) + echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" + break + fi + sleep 1 + done + return $WAITFORIT_result +} + +wait_for_wrapper() +{ + # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 + if [[ $WAITFORIT_QUIET -eq 1 ]]; then + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + else + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + fi + WAITFORIT_PID=$! + trap "kill -INT -$WAITFORIT_PID" INT + wait $WAITFORIT_PID + WAITFORIT_RESULT=$? + if [[ $WAITFORIT_RESULT -ne 0 ]]; then + echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + fi + return $WAITFORIT_RESULT +} + +# process arguments +while [[ $# -gt 0 ]] +do + case "$1" in + *:* ) + WAITFORIT_hostport=(${1//:/ }) + WAITFORIT_HOST=${WAITFORIT_hostport[0]} + WAITFORIT_PORT=${WAITFORIT_hostport[1]} + shift 1 + ;; + --child) + WAITFORIT_CHILD=1 + shift 1 + ;; + -q | --quiet) + WAITFORIT_QUIET=1 + shift 1 + ;; + -s | --strict) + WAITFORIT_STRICT=1 + shift 1 + ;; + -h) + WAITFORIT_HOST="$2" + if [[ $WAITFORIT_HOST == "" ]]; then break; fi + shift 2 + ;; + --host=*) + WAITFORIT_HOST="${1#*=}" + shift 1 + ;; + -p) + WAITFORIT_PORT="$2" + if [[ $WAITFORIT_PORT == "" ]]; then break; fi + shift 2 + ;; + --port=*) + WAITFORIT_PORT="${1#*=}" + shift 1 + ;; + -t) + WAITFORIT_TIMEOUT="$2" + if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi + shift 2 + ;; + --timeout=*) + WAITFORIT_TIMEOUT="${1#*=}" + shift 1 + ;; + --) + shift + WAITFORIT_CLI=("$@") + break + ;; + --help) + usage + ;; + *) + echoerr "Unknown argument: $1" + usage + ;; + esac +done + +if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then + echoerr "Error: you need to provide a host and port to test." + usage +fi + +WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} +WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} +WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} +WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} + +# Check to see if timeout is from busybox? +WAITFORIT_TIMEOUT_PATH=$(type -p timeout) +WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) + +WAITFORIT_BUSYTIMEFLAG="" +if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then + WAITFORIT_ISBUSY=1 + # Check if busybox timeout uses -t flag + # (recent Alpine versions don't support -t anymore) + if timeout &>/dev/stdout | grep -q -e '-t '; then + WAITFORIT_BUSYTIMEFLAG="-t" + fi +else + WAITFORIT_ISBUSY=0 +fi + +if [[ $WAITFORIT_CHILD -gt 0 ]]; then + wait_for + WAITFORIT_RESULT=$? + exit $WAITFORIT_RESULT +else + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + wait_for_wrapper + WAITFORIT_RESULT=$? + else + wait_for + WAITFORIT_RESULT=$? + fi +fi + +if [[ $WAITFORIT_CLI != "" ]]; then + if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then + echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" + exit $WAITFORIT_RESULT + fi + exec "${WAITFORIT_CLI[@]}" +else + exit $WAITFORIT_RESULT +fi From 9a0c025ca9ead013d95ce99497f5f63024dee0a5 Mon Sep 17 00:00:00 2001 From: Luciano Righetti Date: Fri, 17 Dec 2021 13:20:02 +0100 Subject: [PATCH 082/150] chg: clear cakephp cache --- docker/entrypoint.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 35ef6dd..eb04309 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -9,6 +9,14 @@ run_all_migrations() { ./bin/cake migrations migrate -p ADmad/SocialAuth } +delete_model_cache() { + echo >&2 "Deleting cackephp cache..." + rm -rf ./tmp/cache/models/* + rm -rf ./tmp/cache/persistent/* +} + +delete_model_cache + # waiting for DB to come up for try in 1 2 3 4 5 6; do echo >&2 "migration - attempt $try" From 30700fc4e73c3f249c31c58e25e4aac3d970d941 Mon Sep 17 00:00:00 2001 From: Luciano Righetti Date: Fri, 17 Dec 2021 14:07:07 +0100 Subject: [PATCH 083/150] fix: typo --- docker/entrypoint.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index eb04309..e4c1bfd 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -10,7 +10,7 @@ run_all_migrations() { } delete_model_cache() { - echo >&2 "Deleting cackephp cache..." + echo >&2 "Deleting cakephp cache..." rm -rf ./tmp/cache/models/* rm -rf ./tmp/cache/persistent/* } From ffac2ef78bedaa29c23d3eeeb6f99c4753257d45 Mon Sep 17 00:00:00 2001 From: Luciano Righetti Date: Fri, 17 Dec 2021 17:14:40 +0100 Subject: [PATCH 084/150] fix: add missing copyright notice --- docker/wait-for-it.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docker/wait-for-it.sh b/docker/wait-for-it.sh index d990e0d..65b7f1f 100755 --- a/docker/wait-for-it.sh +++ b/docker/wait-for-it.sh @@ -1,6 +1,10 @@ #!/usr/bin/env bash # Use this script to test if a given TCP host/port are available +# The MIT License (MIT) +# Copyright (c) 2016 Giles Hall +# Source: https://github.com/vishnubob/wait-for-it + WAITFORIT_cmdname=${0##*/} echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } From 7f9418639ebe24dc4751cba40df1e06a2f9f64fe Mon Sep 17 00:00:00 2001 From: Sami Mokaddem Date: Mon, 20 Dec 2021 15:26:36 +0100 Subject: [PATCH 085/150] fix: [main] Prevent setting listeners if dependencies are not loaded --- webroot/js/main.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/webroot/js/main.js b/webroot/js/main.js index caf5d24..c4383ff 100644 --- a/webroot/js/main.js +++ b/webroot/js/main.js @@ -279,10 +279,12 @@ $(document).ready(() => { overloadBSDropdown(); addSupportOfNestedDropdown(); - const debouncedGlobalSearch = debounce(performGlobalSearch, 400) - $('#globalSearch') - .keydown(debouncedGlobalSearch) - .keydown(focusSearchResults); + if (window.debounce) { + const debouncedGlobalSearch = debounce(performGlobalSearch, 400) + $('#globalSearch') + .keydown(debouncedGlobalSearch) + .keydown(focusSearchResults); + } $('.lock-sidebar a.btn-lock-sidebar').click(() => { const $sidebar = $('.sidebar') From 30ec856dc3e871fd75276a54e6f67a6a4cab4cf6 Mon Sep 17 00:00:00 2001 From: Sami Mokaddem Date: Tue, 21 Dec 2021 12:34:37 +0100 Subject: [PATCH 086/150] fix: [local_tool:batchApiAction] Various UI and backend fixes --- src/Controller/LocalToolsController.php | 6 +++++- src/Lib/default/local_tool_connectors/MispConnector.php | 2 +- templates/LocalTools/connector_index.php | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Controller/LocalToolsController.php b/src/Controller/LocalToolsController.php index 55340f4..a0d31b9 100644 --- a/src/Controller/LocalToolsController.php +++ b/src/Controller/LocalToolsController.php @@ -68,7 +68,11 @@ class LocalToolsController extends AppController 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); + try { + $tmpResult = $this->LocalTools->action($this->ACL->getUser()['id'], $connection->connector, $actionName, $params, $this->request); + } catch (\Exception $e) { + $tmpResult = ['success' => false, 'message' => $e->getMessage(), 'data' => []]; + } $tmpResult['connection'] = $connection; $results[$connection->id] = $tmpResult; $successes += $tmpResult['success'] ? 1 : 0; diff --git a/src/Lib/default/local_tool_connectors/MispConnector.php b/src/Lib/default/local_tool_connectors/MispConnector.php index e84f082..59fd2c7 100644 --- a/src/Lib/default/local_tool_connectors/MispConnector.php +++ b/src/Lib/default/local_tool_connectors/MispConnector.php @@ -819,7 +819,7 @@ class MispConnector extends CommonConnectorTools [ 'field' => 'connection_ids', 'type' => 'hidden', - 'value' => $params['connection_ids'] + 'value' => json_encode($params['connection_ids']) ], [ 'field' => 'method', diff --git a/templates/LocalTools/connector_index.php b/templates/LocalTools/connector_index.php index e14be3b..e6ef205 100644 --- a/templates/LocalTools/connector_index.php +++ b/templates/LocalTools/connector_index.php @@ -155,7 +155,7 @@ echo $this->element('genericElements/IndexTable/index_table', [ tableData ) const $footer = $(modalObject.ajaxApi.statusNode).parent() - modalObject.ajaxApi.statusNode.remove() + modalObject.ajaxApi.options.statusNode.remove() const $cancelButton = $footer.find('button[data-dismiss="modal"]') $cancelButton.text('').removeClass('btn-secondary').addClass('btn-primary') } From 58e32782ca8da5b1a1d04797c244d07a418a3d30 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 22 Dec 2021 12:13:27 +0100 Subject: [PATCH 087/150] chg: misp connector index changes --- .../local_tool_connectors/MispConnector.php | 120 +++++++++--------- 1 file changed, 60 insertions(+), 60 deletions(-) diff --git a/src/Lib/default/local_tool_connectors/MispConnector.php b/src/Lib/default/local_tool_connectors/MispConnector.php index e84f082..57f9944 100644 --- a/src/Lib/default/local_tool_connectors/MispConnector.php +++ b/src/Lib/default/local_tool_connectors/MispConnector.php @@ -293,70 +293,70 @@ class MispConnector extends CommonConnectorTools $response = $this->getData('/servers/serverSettings', $params); $data = $response->getJson(); if (!empty($data['finalSettings'])) { - $finalSettings = [ - 'type' => 'index', - 'data' => [ - 'data' => $data['finalSettings'], - 'skip_pagination' => 1, - 'top_bar' => [ - 'children' => [ - [ - 'type' => 'search', - 'button' => __('Filter'), - 'placeholder' => __('Enter value to search'), - 'data' => '', - 'searchKey' => 'value', - 'additionalUrlParams' => $urlParams - ] - ] - ], - 'fields' => [ + $finalSettings = [ + 'type' => 'index', + 'data' => [ + 'data' => $data['finalSettings'], + 'skip_pagination' => 1, + 'top_bar' => [ + 'children' => [ [ - 'name' => 'Setting', - 'sort' => 'setting', - 'data_path' => 'setting', - ], - [ - 'name' => 'Criticality', - 'sort' => 'level', - 'data_path' => 'level', - 'arrayData' => [ - 0 => 'Critical', - 1 => 'Recommended', - 2 => 'Optional' - ], - 'element' => 'array_lookup_field' - ], - [ - 'name' => __('Value'), - 'sort' => 'value', - 'data_path' => 'value', - 'options' => 'options' - ], - [ - 'name' => __('Type'), - 'sort' => 'type', - 'data_path' => 'type', - ], - [ - 'name' => __('Error message'), - 'sort' => 'errorMessage', - 'data_path' => 'errorMessage', - ] - ], - 'title' => false, - 'description' => false, - 'pull' => 'right', - 'actions' => [ - [ - 'open_modal' => '/localTools/action/' . h($params['connection']['id']) . '/modifySettingAction?setting={{0}}', - 'modal_params_data_path' => ['setting'], - 'icon' => 'download', - 'reload_url' => '/localTools/action/' . h($params['connection']['id']) . '/ServerSettingsAction' + 'type' => 'search', + 'button' => __('Filter'), + 'placeholder' => __('Enter value to search'), + 'data' => '', + 'searchKey' => 'value', + 'additionalUrlParams' => $urlParams ] ] + ], + 'fields' => [ + [ + 'name' => 'Setting', + 'sort' => 'setting', + 'data_path' => 'setting', + ], + [ + 'name' => 'Criticality', + 'sort' => 'level', + 'data_path' => 'level', + 'arrayData' => [ + 0 => 'Critical', + 1 => 'Recommended', + 2 => 'Optional' + ], + 'element' => 'array_lookup_field' + ], + [ + 'name' => __('Value'), + 'sort' => 'value', + 'data_path' => 'value', + 'options' => 'options' + ], + [ + 'name' => __('Type'), + 'sort' => 'type', + 'data_path' => 'type', + ], + [ + 'name' => __('Error message'), + 'sort' => 'errorMessage', + 'data_path' => 'errorMessage', + ] + ], + 'title' => false, + 'description' => false, + 'pull' => 'right', + 'actions' => [ + [ + 'open_modal' => '/localTools/action/' . h($params['connection']['id']) . '/modifySettingAction?setting={{0}}', + 'modal_params_data_path' => ['setting'], + 'icon' => 'download', + 'reload_url' => '/localTools/action/' . h($params['connection']['id']) . '/ServerSettingsAction' + ] ] - ]; + ] + ]; if (!empty($params['quickFilter'])) { $needle = strtolower($params['quickFilter']); foreach ($finalSettings['data']['data'] as $k => $v) { From 136148705a8b53c6de7b3ac0bb67c7b757fe2e65 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 22 Dec 2021 12:26:37 +0100 Subject: [PATCH 088/150] chg: [keycloak] added screw to loosen timing issues --- src/Application.php | 1 + .../Table/SettingProviders/CerebrateSettingsProvider.php | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/src/Application.php b/src/Application.php index a63a6ce..0bf89c9 100644 --- a/src/Application.php +++ b/src/Application.php @@ -118,6 +118,7 @@ class Application extends BaseApplication implements AuthenticationServiceProvid 'collectionFactory' => null, 'logErrors' => true, ])); + \SocialConnect\JWX\JWT::$screw = Configure::check('keycloak.screw') ? Configure::read('keycloak.screw') : 0; } $middlewareQueue->add(new AuthenticationMiddleware($this)) ->add(new BodyParserMiddleware()); diff --git a/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php b/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php index 25365de..fa80078 100644 --- a/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php +++ b/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php @@ -211,6 +211,13 @@ class CerebrateSettingsProvider extends BaseSettingsProvider }, 'dependsOn' => 'keycloak.enabled' ], + 'keycloak.screw' => [ + 'name' => 'Screw', + 'type' => 'string', + 'severity' => 'info', + 'default' => 0, + 'description' => __('The misalignment allowed when validating JWT tokens between cerebrate and keycloak. Whilst crisp timings are essential for any timing push, perfect timing is only achievable by GSL participants. (given in seconds)') + ], 'keycloak.mapping.org_uuid' => [ 'name' => 'org_uuid mapping', 'type' => 'string', From a473a9d3fb2e6a8ed7ffbdb7f37ab5c91b0d3188 Mon Sep 17 00:00:00 2001 From: Luciano Righetti Date: Wed, 5 Jan 2022 17:44:02 +0100 Subject: [PATCH 089/150] new: initial api and integration tests. --- .gitignore | 2 + config/Migrations/schema-dump-default.lock | Bin 42139 -> 56757 bytes phpunit.xml.dist | 18 +-- src/Model/Table/AuditLogsTable.php | 4 +- tests/Fixture/AuthKeysFixture.php | 36 +++++ tests/Fixture/IndividualsFixture.php | 25 ++++ tests/Fixture/RolesFixture.php | 24 ++++ tests/Fixture/UsersFixture.php | 38 ++++++ tests/README.md | 48 +++++++ tests/TestCase/Api/UsersApiTest.php | 40 ++++++ tests/TestCase/ApplicationTest.php | 6 +- .../Controller/PagesControllerTest.php | 126 ------------------ .../Controller/UsersControllerTest.php | 32 +++++ tests/bootstrap.php | 17 +++ 14 files changed, 278 insertions(+), 138 deletions(-) create mode 100644 tests/Fixture/AuthKeysFixture.php create mode 100644 tests/Fixture/IndividualsFixture.php create mode 100644 tests/Fixture/RolesFixture.php create mode 100644 tests/Fixture/UsersFixture.php create mode 100644 tests/README.md create mode 100644 tests/TestCase/Api/UsersApiTest.php delete mode 100644 tests/TestCase/Controller/PagesControllerTest.php create mode 100644 tests/TestCase/Controller/UsersControllerTest.php diff --git a/.gitignore b/.gitignore index f84928d..869728c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ vendor webroot/theme/node_modules .vscode docker/run/ +.phpunit.result.cache +config.json \ No newline at end of file diff --git a/config/Migrations/schema-dump-default.lock b/config/Migrations/schema-dump-default.lock index 291060ccbeef6d3415e60d985954fd26141e1c02..8c9385cea7539531476901ab307ae28e67e054d4 100644 GIT binary patch literal 56757 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 delta 3761 zcmb_fdrVVj6i=ars!U!*KweVRK|$%Il*esAamEFV=gaN)I=^$i z^ZT9C&aTi8`$7^7G*w79d1xx1RvPMTR(rkK?$vt@FmyRzqrwkkomr`Sa^31-XDM2# z(rtK7d{vJ1`&{hjKeK(!%Yw;x&(rA&#WJmg4gIL_R?m`}apcnlBEP=3I&A9MkjEMoTxrV0sGdytWux za#O(m$tLFH5}gDwx#$My$sG%z!#IMMQa}Nv#yK0c2*+a^u0wU6oWsOC$U6`^2NlGz z&2Dz1G(yy2HH^${9*=bD#%%bdTRs6S9eRqk%?fM=i7HkUq9zF9U#?0R+#v%wZ9xS5 z__ft2=3>y$JzIm#wAE0jhecG`GG0jYgv)08W_P30YjfE3wdTgjI6qMcNXkHou?YeD8}M zdfL4cx=s|47J-4{&St@H11Y33%Wfv?&69uBZ`B@O)u{Gp*~33I7C{T^du zMinu7Y(qcHKI{i)bT)H+(~1y|fUvXvGQ4K&C%p-7OWl|Wzi#%jgxlxLh4)RWI58C{ zgoj5>pnoNa(*dT+oE5B2Z8cSRWpu3y#jCXTPoiwITQi~B`2|iyy4G@Nv>7Yh_I2=yIdvG*lLzzN@a7-0o_ z)Z~KY2c^uZ?RzBw@j8DfO{yi*EC^eTBB}`0$Za*)Z5{(lbRr35d`Sr2Af(Q8qA+sy zl33PE-5Zh`Bc=$VX@QL(7;@ra`S*GS_K89rA=x>tPGF$1a;0f7CIa_`-T@JY!E*_a zrrwv1Bzem2|4kDG9NM)4p$zTHz%jU5R5V+qVwHg_SxHd2-y#~D0wHBc*@BZxDw!Jx zG{Mwii}a^-7VpW?OShwrwA)<&&1?L%*!cGa_=E`x%hyREs%aIBM6ZF;bzz|S+>fY* zt-ltAy0L;{mFFsMb!#cfSKP)cw|QW=W(*KbG-nCkQ(qR%=ix%!8WtSA#7^pH7C+_S?8gKn<+s`JJ)wM+d3qS)GKLT>paI1z{%J zCvEvEbK;G-HimO>eos4bFiPNHke0!t1x6@m8jz+kczmup2n6wUS8p-S_VgyAiVXKk zLh;Ali#{ByE_p?^UmB#7d7;6l-0CsAC!Z2ZYx|+%vpL)gGL1Lh3ms3g*{y6JCiA*& zb}OvBb@WO2LA^#8k`6Nk)Gi&Ix)TXMSrqW~u#^1DLTIEj+Ws8n=kxssYtD)Z diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 7107122..7be6529 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -15,17 +15,17 @@ tests/TestCase/ - + + ./tests/TestCase/Controller + + + ./tests/TestCase/Api + - - - - - - - - + + + diff --git a/src/Model/Table/AuditLogsTable.php b/src/Model/Table/AuditLogsTable.php index efd6339..7987af6 100644 --- a/src/Model/Table/AuditLogsTable.php +++ b/src/Model/Table/AuditLogsTable.php @@ -163,8 +163,8 @@ class AuditLogsTable extends AppTable if ($this->user !== null) { return $this->user; } - - $this->user = ['id' => 0, /*'org_id' => 0, */'authkey_id' => 0, 'request_type' => self::REQUEST_TYPE_DEFAULT]; + + $this->user = ['id' => 0, /*'org_id' => 0, */'authkey_id' => 0, 'request_type' => self::REQUEST_TYPE_DEFAULT, 'name' => '']; $isShell = (php_sapi_name() === 'cli'); if ($isShell) { diff --git a/tests/Fixture/AuthKeysFixture.php b/tests/Fixture/AuthKeysFixture.php new file mode 100644 index 0000000..5924c5b --- /dev/null +++ b/tests/Fixture/AuthKeysFixture.php @@ -0,0 +1,36 @@ +records = [ + [ + 'id' => 1, + 'uuid' => '3ebfbe50-e7d2-406e-a092-f031e604b6e5', + 'authkey' => $hasher->hash(self::ADMIN_API_KEY), + 'authkey_start' => '4cd6', + 'authkey_end' => '4c2f', + 'expiration' => 0, + 'user_id' => 1, + 'comment' => '', + 'created' => time(), + 'modified' => time() + ] + ]; + parent::init(); + } +} diff --git a/tests/Fixture/IndividualsFixture.php b/tests/Fixture/IndividualsFixture.php new file mode 100644 index 0000000..31261f2 --- /dev/null +++ b/tests/Fixture/IndividualsFixture.php @@ -0,0 +1,25 @@ + 1, + 'uuid' => '3ebfbe50-e7d2-406e-a092-f031e604b6e1', + 'email' => 'admin@admin.test', + 'first_name' => 'admin', + 'last_name' => 'admin', + 'position' => 'admin', + 'created' => '2022-01-04 10:00:00', + 'modified' => '2022-01-04 10:00:00' + ] + ]; +} diff --git a/tests/Fixture/RolesFixture.php b/tests/Fixture/RolesFixture.php new file mode 100644 index 0000000..ced82a1 --- /dev/null +++ b/tests/Fixture/RolesFixture.php @@ -0,0 +1,24 @@ + 1, + 'uuid' => '3ebfbe50-e7d2-406e-a092-f031e604b6e4', + 'name' => 'admin', + 'is_default' => true, + 'perm_admin' => true, + 'perm_sync' => true, + 'perm_org_admin' => true + ] + ]; +} diff --git a/tests/Fixture/UsersFixture.php b/tests/Fixture/UsersFixture.php new file mode 100644 index 0000000..df6b8bd --- /dev/null +++ b/tests/Fixture/UsersFixture.php @@ -0,0 +1,38 @@ +records = [ + [ + 'id' => 1, + 'uuid' => '3ebfbe50-e7d2-406e-a092-f031e604b6e5', + 'username' => self::ADMIN_USER, + 'password' => $hasher->hash(self::ADMIN_PASSWORD), + 'role_id' => 1, + 'individual_id' => 1, + 'disabled' => 0, + 'organisation_id' => 1, + 'created' => '2022-01-04 10:00:00', + 'modified' => '2022-01-04 10:00:00' + ] + ]; + parent::init(); + } +} diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..898e249 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,48 @@ +# Testing +Add a test database to your `config/app_local.php` config file and set `debug` mode to `true`. +```php +'debug' => true, +'Datasources' => [ + 'default' => [ + ... + ], + /* + * The test connection is used during the test suite. + */ + 'test' => [ + 'host' => 'localhost', + 'username' => 'cerebrate', + 'password' => 'cerebrate', + 'database' => 'cerebrate_test', + ], +], +``` + +## Runing the tests + +``` +$ composer install +$ vendor/bin/phpunit +PHPUnit 8.5.22 by Sebastian Bergmann and contributors. + +..... 5 / 5 (100%) + +Time: 11.61 seconds, Memory: 26.00 MB + +OK (5 tests, 15 assertions) +``` + +Running a specific suite: +``` +$ vendor/bin/phpunit --testsuite=api +``` +Available suites: +* `app`: runs all test suites +* `api`: runs only api tests +* `controller`: runs only controller tests +* _to be continued ..._ + +By default the database is re-generated before running the test suite, to skip this step and speed up the test run use the `-d skip-migrations` option: +``` +$ vendor/bin/phpunit -d skip-migrations +``` diff --git a/tests/TestCase/Api/UsersApiTest.php b/tests/TestCase/Api/UsersApiTest.php new file mode 100644 index 0000000..5353305 --- /dev/null +++ b/tests/TestCase/Api/UsersApiTest.php @@ -0,0 +1,40 @@ +configRequest([ + 'headers' => [ + // this does not work: https://book.cakephp.org/4/en/development/testing.html#testing-stateless-authentication-and-apis + // 'Authorization' => AuthKeysFixture::ADMIN_API_KEY, + 'Accept' => 'application/json' + ] + ]); + + $this->get('/users/view'); + + $this->assertResponseOk(); + $this->assertResponseContains(sprintf('"username": "%s"', UsersFixture::ADMIN_USER)); + } +} diff --git a/tests/TestCase/ApplicationTest.php b/tests/TestCase/ApplicationTest.php index cd09f1e..e2d3183 100644 --- a/tests/TestCase/ApplicationTest.php +++ b/tests/TestCase/ApplicationTest.php @@ -40,10 +40,14 @@ class ApplicationTest extends IntegrationTestCase $app->bootstrap(); $plugins = $app->getPlugins(); - $this->assertCount(3, $plugins); + $this->assertCount(7, $plugins); $this->assertSame('Bake', $plugins->get('Bake')->getName()); $this->assertSame('DebugKit', $plugins->get('DebugKit')->getName()); $this->assertSame('Migrations', $plugins->get('Migrations')->getName()); + $this->assertSame('Authentication', $plugins->get('Authentication')->getName()); + $this->assertSame('ADmad/SocialAuth', $plugins->get('ADmad/SocialAuth')->getName()); + $this->assertSame('Tags', $plugins->get('Tags')->getName()); + $this->assertSame('Cake/TwigView', $plugins->get('Cake/TwigView')->getName()); } /** diff --git a/tests/TestCase/Controller/PagesControllerTest.php b/tests/TestCase/Controller/PagesControllerTest.php deleted file mode 100644 index f2958f9..0000000 --- a/tests/TestCase/Controller/PagesControllerTest.php +++ /dev/null @@ -1,126 +0,0 @@ -get('/'); - $this->assertResponseOk(); - $this->get('/'); - $this->assertResponseOk(); - } - - /** - * testDisplay method - * - * @return void - */ - public function testDisplay() - { - $this->get('/pages/home'); - $this->assertResponseOk(); - $this->assertResponseContains('CakePHP'); - $this->assertResponseContains(''); - } - - /** - * Test that missing template renders 404 page in production - * - * @return void - */ - public function testMissingTemplate() - { - Configure::write('debug', false); - $this->get('/pages/not_existing'); - - $this->assertResponseError(); - $this->assertResponseContains('Error'); - } - - /** - * Test that missing template in debug mode renders missing_template error page - * - * @return void - */ - public function testMissingTemplateInDebug() - { - Configure::write('debug', true); - $this->get('/pages/not_existing'); - - $this->assertResponseFailure(); - $this->assertResponseContains('Missing Template'); - $this->assertResponseContains('Stacktrace'); - $this->assertResponseContains('not_existing.php'); - } - - /** - * Test directory traversal protection - * - * @return void - */ - public function testDirectoryTraversalProtection() - { - $this->get('/pages/../Layout/ajax'); - $this->assertResponseCode(403); - $this->assertResponseContains('Forbidden'); - } - - /** - * Test that CSRF protection is applied to page rendering. - * - * @reutrn void - */ - public function testCsrfAppliedError() - { - $this->post('/pages/home', ['hello' => 'world']); - - $this->assertResponseCode(403); - $this->assertResponseContains('CSRF'); - } - - /** - * Test that CSRF protection is applied to page rendering. - * - * @reutrn void - */ - public function testCsrfAppliedOk() - { - $this->enableCsrfToken(); - $this->post('/pages/home', ['hello' => 'world']); - - $this->assertResponseCode(200); - $this->assertResponseContains('CakePHP'); - } -} diff --git a/tests/TestCase/Controller/UsersControllerTest.php b/tests/TestCase/Controller/UsersControllerTest.php new file mode 100644 index 0000000..0c856e8 --- /dev/null +++ b/tests/TestCase/Controller/UsersControllerTest.php @@ -0,0 +1,32 @@ +enableCsrfToken(); + $this->enableSecurityToken(); + + $this->post('/users/login', [ + 'username' => UsersFixture::ADMIN_USER, + 'password' => UsersFixture::ADMIN_PASSWORD, + ]); + + $this->assertSessionHasKey('authUser.uuid'); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 962815c..f8088be 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,4 +1,5 @@ ['*']`, prevents migrations from droping already created tables + (new SchemaLoader())->loadSqlFiles('./INSTALL/mysql.sql', 'test'); + $migrator = new Migrator(); + $migrator->runMany([ + ['connection' => 'test', 'skip' => ['*']], + ['plugin' => 'Tags', 'connection' => 'test', 'skip' => ['*']], + ['plugin' => 'ADmad/SocialAuth', 'connection' => 'test', 'skip' => ['*']] + ]); +} From f45727704f319e6ae30e32a311266e3de8c5db08 Mon Sep 17 00:00:00 2001 From: Luciano Righetti Date: Wed, 5 Jan 2022 17:44:24 +0100 Subject: [PATCH 090/150] fix: deprecation warning --- src/Controller/Component/ParamHandlerComponent.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Controller/Component/ParamHandlerComponent.php b/src/Controller/Component/ParamHandlerComponent.php index f76a578..89d6cce 100644 --- a/src/Controller/Component/ParamHandlerComponent.php +++ b/src/Controller/Component/ParamHandlerComponent.php @@ -47,7 +47,7 @@ class ParamHandlerComponent extends Component return $this->isRest; } if ($this->request->is('json')) { - if (!empty($this->request->input()) && empty($this->request->input('json_decode'))) { + if (!empty($this->request->getBody()) && !empty($this->request->getParsedBody())) { throw new MethodNotAllowedException('Invalid JSON input. Make sure that the JSON input is a correctly formatted JSON string. This request has been blocked to avoid an unfiltered request.'); } $this->isRest = true; From a69608530c9a31953df898a9a04643cf69e10069 Mon Sep 17 00:00:00 2001 From: Luciano Righetti Date: Fri, 7 Jan 2022 13:45:52 +0100 Subject: [PATCH 091/150] new: add /api openapi spec view with redoc, add faker to fixtures, validate api responses with openapi spec, add /api/v1/ prefix to api routes --- .gitignore | 2 +- composer.json | 3 +- config/routes.php | 22 +- src/Controller/ApiController.php | 19 ++ src/Controller/Component/ACLComponent.php | 3 + templates/Api/index.php | 2 + tests/Fixture/AuthKeysFixture.php | 52 ++++- tests/Fixture/IndividualsFixture.php | 72 +++++- tests/Fixture/OrganisationsFixture.php | 52 +++++ tests/Fixture/RolesFixture.php | 60 ++++- tests/Fixture/UsersFixture.php | 78 ++++++- tests/Helper/ApiTestTrait.php | 79 +++++++ tests/README.md | 11 +- tests/TestCase/Api/Users/UsersApiTest.php | 55 +++++ tests/TestCase/Api/UsersApiTest.php | 40 ---- .../{ => Users}/UsersControllerTest.php | 2 +- tests/bootstrap.php | 2 + webroot/docs/openapi.yaml | 215 ++++++++++++++++++ 18 files changed, 671 insertions(+), 98 deletions(-) create mode 100644 src/Controller/ApiController.php create mode 100644 templates/Api/index.php create mode 100644 tests/Fixture/OrganisationsFixture.php create mode 100644 tests/Helper/ApiTestTrait.php create mode 100644 tests/TestCase/Api/Users/UsersApiTest.php delete mode 100644 tests/TestCase/Api/UsersApiTest.php rename tests/TestCase/Controller/{ => Users}/UsersControllerTest.php (93%) create mode 100644 webroot/docs/openapi.yaml diff --git a/.gitignore b/.gitignore index 869728c..ba05ac6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,4 @@ webroot/theme/node_modules .vscode docker/run/ .phpunit.result.cache -config.json \ No newline at end of file +config.json diff --git a/composer.json b/composer.json index bc562a0..4e1af85 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,9 @@ "cakephp/bake": "^2.0.3", "cakephp/cakephp-codesniffer": "~4.0.0", "cakephp/debug_kit": "^4.0", + "fzaninotto/faker": "^1.9", "josegonzalez/dotenv": "^3.2", + "league/openapi-psr7-validator": "^0.16.4", "phpunit/phpunit": "^8.5", "psy/psysh": "@stable" }, @@ -44,7 +46,6 @@ "scripts": { "post-install-cmd": "App\\Console\\Installer::postInstall", "post-create-project-cmd": "App\\Console\\Installer::postInstall", - "post-autoload-dump": "Cake\\Composer\\Installer\\PluginInstaller::postAutoloadDump", "check": [ "@test", "@cs-check" diff --git a/config/routes.php b/config/routes.php index ac1ab26..e467725 100644 --- a/config/routes.php +++ b/config/routes.php @@ -92,14 +92,14 @@ $routes->prefix('Open', function (RouteBuilder $routes) { $routes->fallbacks(DashedRoute::class); }); -/* - * If you need a different set of middleware or none at all, - * open new scope and define routes there. - * - * ``` - * $routes->scope('/api', function (RouteBuilder $builder) { - * // No $builder->applyMiddleware() here. - * // Connect API actions here. - * }); - * ``` - */ +// API routes +$routes->scope('/api', function (RouteBuilder $routes) { + // $routes->applyMiddleware('ratelimit', 'auth.api'); + $routes->scope('/v1', function (RouteBuilder $routes) { + // $routes->applyMiddleware('v1compat'); + $routes->setExtensions(['json']); + + // Generic API route + $routes->connect('/{controller}/{action}/*'); + }); +}); \ No newline at end of file diff --git a/src/Controller/ApiController.php b/src/Controller/ApiController.php new file mode 100644 index 0000000..65cd11c --- /dev/null +++ b/src/Controller/ApiController.php @@ -0,0 +1,19 @@ +set('url', $url); + } +} diff --git a/src/Controller/Component/ACLComponent.php b/src/Controller/Component/ACLComponent.php index afa70ff..0c6d897 100644 --- a/src/Controller/Component/ACLComponent.php +++ b/src/Controller/Component/ACLComponent.php @@ -193,6 +193,9 @@ class ACLComponent extends Component 'getBookmarks' => ['*'], 'saveBookmark' => ['*'], 'deleteBookmark' => ['*'] + ], + 'Api' => [ + 'index' => ['*'] ] ); diff --git a/templates/Api/index.php b/templates/Api/index.php new file mode 100644 index 0000000..96be4b8 --- /dev/null +++ b/templates/Api/index.php @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/tests/Fixture/AuthKeysFixture.php b/tests/Fixture/AuthKeysFixture.php index 5924c5b..8291811 100644 --- a/tests/Fixture/AuthKeysFixture.php +++ b/tests/Fixture/AuthKeysFixture.php @@ -11,24 +11,60 @@ class AuthKeysFixture extends TestFixture { public $connection = 'test'; - public const ADMIN_API_KEY = '4cd687b314a3b9c4d83264e6195b9a3706ef4c2f'; + public const ADMIN_API_KEY = 'd033e22ae348aeb5660fc2140aec35850c4da997'; + public const SYNC_API_KEY = '6b387ced110858dcbcda36edb044dc18f91a0894'; + public const ORG_ADMIN_API_KEY = '1c4685d281d478dbcebd494158024bc3539004d0'; + public const USER_API_KEY = '12dea96fec20593566ab75692c9949596833adc9'; public function init(): void { $hasher = new DefaultPasswordHasher(); + $faker = \Faker\Factory::create(); $this->records = [ [ - 'id' => 1, - 'uuid' => '3ebfbe50-e7d2-406e-a092-f031e604b6e5', + 'uuid' => $faker->uuid(), 'authkey' => $hasher->hash(self::ADMIN_API_KEY), - 'authkey_start' => '4cd6', - 'authkey_end' => '4c2f', + 'authkey_start' => substr(self::ADMIN_API_KEY, 0, 4), + 'authkey_end' => substr(self::ADMIN_API_KEY, -4), 'expiration' => 0, - 'user_id' => 1, + 'user_id' => UsersFixture::USER_ADMIN_ID, 'comment' => '', - 'created' => time(), - 'modified' => time() + 'created' => $faker->dateTime()->getTimestamp(), + 'modified' => $faker->dateTime()->getTimestamp() + ], + [ + 'uuid' => $faker->uuid(), + 'authkey' => $hasher->hash(self::SYNC_API_KEY), + 'authkey_start' => substr(self::SYNC_API_KEY, 0, 4), + 'authkey_end' => substr(self::SYNC_API_KEY, -4), + 'expiration' => 0, + 'user_id' => UsersFixture::USER_SYNC_ID, + 'comment' => '', + 'created' => $faker->dateTime()->getTimestamp(), + 'modified' => $faker->dateTime()->getTimestamp() + ], + [ + 'uuid' => $faker->uuid(), + 'authkey' => $hasher->hash(self::ORG_ADMIN_API_KEY), + 'authkey_start' => substr(self::ORG_ADMIN_API_KEY, 0, 4), + 'authkey_end' => substr(self::ORG_ADMIN_API_KEY, -4), + 'expiration' => 0, + 'user_id' => UsersFixture::USER_ORG_ADMIN_ID, + 'comment' => '', + 'created' => $faker->dateTime()->getTimestamp(), + 'modified' => $faker->dateTime()->getTimestamp() + ], + [ + 'uuid' => $faker->uuid(), + 'authkey' => $hasher->hash(self::USER_API_KEY), + 'authkey_start' => substr(self::USER_API_KEY, 0, 4), + 'authkey_end' => substr(self::USER_API_KEY, -4), + 'expiration' => 0, + 'user_id' => UsersFixture::USER_REGULAR_USER_ID, + 'comment' => '', + 'created' => $faker->dateTime()->getTimestamp(), + 'modified' => $faker->dateTime()->getTimestamp() ] ]; parent::init(); diff --git a/tests/Fixture/IndividualsFixture.php b/tests/Fixture/IndividualsFixture.php index 31261f2..9c96f8b 100644 --- a/tests/Fixture/IndividualsFixture.php +++ b/tests/Fixture/IndividualsFixture.php @@ -10,16 +10,64 @@ class IndividualsFixture extends TestFixture { public $connection = 'test'; - public $records = [ - [ - 'id' => 1, - 'uuid' => '3ebfbe50-e7d2-406e-a092-f031e604b6e1', - 'email' => 'admin@admin.test', - 'first_name' => 'admin', - 'last_name' => 'admin', - 'position' => 'admin', - 'created' => '2022-01-04 10:00:00', - 'modified' => '2022-01-04 10:00:00' - ] - ]; + // Admin individual + public const INDIVIDUAL_ADMIN_ID = 1; + + // Sync individual + public const INDIVIDUAL_SYNC_ID = 2; + + // Org Admin individual + public const INDIVIDUAL_ORG_ADMIN_ID = 3; + + // Regular User individual + public const INDIVIDUAL_REGULAR_USER_ID = 4; + + public function init(): void + { + $faker = \Faker\Factory::create(); + + $this->records = [ + [ + 'id' => self::INDIVIDUAL_ADMIN_ID, + 'uuid' => $faker->uuid(), + 'email' => $faker->email(), + 'first_name' => $faker->firstName, + 'last_name' => $faker->lastName, + 'position' => 'admin', + 'created' => $faker->dateTime()->getTimestamp(), + 'modified' => $faker->dateTime()->getTimestamp() + ], + [ + 'id' => self::INDIVIDUAL_SYNC_ID, + 'uuid' => $faker->uuid(), + 'email' => $faker->email(), + 'first_name' => $faker->firstName, + 'last_name' => $faker->lastName, + 'position' => 'sync', + 'created' => $faker->dateTime()->getTimestamp(), + 'modified' => $faker->dateTime()->getTimestamp() + ], + [ + 'id' => self::INDIVIDUAL_ORG_ADMIN_ID, + 'uuid' => $faker->uuid(), + 'email' => $faker->email(), + 'first_name' => $faker->firstName, + 'last_name' => $faker->lastName, + 'position' => 'org_admin', + 'created' => $faker->dateTime()->getTimestamp(), + 'modified' => $faker->dateTime()->getTimestamp() + ], + [ + 'id' => self::INDIVIDUAL_REGULAR_USER_ID, + 'uuid' => $faker->uuid(), + 'email' => $faker->email(), + 'first_name' => $faker->firstName, + 'last_name' => $faker->lastName, + 'position' => 'user', + 'created' => $faker->dateTime()->getTimestamp(), + 'modified' => $faker->dateTime()->getTimestamp() + ] + ]; + parent::init(); + } } diff --git a/tests/Fixture/OrganisationsFixture.php b/tests/Fixture/OrganisationsFixture.php new file mode 100644 index 0000000..d8b81f9 --- /dev/null +++ b/tests/Fixture/OrganisationsFixture.php @@ -0,0 +1,52 @@ +records = [ + [ + 'id' => self::ORGANISATION_A_ID, + 'uuid' => $faker->uuid(), + 'name' => 'Organisation A', + 'url' => $faker->url, + 'nationality' => $faker->countryCode, + 'sector' => 'IT', + 'type' => '', + 'contacts' => '', + 'created' => $faker->dateTime()->getTimestamp(), + 'modified' => $faker->dateTime()->getTimestamp() + ], + [ + 'id' => self::ORGANISATION_B_ID, + 'uuid' => $faker->uuid(), + 'name' => 'Organisation B', + 'url' => $faker->url, + 'nationality' => $faker->countryCode, + 'sector' => 'IT', + 'type' => '', + 'contacts' => '', + 'created' => $faker->dateTime()->getTimestamp(), + 'modified' => $faker->dateTime()->getTimestamp() + ] + ]; + parent::init(); + } +} diff --git a/tests/Fixture/RolesFixture.php b/tests/Fixture/RolesFixture.php index ced82a1..1230e8f 100644 --- a/tests/Fixture/RolesFixture.php +++ b/tests/Fixture/RolesFixture.php @@ -10,15 +10,53 @@ class RolesFixture extends TestFixture { public $connection = 'test'; - public $records = [ - [ - 'id' => 1, - 'uuid' => '3ebfbe50-e7d2-406e-a092-f031e604b6e4', - 'name' => 'admin', - 'is_default' => true, - 'perm_admin' => true, - 'perm_sync' => true, - 'perm_org_admin' => true - ] - ]; + public const ROLE_ADMIN_ID = 1; + public const ROLE_SYNC_ID = 2; + public const ROLE_ORG_ADMIN_ID = 3; + public const ROLE_REGULAR_USER_ID = 4; + + public function init(): void + { + $faker = \Faker\Factory::create(); + + $this->records = [ + [ + 'id' => self::ROLE_ADMIN_ID, + 'uuid' => $faker->uuid(), + 'name' => 'admin', + 'is_default' => false, + 'perm_admin' => true, + 'perm_sync' => false, + 'perm_org_admin' => false + ], + [ + 'id' => self::ROLE_SYNC_ID, + 'uuid' => $faker->uuid(), + 'name' => 'sync', + 'is_default' => false, + 'perm_admin' => false, + 'perm_sync' => true, + 'perm_org_admin' => false + ], + [ + 'id' => self::ROLE_ORG_ADMIN_ID, + 'uuid' => $faker->uuid(), + 'name' => 'org_admin', + 'is_default' => false, + 'perm_admin' => false, + 'perm_sync' => false, + 'perm_org_admin' => true + ], + [ + 'id' => self::ROLE_REGULAR_USER_ID, + 'uuid' => $faker->uuid(), + 'name' => 'user', + 'is_default' => true, + 'perm_admin' => false, + 'perm_sync' => false, + 'perm_org_admin' => false + ] + ]; + parent::init(); + } } diff --git a/tests/Fixture/UsersFixture.php b/tests/Fixture/UsersFixture.php index df6b8bd..ba35e7a 100644 --- a/tests/Fixture/UsersFixture.php +++ b/tests/Fixture/UsersFixture.php @@ -11,26 +11,80 @@ class UsersFixture extends TestFixture { public $connection = 'test'; - public const ADMIN_USER = 'admin'; - public const ADMIN_PASSWORD = 'Password1234'; + // Admin user + public const USER_ADMIN_ID = 1; + public const USER_ADMIN_USERNAME = 'admin'; + public const USER_ADMIN_PASSWORD = 'AdminPassword'; + + // Sync user + public const USER_SYNC_ID = 2; + public const USER_SYNC_USERNAME = 'sync'; + public const USER_SYNC_PASSWORD = 'SyncPassword'; + + // Org Admin user + public const USER_ORG_ADMIN_ID = 3; + public const USER_ORG_ADMIN_USERNAME = 'org_admin'; + public const USER_ORG_ADMIN_PASSWORD = 'OrgAdminPassword'; + + // Regular User user + public const USER_REGULAR_USER_ID = 4; + public const USER_REGULAR_USER_USERNAME = 'user'; + public const USER_REGULAR_USER_PASSWORD = 'UserPassword'; + public function init(): void { $hasher = new DefaultPasswordHasher(); - + $faker = \Faker\Factory::create(); $this->records = [ [ - 'id' => 1, - 'uuid' => '3ebfbe50-e7d2-406e-a092-f031e604b6e5', - 'username' => self::ADMIN_USER, - 'password' => $hasher->hash(self::ADMIN_PASSWORD), - 'role_id' => 1, - 'individual_id' => 1, + 'id' => self::USER_ADMIN_ID, + 'uuid' => $faker->uuid(), + 'username' => self::USER_ADMIN_USERNAME, + 'password' => $hasher->hash(self::USER_ADMIN_PASSWORD), + 'role_id' => RolesFixture::ROLE_ADMIN_ID, + 'individual_id' => IndividualsFixture::INDIVIDUAL_ADMIN_ID, 'disabled' => 0, - 'organisation_id' => 1, - 'created' => '2022-01-04 10:00:00', - 'modified' => '2022-01-04 10:00:00' + 'organisation_id' => OrganisationsFixture::ORGANISATION_A_ID, + 'created' => $faker->dateTime()->getTimestamp(), + 'modified' => $faker->dateTime()->getTimestamp() + ], + [ + 'id' => self::USER_SYNC_ID, + 'uuid' => $faker->uuid(), + 'username' => self::USER_SYNC_USERNAME, + 'password' => $hasher->hash(self::USER_SYNC_PASSWORD), + 'role_id' => RolesFixture::ROLE_SYNC_ID, + 'individual_id' => IndividualsFixture::INDIVIDUAL_SYNC_ID, + 'disabled' => 0, + 'organisation_id' => OrganisationsFixture::ORGANISATION_A_ID, + 'created' => $faker->dateTime()->getTimestamp(), + 'modified' => $faker->dateTime()->getTimestamp() + ], + [ + 'id' => self::USER_ORG_ADMIN_ID, + 'uuid' => $faker->uuid(), + 'username' => self::USER_ORG_ADMIN_USERNAME, + 'password' => $hasher->hash(self::USER_ORG_ADMIN_PASSWORD), + 'role_id' => RolesFixture::ROLE_ORG_ADMIN_ID, + 'individual_id' => IndividualsFixture::INDIVIDUAL_ORG_ADMIN_ID, + 'disabled' => 0, + 'organisation_id' => OrganisationsFixture::ORGANISATION_A_ID, + 'created' => $faker->dateTime()->getTimestamp(), + 'modified' => $faker->dateTime()->getTimestamp() + ], + [ + 'id' => self::USER_REGULAR_USER_ID, + 'uuid' => $faker->uuid(), + 'username' => self::USER_REGULAR_USER_USERNAME, + 'password' => $hasher->hash(self::USER_REGULAR_USER_PASSWORD), + 'role_id' => RolesFixture::ROLE_REGULAR_USER_ID, + 'individual_id' => IndividualsFixture::INDIVIDUAL_REGULAR_USER_ID, + 'disabled' => 0, + 'organisation_id' => OrganisationsFixture::ORGANISATION_A_ID, + 'created' => $faker->dateTime()->getTimestamp(), + 'modified' => $faker->dateTime()->getTimestamp() ] ]; parent::init(); diff --git a/tests/Helper/ApiTestTrait.php b/tests/Helper/ApiTestTrait.php new file mode 100644 index 0000000..e749677 --- /dev/null +++ b/tests/Helper/ApiTestTrait.php @@ -0,0 +1,79 @@ +_authToken = $authToken; + + // somehow this is not set automatically in test environment + $_SERVER['HTTP_AUTHORIZATION'] = $authToken; + + $this->configRequest([ + 'headers' => [ + 'Accept' => 'application/json', + 'Authorization' => $this->_authToken + ] + ]); + } + + /** + * Parse the OpenAPI specification and create a validator + * + * @param string $specFile + * @return void + */ + public function initializeValidator(string $specFile): void + { + $this->validator = (new ValidatorBuilder)->fromYamlFile($specFile); + $this->requestValidator = $this->validator->getRequestValidator(); + $this->responseValidator = $this->validator->getResponseValidator(); + } + + /** + * Validates the API request against the OpenAPI spec + * + * @param string $path The path to the API endpoint + * @param string $method The HTTP method used to call the endpoint + * @return void + */ + public function validateRequest(string $endpoint, string $method = 'get'): void + { + // TODO: find a workaround to create a PSR-7 request object for validation + throw NotImplementedException("Unfortunately cakephp does not save the PSR-7 request object in the test context"); + } + + /** + * Validates the API response against the OpenAPI spec + * + * @param string $path The path to the API endpoint + * @param string $method The HTTP method used to call the endpoint + * @return void + */ + public function validateResponse(string $endpoint, string $method = 'get'): void + { + $address = new OperationAddress($endpoint, $method); + $this->responseValidator->validate($address, $this->_response); + } +} diff --git a/tests/README.md b/tests/README.md index 898e249..f37bcf3 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,5 +1,14 @@ # Testing -Add a test database to your `config/app_local.php` config file and set `debug` mode to `true`. + +1. Add a `cerebrate_test` database to the db: +```mysql +CREATE DATABASE cerebrate_test; +GRANT ALL PRIVILEGES ON cerebrate_test.* to cerebrate@localhost; +FLUSH PRIVILEGES; +QUIT; +``` + +2. Add a the test database to your `config/app_local.php` config file and set `debug` mode to `true`. ```php 'debug' => true, 'Datasources' => [ diff --git a/tests/TestCase/Api/Users/UsersApiTest.php b/tests/TestCase/Api/Users/UsersApiTest.php new file mode 100644 index 0000000..2f2c2b8 --- /dev/null +++ b/tests/TestCase/Api/Users/UsersApiTest.php @@ -0,0 +1,55 @@ +initializeValidator(APP . '../webroot/docs/openapi.yaml'); + } + + public function testViewMe(): void + { + $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + $this->get(self::ENDPOINT); + + $this->assertResponseOk(); + $this->assertResponseContains(sprintf('"username": "%s"', UsersFixture::USER_ADMIN_USERNAME)); + // TODO: $this->validateRequest() + $this->validateResponse(self::ENDPOINT); + } + + public function testViewById(): void + { + $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + $url = sprintf('%s/%d', self::ENDPOINT, UsersFixture::USER_ADMIN_ID); + $this->get($url); + + $this->assertResponseOk(); + $this->assertResponseContains(sprintf('"username": "%s"', UsersFixture::USER_ADMIN_USERNAME)); + // TODO: $this->validateRequest() + $this->validateResponse($url); + } +} diff --git a/tests/TestCase/Api/UsersApiTest.php b/tests/TestCase/Api/UsersApiTest.php deleted file mode 100644 index 5353305..0000000 --- a/tests/TestCase/Api/UsersApiTest.php +++ /dev/null @@ -1,40 +0,0 @@ -configRequest([ - 'headers' => [ - // this does not work: https://book.cakephp.org/4/en/development/testing.html#testing-stateless-authentication-and-apis - // 'Authorization' => AuthKeysFixture::ADMIN_API_KEY, - 'Accept' => 'application/json' - ] - ]); - - $this->get('/users/view'); - - $this->assertResponseOk(); - $this->assertResponseContains(sprintf('"username": "%s"', UsersFixture::ADMIN_USER)); - } -} diff --git a/tests/TestCase/Controller/UsersControllerTest.php b/tests/TestCase/Controller/Users/UsersControllerTest.php similarity index 93% rename from tests/TestCase/Controller/UsersControllerTest.php rename to tests/TestCase/Controller/Users/UsersControllerTest.php index 0c856e8..37dd73a 100644 --- a/tests/TestCase/Controller/UsersControllerTest.php +++ b/tests/TestCase/Controller/Users/UsersControllerTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Test\TestCase\Controller; +namespace App\Test\TestCase\Controller\Users; use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; diff --git a/tests/bootstrap.php b/tests/bootstrap.php index f8088be..9283095 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -66,4 +66,6 @@ if (!in_array('skip-migrations', $_SERVER['argv'])) { ['plugin' => 'Tags', 'connection' => 'test', 'skip' => ['*']], ['plugin' => 'ADmad/SocialAuth', 'connection' => 'test', 'skip' => ['*']] ]); +}else{ + echo "[ * ] Skipping migrations ...\n"; } diff --git a/webroot/docs/openapi.yaml b/webroot/docs/openapi.yaml new file mode 100644 index 0000000..5a55225 --- /dev/null +++ b/webroot/docs/openapi.yaml @@ -0,0 +1,215 @@ +openapi: 3.0.0 +info: + version: 1.3.0 + title: Cerebrate Project API + description: | + + TODO: markdown description + +servers: + - url: https://cerebrate.local + +tags: + - name: Users + description: "TODO: users resource descriptions" + +paths: + /api/v1/users/view: + get: + summary: "Get information about the current user" + operationId: viewUserMe + tags: + - Users + responses: + "200": + $ref: "#/components/responses/ViewUserResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + + /api/v1/users/view/{userId}: + get: + summary: "Get information of a user by id" + operationId: viewUserById + tags: + - Users + parameters: + - $ref: "#/components/parameters/userId" + responses: + "200": + $ref: "#/components/responses/ViewUserResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + +components: + schemas: + # General + UUID: + type: string + format: uuid + maxLength: 36 + example: "c99506a6-1255-4b71-afa5-7b8ba48c3b1b" + + ID: + type: integer + format: int32 + example: 1 + + DateTime: + type: string + format: datetime + example: "2022-01-05T11:19:26+00:00" + + # Users + Username: + type: string + example: "admin" + + User: + type: object + properties: + id: + $ref: "#/components/schemas/ID" + uuid: + $ref: "#/components/schemas/UUID" + username: + $ref: "#/components/schemas/Username" + role_id: + $ref: "#/components/schemas/ID" + individual_id: + $ref: "#/components/schemas/ID" + disabled: + type: boolean + created: + $ref: "#/components/schemas/DateTime" + modified: + $ref: "#/components/schemas/DateTime" + organisation_id: + $ref: "#/components/schemas/ID" + + # Individuals + + # Organisations + + # Roles + RoleName: + type: string + maxLength: 255 + example: "admin" + + Role: + type: object + properties: + id: + $ref: "#/components/schemas/ID" + name: + $ref: "#/components/schemas/RoleName" + is_default: + type: boolean + perm_admin: + type: boolean + perm_sync: + type: boolean + perm_org_admin: + type: boolean + + # Errors + ApiError: + type: object + required: + - name + - message + - url + properties: + name: + type: string + message: + type: string + url: + type: string + example: "/users" + + UnauthorizedApiError: + type: object + required: + - name + - message + - url + properties: + name: + type: string + example: "Authentication failed. Please make sure you pass the API key of an API enabled user along in the Authorization header." + message: + type: string + example: "Authentication failed. Please make sure you pass the API key of an API enabled user along in the Authorization header." + url: + type: string + example: "/users" + + NotFoundApiError: + type: object + required: + - name + - message + - url + properties: + name: + type: string + example: "Invalid user" + message: + type: string + example: "Invalid user" + url: + type: string + example: "/users/1234" + + parameters: + userId: + name: userId + in: path + description: "Numeric ID of the User" + required: true + schema: + $ref: "#/components/schemas/ID" + + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: Authorization + description: | + The authorization is performed by using the following header in the HTTP requests: + + Authorization: YOUR_API_KEY + + # requestBodies: + + responses: + # User + ViewUserResponse: + description: "User response" + content: + application/json: + schema: + $ref: "#/components/schemas/User" + + # Errors + ApiErrorResponse: + description: "Unexpected API error" + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" + + UnauthorizedApiErrorResponse: + description: "Authentication failed. Please make sure you pass the API key of an API enabled user along in the Authorization header." + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedApiError" + +security: + - ApiKeyAuth: [] From 5b3bef13e2678dd5a5397bddea2e46f3dd639eeb Mon Sep 17 00:00:00 2001 From: Luciano Righetti Date: Fri, 7 Jan 2022 13:47:20 +0100 Subject: [PATCH 092/150] fix: test --- tests/TestCase/Controller/Users/UsersControllerTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/TestCase/Controller/Users/UsersControllerTest.php b/tests/TestCase/Controller/Users/UsersControllerTest.php index 37dd73a..7ef6e4c 100644 --- a/tests/TestCase/Controller/Users/UsersControllerTest.php +++ b/tests/TestCase/Controller/Users/UsersControllerTest.php @@ -23,8 +23,8 @@ class UsersControllerTest extends TestCase $this->enableSecurityToken(); $this->post('/users/login', [ - 'username' => UsersFixture::ADMIN_USER, - 'password' => UsersFixture::ADMIN_PASSWORD, + 'username' => UsersFixture::USER_ADMIN_USERNAME, + 'password' => UsersFixture::USER_ADMIN_PASSWORD, ]); $this->assertSessionHasKey('authUser.uuid'); From 3923064d07c7972c92c1f7f530c41ef53b9d6ff1 Mon Sep 17 00:00:00 2001 From: Luciano Righetti Date: Fri, 7 Jan 2022 14:37:04 +0100 Subject: [PATCH 093/150] chg: migrate mysql.sql initial schema to a phinx migration --- INSTALL/INSTALL.md | 6 - INSTALL/mysql.sql | 408 ----- .../20210311000000_InitialSchema.php | 1464 +++++++++++++++++ debian/install | 1 - docker/README.md | 17 - docker/docker-compose.yml | 1 - tests/bootstrap.php | 14 +- 7 files changed, 1469 insertions(+), 442 deletions(-) delete mode 100644 INSTALL/mysql.sql create mode 100644 config/Migrations/20210311000000_InitialSchema.php diff --git a/INSTALL/INSTALL.md b/INSTALL/INSTALL.md index fc6e131..f4cef5f 100644 --- a/INSTALL/INSTALL.md +++ b/INSTALL/INSTALL.md @@ -74,12 +74,6 @@ sudo mysql -e "GRANT ALL PRIVILEGES ON cerebrate.* to cerebrate@localhost;" sudo mysql -e "FLUSH PRIVILEGES;" ``` -Load the default table structure into the database - -```bash -sudo mysql -u cerebrate -p cerebrate < /var/www/cerebrate/INSTALL/mysql.sql -``` - create your local configuration and set the db credentials ```bash diff --git a/INSTALL/mysql.sql b/INSTALL/mysql.sql deleted file mode 100644 index ed39991..0000000 --- a/INSTALL/mysql.sql +++ /dev/null @@ -1,408 +0,0 @@ --- MySQL dump 10.16 Distrib 10.1.44-MariaDB, for debian-linux-gnu (x86_64) --- --- Host: localhost Database: cerebrate --- ------------------------------------------------------ --- Server version 10.1.44-MariaDB-0ubuntu0.18.04.1 - -/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; -/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; -/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; -/*!40101 SET NAMES utf8mb4 */; -/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; -/*!40103 SET TIME_ZONE='+00:00' */; -/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; -/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; -/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; -/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; - --- --- Table structure for table `alignment_tags` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE IF NOT EXISTS `alignment_tags` ( - `id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `alignment_id` int(10) unsigned NOT NULL, - `tag_id` int(10) unsigned NOT NULL, - PRIMARY KEY (`id`), - KEY `alignment_id` (`alignment_id`), - KEY `tag_id` (`tag_id`), - CONSTRAINT `alignment_tags_ibfk_1` FOREIGN KEY (`alignment_id`) REFERENCES `alignments` (`id`), - CONSTRAINT `alignment_tags_ibfk_10` FOREIGN KEY (`tag_id`) REFERENCES `tags` (`id`), - CONSTRAINT `alignment_tags_ibfk_11` FOREIGN KEY (`alignment_id`) REFERENCES `alignments` (`id`), - CONSTRAINT `alignment_tags_ibfk_12` FOREIGN KEY (`tag_id`) REFERENCES `tags` (`id`), - CONSTRAINT `alignment_tags_ibfk_2` FOREIGN KEY (`tag_id`) REFERENCES `tags` (`id`), - CONSTRAINT `alignment_tags_ibfk_3` FOREIGN KEY (`alignment_id`) REFERENCES `alignments` (`id`), - CONSTRAINT `alignment_tags_ibfk_4` FOREIGN KEY (`tag_id`) REFERENCES `tags` (`id`), - CONSTRAINT `alignment_tags_ibfk_5` FOREIGN KEY (`alignment_id`) REFERENCES `alignments` (`id`), - CONSTRAINT `alignment_tags_ibfk_6` FOREIGN KEY (`tag_id`) REFERENCES `tags` (`id`), - CONSTRAINT `alignment_tags_ibfk_7` FOREIGN KEY (`alignment_id`) REFERENCES `alignments` (`id`), - CONSTRAINT `alignment_tags_ibfk_8` FOREIGN KEY (`tag_id`) REFERENCES `tags` (`id`), - CONSTRAINT `alignment_tags_ibfk_9` FOREIGN KEY (`alignment_id`) REFERENCES `alignments` (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `alignments` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE IF NOT EXISTS `alignments` ( - `id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `individual_id` int(10) unsigned NOT NULL, - `organisation_id` int(10) unsigned NOT NULL, - `type` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT 'member', - PRIMARY KEY (`id`), - KEY `individual_id` (`individual_id`), - KEY `organisation_id` (`organisation_id`), - CONSTRAINT `alignments_ibfk_1` FOREIGN KEY (`individual_id`) REFERENCES `individuals` (`id`), - CONSTRAINT `alignments_ibfk_2` FOREIGN KEY (`organisation_id`) REFERENCES `organisations` (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `auth_keys` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE IF NOT EXISTS `auth_keys` ( - `id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `uuid` varchar(40) COLLATE utf8mb4_unicode_ci NOT NULL, - `authkey` varchar(72) CHARACTER SET ascii DEFAULT NULL, - `authkey_start` varchar(4) CHARACTER SET ascii DEFAULT NULL, - `authkey_end` varchar(4) CHARACTER SET ascii DEFAULT NULL, - `created` int(10) unsigned NOT NULL, - `expiration` int(10) unsigned NOT NULL, - `user_id` int(10) unsigned NOT NULL, - `comment` text COLLATE utf8mb4_unicode_ci, - PRIMARY KEY (`id`), - KEY `authkey_start` (`authkey_start`), - KEY `authkey_end` (`authkey_end`), - KEY `created` (`created`), - KEY `expiration` (`expiration`), - KEY `user_id` (`user_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `broods` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE IF NOT EXISTS `broods` ( - `id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `uuid` varchar(40) CHARACTER SET ascii DEFAULT NULL, - `name` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, - `url` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, - `description` text COLLATE utf8mb4_unicode_ci, - `organisation_id` int(10) unsigned NOT NULL, - `trusted` tinyint(1) DEFAULT NULL, - `pull` tinyint(1) DEFAULT NULL, - `skip_proxy` tinyint(1) DEFAULT NULL, - `authkey` varchar(40) CHARACTER SET ascii DEFAULT NULL, - PRIMARY KEY (`id`), - KEY `uuid` (`uuid`), - KEY `name` (`name`), - KEY `url` (`url`), - KEY `authkey` (`authkey`), - KEY `organisation_id` (`organisation_id`), - FOREIGN KEY (`organisation_id`) REFERENCES `organisations` (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `encryption_keys` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE IF NOT EXISTS `encryption_keys` ( - `id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `uuid` varchar(40) CHARACTER SET ascii DEFAULT NULL, - `type` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, - `encryption_key` text COLLATE utf8mb4_unicode_ci, - `revoked` tinyint(1) DEFAULT NULL, - `expires` int(10) unsigned DEFAULT NULL, - `owner_id` int(10) unsigned DEFAULT NULL, - `owner_type` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL, - PRIMARY KEY (`id`), - KEY `uuid` (`uuid`), - KEY `type` (`type`), - KEY `expires` (`expires`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `individual_encryption_keys` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE IF NOT EXISTS `individual_encryption_keys` ( - `id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `individual_id` int(10) unsigned NOT NULL, - `encryption_key_id` int(10) unsigned NOT NULL, - PRIMARY KEY (`id`), - KEY `individual_id` (`individual_id`), - KEY `encryption_key_id` (`encryption_key_id`), - CONSTRAINT `individual_encryption_keys_ibfk_1` FOREIGN KEY (`individual_id`) REFERENCES `individuals` (`id`), - CONSTRAINT `individual_encryption_keys_ibfk_2` FOREIGN KEY (`encryption_key_id`) REFERENCES `encryption_keys` (`id`), - CONSTRAINT `individual_encryption_keys_ibfk_3` FOREIGN KEY (`individual_id`) REFERENCES `individuals` (`id`), - CONSTRAINT `individual_encryption_keys_ibfk_4` FOREIGN KEY (`encryption_key_id`) REFERENCES `encryption_keys` (`id`), - CONSTRAINT `individual_encryption_keys_ibfk_5` FOREIGN KEY (`individual_id`) REFERENCES `individuals` (`id`), - CONSTRAINT `individual_encryption_keys_ibfk_6` FOREIGN KEY (`encryption_key_id`) REFERENCES `encryption_keys` (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `individuals` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE IF NOT EXISTS `individuals` ( - `id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `uuid` varchar(40) CHARACTER SET ascii DEFAULT NULL, - `email` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, - `first_name` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, - `last_name` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, - `position` text COLLATE utf8mb4_unicode_ci, - PRIMARY KEY (`id`), - KEY `uuid` (`uuid`), - KEY `email` (`email`), - KEY `first_name` (`first_name`), - KEY `last_name` (`last_name`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `local_tools` --- - -CREATE TABLE IF NOT EXISTS `local_tools` ( - `id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `name` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, - `connector` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, - `settings` text COLLATE utf8mb4_unicode_ci, - `exposed` tinyint(1) NOT NULL, - `description` text COLLATE utf8mb4_unicode_ci, - PRIMARY KEY (`id`), - KEY `name` (`name`), - KEY `connector` (`connector`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- --- Table structure for table `organisation_encryption_keys` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE IF NOT EXISTS `organisation_encryption_keys` ( - `id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `organisation_id` int(10) unsigned NOT NULL, - `encryption_key_id` int(10) unsigned NOT NULL, - PRIMARY KEY (`id`), - KEY `organisation_id` (`organisation_id`), - KEY `encryption_key_id` (`encryption_key_id`), - CONSTRAINT `organisation_encryption_keys_ibfk_1` FOREIGN KEY (`organisation_id`) REFERENCES `organisations` (`id`), - CONSTRAINT `organisation_encryption_keys_ibfk_2` FOREIGN KEY (`encryption_key_id`) REFERENCES `encryption_keys` (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `organisations` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE IF NOT EXISTS `organisations` ( - `id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `uuid` varchar(40) CHARACTER SET ascii DEFAULT NULL, - `name` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, - `url` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL, - `nationality` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL, - `sector` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL, - `type` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL, - `contacts` text COLLATE utf8mb4_unicode_ci, - PRIMARY KEY (`id`), - KEY `uuid` (`uuid`), - KEY `name` (`name`), - KEY `url` (`url`), - KEY `nationality` (`nationality`), - KEY `sector` (`sector`), - KEY `type` (`type`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `roles` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE IF NOT EXISTS `roles` ( - `id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `uuid` varchar(40) CHARACTER SET ascii DEFAULT NULL, - `name` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, - `is_default` tinyint(1) DEFAULT NULL, - `perm_admin` tinyint(1) DEFAULT NULL, - PRIMARY KEY (`id`), - KEY `name` (`name`), - KEY `uuid` (`uuid`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `tags` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE IF NOT EXISTS `tags` ( - `id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `name` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, - `description` text COLLATE utf8mb4_unicode_ci, - `colour` varchar(6) CHARACTER SET ascii NOT NULL, - PRIMARY KEY (`id`), - KEY `name` (`name`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `users` --- - -CREATE TABLE IF NOT EXISTS `users` ( - `id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `uuid` varchar(40) CHARACTER SET ascii DEFAULT NULL, - `username` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL, - `password` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL, - `role_id` int(11) unsigned NOT NULL, - `individual_id` int(11) unsigned NOT NULL, - `disabled` tinyint(1) DEFAULT '0', - PRIMARY KEY (`id`), - KEY `uuid` (`uuid`), - KEY `role_id` (`role_id`), - KEY `individual_id` (`individual_id`), - CONSTRAINT `users_ibfk_1` FOREIGN KEY (`role_id`) REFERENCES `roles` (`id`), - CONSTRAINT `users_ibfk_2` FOREIGN KEY (`individual_id`) REFERENCES `individuals` (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -/*!40101 SET character_set_client = @saved_cs_client */; -/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; - -CREATE TABLE IF NOT EXISTS `sharing_groups` ( - `id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `uuid` varchar(40) CHARACTER SET ascii DEFAULT NULL, - `name` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, - `releasability` text DEFAULT NULL, - `description` text DEFAULT NULL, - `organisation_id` int(10) unsigned NOT NULL, - `user_id` int(10) unsigned NOT NULL, - `active` tinyint(1) DEFAULT '1', - `local` tinyint(1) DEFAULT '1', - PRIMARY KEY (`id`), - KEY `uuid` (`uuid`), - KEY `user_id` (`user_id`), - KEY `organisation_id` (`organisation_id`), - KEY `name` (`name`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - -CREATE TABLE IF NOT EXISTS `sgo` ( - `id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `sharing_group_id` int(10) unsigned NOT NULL, - `organisation_id` int(10) unsigned NOT NULL, - `deleted` tinyint(1) DEFAULT 0, - PRIMARY KEY (`id`), - KEY `sharing_group_id` (`sharing_group_id`), - KEY `organisation_id` (`organisation_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - -CREATE TABLE IF NOT EXISTS `meta_fields` ( - `id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `scope` varchar(191) NOT NULL, - `parent_id` int(10) unsigned NOT NULL, - `field` varchar(191) NOT NULL, - `value` varchar(191) NOT NULL, - `uuid` varchar(40) CHARACTER SET ascii DEFAULT NULL, - `meta_template_id` int(10) unsigned NOT NULL, - `meta_template_field_id` int(10) unsigned NOT NULL, - `is_default` tinyint(1) NOT NULL DEFAULT 0, - PRIMARY KEY (`id`), - KEY `scope` (`scope`), - KEY `uuid` (`uuid`), - KEY `parent_id` (`parent_id`), - KEY `field` (`field`), - KEY `value` (`value`), - KEY `meta_template_id` (`meta_template_id`), - KEY `meta_template_field_id` (`meta_template_field_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - -CREATE TABLE IF NOT EXISTS `meta_templates` ( - `id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `scope` varchar(191) NOT NULL, - `name` varchar(191) NOT NULL, - `namespace` varchar(191) NOT NULL, - `description` text, - `version` varchar(191) NOT NULL, - `uuid` varchar(40) CHARACTER SET ascii, - `source` varchar(191), - `enabled` tinyint(1) DEFAULT 0, - `is_default` tinyint(1) NOT NULL DEFAULT 0, - PRIMARY KEY (`id`), - KEY `scope` (`scope`), - KEY `source` (`source`), - KEY `name` (`name`), - KEY `namespace` (`namespace`), - KEY `version` (`version`), - KEY `uuid` (`uuid`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - -CREATE TABLE IF NOT EXISTS `meta_template_fields` ( - `id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `field` varchar(191) NOT NULL, - `type` varchar(191) NOT NULL, - `meta_template_id` int(10) unsigned NOT NULL, - `regex` text, - `multiple` tinyint(1) DEFAULT 0, - `enabled` tinyint(1) DEFAULT 0, - PRIMARY KEY (`id`), - CONSTRAINT `meta_template_id` FOREIGN KEY (`meta_template_id`) REFERENCES `meta_templates` (`id`), - KEY `field` (`field`), - KEY `type` (`type`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - -CREATE TABLE IF NOT EXISTS `audit_logs` ( - `id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `created` datetime NOT NULL, - `user_id` int(10) unsigned DEFAULT NULL, - `authkey_id` int(10) unsigned DEFAULT NULL, - `request_ip` varbinary(16) DEFAULT NULL, - `request_type` tinyint NOT NULL, - `request_id` varchar(191) DEFAULT NULL, - `request_action` varchar(20) NOT NULL, - `model` varchar(80) NOT NULL, - `model_id` int(10) unsigned DEFAULT NULL, - `model_title` text DEFAULT NULL, - `change` blob, - PRIMARY KEY (`id`), - KEY `user_id` (`user_id`), - KEY `request_ip` (`request_ip`), - KEY `model` (`model`), - KEY `request_action` (`request_action`), - KEY `model_id` (`model_id`), - KEY `created` (`created`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - -/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; -/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; -/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; -/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; -/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; -/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; -/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; - --- Dump completed on 2020-06-22 14:30:02 diff --git a/config/Migrations/20210311000000_InitialSchema.php b/config/Migrations/20210311000000_InitialSchema.php new file mode 100644 index 0000000..d1e2ed1 --- /dev/null +++ b/config/Migrations/20210311000000_InitialSchema.php @@ -0,0 +1,1464 @@ +execute('SET unique_checks=0; SET foreign_key_checks=0;'); + $this->execute("ALTER DATABASE CHARACTER SET 'utf8mb4';"); + $this->execute("ALTER DATABASE COLLATE='utf8mb4_general_ci';"); + $this->table('broods', [ + 'id' => false, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'comment' => '', + 'row_format' => 'DYNAMIC', + ]) + ->addColumn('id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'identity' => 'enable', + ]) + ->addColumn('uuid', 'string', [ + 'null' => true, + 'default' => null, + 'limit' => 40, + 'collation' => 'ascii_general_ci', + 'encoding' => 'ascii', + 'after' => 'id', + ]) + ->addColumn('name', 'string', [ + 'null' => false, + 'limit' => 191, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'uuid', + ]) + ->addColumn('url', 'string', [ + 'null' => false, + 'limit' => 191, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'name', + ]) + ->addColumn('description', 'text', [ + 'null' => true, + 'default' => null, + 'limit' => 65535, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'url', + ]) + ->addColumn('organisation_id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'after' => 'description', + ]) + ->addColumn('trusted', 'boolean', [ + 'null' => true, + 'default' => null, + 'limit' => MysqlAdapter::INT_TINY, + 'after' => 'organisation_id', + ]) + ->addColumn('pull', 'boolean', [ + 'null' => true, + 'default' => null, + 'limit' => MysqlAdapter::INT_TINY, + 'after' => 'trusted', + ]) + ->addColumn('skip_proxy', 'boolean', [ + 'null' => true, + 'default' => null, + 'limit' => MysqlAdapter::INT_TINY, + 'after' => 'pull', + ]) + ->addColumn('authkey', 'string', [ + 'null' => true, + 'default' => null, + 'limit' => 40, + 'collation' => 'ascii_general_ci', + 'encoding' => 'ascii', + 'after' => 'skip_proxy', + ]) + ->addIndex(['uuid'], [ + 'name' => 'uuid', + 'unique' => false, + ]) + ->addIndex(['name'], [ + 'name' => 'name', + 'unique' => false, + ]) + ->addIndex(['url'], [ + 'name' => 'url', + 'unique' => false, + ]) + ->addIndex(['authkey'], [ + 'name' => 'authkey', + 'unique' => false, + ]) + ->addIndex(['organisation_id'], [ + 'name' => 'organisation_id', + 'unique' => false, + ]) + ->addForeignKey('organisation_id', 'organisations', 'id', [ + 'constraint' => 'broods_ibfk_1', + 'update' => 'RESTRICT', + 'delete' => 'RESTRICT', + ]) + ->create(); + $this->table('sharing_groups', [ + 'id' => false, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'comment' => '', + 'row_format' => 'DYNAMIC', + ]) + ->addColumn('id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'identity' => 'enable', + ]) + ->addColumn('uuid', 'string', [ + 'null' => true, + 'default' => null, + 'limit' => 40, + 'collation' => 'ascii_general_ci', + 'encoding' => 'ascii', + 'after' => 'id', + ]) + ->addColumn('name', 'string', [ + 'null' => false, + 'limit' => 191, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'uuid', + ]) + ->addColumn('releasability', 'text', [ + 'null' => true, + 'default' => null, + 'limit' => 65535, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'name', + ]) + ->addColumn('description', 'text', [ + 'null' => true, + 'default' => null, + 'limit' => 65535, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'releasability', + ]) + ->addColumn('organisation_id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'after' => 'description', + ]) + ->addColumn('user_id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'after' => 'organisation_id', + ]) + ->addColumn('active', 'boolean', [ + 'null' => true, + 'default' => '1', + 'limit' => MysqlAdapter::INT_TINY, + 'after' => 'user_id', + ]) + ->addColumn('local', 'boolean', [ + 'null' => true, + 'default' => '1', + 'limit' => MysqlAdapter::INT_TINY, + 'after' => 'active', + ]) + ->addIndex(['uuid'], [ + 'name' => 'uuid', + 'unique' => false, + ]) + ->addIndex(['user_id'], [ + 'name' => 'user_id', + 'unique' => false, + ]) + ->addIndex(['organisation_id'], [ + 'name' => 'organisation_id', + 'unique' => false, + ]) + ->addIndex(['name'], [ + 'name' => 'name', + 'unique' => false, + ]) + ->create(); + $this->table('alignment_tags', [ + 'id' => false, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'comment' => '', + 'row_format' => 'DYNAMIC', + ]) + ->addColumn('id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'identity' => 'enable', + ]) + ->addColumn('alignment_id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'after' => 'id', + ]) + ->addColumn('tag_id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'after' => 'alignment_id', + ]) + ->addIndex(['alignment_id'], [ + 'name' => 'alignment_id', + 'unique' => false, + ]) + ->addIndex(['tag_id'], [ + 'name' => 'tag_id', + 'unique' => false, + ]) + ->addForeignKey('alignment_id', 'alignments', 'id', [ + 'constraint' => 'alignment_tags_ibfk_1', + 'update' => 'RESTRICT', + 'delete' => 'RESTRICT', + ]) + ->addForeignKey('tag_id', 'tags', 'id', [ + 'constraint' => 'alignment_tags_ibfk_10', + 'update' => 'RESTRICT', + 'delete' => 'RESTRICT', + ]) + ->addForeignKey('alignment_id', 'alignments', 'id', [ + 'constraint' => 'alignment_tags_ibfk_11', + 'update' => 'RESTRICT', + 'delete' => 'RESTRICT', + ]) + ->addForeignKey('tag_id', 'tags', 'id', [ + 'constraint' => 'alignment_tags_ibfk_12', + 'update' => 'RESTRICT', + 'delete' => 'RESTRICT', + ]) + ->addForeignKey('tag_id', 'tags', 'id', [ + 'constraint' => 'alignment_tags_ibfk_2', + 'update' => 'RESTRICT', + 'delete' => 'RESTRICT', + ]) + ->addForeignKey('alignment_id', 'alignments', 'id', [ + 'constraint' => 'alignment_tags_ibfk_3', + 'update' => 'RESTRICT', + 'delete' => 'RESTRICT', + ]) + ->addForeignKey('tag_id', 'tags', 'id', [ + 'constraint' => 'alignment_tags_ibfk_4', + 'update' => 'RESTRICT', + 'delete' => 'RESTRICT', + ]) + ->addForeignKey('alignment_id', 'alignments', 'id', [ + 'constraint' => 'alignment_tags_ibfk_5', + 'update' => 'RESTRICT', + 'delete' => 'RESTRICT', + ]) + ->addForeignKey('tag_id', 'tags', 'id', [ + 'constraint' => 'alignment_tags_ibfk_6', + 'update' => 'RESTRICT', + 'delete' => 'RESTRICT', + ]) + ->addForeignKey('alignment_id', 'alignments', 'id', [ + 'constraint' => 'alignment_tags_ibfk_7', + 'update' => 'RESTRICT', + 'delete' => 'RESTRICT', + ]) + ->addForeignKey('tag_id', 'tags', 'id', [ + 'constraint' => 'alignment_tags_ibfk_8', + 'update' => 'RESTRICT', + 'delete' => 'RESTRICT', + ]) + ->addForeignKey('alignment_id', 'alignments', 'id', [ + 'constraint' => 'alignment_tags_ibfk_9', + 'update' => 'RESTRICT', + 'delete' => 'RESTRICT', + ]) + ->create(); + $this->table('meta_templates', [ + 'id' => false, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'comment' => '', + 'row_format' => 'DYNAMIC', + ]) + ->addColumn('id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'identity' => 'enable', + ]) + ->addColumn('scope', 'string', [ + 'null' => false, + 'limit' => 191, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'id', + ]) + ->addColumn('name', 'string', [ + 'null' => false, + 'limit' => 191, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'scope', + ]) + ->addColumn('namespace', 'string', [ + 'null' => false, + 'limit' => 191, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'name', + ]) + ->addColumn('description', 'text', [ + 'null' => true, + 'default' => null, + 'limit' => 65535, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'namespace', + ]) + ->addColumn('version', 'string', [ + 'null' => false, + 'limit' => 191, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'description', + ]) + ->addColumn('uuid', 'string', [ + 'null' => true, + 'default' => null, + 'limit' => 40, + 'collation' => 'ascii_general_ci', + 'encoding' => 'ascii', + 'after' => 'version', + ]) + ->addColumn('source', 'string', [ + 'null' => true, + 'default' => null, + 'limit' => 191, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'uuid', + ]) + ->addColumn('enabled', 'boolean', [ + 'null' => true, + 'default' => '0', + 'limit' => MysqlAdapter::INT_TINY, + 'after' => 'source', + ]) + ->addColumn('is_default', 'boolean', [ + 'null' => false, + 'default' => '0', + 'limit' => MysqlAdapter::INT_TINY, + 'after' => 'enabled', + ]) + ->addIndex(['scope'], [ + 'name' => 'scope', + 'unique' => false, + ]) + ->addIndex(['source'], [ + 'name' => 'source', + 'unique' => false, + ]) + ->addIndex(['name'], [ + 'name' => 'name', + 'unique' => false, + ]) + ->addIndex(['namespace'], [ + 'name' => 'namespace', + 'unique' => false, + ]) + ->addIndex(['version'], [ + 'name' => 'version', + 'unique' => false, + ]) + ->addIndex(['uuid'], [ + 'name' => 'uuid', + 'unique' => false, + ]) + ->create(); + $this->table('individuals', [ + 'id' => false, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'comment' => '', + 'row_format' => 'DYNAMIC', + ]) + ->addColumn('id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'identity' => 'enable', + ]) + ->addColumn('uuid', 'string', [ + 'null' => true, + 'default' => null, + 'limit' => 40, + 'collation' => 'ascii_general_ci', + 'encoding' => 'ascii', + 'after' => 'id', + ]) + ->addColumn('email', 'string', [ + 'null' => false, + 'limit' => 191, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'uuid', + ]) + ->addColumn('first_name', 'string', [ + 'null' => false, + 'limit' => 191, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'email', + ]) + ->addColumn('last_name', 'string', [ + 'null' => false, + 'limit' => 191, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'first_name', + ]) + ->addColumn('position', 'text', [ + 'null' => true, + 'default' => null, + 'limit' => 65535, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'last_name', + ]) + ->addIndex(['uuid'], [ + 'name' => 'uuid', + 'unique' => false, + ]) + ->addIndex(['email'], [ + 'name' => 'email', + 'unique' => false, + ]) + ->addIndex(['first_name'], [ + 'name' => 'first_name', + 'unique' => false, + ]) + ->addIndex(['last_name'], [ + 'name' => 'last_name', + 'unique' => false, + ]) + ->create(); + $this->table('organisations', [ + 'id' => false, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'comment' => '', + 'row_format' => 'DYNAMIC', + ]) + ->addColumn('id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'identity' => 'enable', + ]) + ->addColumn('uuid', 'string', [ + 'null' => true, + 'default' => null, + 'limit' => 40, + 'collation' => 'ascii_general_ci', + 'encoding' => 'ascii', + 'after' => 'id', + ]) + ->addColumn('name', 'string', [ + 'null' => false, + 'limit' => 191, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'uuid', + ]) + ->addColumn('url', 'string', [ + 'null' => true, + 'default' => null, + 'limit' => 191, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'name', + ]) + ->addColumn('nationality', 'string', [ + 'null' => true, + 'default' => null, + 'limit' => 191, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'url', + ]) + ->addColumn('sector', 'string', [ + 'null' => true, + 'default' => null, + 'limit' => 191, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'nationality', + ]) + ->addColumn('type', 'string', [ + 'null' => true, + 'default' => null, + 'limit' => 191, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'sector', + ]) + ->addColumn('contacts', 'text', [ + 'null' => true, + 'default' => null, + 'limit' => 65535, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'type', + ]) + ->addIndex(['uuid'], [ + 'name' => 'uuid', + 'unique' => false, + ]) + ->addIndex(['name'], [ + 'name' => 'name', + 'unique' => false, + ]) + ->addIndex(['url'], [ + 'name' => 'url', + 'unique' => false, + ]) + ->addIndex(['nationality'], [ + 'name' => 'nationality', + 'unique' => false, + ]) + ->addIndex(['sector'], [ + 'name' => 'sector', + 'unique' => false, + ]) + ->addIndex(['type'], [ + 'name' => 'type', + 'unique' => false, + ]) + ->create(); + $this->table('encryption_keys', [ + 'id' => false, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'comment' => '', + 'row_format' => 'DYNAMIC', + ]) + ->addColumn('id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'identity' => 'enable', + ]) + ->addColumn('uuid', 'string', [ + 'null' => true, + 'default' => null, + 'limit' => 40, + 'collation' => 'ascii_general_ci', + 'encoding' => 'ascii', + 'after' => 'id', + ]) + ->addColumn('type', 'string', [ + 'null' => false, + 'limit' => 191, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'uuid', + ]) + ->addColumn('encryption_key', 'text', [ + 'null' => true, + 'default' => null, + 'limit' => 65535, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'type', + ]) + ->addColumn('revoked', 'boolean', [ + 'null' => true, + 'default' => null, + 'limit' => MysqlAdapter::INT_TINY, + 'after' => 'encryption_key', + ]) + ->addColumn('expires', 'integer', [ + 'null' => true, + 'default' => null, + 'limit' => '10', + 'signed' => false, + 'after' => 'revoked', + ]) + ->addColumn('owner_id', 'integer', [ + 'null' => true, + 'default' => null, + 'limit' => '10', + 'signed' => false, + 'after' => 'expires', + ]) + ->addColumn('owner_type', 'string', [ + 'null' => false, + 'limit' => 20, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'owner_id', + ]) + ->addIndex(['uuid'], [ + 'name' => 'uuid', + 'unique' => false, + ]) + ->addIndex(['type'], [ + 'name' => 'type', + 'unique' => false, + ]) + ->addIndex(['expires'], [ + 'name' => 'expires', + 'unique' => false, + ]) + ->create(); + $this->table('meta_fields', [ + 'id' => false, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'comment' => '', + 'row_format' => 'DYNAMIC', + ]) + ->addColumn('id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'identity' => 'enable', + ]) + ->addColumn('scope', 'string', [ + 'null' => false, + 'limit' => 191, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'id', + ]) + ->addColumn('parent_id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'after' => 'scope', + ]) + ->addColumn('field', 'string', [ + 'null' => false, + 'limit' => 191, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'parent_id', + ]) + ->addColumn('value', 'string', [ + 'null' => false, + 'limit' => 191, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'field', + ]) + ->addColumn('uuid', 'string', [ + 'null' => true, + 'default' => null, + 'limit' => 40, + 'collation' => 'ascii_general_ci', + 'encoding' => 'ascii', + 'after' => 'value', + ]) + ->addColumn('meta_template_id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'after' => 'uuid', + ]) + ->addColumn('meta_template_field_id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'after' => 'meta_template_id', + ]) + ->addColumn('is_default', 'boolean', [ + 'null' => false, + 'default' => '0', + 'limit' => MysqlAdapter::INT_TINY, + 'after' => 'meta_template_field_id', + ]) + ->addIndex(['scope'], [ + 'name' => 'scope', + 'unique' => false, + ]) + ->addIndex(['uuid'], [ + 'name' => 'uuid', + 'unique' => false, + ]) + ->addIndex(['parent_id'], [ + 'name' => 'parent_id', + 'unique' => false, + ]) + ->addIndex(['field'], [ + 'name' => 'field', + 'unique' => false, + ]) + ->addIndex(['value'], [ + 'name' => 'value', + 'unique' => false, + ]) + ->addIndex(['meta_template_id'], [ + 'name' => 'meta_template_id', + 'unique' => false, + ]) + ->addIndex(['meta_template_field_id'], [ + 'name' => 'meta_template_field_id', + 'unique' => false, + ]) + ->create(); + $this->table('audit_logs', [ + 'id' => false, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'comment' => '', + 'row_format' => 'DYNAMIC', + ]) + ->addColumn('id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'identity' => 'enable', + ]) + ->addColumn('created', 'datetime', [ + 'null' => false, + 'after' => 'id', + ]) + ->addColumn('user_id', 'integer', [ + 'null' => true, + 'default' => null, + 'limit' => '10', + 'signed' => false, + 'after' => 'created', + ]) + ->addColumn('authkey_id', 'integer', [ + 'null' => true, + 'default' => null, + 'limit' => '10', + 'signed' => false, + 'after' => 'user_id', + ]) + ->addColumn('request_ip', 'varbinary', [ + 'null' => true, + 'default' => null, + 'limit' => 16, + 'after' => 'authkey_id', + ]) + ->addColumn('request_type', 'integer', [ + 'null' => false, + 'limit' => MysqlAdapter::INT_TINY, + 'after' => 'request_ip', + ]) + ->addColumn('request_id', 'string', [ + 'null' => true, + 'default' => null, + 'limit' => 191, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'request_type', + ]) + ->addColumn('request_action', 'string', [ + 'null' => false, + 'limit' => 20, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'request_id', + ]) + ->addColumn('model', 'string', [ + 'null' => false, + 'limit' => 80, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'request_action', + ]) + ->addColumn('model_id', 'integer', [ + 'null' => true, + 'default' => null, + 'limit' => '10', + 'signed' => false, + 'after' => 'model', + ]) + ->addColumn('model_title', 'text', [ + 'null' => true, + 'default' => null, + 'limit' => 65535, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'model_id', + ]) + ->addColumn('change', 'blob', [ + 'null' => true, + 'default' => null, + 'limit' => MysqlAdapter::BLOB_REGULAR, + 'after' => 'model_title', + ]) + ->addIndex(['user_id'], [ + 'name' => 'user_id', + 'unique' => false, + ]) + ->addIndex(['request_ip'], [ + 'name' => 'request_ip', + 'unique' => false, + ]) + ->addIndex(['model'], [ + 'name' => 'model', + 'unique' => false, + ]) + ->addIndex(['request_action'], [ + 'name' => 'request_action', + 'unique' => false, + ]) + ->addIndex(['model_id'], [ + 'name' => 'model_id', + 'unique' => false, + ]) + ->addIndex(['created'], [ + 'name' => 'created', + 'unique' => false, + ]) + ->create(); + $this->table('organisation_encryption_keys', [ + 'id' => false, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'comment' => '', + 'row_format' => 'DYNAMIC', + ]) + ->addColumn('id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'identity' => 'enable', + ]) + ->addColumn('organisation_id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'after' => 'id', + ]) + ->addColumn('encryption_key_id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'after' => 'organisation_id', + ]) + ->addIndex(['organisation_id'], [ + 'name' => 'organisation_id', + 'unique' => false, + ]) + ->addIndex(['encryption_key_id'], [ + 'name' => 'encryption_key_id', + 'unique' => false, + ]) + ->addForeignKey('organisation_id', 'organisations', 'id', [ + 'constraint' => 'organisation_encryption_keys_ibfk_1', + 'update' => 'RESTRICT', + 'delete' => 'RESTRICT', + ]) + ->addForeignKey('encryption_key_id', 'encryption_keys', 'id', [ + 'constraint' => 'organisation_encryption_keys_ibfk_2', + 'update' => 'RESTRICT', + 'delete' => 'RESTRICT', + ]) + ->create(); + $this->table('users', [ + 'id' => false, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'comment' => '', + 'row_format' => 'DYNAMIC', + ]) + ->addColumn('id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'identity' => 'enable', + ]) + ->addColumn('uuid', 'string', [ + 'null' => true, + 'default' => null, + 'limit' => 40, + 'collation' => 'ascii_general_ci', + 'encoding' => 'ascii', + 'after' => 'id', + ]) + ->addColumn('username', 'string', [ + 'null' => true, + 'default' => null, + 'limit' => 191, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'uuid', + ]) + ->addColumn('password', 'string', [ + 'null' => true, + 'default' => null, + 'limit' => 191, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'username', + ]) + ->addColumn('role_id', 'integer', [ + 'null' => false, + 'limit' => MysqlAdapter::INT_REGULAR, + 'signed' => false, + 'after' => 'password', + ]) + ->addColumn('individual_id', 'integer', [ + 'null' => false, + 'limit' => MysqlAdapter::INT_REGULAR, + 'signed' => false, + 'after' => 'role_id', + ]) + ->addColumn('disabled', 'boolean', [ + 'null' => true, + 'default' => '0', + 'limit' => MysqlAdapter::INT_TINY, + 'after' => 'individual_id', + ]) + ->addIndex(['uuid'], [ + 'name' => 'uuid', + 'unique' => false, + ]) + ->addIndex(['role_id'], [ + 'name' => 'role_id', + 'unique' => false, + ]) + ->addIndex(['individual_id'], [ + 'name' => 'individual_id', + 'unique' => false, + ]) + ->addForeignKey('role_id', 'roles', 'id', [ + 'constraint' => 'users_ibfk_1', + 'update' => 'RESTRICT', + 'delete' => 'RESTRICT', + ]) + ->addForeignKey('individual_id', 'individuals', 'id', [ + 'constraint' => 'users_ibfk_2', + 'update' => 'RESTRICT', + 'delete' => 'RESTRICT', + ]) + ->create(); + $this->table('roles', [ + 'id' => false, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'comment' => '', + 'row_format' => 'DYNAMIC', + ]) + ->addColumn('id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'identity' => 'enable', + ]) + ->addColumn('uuid', 'string', [ + 'null' => true, + 'default' => null, + 'limit' => 40, + 'collation' => 'ascii_general_ci', + 'encoding' => 'ascii', + 'after' => 'id', + ]) + ->addColumn('name', 'string', [ + 'null' => false, + 'limit' => 191, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'uuid', + ]) + ->addColumn('is_default', 'boolean', [ + 'null' => true, + 'default' => null, + 'limit' => MysqlAdapter::INT_TINY, + 'after' => 'name', + ]) + ->addColumn('perm_admin', 'boolean', [ + 'null' => true, + 'default' => null, + 'limit' => MysqlAdapter::INT_TINY, + 'after' => 'is_default', + ]) + ->addIndex(['name'], [ + 'name' => 'name', + 'unique' => false, + ]) + ->addIndex(['uuid'], [ + 'name' => 'uuid', + 'unique' => false, + ]) + ->create(); + $this->table('tags', [ + 'id' => false, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'comment' => '', + 'row_format' => 'DYNAMIC', + ]) + ->addColumn('id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'identity' => 'enable', + ]) + ->addColumn('name', 'string', [ + 'null' => false, + 'limit' => 191, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'id', + ]) + ->addColumn('description', 'text', [ + 'null' => true, + 'default' => null, + 'limit' => 65535, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'name', + ]) + ->addColumn('colour', 'string', [ + 'null' => false, + 'limit' => 6, + 'collation' => 'ascii_general_ci', + 'encoding' => 'ascii', + 'after' => 'description', + ]) + ->addIndex(['name'], [ + 'name' => 'name', + 'unique' => false, + ]) + ->create(); + $this->table('individual_encryption_keys', [ + 'id' => false, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'comment' => '', + 'row_format' => 'DYNAMIC', + ]) + ->addColumn('id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'identity' => 'enable', + ]) + ->addColumn('individual_id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'after' => 'id', + ]) + ->addColumn('encryption_key_id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'after' => 'individual_id', + ]) + ->addIndex(['individual_id'], [ + 'name' => 'individual_id', + 'unique' => false, + ]) + ->addIndex(['encryption_key_id'], [ + 'name' => 'encryption_key_id', + 'unique' => false, + ]) + ->addForeignKey('individual_id', 'individuals', 'id', [ + 'constraint' => 'individual_encryption_keys_ibfk_1', + 'update' => 'RESTRICT', + 'delete' => 'RESTRICT', + ]) + ->addForeignKey('encryption_key_id', 'encryption_keys', 'id', [ + 'constraint' => 'individual_encryption_keys_ibfk_2', + 'update' => 'RESTRICT', + 'delete' => 'RESTRICT', + ]) + ->addForeignKey('individual_id', 'individuals', 'id', [ + 'constraint' => 'individual_encryption_keys_ibfk_3', + 'update' => 'RESTRICT', + 'delete' => 'RESTRICT', + ]) + ->addForeignKey('encryption_key_id', 'encryption_keys', 'id', [ + 'constraint' => 'individual_encryption_keys_ibfk_4', + 'update' => 'RESTRICT', + 'delete' => 'RESTRICT', + ]) + ->addForeignKey('individual_id', 'individuals', 'id', [ + 'constraint' => 'individual_encryption_keys_ibfk_5', + 'update' => 'RESTRICT', + 'delete' => 'RESTRICT', + ]) + ->addForeignKey('encryption_key_id', 'encryption_keys', 'id', [ + 'constraint' => 'individual_encryption_keys_ibfk_6', + 'update' => 'RESTRICT', + 'delete' => 'RESTRICT', + ]) + ->create(); + $this->table('meta_template_fields', [ + 'id' => false, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'comment' => '', + 'row_format' => 'DYNAMIC', + ]) + ->addColumn('id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'identity' => 'enable', + ]) + ->addColumn('field', 'string', [ + 'null' => false, + 'limit' => 191, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'id', + ]) + ->addColumn('type', 'string', [ + 'null' => false, + 'limit' => 191, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'field', + ]) + ->addColumn('meta_template_id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'after' => 'type', + ]) + ->addColumn('regex', 'text', [ + 'null' => true, + 'default' => null, + 'limit' => 65535, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'meta_template_id', + ]) + ->addColumn('multiple', 'boolean', [ + 'null' => true, + 'default' => '0', + 'limit' => MysqlAdapter::INT_TINY, + 'after' => 'regex', + ]) + ->addColumn('enabled', 'boolean', [ + 'null' => true, + 'default' => '0', + 'limit' => MysqlAdapter::INT_TINY, + 'after' => 'multiple', + ]) + ->addIndex(['meta_template_id'], [ + 'name' => 'meta_template_id', + 'unique' => false, + ]) + ->addIndex(['field'], [ + 'name' => 'field', + 'unique' => false, + ]) + ->addIndex(['type'], [ + 'name' => 'type', + 'unique' => false, + ]) + ->addForeignKey('meta_template_id', 'meta_templates', 'id', [ + 'constraint' => 'meta_template_id', + 'update' => 'RESTRICT', + 'delete' => 'RESTRICT', + ]) + ->create(); + $this->table('auth_keys', [ + 'id' => false, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'comment' => '', + 'row_format' => 'DYNAMIC', + ]) + ->addColumn('id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'identity' => 'enable', + ]) + ->addColumn('uuid', 'string', [ + 'null' => false, + 'limit' => 40, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'id', + ]) + ->addColumn('authkey', 'string', [ + 'null' => true, + 'default' => null, + 'limit' => 72, + 'collation' => 'ascii_general_ci', + 'encoding' => 'ascii', + 'after' => 'uuid', + ]) + ->addColumn('authkey_start', 'string', [ + 'null' => true, + 'default' => null, + 'limit' => 4, + 'collation' => 'ascii_general_ci', + 'encoding' => 'ascii', + 'after' => 'authkey', + ]) + ->addColumn('authkey_end', 'string', [ + 'null' => true, + 'default' => null, + 'limit' => 4, + 'collation' => 'ascii_general_ci', + 'encoding' => 'ascii', + 'after' => 'authkey_start', + ]) + ->addColumn('created', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'after' => 'authkey_end', + ]) + ->addColumn('expiration', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'after' => 'created', + ]) + ->addColumn('user_id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'after' => 'expiration', + ]) + ->addColumn('comment', 'text', [ + 'null' => true, + 'default' => null, + 'limit' => 65535, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'user_id', + ]) + ->addIndex(['authkey_start'], [ + 'name' => 'authkey_start', + 'unique' => false, + ]) + ->addIndex(['authkey_end'], [ + 'name' => 'authkey_end', + 'unique' => false, + ]) + ->addIndex(['created'], [ + 'name' => 'created', + 'unique' => false, + ]) + ->addIndex(['expiration'], [ + 'name' => 'expiration', + 'unique' => false, + ]) + ->addIndex(['user_id'], [ + 'name' => 'user_id', + 'unique' => false, + ]) + ->create(); + $this->table('local_tools', [ + 'id' => false, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'comment' => '', + 'row_format' => 'DYNAMIC', + ]) + ->addColumn('id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'identity' => 'enable', + ]) + ->addColumn('name', 'string', [ + 'null' => false, + 'limit' => 191, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'id', + ]) + ->addColumn('connector', 'string', [ + 'null' => false, + 'limit' => 191, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'name', + ]) + ->addColumn('settings', 'text', [ + 'null' => true, + 'default' => null, + 'limit' => 65535, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'connector', + ]) + ->addColumn('exposed', 'boolean', [ + 'null' => false, + 'limit' => MysqlAdapter::INT_TINY, + 'after' => 'settings', + ]) + ->addColumn('description', 'text', [ + 'null' => true, + 'default' => null, + 'limit' => 65535, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'exposed', + ]) + ->addIndex(['name'], [ + 'name' => 'name', + 'unique' => false, + ]) + ->addIndex(['connector'], [ + 'name' => 'connector', + 'unique' => false, + ]) + ->create(); + $this->table('alignments', [ + 'id' => false, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'comment' => '', + 'row_format' => 'DYNAMIC', + ]) + ->addColumn('id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'identity' => 'enable', + ]) + ->addColumn('individual_id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'after' => 'id', + ]) + ->addColumn('organisation_id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'after' => 'individual_id', + ]) + ->addColumn('type', 'string', [ + 'null' => true, + 'default' => 'member', + 'limit' => 191, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + 'after' => 'organisation_id', + ]) + ->addIndex(['individual_id'], [ + 'name' => 'individual_id', + 'unique' => false, + ]) + ->addIndex(['organisation_id'], [ + 'name' => 'organisation_id', + 'unique' => false, + ]) + ->addForeignKey('individual_id', 'individuals', 'id', [ + 'constraint' => 'alignments_ibfk_1', + 'update' => 'RESTRICT', + 'delete' => 'RESTRICT', + ]) + ->addForeignKey('organisation_id', 'organisations', 'id', [ + 'constraint' => 'alignments_ibfk_2', + 'update' => 'RESTRICT', + 'delete' => 'RESTRICT', + ]) + ->create(); + $this->table('sgo', [ + 'id' => false, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'comment' => '', + 'row_format' => 'DYNAMIC', + ]) + ->addColumn('id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'identity' => 'enable', + ]) + ->addColumn('sharing_group_id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'after' => 'id', + ]) + ->addColumn('organisation_id', 'integer', [ + 'null' => false, + 'limit' => '10', + 'signed' => false, + 'after' => 'sharing_group_id', + ]) + ->addColumn('deleted', 'boolean', [ + 'null' => true, + 'default' => '0', + 'limit' => MysqlAdapter::INT_TINY, + 'after' => 'organisation_id', + ]) + ->addIndex(['sharing_group_id'], [ + 'name' => 'sharing_group_id', + 'unique' => false, + ]) + ->addIndex(['organisation_id'], [ + 'name' => 'organisation_id', + 'unique' => false, + ]) + ->create(); + $this->execute('SET unique_checks=1; SET foreign_key_checks=1;'); + } +} diff --git a/debian/install b/debian/install index 2ee55e9..12d7cc8 100755 --- a/debian/install +++ b/debian/install @@ -7,4 +7,3 @@ webroot /usr/share/php-cerebrate config /usr/share/php-cerebrate debian/cerebrate.local.conf /etc/apache2/sites-available/ debian/config.php /etc/cerebrate/ -INSTALL/mysql.sql => /usr/share/dbconfig-common/data/php-cerebrate/install/mysql diff --git a/docker/README.md b/docker/README.md index 1074c0c..9bf0154 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,20 +1,3 @@ -# Database init - -For the `docker-compose` setup to work you must initialize database with -what is in `../INSTALL/mysql.sql` - -``` -mkdir -p run/dbinit/ -cp ../INSTALL/mysql.sql run/dbinit/ -``` - -The MariaDB container has a volume mounted as follow -`- ./run/dbinit:/docker-entrypoint-initdb.d/:ro` - -So that on startup the container will source files in this directory to seed -the database. Once it's done the container will run normally and Cerebrate will -be able to roll its database migration scripts - # Actual data and volumes The actual database will be located in `./run/database` exposed with the diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 6aa0b73..821ab5f 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -5,7 +5,6 @@ services: restart: always volumes: - ./run/database:/var/lib/mysql - - ./run/dbinit:/docker-entrypoint-initdb.d/:ro environment: MARIADB_RANDOM_ROOT_PASSWORD: "yes" MYSQL_DATABASE: "cerebrate" diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 9283095..6df9c27 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -54,18 +54,14 @@ ConnectionManager::alias('test_debug_kit', 'debug_kit'); // has been written to. session_id('cli'); -// Load db schema from mysql.sql and run migrations -// super hacky way to skip migrations +// hacky way to skip migrations if (!in_array('skip-migrations', $_SERVER['argv'])) { - // TODO: Removing mysql.sql and relying only in migrations would be ideal - // in the meantime, `'skip' => ['*']`, prevents migrations from droping already created tables - (new SchemaLoader())->loadSqlFiles('./INSTALL/mysql.sql', 'test'); $migrator = new Migrator(); $migrator->runMany([ - ['connection' => 'test', 'skip' => ['*']], - ['plugin' => 'Tags', 'connection' => 'test', 'skip' => ['*']], - ['plugin' => 'ADmad/SocialAuth', 'connection' => 'test', 'skip' => ['*']] + ['connection' => 'test'], + ['plugin' => 'Tags', 'connection' => 'test'], + ['plugin' => 'ADmad/SocialAuth', 'connection' => 'test'] ]); -}else{ +} else { echo "[ * ] Skipping migrations ...\n"; } From c14b84fcc08e4f99c98b001f08194b4239245fac Mon Sep 17 00:00:00 2001 From: Luciano Righetti Date: Fri, 7 Jan 2022 17:07:09 +0100 Subject: [PATCH 094/150] chg: rename test files --- .../TestCase/Api/Users/IndexUsersApiTest.php | 43 +++++++++++++++ tests/TestCase/Api/Users/ViewUserApiTest.php | 55 +++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 tests/TestCase/Api/Users/IndexUsersApiTest.php create mode 100644 tests/TestCase/Api/Users/ViewUserApiTest.php diff --git a/tests/TestCase/Api/Users/IndexUsersApiTest.php b/tests/TestCase/Api/Users/IndexUsersApiTest.php new file mode 100644 index 0000000..46e615d --- /dev/null +++ b/tests/TestCase/Api/Users/IndexUsersApiTest.php @@ -0,0 +1,43 @@ +initializeValidator(APP . '../webroot/docs/openapi.yaml'); + } + + public function testIndex(): void + { + $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + $this->get(self::ENDPOINT); + + $this->assertResponseOk(); + $this->assertResponseContains(sprintf('"username": "%s"', UsersFixture::USER_ADMIN_USERNAME)); + // TODO: $this->validateRequest() + $this->validateResponse(self::ENDPOINT); + } +} diff --git a/tests/TestCase/Api/Users/ViewUserApiTest.php b/tests/TestCase/Api/Users/ViewUserApiTest.php new file mode 100644 index 0000000..7c5a346 --- /dev/null +++ b/tests/TestCase/Api/Users/ViewUserApiTest.php @@ -0,0 +1,55 @@ +initializeValidator(APP . '../webroot/docs/openapi.yaml'); + } + + public function testViewMe(): void + { + $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + $this->get(self::ENDPOINT); + + $this->assertResponseOk(); + $this->assertResponseContains(sprintf('"username": "%s"', UsersFixture::USER_ADMIN_USERNAME)); + // TODO: $this->validateRequest() + $this->validateResponse(self::ENDPOINT); + } + + public function testViewById(): void + { + $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + $url = sprintf('%s/%d', self::ENDPOINT, UsersFixture::USER_ADMIN_ID); + $this->get($url); + + $this->assertResponseOk(); + $this->assertResponseContains(sprintf('"username": "%s"', UsersFixture::USER_ADMIN_USERNAME)); + // TODO: $this->validateRequest() + $this->validateResponse($url); + } +} From 6776789fdffd6f7cd24ea6a7e7cbe4ecf1ca6298 Mon Sep 17 00:00:00 2001 From: Luciano Righetti Date: Fri, 7 Jan 2022 17:08:00 +0100 Subject: [PATCH 095/150] new: add /api/v1/users/index api test --- tests/Helper/ApiTestTrait.php | 7 ++ tests/README.md | 13 +++- tests/TestCase/Api/Users/UsersApiTest.php | 55 -------------- webroot/docs/openapi.yaml | 89 ++++++++++++++++++----- 4 files changed, 90 insertions(+), 74 deletions(-) delete mode 100644 tests/TestCase/Api/Users/UsersApiTest.php diff --git a/tests/Helper/ApiTestTrait.php b/tests/Helper/ApiTestTrait.php index e749677..572b7c6 100644 --- a/tests/Helper/ApiTestTrait.php +++ b/tests/Helper/ApiTestTrait.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Test\Helper; +use Cake\Http\Exception\NotImplementedException; use \League\OpenAPIValidation\PSR7\ValidatorBuilder; use \League\OpenAPIValidation\PSR7\RequestValidator; use \League\OpenAPIValidation\PSR7\ResponseValidator; @@ -38,6 +39,12 @@ trait ApiTestTrait ]); } + public function assertResponseContainsArray(array $expected): void + { + $responseArray = json_decode((string)$this->_response->getBody(), true); + throw new NotImplementedException('TODO: see codeception seeResponseContainsJson()'); + } + /** * Parse the OpenAPI specification and create a validator * diff --git a/tests/README.md b/tests/README.md index f37bcf3..732aafc 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,5 +1,5 @@ # Testing - +## Configuration 1. Add a `cerebrate_test` database to the db: ```mysql CREATE DATABASE cerebrate_test; @@ -55,3 +55,14 @@ By default the database is re-generated before running the test suite, to skip t ``` $ vendor/bin/phpunit -d skip-migrations ``` + +## Coverage +HTML: +``` +$ vendor/bin/phpunit --coverage-html tmp/coverage +``` + +XML: +``` +$ vendor/bin/phpunit --verbose --coverage-clover=coverage.xml +``` diff --git a/tests/TestCase/Api/Users/UsersApiTest.php b/tests/TestCase/Api/Users/UsersApiTest.php deleted file mode 100644 index 2f2c2b8..0000000 --- a/tests/TestCase/Api/Users/UsersApiTest.php +++ /dev/null @@ -1,55 +0,0 @@ -initializeValidator(APP . '../webroot/docs/openapi.yaml'); - } - - public function testViewMe(): void - { - $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); - $this->get(self::ENDPOINT); - - $this->assertResponseOk(); - $this->assertResponseContains(sprintf('"username": "%s"', UsersFixture::USER_ADMIN_USERNAME)); - // TODO: $this->validateRequest() - $this->validateResponse(self::ENDPOINT); - } - - public function testViewById(): void - { - $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); - $url = sprintf('%s/%d', self::ENDPOINT, UsersFixture::USER_ADMIN_ID); - $this->get($url); - - $this->assertResponseOk(); - $this->assertResponseContains(sprintf('"username": "%s"', UsersFixture::USER_ADMIN_USERNAME)); - // TODO: $this->validateRequest() - $this->validateResponse($url); - } -} diff --git a/webroot/docs/openapi.yaml b/webroot/docs/openapi.yaml index 5a55225..00fd682 100644 --- a/webroot/docs/openapi.yaml +++ b/webroot/docs/openapi.yaml @@ -11,9 +11,25 @@ servers: tags: - name: Users - description: "TODO: users resource descriptions" + description: "Users enrolled in this Cerebrate instance." paths: + /api/v1/users/index: + get: + summary: "Get users list" + operationId: getUsers + tags: + - Users + responses: + "200": + $ref: "#/components/responses/GetUsersResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + /api/v1/users/view: get: summary: "Get information about the current user" @@ -22,7 +38,7 @@ paths: - Users responses: "200": - $ref: "#/components/responses/ViewUserResponse" + $ref: "#/components/responses/GetUserResponse" "403": $ref: "#/components/responses/UnauthorizedApiErrorResponse" default: @@ -38,7 +54,7 @@ paths: - $ref: "#/components/parameters/userId" responses: "200": - $ref: "#/components/responses/ViewUserResponse" + $ref: "#/components/responses/GetUserResponse" "403": $ref: "#/components/responses/UnauthorizedApiErrorResponse" default: @@ -90,6 +106,11 @@ components: organisation_id: $ref: "#/components/schemas/ID" + UserList: + type: array + items: + $ref: "#/components/schemas/User" + # Individuals # Organisations @@ -120,51 +141,69 @@ components: ApiError: type: object required: - - name - message - url + - code properties: - name: - type: string message: type: string url: type: string - example: "/users" + example: "/api/v1/users" + code: + type: integer + example: 500 UnauthorizedApiError: type: object required: - - name - message - url + - code properties: - name: - type: string - example: "Authentication failed. Please make sure you pass the API key of an API enabled user along in the Authorization header." message: type: string example: "Authentication failed. Please make sure you pass the API key of an API enabled user along in the Authorization header." url: type: string - example: "/users" + example: "/api/v1/users" + code: + type: integer + example: 403 + + MethodNotAllowedApiError: + type: object + required: + - message + - url + - code + properties: + message: + type: string + example: "You do not have permission to use this functionality." + url: + type: string + example: "/api/v1/users/index" + code: + type: integer + example: 405 NotFoundApiError: type: object required: - - name - message - url + - code properties: - name: - type: string - example: "Invalid user" message: type: string example: "Invalid user" url: type: string - example: "/users/1234" + example: "/api/v1/users/users/view/1234" + code: + type: integer + example: 404 parameters: userId: @@ -189,13 +228,20 @@ components: responses: # User - ViewUserResponse: + GetUserResponse: description: "User response" content: application/json: schema: $ref: "#/components/schemas/User" + GetUsersResponse: + description: "User response" + content: + application/json: + schema: + $ref: "#/components/schemas/UserList" + # Errors ApiErrorResponse: description: "Unexpected API error" @@ -211,5 +257,12 @@ components: schema: $ref: "#/components/schemas/UnauthorizedApiError" + MethodNotAllowedApiErrorResponse: + description: "Method not allowed. Your User Role is not allowed to access this resource." + content: + application/json: + schema: + $ref: "#/components/schemas/MethodNotAllowedApiError" + security: - ApiKeyAuth: [] From 28650fa91ca7a5725abfd8b23edda802d2ba61c6 Mon Sep 17 00:00:00 2001 From: Luciano Righetti Date: Mon, 10 Jan 2022 11:58:52 +0100 Subject: [PATCH 096/150] add: add users add/edit/delete api tests and openapi docs. --- tests/Fixture/AuthKeysFixture.php | 8 +- tests/Helper/ApiTestTrait.php | 44 ++++++++- tests/TestCase/Api/Users/AddUserApiTest.php | 77 ++++++++++++++++ .../TestCase/Api/Users/DeleteUserApiTest.php | 60 ++++++++++++ tests/TestCase/Api/Users/EditUserApiTest.php | 91 +++++++++++++++++++ .../TestCase/Api/Users/IndexUsersApiTest.php | 6 +- tests/TestCase/Api/Users/ViewUserApiTest.php | 20 ++-- 7 files changed, 287 insertions(+), 19 deletions(-) create mode 100644 tests/TestCase/Api/Users/AddUserApiTest.php create mode 100644 tests/TestCase/Api/Users/DeleteUserApiTest.php create mode 100644 tests/TestCase/Api/Users/EditUserApiTest.php diff --git a/tests/Fixture/AuthKeysFixture.php b/tests/Fixture/AuthKeysFixture.php index 8291811..802ca5c 100644 --- a/tests/Fixture/AuthKeysFixture.php +++ b/tests/Fixture/AuthKeysFixture.php @@ -14,7 +14,7 @@ class AuthKeysFixture extends TestFixture public const ADMIN_API_KEY = 'd033e22ae348aeb5660fc2140aec35850c4da997'; public const SYNC_API_KEY = '6b387ced110858dcbcda36edb044dc18f91a0894'; public const ORG_ADMIN_API_KEY = '1c4685d281d478dbcebd494158024bc3539004d0'; - public const USER_API_KEY = '12dea96fec20593566ab75692c9949596833adc9'; + public const REGULAR_USER_API_KEY = '12dea96fec20593566ab75692c9949596833adc9'; public function init(): void { @@ -57,9 +57,9 @@ class AuthKeysFixture extends TestFixture ], [ 'uuid' => $faker->uuid(), - 'authkey' => $hasher->hash(self::USER_API_KEY), - 'authkey_start' => substr(self::USER_API_KEY, 0, 4), - 'authkey_end' => substr(self::USER_API_KEY, -4), + 'authkey' => $hasher->hash(self::REGULAR_USER_API_KEY), + 'authkey_start' => substr(self::REGULAR_USER_API_KEY, 0, 4), + 'authkey_end' => substr(self::REGULAR_USER_API_KEY, -4), 'expiration' => 0, 'user_id' => UsersFixture::USER_REGULAR_USER_ID, 'comment' => '', diff --git a/tests/Helper/ApiTestTrait.php b/tests/Helper/ApiTestTrait.php index 572b7c6..21b0c8b 100644 --- a/tests/Helper/ApiTestTrait.php +++ b/tests/Helper/ApiTestTrait.php @@ -65,7 +65,7 @@ trait ApiTestTrait * @param string $method The HTTP method used to call the endpoint * @return void */ - public function validateRequest(string $endpoint, string $method = 'get'): void + public function assertRequestMatchesOpenApiSpec(string $endpoint, string $method = 'get'): void { // TODO: find a workaround to create a PSR-7 request object for validation throw NotImplementedException("Unfortunately cakephp does not save the PSR-7 request object in the test context"); @@ -78,9 +78,49 @@ trait ApiTestTrait * @param string $method The HTTP method used to call the endpoint * @return void */ - public function validateResponse(string $endpoint, string $method = 'get'): void + public function assertResponseMatchesOpenApiSpec(string $endpoint, string $method = 'get'): void { $address = new OperationAddress($endpoint, $method); $this->responseValidator->validate($address, $this->_response); } + + /** + * Validates a record exists in the database + * + * @param string $table The table name + * @param array $conditions The conditions to check + * @return void + * @throws \Exception + * @throws \Cake\Datasource\Exception\RecordNotFoundException + * + * @see https://book.cakephp.org/4/en/orm-query-builder.html + */ + public function assertDbRecordExists(string $table, array $conditions): void + { + $record = $this->getTableLocator()->get($table)->find()->where($conditions)->first(); + if (!$record) { + throw new \PHPUnit\Framework\AssertionFailedError("Record not found in table '$table' with conditions: " . json_encode($conditions)); + } + $this->assertNotEmpty($record); + } + + /** + * Validates a record do notexists in the database + * + * @param string $table The table name + * @param array $conditions The conditions to check + * @return void + * @throws \Exception + * @throws \Cake\Datasource\Exception\RecordNotFoundException + * + * @see https://book.cakephp.org/4/en/orm-query-builder.html + */ + public function assertDbRecordNotExists(string $table, array $conditions): void + { + $record = $this->getTableLocator()->get($table)->find()->where($conditions)->first(); + if ($record) { + throw new \PHPUnit\Framework\AssertionFailedError("Record found in table '$table' with conditions: " . json_encode($conditions)); + } + $this->assertEmpty($record); + } } diff --git a/tests/TestCase/Api/Users/AddUserApiTest.php b/tests/TestCase/Api/Users/AddUserApiTest.php new file mode 100644 index 0000000..b3d85a3 --- /dev/null +++ b/tests/TestCase/Api/Users/AddUserApiTest.php @@ -0,0 +1,77 @@ +initializeValidator(APP . '../webroot/docs/openapi.yaml'); + } + + public function testAddUser(): void + { + $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + $this->post( + self::ENDPOINT, + [ + 'individual_id' => UsersFixture::USER_REGULAR_USER_ID, + 'organisation_id' => OrganisationsFixture::ORGANISATION_A_ID, + 'role_id' => RolesFixture::ROLE_REGULAR_USER_ID, + 'disabled' => false, + 'username' => 'test', + 'password' => 'Password123456!', + ] + ); + + $this->assertResponseOk(); + $this->assertResponseContains('"username": "test"'); + $this->assertDbRecordExists('Users', ['username' => 'test']); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'post'); + } + + public function testAddUserNotAllowedToRegularUser(): void + { + $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); + $this->post( + self::ENDPOINT, + [ + 'individual_id' => UsersFixture::USER_REGULAR_USER_ID, + 'organisation_id' => OrganisationsFixture::ORGANISATION_A_ID, + 'role_id' => RolesFixture::ROLE_REGULAR_USER_ID, + 'disabled' => false, + 'username' => 'test', + 'password' => 'Password123456!' + ] + ); + + $this->assertResponseCode(405); + $this->assertDbRecordNotExists('Users', ['username' => 'test']); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'post'); + } +} diff --git a/tests/TestCase/Api/Users/DeleteUserApiTest.php b/tests/TestCase/Api/Users/DeleteUserApiTest.php new file mode 100644 index 0000000..dad2d1f --- /dev/null +++ b/tests/TestCase/Api/Users/DeleteUserApiTest.php @@ -0,0 +1,60 @@ +initializeValidator(APP . '../webroot/docs/openapi.yaml'); + } + + public function testDeleteUser(): void + { + $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + $url = sprintf('%s/%d', self::ENDPOINT, UsersFixture::USER_REGULAR_USER_ID); + $this->delete($url); + + $this->assertResponseOk(); + $this->assertDbRecordNotExists('Users', ['id' => UsersFixture::USER_REGULAR_USER_ID]); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url, 'delete'); + $this->addWarning('TODO: CRUDComponent::delete() sets some view variables, does not take into account `isRest()`, fix it.'); + } + + public function testDeleteUserNotAllowedToRegularUser(): void + { + $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); + $url = sprintf('%s/%d', self::ENDPOINT, UsersFixture::USER_ORG_ADMIN_ID); + $this->delete($url); + + $this->assertResponseCode(405); + $this->assertDbRecordExists('Users', ['id' => UsersFixture::USER_ORG_ADMIN_ID]); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url, 'delete'); + $this->addWarning('TODO: CRUDComponent::delete() sets some view variables, does not take into account `isRest()`, fix it.'); + } +} diff --git a/tests/TestCase/Api/Users/EditUserApiTest.php b/tests/TestCase/Api/Users/EditUserApiTest.php new file mode 100644 index 0000000..6c94877 --- /dev/null +++ b/tests/TestCase/Api/Users/EditUserApiTest.php @@ -0,0 +1,91 @@ +initializeValidator(APP . '../webroot/docs/openapi.yaml'); + } + + public function testEditUser(): void + { + $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + $url = sprintf('%s/%d', self::ENDPOINT, UsersFixture::USER_REGULAR_USER_ID); + $this->put( + $url, + [ + 'id' => UsersFixture::USER_REGULAR_USER_ID, + 'role_id' => RolesFixture::ROLE_ORG_ADMIN_ID, + ] + ); + + $this->assertResponseOk(); + $this->assertDbRecordExists('Users', [ + 'id' => UsersFixture::USER_REGULAR_USER_ID, + 'role_id' => RolesFixture::ROLE_ORG_ADMIN_ID + ]); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url, 'put'); + } + + public function testEditRoleNotAllowedToRegularUser(): void + { + $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); + $this->put( + self::ENDPOINT, + [ + 'role_id' => RolesFixture::ROLE_ADMIN_ID, + ] + ); + + $this->assertDbRecordNotExists('Users', [ + 'id' => UsersFixture::USER_REGULAR_USER_ID, + 'role_id' => RolesFixture::ROLE_ADMIN_ID + ]); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'put'); + } + + public function testEditSelfUser(): void + { + $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); + $this->put( + self::ENDPOINT, + [ + 'username' => 'test', + ] + ); + + $this->assertDbRecordExists('Users', [ + 'id' => UsersFixture::USER_REGULAR_USER_ID, + 'username' => 'test' + ]); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'put'); + } +} diff --git a/tests/TestCase/Api/Users/IndexUsersApiTest.php b/tests/TestCase/Api/Users/IndexUsersApiTest.php index 46e615d..6721e77 100644 --- a/tests/TestCase/Api/Users/IndexUsersApiTest.php +++ b/tests/TestCase/Api/Users/IndexUsersApiTest.php @@ -30,14 +30,14 @@ class IndexUsersApiTest extends TestCase $this->initializeValidator(APP . '../webroot/docs/openapi.yaml'); } - public function testIndex(): void + public function testIndexUsers(): void { $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); $this->get(self::ENDPOINT); $this->assertResponseOk(); $this->assertResponseContains(sprintf('"username": "%s"', UsersFixture::USER_ADMIN_USERNAME)); - // TODO: $this->validateRequest() - $this->validateResponse(self::ENDPOINT); + // TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT); } } diff --git a/tests/TestCase/Api/Users/ViewUserApiTest.php b/tests/TestCase/Api/Users/ViewUserApiTest.php index 7c5a346..34c9c62 100644 --- a/tests/TestCase/Api/Users/ViewUserApiTest.php +++ b/tests/TestCase/Api/Users/ViewUserApiTest.php @@ -30,26 +30,26 @@ class ViewUserApiTest extends TestCase $this->initializeValidator(APP . '../webroot/docs/openapi.yaml'); } - public function testViewMe(): void + public function testViewMyUser(): void { $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); $this->get(self::ENDPOINT); $this->assertResponseOk(); $this->assertResponseContains(sprintf('"username": "%s"', UsersFixture::USER_ADMIN_USERNAME)); - // TODO: $this->validateRequest() - $this->validateResponse(self::ENDPOINT); + // TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT); } - - public function testViewById(): void + + public function testViewUserById(): void { $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); - $url = sprintf('%s/%d', self::ENDPOINT, UsersFixture::USER_ADMIN_ID); + $url = sprintf('%s/%d', self::ENDPOINT, UsersFixture::USER_REGULAR_USER_ID); $this->get($url); - + $this->assertResponseOk(); - $this->assertResponseContains(sprintf('"username": "%s"', UsersFixture::USER_ADMIN_USERNAME)); - // TODO: $this->validateRequest() - $this->validateResponse($url); + $this->assertResponseContains(sprintf('"username": "%s"', UsersFixture::USER_REGULAR_USER_USERNAME)); + // TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url); } } From ce1a51cc3903315f127842c193ac7b4e7fb47bce Mon Sep 17 00:00:00 2001 From: Luciano Righetti Date: Mon, 10 Jan 2022 11:59:23 +0100 Subject: [PATCH 097/150] fix: incorrect check --- src/Controller/Component/ParamHandlerComponent.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Controller/Component/ParamHandlerComponent.php b/src/Controller/Component/ParamHandlerComponent.php index 89d6cce..e52e5f8 100644 --- a/src/Controller/Component/ParamHandlerComponent.php +++ b/src/Controller/Component/ParamHandlerComponent.php @@ -4,6 +4,7 @@ namespace App\Controller\Component; use Cake\Controller\Component; use Cake\Core\Configure; +use Cake\Http\Exception\MethodNotAllowedException; class ParamHandlerComponent extends Component { @@ -47,7 +48,7 @@ class ParamHandlerComponent extends Component return $this->isRest; } if ($this->request->is('json')) { - if (!empty($this->request->getBody()) && !empty($this->request->getParsedBody())) { + if (!empty((string)$this->request->getBody()) && empty($this->request->getParsedBody())) { throw new MethodNotAllowedException('Invalid JSON input. Make sure that the JSON input is a correctly formatted JSON string. This request has been blocked to avoid an unfiltered request.'); } $this->isRest = true; From 71072f60eb8c5d9b68c19a4e750d8f3ecb37ce3e Mon Sep 17 00:00:00 2001 From: Luciano Righetti Date: Mon, 10 Jan 2022 11:59:55 +0100 Subject: [PATCH 098/150] chg: extend openapi spec --- webroot/docs/openapi.yaml | 134 +++++++++++++++++++++++++++++++++++--- 1 file changed, 125 insertions(+), 9 deletions(-) diff --git a/webroot/docs/openapi.yaml b/webroot/docs/openapi.yaml index 00fd682..f0f184f 100644 --- a/webroot/docs/openapi.yaml +++ b/webroot/docs/openapi.yaml @@ -3,8 +3,7 @@ info: version: 1.3.0 title: Cerebrate Project API description: | - - TODO: markdown description + Cerebrate is an open-source platform meant to act as a trusted contact information provider and interconnection orchestrator for other security tools. servers: - url: https://cerebrate.local @@ -22,7 +21,7 @@ paths: - Users responses: "200": - $ref: "#/components/responses/GetUsersResponse" + $ref: "#/components/responses/UserListResponse" "403": $ref: "#/components/responses/UnauthorizedApiErrorResponse" "405": @@ -38,9 +37,11 @@ paths: - Users responses: "200": - $ref: "#/components/responses/GetUserResponse" + $ref: "#/components/responses/UserResponse" "403": $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" default: $ref: "#/components/responses/ApiErrorResponse" @@ -54,9 +55,85 @@ paths: - $ref: "#/components/parameters/userId" responses: "200": - $ref: "#/components/responses/GetUserResponse" + $ref: "#/components/responses/UserResponse" "403": $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + + /api/v1/users/add: + post: + summary: "Add user" + operationId: addUser + tags: + - Users + requestBody: + $ref: "#/components/requestBodies/AddUserRequest" + responses: + "200": + $ref: "#/components/responses/UserResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + + /api/v1/users/edit: + put: + summary: "Edit current user" + operationId: editUser + tags: + - Users + requestBody: + $ref: "#/components/requestBodies/EditUserRequest" + responses: + "200": + $ref: "#/components/responses/UserResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + + /api/v1/users/edit/{userId}: + put: + summary: "Edit current user" + operationId: editUserById + tags: + - Users + parameters: + - $ref: "#/components/parameters/userId" + requestBody: + $ref: "#/components/requestBodies/EditUserRequest" + responses: + "200": + $ref: "#/components/responses/UserResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + + /api/v1/users/delete/{userId}: + delete: + summary: "Delete user by ID" + operationId: deleteUserById + tags: + - Users + parameters: + - $ref: "#/components/parameters/userId" + responses: + "200": + $ref: "#/components/responses/UserResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" default: $ref: "#/components/responses/ApiErrorResponse" @@ -224,19 +301,58 @@ components: Authorization: YOUR_API_KEY - # requestBodies: + requestBodies: + AddUserRequest: + required: true + content: + application/json: + schema: + type: object + properties: + individual_id: + $ref: "#/components/schemas/ID" + organisation_id: + $ref: "#/components/schemas/ID" + role_id: + $ref: "#/components/schemas/ID" + disabled: + type: boolean + username: + $ref: "#/components/schemas/Username" + password: + type: string + + EditUserRequest: + required: true + content: + application/json: + schema: + type: object + properties: + individual_id: + $ref: "#/components/schemas/ID" + organisation_id: + $ref: "#/components/schemas/ID" + role_id: + $ref: "#/components/schemas/ID" + disabled: + type: boolean + username: + $ref: "#/components/schemas/Username" + password: + type: string responses: # User - GetUserResponse: + UserResponse: description: "User response" content: application/json: schema: $ref: "#/components/schemas/User" - GetUsersResponse: - description: "User response" + UserListResponse: + description: "Users list response" content: application/json: schema: From b954e1106434a2dd03433e712816626b6a49e17f Mon Sep 17 00:00:00 2001 From: Luciano Righetti Date: Mon, 10 Jan 2022 16:19:58 +0100 Subject: [PATCH 099/150] add: add/edit operations api tests and openapi spec --- tests/README.md | 4 +- .../Organisations/AddOrganisationApiTest.php | 85 +++++++++ .../Organisations/EditOrganisationApiTest.php | 81 +++++++++ tests/TestCase/Api/Users/AddUserApiTest.php | 1 + .../TestCase/Api/Users/DeleteUserApiTest.php | 1 + tests/TestCase/Api/Users/EditUserApiTest.php | 1 + .../TestCase/Api/Users/IndexUsersApiTest.php | 1 + tests/TestCase/Api/Users/ViewUserApiTest.php | 1 + webroot/docs/openapi.yaml | 166 ++++++++++++++++++ 9 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 tests/TestCase/Api/Organisations/AddOrganisationApiTest.php create mode 100644 tests/TestCase/Api/Organisations/EditOrganisationApiTest.php diff --git a/tests/README.md b/tests/README.md index 732aafc..0486b3b 100644 --- a/tests/README.md +++ b/tests/README.md @@ -31,7 +31,7 @@ QUIT; ``` $ composer install -$ vendor/bin/phpunit +$ vendor/bin/phpunit PHPUnit 8.5.22 by Sebastian Bergmann and contributors. ..... 5 / 5 (100%) @@ -43,7 +43,7 @@ OK (5 tests, 15 assertions) Running a specific suite: ``` -$ vendor/bin/phpunit --testsuite=api +$ vendor/bin/phpunit --testsuite=api --testdox ``` Available suites: * `app`: runs all test suites diff --git a/tests/TestCase/Api/Organisations/AddOrganisationApiTest.php b/tests/TestCase/Api/Organisations/AddOrganisationApiTest.php new file mode 100644 index 0000000..4f43a09 --- /dev/null +++ b/tests/TestCase/Api/Organisations/AddOrganisationApiTest.php @@ -0,0 +1,85 @@ +initializeValidator(APP . '../webroot/docs/openapi.yaml'); + } + + public function testAddOrganisation(): void + { + $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + + $faker = \Faker\Factory::create(); + $uuid = $faker->uuid; + + $this->post( + self::ENDPOINT, + [ + 'name' => 'Test Organisation', + 'description' => $faker->text, + 'uuid' => $uuid, + 'url' => 'http://example.com', + 'nationality' => 'US', + 'sector' => 'sector', + 'type' => 'type', + ] + ); + + $this->assertResponseOk(); + $this->assertResponseContains(sprintf('"uuid": "%s"', $uuid)); + $this->assertDbRecordExists('Organisations', ['uuid' => $uuid]); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'post'); + } + + public function testAddOrganisationNotAllowedToRegularUser(): void + { + $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); + + $faker = \Faker\Factory::create(); + $uuid = $faker->uuid; + + $this->post( + self::ENDPOINT, + [ + 'name' => 'Test Organisation', + 'description' => $faker->text, + 'uuid' => $uuid, + 'url' => 'http://example.com', + 'nationality' => 'US', + 'sector' => 'sector', + 'type' => 'type', + ] + ); + + $this->assertResponseCode(405); + $this->assertDbRecordNotExists('Organisations', ['uuid' => $uuid]); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'post'); + } +} diff --git a/tests/TestCase/Api/Organisations/EditOrganisationApiTest.php b/tests/TestCase/Api/Organisations/EditOrganisationApiTest.php new file mode 100644 index 0000000..61b2cab --- /dev/null +++ b/tests/TestCase/Api/Organisations/EditOrganisationApiTest.php @@ -0,0 +1,81 @@ +initializeValidator(APP . '../webroot/docs/openapi.yaml'); + } + + public function testEditOrganisation(): void + { + $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + + $url = sprintf('%s/%d', self::ENDPOINT, OrganisationsFixture::ORGANISATION_A_ID); + $this->put( + $url, + [ + 'name' => 'Test Organisation 4321', + ] + ); + + $this->assertResponseOk(); + $this->assertDbRecordExists( + 'Organisations', + [ + 'id' => OrganisationsFixture::ORGANISATION_A_ID, + 'name' => 'Test Organisation 4321', + ] + ); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url, 'put'); + } + + public function testEditOrganisationNotAllowedToRegularUser(): void + { + $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); + + $url = sprintf('%s/%d', self::ENDPOINT, OrganisationsFixture::ORGANISATION_B_ID); + $this->put( + $url, + [ + 'name' => 'Test Organisation 1234' + ] + ); + + $this->assertResponseCode(405); + $this->assertDbRecordNotExists( + 'Organisations', + [ + 'id' => OrganisationsFixture::ORGANISATION_B_ID, + 'name' => 'Test Organisation 1234' + ] + ); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url, 'put'); + } +} diff --git a/tests/TestCase/Api/Users/AddUserApiTest.php b/tests/TestCase/Api/Users/AddUserApiTest.php index b3d85a3..2d23f2a 100644 --- a/tests/TestCase/Api/Users/AddUserApiTest.php +++ b/tests/TestCase/Api/Users/AddUserApiTest.php @@ -20,6 +20,7 @@ class AddUserApiTest extends TestCase protected const ENDPOINT = '/api/v1/users/add'; protected $fixtures = [ + 'app.Organisations', 'app.Individuals', 'app.Roles', 'app.Users', diff --git a/tests/TestCase/Api/Users/DeleteUserApiTest.php b/tests/TestCase/Api/Users/DeleteUserApiTest.php index dad2d1f..47a0580 100644 --- a/tests/TestCase/Api/Users/DeleteUserApiTest.php +++ b/tests/TestCase/Api/Users/DeleteUserApiTest.php @@ -20,6 +20,7 @@ class DeleteUserApiTest extends TestCase protected const ENDPOINT = '/api/v1/users/delete'; protected $fixtures = [ + 'app.Organisations', 'app.Individuals', 'app.Roles', 'app.Users', diff --git a/tests/TestCase/Api/Users/EditUserApiTest.php b/tests/TestCase/Api/Users/EditUserApiTest.php index 6c94877..5ac568a 100644 --- a/tests/TestCase/Api/Users/EditUserApiTest.php +++ b/tests/TestCase/Api/Users/EditUserApiTest.php @@ -20,6 +20,7 @@ class EditUserApiTest extends TestCase protected const ENDPOINT = '/api/v1/users/edit'; protected $fixtures = [ + 'app.Organisations', 'app.Individuals', 'app.Roles', 'app.Users', diff --git a/tests/TestCase/Api/Users/IndexUsersApiTest.php b/tests/TestCase/Api/Users/IndexUsersApiTest.php index 6721e77..403a046 100644 --- a/tests/TestCase/Api/Users/IndexUsersApiTest.php +++ b/tests/TestCase/Api/Users/IndexUsersApiTest.php @@ -18,6 +18,7 @@ class IndexUsersApiTest extends TestCase protected const ENDPOINT = '/api/v1/users/index'; protected $fixtures = [ + 'app.Organisations', 'app.Individuals', 'app.Roles', 'app.Users', diff --git a/tests/TestCase/Api/Users/ViewUserApiTest.php b/tests/TestCase/Api/Users/ViewUserApiTest.php index 34c9c62..99bdafe 100644 --- a/tests/TestCase/Api/Users/ViewUserApiTest.php +++ b/tests/TestCase/Api/Users/ViewUserApiTest.php @@ -18,6 +18,7 @@ class ViewUserApiTest extends TestCase protected const ENDPOINT = '/api/v1/users/view'; protected $fixtures = [ + 'app.Organisations', 'app.Individuals', 'app.Roles', 'app.Users', diff --git a/webroot/docs/openapi.yaml b/webroot/docs/openapi.yaml index f0f184f..a2a161e 100644 --- a/webroot/docs/openapi.yaml +++ b/webroot/docs/openapi.yaml @@ -11,6 +11,8 @@ servers: tags: - name: Users description: "Users enrolled in this Cerebrate instance." + - name: Organisations + description: "Organisations can be equivalent to legal entities or specific individual teams within such entities. Their purpose is to relate individuals to their affiliations and for release control of information using the Trust Circles." paths: /api/v1/users/index: @@ -137,6 +139,44 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" + /api/v1/organisations/add: + post: + summary: "Add organisation" + operationId: addOrganisation + tags: + - Organisations + requestBody: + $ref: "#/components/requestBodies/AddOrganisationRequest" + responses: + "200": + $ref: "#/components/responses/OrganisationResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + + /api/v1/organisations/edit/{organisationId}: + put: + summary: "Edit organisation" + operationId: editOrganisation + tags: + - Organisations + parameters: + - $ref: "#/components/parameters/organisationId" + requestBody: + $ref: "#/components/requestBodies/EditOrganisationRequest" + responses: + "200": + $ref: "#/components/responses/OrganisationResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + components: schemas: # General @@ -191,6 +231,73 @@ components: # Individuals # Organisations + OrganisationName: + type: string + + OrganisationUrl: + type: string + + OrganisationSector: + type: string + nullable: true + + OrganisationType: + type: string + nullable: true + + OrganisationContacts: + type: string + nullable: true + + OrganisationNationality: + type: string + nullable: true + + Organisation: + type: object + properties: + id: + $ref: "#/components/schemas/ID" + uuid: + $ref: "#/components/schemas/UUID" + name: + $ref: "#/components/schemas/OrganisationName" + url: + $ref: "#/components/schemas/OrganisationUrl" + nationality: + $ref: "#/components/schemas/OrganisationNationality" + sector: + $ref: "#/components/schemas/OrganisationSector" + type: + $ref: "#/components/schemas/OrganisationType" + contacts: + $ref: "#/components/schemas/OrganisationContacts" + created: + $ref: "#/components/schemas/DateTime" + modified: + $ref: "#/components/schemas/DateTime" + tags: + $ref: "#/components/schemas/TagList" + aligments: + $ref: "#/components/schemas/AligmentList" + + # Tags + Tag: + type: object + + TagList: + type: array + items: + $ref: "#/components/schemas/Tag" + + # Alignments + Alignment: + type: object + + AligmentList: + type: array + items: + $ref: "#/components/schemas/Alignment" # Roles RoleName: @@ -291,6 +398,14 @@ components: schema: $ref: "#/components/schemas/ID" + organisationId: + name: organisationId + in: path + description: "Numeric ID of the Organisation" + required: true + schema: + $ref: "#/components/schemas/ID" + securitySchemes: ApiKeyAuth: type: apiKey @@ -342,6 +457,50 @@ components: password: type: string + AddOrganisationRequest: + required: true + content: + application/json: + schema: + type: object + properties: + uuid: + $ref: "#/components/schemas/UUID" + name: + $ref: "#/components/schemas/OrganisationName" + url: + $ref: "#/components/schemas/OrganisationUrl" + nationality: + $ref: "#/components/schemas/OrganisationNationality" + sector: + $ref: "#/components/schemas/OrganisationSector" + type: + $ref: "#/components/schemas/OrganisationType" + contacts: + $ref: "#/components/schemas/OrganisationContacts" + + EditOrganisationRequest: + required: true + content: + application/json: + schema: + type: object + properties: + uuid: + $ref: "#/components/schemas/UUID" + name: + $ref: "#/components/schemas/OrganisationName" + url: + $ref: "#/components/schemas/OrganisationUrl" + nationality: + $ref: "#/components/schemas/OrganisationNationality" + sector: + $ref: "#/components/schemas/OrganisationSector" + type: + $ref: "#/components/schemas/OrganisationType" + contacts: + $ref: "#/components/schemas/OrganisationContacts" + responses: # User UserResponse: @@ -358,6 +517,13 @@ components: schema: $ref: "#/components/schemas/UserList" + OrganisationResponse: + description: "Organisation response" + content: + application/json: + schema: + $ref: "#/components/schemas/Organisation" + # Errors ApiErrorResponse: description: "Unexpected API error" From 241e760ad2823ea91c2bba46eb5a84bdf25b9d0d Mon Sep 17 00:00:00 2001 From: Luciano Righetti Date: Mon, 10 Jan 2022 16:20:22 +0100 Subject: [PATCH 100/150] add: add API menu option --- src/Controller/Component/Navigation/sidemenu.php | 7 ++++++- src/Controller/Component/NavigationComponent.php | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Controller/Component/Navigation/sidemenu.php b/src/Controller/Component/Navigation/sidemenu.php index fcca213..56a0154 100644 --- a/src/Controller/Component/Navigation/sidemenu.php +++ b/src/Controller/Component/Navigation/sidemenu.php @@ -45,7 +45,12 @@ class Sidemenu { 'label' => __('Broods'), 'icon' => $this->iconTable['Broods'], 'url' => '/broods/index', - ] + ], + 'API' => [ + 'label' => __('API'), + 'icon' => $this->iconTable['API'], + 'url' => '/api/index', + ], ], __('Administration') => [ 'Roles' => [ diff --git a/src/Controller/Component/NavigationComponent.php b/src/Controller/Component/NavigationComponent.php index f0dd453..b8caee7 100644 --- a/src/Controller/Component/NavigationComponent.php +++ b/src/Controller/Component/NavigationComponent.php @@ -34,6 +34,7 @@ class NavigationComponent extends Component 'LocalTools' => 'tools', 'Instance' => 'server', 'Tags' => 'tags', + 'API' => 'code', ]; public function initialize(array $config): void From 4dedb4702bd0eacac1e732aa3f8dfb48ecc4f37f Mon Sep 17 00:00:00 2001 From: Sami Mokaddem Date: Tue, 11 Jan 2022 08:20:20 +0100 Subject: [PATCH 101/150] new: [doc] Added slides about cerebrate and local tools synchronisations --- documentation/cerebrate-synchornization.pdf | Bin 0 -> 46517 bytes documentation/local-tools-inter-connection.pdf | Bin 0 -> 217031 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 documentation/cerebrate-synchornization.pdf create mode 100644 documentation/local-tools-inter-connection.pdf diff --git a/documentation/cerebrate-synchornization.pdf b/documentation/cerebrate-synchornization.pdf new file mode 100644 index 0000000000000000000000000000000000000000..946306d6d1523059541316c262affe36c92b8c47 GIT binary patch literal 46517 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 z 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 KMM_y3@4o=j=r?-+ literal 0 HcmV?d00001 diff --git a/documentation/local-tools-inter-connection.pdf b/documentation/local-tools-inter-connection.pdf new file mode 100644 index 0000000000000000000000000000000000000000..5014c98957660b8756051cb84b9286d3846bcfea GIT binary patch literal 217031 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

S&v43vb%{Xtj*^QN#gX7im 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

`RPa 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 z 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 literal 0 HcmV?d00001 From 229fd98b8ed97532109050b9b9dc61c2ce9224bc Mon Sep 17 00:00:00 2001 From: iglocska Date: Tue, 11 Jan 2022 10:21:03 +0100 Subject: [PATCH 102/150] fix: [migrations] correct string length to avoid strict mode issues with keys exceeding 767 bytes --- .../config/Migrations/20210831121348_TagSystem.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/Tags/config/Migrations/20210831121348_TagSystem.php b/plugins/Tags/config/Migrations/20210831121348_TagSystem.php index bb2e412..98492a3 100644 --- a/plugins/Tags/config/Migrations/20210831121348_TagSystem.php +++ b/plugins/Tags/config/Migrations/20210831121348_TagSystem.php @@ -10,22 +10,22 @@ class TagSystem extends AbstractMigration $tags = $this->table('tags_tags'); $tags->addColumn('namespace', 'string', [ 'default' => null, - 'limit' => 255, + 'limit' => 191, 'null' => true, ]) ->addColumn('predicate', 'string', [ 'default' => null, - 'limit' => 255, + 'limit' => 191, 'null' => true, ]) ->addColumn('value', 'string', [ 'default' => null, - 'limit' => 255, + 'limit' => 191, 'null' => true, ]) ->addColumn('name', 'string', [ 'default' => null, - 'limit' => 255, + 'limit' => 191, 'null' => false, ]) ->addColumn('colour', 'string', [ @@ -66,7 +66,7 @@ class TagSystem extends AbstractMigration ]) ->addColumn('fk_model', 'string', [ 'default' => null, - 'limit' => 255, + 'limit' => 191, 'null' => false, 'comment' => 'The model name of the entity being tagged' ]) @@ -86,4 +86,4 @@ class TagSystem extends AbstractMigration $tagged->addIndex(['tag_id', 'fk_id', 'fk_model'], ['unique' => true]) ->update(); } -} \ No newline at end of file +} From f774f68ede3c84b566cbb64699898125c54c03a5 Mon Sep 17 00:00:00 2001 From: Luciano Righetti Date: Tue, 11 Jan 2022 12:33:34 +0100 Subject: [PATCH 103/150] add: add api tests for tags and orgs, extend openapi spec, fix routes for tags plugin --- config/routes.php | 15 +- tests/Fixture/TagsTaggedsFixture.php | 40 ++++ tests/Fixture/TagsTagsFixture.php | 87 ++++++++ .../DeleteOrganisationApiTest.php | 59 ++++++ .../IndexOrganisationsApiTest.php | 45 ++++ .../Organisations/TagOrganisationApiTest.php | 86 ++++++++ .../UntagOrganisationApiTest.php | 86 ++++++++ tests/TestCase/Api/Tags/IndexTagsApiTest.php | 47 +++++ tests/TestCase/Api/Users/EditUserApiTest.php | 1 - webroot/docs/openapi.yaml | 194 +++++++++++++++++- 10 files changed, 657 insertions(+), 3 deletions(-) create mode 100644 tests/Fixture/TagsTaggedsFixture.php create mode 100644 tests/Fixture/TagsTagsFixture.php create mode 100644 tests/TestCase/Api/Organisations/DeleteOrganisationApiTest.php create mode 100644 tests/TestCase/Api/Organisations/IndexOrganisationsApiTest.php create mode 100644 tests/TestCase/Api/Organisations/TagOrganisationApiTest.php create mode 100644 tests/TestCase/Api/Organisations/UntagOrganisationApiTest.php create mode 100644 tests/TestCase/Api/Tags/IndexTagsApiTest.php diff --git a/config/routes.php b/config/routes.php index e467725..d51121a 100644 --- a/config/routes.php +++ b/config/routes.php @@ -101,5 +101,18 @@ $routes->scope('/api', function (RouteBuilder $routes) { // Generic API route $routes->connect('/{controller}/{action}/*'); + + // Tags plugin routes + $routes->plugin( + 'tags', + ['path' => '/tags'], + function ($routes) { + $routes->setRouteClass(DashedRoute::class); + $routes->connect( + '/{action}/*', + ['controller' => 'Tags'] + ); + } + ); }); -}); \ No newline at end of file +}); diff --git a/tests/Fixture/TagsTaggedsFixture.php b/tests/Fixture/TagsTaggedsFixture.php new file mode 100644 index 0000000..e2bf70f --- /dev/null +++ b/tests/Fixture/TagsTaggedsFixture.php @@ -0,0 +1,40 @@ +records = [ + [ + 'tag_id' => TagsTagsFixture::TAG_ORG_A_ID, + 'fk_id' => OrganisationsFixture::ORGANISATION_A_ID, + 'fk_model' => 'Organisations', + 'created' => $faker->dateTime()->getTimestamp(), + 'modified' => $faker->dateTime()->getTimestamp() + ], + [ + 'tag_id' => TagsTagsFixture::TAG_ORG_B_ID, + 'fk_id' => OrganisationsFixture::ORGANISATION_B_ID, + 'fk_model' => 'Organisations', + 'created' => $faker->dateTime()->getTimestamp(), + 'modified' => $faker->dateTime()->getTimestamp() + ], + ]; + parent::init(); + } +} diff --git a/tests/Fixture/TagsTagsFixture.php b/tests/Fixture/TagsTagsFixture.php new file mode 100644 index 0000000..002bc9e --- /dev/null +++ b/tests/Fixture/TagsTagsFixture.php @@ -0,0 +1,87 @@ +records = [ + [ + 'id' => self::TAG_RED_ID, + 'name' => 'red', + 'namespace' => null, + 'predicate' => null, + 'value' => null, + 'colour' => 'FF0000', + 'counter' => 0, + 'text_colour' => 'red', + 'created' => $faker->dateTime()->getTimestamp(), + 'modified' => $faker->dateTime()->getTimestamp() + ], + [ + 'id' => self::TAG_GREEN_ID, + 'name' => 'green', + 'namespace' => null, + 'predicate' => null, + 'value' => null, + 'colour' => '00FF00', + 'counter' => 0, + 'text_colour' => 'green', + 'created' => $faker->dateTime()->getTimestamp(), + 'modified' => $faker->dateTime()->getTimestamp() + ], + [ + 'id' => self::TAG_BLUE_ID, + 'name' => 'blue', + 'namespace' => null, + 'predicate' => null, + 'value' => null, + 'colour' => '0000FF', + 'counter' => 0, + 'text_colour' => 'blue', + 'created' => $faker->dateTime()->getTimestamp(), + 'modified' => $faker->dateTime()->getTimestamp() + ], + [ + 'id' => self::TAG_ORG_A_ID, + 'name' => 'org-a', + 'namespace' => null, + 'predicate' => null, + 'value' => null, + 'colour' => '000000', + 'counter' => 0, + 'text_colour' => 'black', + 'created' => $faker->dateTime()->getTimestamp(), + 'modified' => $faker->dateTime()->getTimestamp() + ], + [ + 'id' => self::TAG_ORG_B_ID, + 'name' => 'org-b', + 'namespace' => null, + 'predicate' => null, + 'value' => null, + 'colour' => '000000', + 'counter' => 0, + 'text_colour' => 'black', + 'created' => $faker->dateTime()->getTimestamp(), + 'modified' => $faker->dateTime()->getTimestamp() + ] + ]; + parent::init(); + } +} diff --git a/tests/TestCase/Api/Organisations/DeleteOrganisationApiTest.php b/tests/TestCase/Api/Organisations/DeleteOrganisationApiTest.php new file mode 100644 index 0000000..9840faf --- /dev/null +++ b/tests/TestCase/Api/Organisations/DeleteOrganisationApiTest.php @@ -0,0 +1,59 @@ +initializeValidator(APP . '../webroot/docs/openapi.yaml'); + } + + public function testDeleteOrganisation(): void + { + $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + $url = sprintf('%s/%d', self::ENDPOINT, OrganisationsFixture::ORGANISATION_B_ID); + $this->delete($url); + + $this->assertResponseOk(); + $this->assertDbRecordNotExists('Organisations', ['id' => OrganisationsFixture::ORGANISATION_B_ID]); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url, 'delete'); + $this->addWarning('TODO: CRUDComponent::delete() sets some view variables, does not take into account `isRest()`, fix it.'); + } + + public function testDeleteOrganisationNotAllowedToRegularUser(): void + { + $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); + $url = sprintf('%s/%d', self::ENDPOINT, OrganisationsFixture::ORGANISATION_B_ID); + $this->delete($url); + + $this->assertResponseCode(405); + $this->assertDbRecordExists('Organisations', ['id' => OrganisationsFixture::ORGANISATION_B_ID]); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url, 'delete'); + $this->addWarning('TODO: CRUDComponent::delete() sets some view variables, does not take into account `isRest()`, fix it.'); + } +} diff --git a/tests/TestCase/Api/Organisations/IndexOrganisationsApiTest.php b/tests/TestCase/Api/Organisations/IndexOrganisationsApiTest.php new file mode 100644 index 0000000..ba7c255 --- /dev/null +++ b/tests/TestCase/Api/Organisations/IndexOrganisationsApiTest.php @@ -0,0 +1,45 @@ +initializeValidator(APP . '../webroot/docs/openapi.yaml'); + } + + public function testIndexOrganisations(): void + { + $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + $this->get(self::ENDPOINT); + + $this->assertResponseOk(); + $this->assertResponseContains(sprintf('"id": %d', OrganisationsFixture::ORGANISATION_A_ID)); + $this->assertResponseContains(sprintf('"id": %d', OrganisationsFixture::ORGANISATION_B_ID)); + // TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT); + } +} diff --git a/tests/TestCase/Api/Organisations/TagOrganisationApiTest.php b/tests/TestCase/Api/Organisations/TagOrganisationApiTest.php new file mode 100644 index 0000000..e23fcbc --- /dev/null +++ b/tests/TestCase/Api/Organisations/TagOrganisationApiTest.php @@ -0,0 +1,86 @@ +initializeValidator(APP . '../webroot/docs/openapi.yaml'); + } + + public function testTagOrganisation(): void + { + $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + + $url = sprintf('%s/%d', self::ENDPOINT, OrganisationsFixture::ORGANISATION_A_ID); + $this->post( + $url, + [ + 'tag_list' => "[\"red\"]" + ] + ); + + $this->assertResponseOk(); + $this->assertDbRecordExists( + 'TagsTagged', + [ + 'tag_id' => TagsTagsFixture::TAG_RED_ID, + 'fk_id' => OrganisationsFixture::ORGANISATION_A_ID, + 'fk_model' => 'Organisations' + ] + ); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url, 'post'); + } + + public function testTagOrganisationNotAllowedToRegularUser(): void + { + $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); + + $url = sprintf('%s/%d', self::ENDPOINT, OrganisationsFixture::ORGANISATION_A_ID); + $this->post( + $url, + [ + 'tag_list' => "[\"green\"]" + ] + ); + + $this->assertResponseCode(405); + $this->assertDbRecordNotExists( + 'TagsTagged', + [ + 'tag_id' => TagsTagsFixture::TAG_GREEN_ID, + 'fk_id' => OrganisationsFixture::ORGANISATION_A_ID, + 'fk_model' => 'Organisations' + ] + ); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url, 'post'); + } +} diff --git a/tests/TestCase/Api/Organisations/UntagOrganisationApiTest.php b/tests/TestCase/Api/Organisations/UntagOrganisationApiTest.php new file mode 100644 index 0000000..0c23ca4 --- /dev/null +++ b/tests/TestCase/Api/Organisations/UntagOrganisationApiTest.php @@ -0,0 +1,86 @@ +initializeValidator(APP . '../webroot/docs/openapi.yaml'); + } + + public function testUntagOrganisation(): void + { + $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + + $url = sprintf('%s/%d', self::ENDPOINT, OrganisationsFixture::ORGANISATION_A_ID); + $this->post( + $url, + [ + 'tag_list' => "[\"org-a\"]" + ] + ); + + $this->assertResponseOk(); + $this->assertDbRecordNotExists( + 'TagsTagged', + [ + 'tag_id' => TagsTagsFixture::TAG_ORG_A_ID, + 'fk_id' => OrganisationsFixture::ORGANISATION_A_ID, + 'fk_model' => 'Organisations' + ] + ); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url, 'post'); + } + + public function testUntagOrganisationNotAllowedToRegularUser(): void + { + $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); + + $url = sprintf('%s/%d', self::ENDPOINT, OrganisationsFixture::ORGANISATION_A_ID); + $this->post( + $url, + [ + 'tag_list' => "[\"org-a\"]" + ] + ); + + $this->assertResponseCode(405); + $this->assertDbRecordExists( + 'TagsTagged', + [ + 'tag_id' => TagsTagsFixture::TAG_ORG_A_ID, + 'fk_id' => OrganisationsFixture::ORGANISATION_A_ID, + 'fk_model' => 'Organisations' + ] + ); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url, 'post'); + } +} diff --git a/tests/TestCase/Api/Tags/IndexTagsApiTest.php b/tests/TestCase/Api/Tags/IndexTagsApiTest.php new file mode 100644 index 0000000..137151f --- /dev/null +++ b/tests/TestCase/Api/Tags/IndexTagsApiTest.php @@ -0,0 +1,47 @@ +initializeValidator(APP . '../webroot/docs/openapi.yaml'); + } + + public function testIndexTags(): void + { + $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + $this->get(self::ENDPOINT); + + + $this->assertResponseOk(); + $this->assertResponseContains('"name": "red"'); + $this->assertResponseContains('"name": "green"'); + $this->assertResponseContains('"name": "blue"'); + // TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT); + } +} diff --git a/tests/TestCase/Api/Users/EditUserApiTest.php b/tests/TestCase/Api/Users/EditUserApiTest.php index 5ac568a..6154c00 100644 --- a/tests/TestCase/Api/Users/EditUserApiTest.php +++ b/tests/TestCase/Api/Users/EditUserApiTest.php @@ -8,7 +8,6 @@ use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; use App\Test\Fixture\UsersFixture; -use App\Test\Fixture\OrganisationsFixture; use App\Test\Fixture\RolesFixture; use App\Test\Helper\ApiTestTrait; diff --git a/webroot/docs/openapi.yaml b/webroot/docs/openapi.yaml index a2a161e..f76a861 100644 --- a/webroot/docs/openapi.yaml +++ b/webroot/docs/openapi.yaml @@ -13,6 +13,8 @@ tags: description: "Users enrolled in this Cerebrate instance." - name: Organisations description: "Organisations can be equivalent to legal entities or specific individual teams within such entities. Their purpose is to relate individuals to their affiliations and for release control of information using the Trust Circles." + - name: Tags + description: "Tags can be attached to entity to quickly classify them, allowing further filtering and searches." paths: /api/v1/users/index: @@ -177,6 +179,96 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" + /api/v1/organisations/index: + get: + summary: "Get organisations" + operationId: getOrganisations + tags: + - Organisations + responses: + "200": + $ref: "#/components/responses/OrganisationListResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + + /api/v1/organisations/delete/{organisationId}: + delete: + summary: "Delete organisation by ID" + operationId: deleteOrganisationById + tags: + - Organisations + parameters: + - $ref: "#/components/parameters/organisationId" + responses: + "200": + $ref: "#/components/responses/OrganisationResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + + /api/v1/organisations/tag/{organisationId}: + post: + summary: "Tag organisation by ID" + operationId: tagOrganisationById + tags: + - Organisations + parameters: + - $ref: "#/components/parameters/organisationId" + requestBody: + $ref: "#/components/requestBodies/TagOrganisationRequest" + responses: + "200": + $ref: "#/components/responses/OrganisationResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + + /api/v1/organisations/untag/{organisationId}: + post: + summary: "Remove organisation tag by ID" + operationId: untagOrganisationById + tags: + - Organisations + parameters: + - $ref: "#/components/parameters/organisationId" + requestBody: + $ref: "#/components/requestBodies/UntagOrganisationRequest" + responses: + "200": + $ref: "#/components/responses/OrganisationResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + + /api/v1/tags/index: + get: + summary: "Get tags list" + operationId: getTags + tags: + - Tags + responses: + "200": + $ref: "#/components/responses/TagListResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + components: schemas: # General @@ -281,9 +373,60 @@ components: aligments: $ref: "#/components/schemas/AligmentList" + OrganisationList: + type: array + items: + $ref: "#/components/schemas/Organisation" + # Tags + TagName: + type: string + example: "white" + + TagNamespace: + type: string + nullable: true + example: "tlp" + + TagPredicate: + type: string + nullable: true + + TagValue: + type: string + nullable: true + + TagColour: + type: string + example: "FFFFFF" + + TagTextColour: + type: string + example: "white" + Tag: type: object + properties: + id: + $ref: "#/components/schemas/ID" + name: + $ref: "#/components/schemas/TagName" + namespace: + $ref: "#/components/schemas/TagNamespace" + predicate: + $ref: "#/components/schemas/TagPredicate" + value: + $ref: "#/components/schemas/TagValue" + colour: + $ref: "#/components/schemas/TagColour" + text_colour: + $ref: "#/components/schemas/TagTextColour" + counter: + type: integer + created: + $ref: "#/components/schemas/DateTime" + modified: + $ref: "#/components/schemas/DateTime" TagList: type: array @@ -417,6 +560,7 @@ components: Authorization: YOUR_API_KEY requestBodies: + # Users AddUserRequest: required: true content: @@ -457,6 +601,7 @@ components: password: type: string + # Organisations AddOrganisationRequest: required: true content: @@ -501,8 +646,32 @@ components: contacts: $ref: "#/components/schemas/OrganisationContacts" + TagOrganisationRequest: + required: true + content: + application/json: + schema: + type: object + properties: + tag_list: + type: string + description: "Stringified JSON array of the tag names to add." + example: '["red"]' + + UntagOrganisationRequest: + required: true + content: + application/json: + schema: + type: object + properties: + tag_list: + type: string + description: "Stringified JSON array of the tag names to remove." + example: '["red"]' + responses: - # User + # Users UserResponse: description: "User response" content: @@ -517,6 +686,7 @@ components: schema: $ref: "#/components/schemas/UserList" + # Organisations OrganisationResponse: description: "Organisation response" content: @@ -524,6 +694,28 @@ components: schema: $ref: "#/components/schemas/Organisation" + OrganisationListResponse: + description: "Organisations list response" + content: + application/json: + schema: + $ref: "#/components/schemas/OrganisationList" + + # Tags + TagResponse: + description: "Tag response" + content: + application/json: + schema: + $ref: "#/components/schemas/Tag" + + TagListResponse: + description: "Tags list response" + content: + application/json: + schema: + $ref: "#/components/schemas/TagList" + # Errors ApiErrorResponse: description: "Unexpected API error" From 5906f6d2c768cf77d7fac45488a66fbc7dec554f Mon Sep 17 00:00:00 2001 From: Luciano Righetti Date: Tue, 11 Jan 2022 17:17:04 +0100 Subject: [PATCH 104/150] add: add individuals api tests and extend openapi spec --- tests/Fixture/IndividualsFixture.php | 18 +- tests/Fixture/OrganisationsFixture.php | 3 - .../Api/Individuals/AddIndividualApiTest.php | 71 ++++++ .../Individuals/DeleteIndividualApiTest.php | 59 +++++ .../Api/Individuals/EditIndividualApiTest.php | 73 ++++++ .../Individuals/IndexIndividualsApiTest.php | 44 ++++ .../Organisations/AddOrganisationApiTest.php | 2 +- .../DeleteOrganisationApiTest.php | 2 +- .../Organisations/EditOrganisationApiTest.php | 2 +- .../Organisations/TagOrganisationApiTest.php | 2 +- .../UntagOrganisationApiTest.php | 2 +- .../Organisations/ViewOrganisationApiTest.php | 48 ++++ tests/TestCase/Api/Users/AddUserApiTest.php | 2 +- .../TestCase/Api/Users/DeleteUserApiTest.php | 2 +- tests/TestCase/Api/Users/EditUserApiTest.php | 2 +- webroot/docs/openapi.yaml | 225 +++++++++++++++++- 16 files changed, 537 insertions(+), 20 deletions(-) create mode 100644 tests/TestCase/Api/Individuals/AddIndividualApiTest.php create mode 100644 tests/TestCase/Api/Individuals/DeleteIndividualApiTest.php create mode 100644 tests/TestCase/Api/Individuals/EditIndividualApiTest.php create mode 100644 tests/TestCase/Api/Individuals/IndexIndividualsApiTest.php create mode 100644 tests/TestCase/Api/Organisations/ViewOrganisationApiTest.php diff --git a/tests/Fixture/IndividualsFixture.php b/tests/Fixture/IndividualsFixture.php index 9c96f8b..13aecdf 100644 --- a/tests/Fixture/IndividualsFixture.php +++ b/tests/Fixture/IndividualsFixture.php @@ -10,17 +10,11 @@ class IndividualsFixture extends TestFixture { public $connection = 'test'; - // Admin individual public const INDIVIDUAL_ADMIN_ID = 1; - - // Sync individual public const INDIVIDUAL_SYNC_ID = 2; - - // Org Admin individual public const INDIVIDUAL_ORG_ADMIN_ID = 3; - - // Regular User individual public const INDIVIDUAL_REGULAR_USER_ID = 4; + public const INDIVIDUAL_A_ID = 5; public function init(): void { @@ -66,6 +60,16 @@ class IndividualsFixture extends TestFixture 'position' => 'user', 'created' => $faker->dateTime()->getTimestamp(), 'modified' => $faker->dateTime()->getTimestamp() + ], + [ + 'id' => self::INDIVIDUAL_A_ID, + 'uuid' => $faker->uuid(), + 'email' => $faker->email(), + 'first_name' => $faker->firstName, + 'last_name' => $faker->lastName, + 'position' => 'user', + 'created' => $faker->dateTime()->getTimestamp(), + 'modified' => $faker->dateTime()->getTimestamp() ] ]; parent::init(); diff --git a/tests/Fixture/OrganisationsFixture.php b/tests/Fixture/OrganisationsFixture.php index d8b81f9..7b7439e 100644 --- a/tests/Fixture/OrganisationsFixture.php +++ b/tests/Fixture/OrganisationsFixture.php @@ -11,10 +11,7 @@ class OrganisationsFixture extends TestFixture { public $connection = 'test'; - // Organisation A public const ORGANISATION_A_ID = 1; - - // Organisation B public const ORGANISATION_B_ID = 2; public function init(): void diff --git a/tests/TestCase/Api/Individuals/AddIndividualApiTest.php b/tests/TestCase/Api/Individuals/AddIndividualApiTest.php new file mode 100644 index 0000000..64855e8 --- /dev/null +++ b/tests/TestCase/Api/Individuals/AddIndividualApiTest.php @@ -0,0 +1,71 @@ +initializeValidator(APP . '../webroot/docs/openapi.yaml'); + } + + public function testAddIndividual(): void + { + $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + $this->post( + self::ENDPOINT, + [ + 'email' => 'john@example.com', + 'first_name' => 'John', + 'last_name' => 'Doe', + 'position' => 'Security Analyst' + ] + ); + + $this->assertResponseOk(); + $this->assertResponseContains('"email": "john@example.com"'); + $this->assertDbRecordExists('Individuals', ['email' => 'john@example.com']); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'post'); + } + + public function testAddUserNotAllowedAsRegularUser(): void + { + $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); + $this->post( + self::ENDPOINT, + [ + 'email' => 'john@example.com', + 'first_name' => 'John', + 'last_name' => 'Doe', + 'position' => 'Security Analyst' + ] + ); + + $this->assertResponseCode(405); + $this->assertDbRecordNotExists('Individuals', ['email' => 'john@example.com']); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'post'); + } +} diff --git a/tests/TestCase/Api/Individuals/DeleteIndividualApiTest.php b/tests/TestCase/Api/Individuals/DeleteIndividualApiTest.php new file mode 100644 index 0000000..c493c4b --- /dev/null +++ b/tests/TestCase/Api/Individuals/DeleteIndividualApiTest.php @@ -0,0 +1,59 @@ +initializeValidator(APP . '../webroot/docs/openapi.yaml'); + } + + public function testDeleteIndividual(): void + { + $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + $url = sprintf('%s/%d', self::ENDPOINT, IndividualsFixture::INDIVIDUAL_A_ID); + $this->delete($url); + + $this->assertResponseOk(); + $this->assertDbRecordNotExists('Individuals', ['id' => IndividualsFixture::INDIVIDUAL_A_ID]); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url, 'delete'); + $this->addWarning('TODO: CRUDComponent::delete() sets some view variables, does not take into account `isRest()`, fix it.'); + } + + public function testDeleteIndividualNotAllowedAsRegularUser(): void + { + $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); + $url = sprintf('%s/%d', self::ENDPOINT, IndividualsFixture::INDIVIDUAL_ADMIN_ID); + $this->delete($url); + + $this->assertResponseCode(405); + $this->assertDbRecordExists('Individuals', ['id' => IndividualsFixture::INDIVIDUAL_ADMIN_ID]); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url, 'delete'); + $this->addWarning('TODO: CRUDComponent::delete() sets some view variables, does not take into account `isRest()`, fix it.'); + } +} diff --git a/tests/TestCase/Api/Individuals/EditIndividualApiTest.php b/tests/TestCase/Api/Individuals/EditIndividualApiTest.php new file mode 100644 index 0000000..fcef7fd --- /dev/null +++ b/tests/TestCase/Api/Individuals/EditIndividualApiTest.php @@ -0,0 +1,73 @@ +initializeValidator(APP . '../webroot/docs/openapi.yaml'); + } + + public function testEditIndividualAsAdmin(): void + { + $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + $url = sprintf('%s/%d', self::ENDPOINT, IndividualsFixture::INDIVIDUAL_REGULAR_USER_ID); + $this->put( + $url, + [ + 'email' => 'foo@bar.com', + ] + ); + + $this->assertResponseOk(); + $this->assertDbRecordExists('Individuals', [ + 'id' => IndividualsFixture::INDIVIDUAL_REGULAR_USER_ID, + 'email' => 'foo@bar.com' + ]); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url, 'put'); + } + + public function testEditAnyIndividualNotAllowedAsRegularUser(): void + { + $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); + $url = sprintf('%s/%d', self::ENDPOINT, IndividualsFixture::INDIVIDUAL_ADMIN_ID); + $this->put( + $url, + [ + 'email' => 'foo@bar.com', + ] + ); + + $this->assertResponseCode(405); + $this->assertDbRecordNotExists('Individuals', [ + 'id' => IndividualsFixture::INDIVIDUAL_ADMIN_ID, + 'email' => 'foo@bar.com' + ]); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url, 'put'); + } +} diff --git a/tests/TestCase/Api/Individuals/IndexIndividualsApiTest.php b/tests/TestCase/Api/Individuals/IndexIndividualsApiTest.php new file mode 100644 index 0000000..55f6be1 --- /dev/null +++ b/tests/TestCase/Api/Individuals/IndexIndividualsApiTest.php @@ -0,0 +1,44 @@ +initializeValidator(APP . '../webroot/docs/openapi.yaml'); + } + + public function testIndexIndividuals(): void + { + $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + $this->get(self::ENDPOINT); + + $this->assertResponseOk(); + $this->assertResponseContains(sprintf('"id": %d', IndividualsFixture::INDIVIDUAL_ADMIN_ID)); + // TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT); + } +} diff --git a/tests/TestCase/Api/Organisations/AddOrganisationApiTest.php b/tests/TestCase/Api/Organisations/AddOrganisationApiTest.php index 4f43a09..a3f2585 100644 --- a/tests/TestCase/Api/Organisations/AddOrganisationApiTest.php +++ b/tests/TestCase/Api/Organisations/AddOrganisationApiTest.php @@ -57,7 +57,7 @@ class AddOrganisationApiTest extends TestCase $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'post'); } - public function testAddOrganisationNotAllowedToRegularUser(): void + public function testAddOrganisationNotAllowedAsRegularUser(): void { $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); diff --git a/tests/TestCase/Api/Organisations/DeleteOrganisationApiTest.php b/tests/TestCase/Api/Organisations/DeleteOrganisationApiTest.php index 9840faf..e16f57b 100644 --- a/tests/TestCase/Api/Organisations/DeleteOrganisationApiTest.php +++ b/tests/TestCase/Api/Organisations/DeleteOrganisationApiTest.php @@ -44,7 +44,7 @@ class DeleteOrganisationApiTest extends TestCase $this->addWarning('TODO: CRUDComponent::delete() sets some view variables, does not take into account `isRest()`, fix it.'); } - public function testDeleteOrganisationNotAllowedToRegularUser(): void + public function testDeleteOrganisationNotAllowedAsRegularUser(): void { $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); $url = sprintf('%s/%d', self::ENDPOINT, OrganisationsFixture::ORGANISATION_B_ID); diff --git a/tests/TestCase/Api/Organisations/EditOrganisationApiTest.php b/tests/TestCase/Api/Organisations/EditOrganisationApiTest.php index 61b2cab..d9cc7c6 100644 --- a/tests/TestCase/Api/Organisations/EditOrganisationApiTest.php +++ b/tests/TestCase/Api/Organisations/EditOrganisationApiTest.php @@ -55,7 +55,7 @@ class EditOrganisationApiTest extends TestCase $this->assertResponseMatchesOpenApiSpec($url, 'put'); } - public function testEditOrganisationNotAllowedToRegularUser(): void + public function testEditOrganisationNotAllowedAsRegularUser(): void { $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); diff --git a/tests/TestCase/Api/Organisations/TagOrganisationApiTest.php b/tests/TestCase/Api/Organisations/TagOrganisationApiTest.php index e23fcbc..ed74c74 100644 --- a/tests/TestCase/Api/Organisations/TagOrganisationApiTest.php +++ b/tests/TestCase/Api/Organisations/TagOrganisationApiTest.php @@ -59,7 +59,7 @@ class TagOrganisationApiTest extends TestCase $this->assertResponseMatchesOpenApiSpec($url, 'post'); } - public function testTagOrganisationNotAllowedToRegularUser(): void + public function testTagOrganisationNotAllowedAsRegularUser(): void { $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); diff --git a/tests/TestCase/Api/Organisations/UntagOrganisationApiTest.php b/tests/TestCase/Api/Organisations/UntagOrganisationApiTest.php index 0c23ca4..c8878d6 100644 --- a/tests/TestCase/Api/Organisations/UntagOrganisationApiTest.php +++ b/tests/TestCase/Api/Organisations/UntagOrganisationApiTest.php @@ -59,7 +59,7 @@ class UntagOrganisationApiTest extends TestCase $this->assertResponseMatchesOpenApiSpec($url, 'post'); } - public function testUntagOrganisationNotAllowedToRegularUser(): void + public function testUntagOrganisationNotAllowedAsRegularUser(): void { $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); diff --git a/tests/TestCase/Api/Organisations/ViewOrganisationApiTest.php b/tests/TestCase/Api/Organisations/ViewOrganisationApiTest.php new file mode 100644 index 0000000..630fd92 --- /dev/null +++ b/tests/TestCase/Api/Organisations/ViewOrganisationApiTest.php @@ -0,0 +1,48 @@ +initializeValidator(APP . '../webroot/docs/openapi.yaml'); + } + + public function testViewOrganisationById(): void + { + $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + $url = sprintf('%s/%d', self::ENDPOINT, OrganisationsFixture::ORGANISATION_A_ID); + $this->get($url); + + $this->assertResponseOk(); + $this->assertResponseContains('"name": "Organisation A"'); + // TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url); + } +} diff --git a/tests/TestCase/Api/Users/AddUserApiTest.php b/tests/TestCase/Api/Users/AddUserApiTest.php index 2d23f2a..e60cf3a 100644 --- a/tests/TestCase/Api/Users/AddUserApiTest.php +++ b/tests/TestCase/Api/Users/AddUserApiTest.php @@ -55,7 +55,7 @@ class AddUserApiTest extends TestCase $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'post'); } - public function testAddUserNotAllowedToRegularUser(): void + public function testAddUserNotAllowedAsRegularUser(): void { $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); $this->post( diff --git a/tests/TestCase/Api/Users/DeleteUserApiTest.php b/tests/TestCase/Api/Users/DeleteUserApiTest.php index 47a0580..69bd87c 100644 --- a/tests/TestCase/Api/Users/DeleteUserApiTest.php +++ b/tests/TestCase/Api/Users/DeleteUserApiTest.php @@ -46,7 +46,7 @@ class DeleteUserApiTest extends TestCase $this->addWarning('TODO: CRUDComponent::delete() sets some view variables, does not take into account `isRest()`, fix it.'); } - public function testDeleteUserNotAllowedToRegularUser(): void + public function testDeleteUserNotAllowedAsRegularUser(): void { $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); $url = sprintf('%s/%d', self::ENDPOINT, UsersFixture::USER_ORG_ADMIN_ID); diff --git a/tests/TestCase/Api/Users/EditUserApiTest.php b/tests/TestCase/Api/Users/EditUserApiTest.php index 6154c00..a8afa11 100644 --- a/tests/TestCase/Api/Users/EditUserApiTest.php +++ b/tests/TestCase/Api/Users/EditUserApiTest.php @@ -53,7 +53,7 @@ class EditUserApiTest extends TestCase $this->assertResponseMatchesOpenApiSpec($url, 'put'); } - public function testEditRoleNotAllowedToRegularUser(): void + public function testEditRoleNotAllowedAsRegularUser(): void { $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); $this->put( diff --git a/webroot/docs/openapi.yaml b/webroot/docs/openapi.yaml index f76a861..d74ad68 100644 --- a/webroot/docs/openapi.yaml +++ b/webroot/docs/openapi.yaml @@ -9,6 +9,8 @@ servers: - url: https://cerebrate.local tags: + - name: Individuals + description: "Individuals are natural persons. They are meant to describe the basic information about an individual that may or may not be a user of this community. Users in genral require an individual object to identify the person behind them - however, no user account is required to store information about an individual. Individuals can have affiliations to organisations and broods as well as cryptographic keys, using which their messages can be verified and which can be used to securely contact them." - name: Users description: "Users enrolled in this Cerebrate instance." - name: Organisations @@ -17,12 +19,88 @@ tags: description: "Tags can be attached to entity to quickly classify them, allowing further filtering and searches." paths: + /api/v1/individuals/index: + get: + summary: "Get individuals list" + operationId: getIndividuals + tags: + - Individuals + parameters: + - $ref: "#/components/parameters/quickFilter" + responses: + "200": + $ref: "#/components/responses/IndividualListResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + + /api/v1/individuals/add: + post: + summary: "Add individual" + operationId: addIndividual + tags: + - Users + requestBody: + $ref: "#/components/requestBodies/AddIndividualRequest" + responses: + "200": + $ref: "#/components/responses/IndividualResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + + /api/v1/individuals/edit/{individualId}: + put: + summary: "Edit individual" + operationId: editIndividual + tags: + - Individuals + parameters: + - $ref: "#/components/parameters/individualId" + requestBody: + $ref: "#/components/requestBodies/EditIndividualRequest" + responses: + "200": + $ref: "#/components/responses/IndividualResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + + /api/v1/individuals/delete/{individualId}: + delete: + summary: "Delete individual by ID" + operationId: deleteIndividualById + tags: + - Individuals + parameters: + - $ref: "#/components/parameters/individualId" + responses: + "200": + $ref: "#/components/responses/IndividualResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + /api/v1/users/index: get: summary: "Get users list" operationId: getUsers tags: - Users + parameters: + - $ref: "#/components/parameters/quickFilter" responses: "200": $ref: "#/components/responses/UserListResponse" @@ -185,6 +263,8 @@ paths: operationId: getOrganisations tags: - Organisations + parameters: + - $ref: "#/components/parameters/quickFilter" responses: "200": $ref: "#/components/responses/OrganisationListResponse" @@ -195,6 +275,24 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" + /api/v1/organisations/view/{organisationId}: + get: + summary: "View organisation by ID" + operationId: getOrganisationById + tags: + - Organisations + parameters: + - $ref: "#/components/parameters/organisationId" + responses: + "200": + $ref: "#/components/responses/OrganisationResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + /api/v1/organisations/delete/{organisationId}: delete: summary: "Delete organisation by ID" @@ -259,6 +357,8 @@ paths: operationId: getTags tags: - Tags + parameters: + - $ref: "#/components/parameters/quickFilter" responses: "200": $ref: "#/components/responses/TagListResponse" @@ -288,11 +388,64 @@ components: format: datetime example: "2022-01-05T11:19:26+00:00" + Email: + type: string + format: email + example: "user@example.com" + + # Individuals + IndividualFirstName: + type: string + example: "John" + + IndividualLastName: + type: string + example: "Doe" + + IndividualFullName: + type: string + example: "John Doe" + + IndividualPosition: + type: string + example: "Security Analyst" + + Individual: + type: object + properties: + id: + $ref: "#/components/schemas/ID" + uuid: + $ref: "#/components/schemas/UUID" + email: + $ref: "#/components/schemas/Email" + first_name: + $ref: "#/components/schemas/IndividualFirstName" + last_name: + $ref: "#/components/schemas/IndividualLastName" + full_name: + $ref: "#/components/schemas/IndividualFullName" + position: + $ref: "#/components/schemas/IndividualPosition" + tags: + $ref: "#/components/schemas/TagList" + aligments: + $ref: "#/components/schemas/AligmentList" + created: + $ref: "#/components/schemas/DateTime" + modified: + $ref: "#/components/schemas/DateTime" + # Users Username: type: string example: "admin" + IndividualList: + type: array + items: + $ref: "#/components/schemas/Individual" + User: type: object properties: @@ -320,8 +473,6 @@ components: items: $ref: "#/components/schemas/User" - # Individuals - # Organisations OrganisationName: type: string @@ -533,6 +684,14 @@ components: example: 404 parameters: + individualId: + name: userId + in: path + description: "Numeric ID of the User" + required: true + schema: + $ref: "#/components/schemas/ID" + userId: name: userId in: path @@ -549,6 +708,14 @@ components: schema: $ref: "#/components/schemas/ID" + quickFilter: + name: quickFilter + in: query + description: "Quick filter used to match multiple attributes such as name, description, emails, etc." + schema: + type: string + example: "user@example.com" + securitySchemes: ApiKeyAuth: type: apiKey @@ -560,6 +727,43 @@ components: Authorization: YOUR_API_KEY requestBodies: + # Individuals + AddIndividualRequest: + required: true + content: + application/json: + schema: + type: object + properties: + uuid: + $ref: "#/components/schemas/UUID" + email: + $ref: "#/components/schemas/IndividualLastName" + first_name: + $ref: "#/components/schemas/IndividualFirstName" + last_name: + type: boolean + position: + $ref: "#/components/schemas/IndividualPosition" + + EditIndividualRequest: + required: true + content: + application/json: + schema: + type: object + properties: + uuid: + $ref: "#/components/schemas/UUID" + email: + $ref: "#/components/schemas/IndividualLastName" + first_name: + $ref: "#/components/schemas/IndividualFirstName" + last_name: + type: boolean + position: + $ref: "#/components/schemas/IndividualPosition" + # Users AddUserRequest: required: true @@ -588,6 +792,8 @@ components: schema: type: object properties: + id: + $ref: "#/components/schemas/ID" individual_id: $ref: "#/components/schemas/ID" organisation_id: @@ -671,6 +877,21 @@ components: example: '["red"]' responses: + # Individuals + IndividualResponse: + description: "Individual response" + content: + application/json: + schema: + $ref: "#/components/schemas/Individual" + + IndividualListResponse: + description: "Individuals list response" + content: + application/json: + schema: + $ref: "#/components/schemas/IndividualList" + # Users UserResponse: description: "User response" From 204c60f739e32364cdd2cdcbcb2e5ddfd3569358 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 12 Jan 2022 10:31:06 +0100 Subject: [PATCH 105/150] fix: [ACL] fixed ACL check on user edit for the admin permission - invalid name used for the lookup (perm_side_admin instead of perm_admin) leading to incorrect downgrading of the permissions --- src/Controller/AppController.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Controller/AppController.php b/src/Controller/AppController.php index 4d41bac..0bb2373 100644 --- a/src/Controller/AppController.php +++ b/src/Controller/AppController.php @@ -54,7 +54,6 @@ class AppController extends Controller public function initialize(): void { parent::initialize(); - $this->loadComponent('RequestHandler'); $this->loadComponent('Flash'); $this->loadComponent('RestResponse'); From 87723c2100fe36232fa52a9b9f296a76c99e9524 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 12 Jan 2022 10:32:47 +0100 Subject: [PATCH 106/150] fix: [ACL] added correct file for previous fix (user edit admin permission check) --- src/Controller/UsersController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Controller/UsersController.php b/src/Controller/UsersController.php index 6331d7b..36a69cf 100644 --- a/src/Controller/UsersController.php +++ b/src/Controller/UsersController.php @@ -97,7 +97,7 @@ class UsersController extends AppController public function edit($id = false) { $currentUser = $this->ACL->getUser(); - if (empty($id) || (empty($currentUser['role']['perm_org_admin']) && empty($currentUser['role']['perm_site_admin']))) { + if (empty($id) || (empty($currentUser['role']['perm_org_admin']) && empty($currentUser['role']['perm_admin']))) { $id = $currentUser['id']; } From c8fd8f4a62674cd64d16019558bc37e9b34e76c9 Mon Sep 17 00:00:00 2001 From: Luciano Righetti Date: Thu, 13 Jan 2022 16:34:43 +0100 Subject: [PATCH 107/150] add: add basic api coverage of inbox processor endpoint, extend openapi spec --- tests/Fixture/InboxFixture.php | 61 ++++++ .../Api/Inbox/CreateInboxEntryApiTest.php | 82 +++++++ .../TestCase/Api/Inbox/IndexInboxApiTest.php | 46 ++++ webroot/docs/openapi.yaml | 203 +++++++++++++++++- 4 files changed, 391 insertions(+), 1 deletion(-) create mode 100644 tests/Fixture/InboxFixture.php create mode 100644 tests/TestCase/Api/Inbox/CreateInboxEntryApiTest.php create mode 100644 tests/TestCase/Api/Inbox/IndexInboxApiTest.php diff --git a/tests/Fixture/InboxFixture.php b/tests/Fixture/InboxFixture.php new file mode 100644 index 0000000..40303b2 --- /dev/null +++ b/tests/Fixture/InboxFixture.php @@ -0,0 +1,61 @@ +records = [ + [ + 'id' => self::INBOX_USER_REGISTRATION_ID, + 'uuid' => $faker->uuid(), + 'scope' => 'User', + 'action' => 'Registration', + 'title' => 'User account creation requested for foo@bar.com', + 'origin' => '::1', + 'comment' => null, + 'description' => 'Handle user account for this cerebrate instance', + 'user_id' => UsersFixture::USER_ADMIN_ID, + 'data' => [ + 'email' => 'foo@bar.com', + 'password' => '$2y$10$dr5C0MWgBx1723yyws0HPudTqHz4k8wJ1PQ1ApVkNuH64LuZAr\/ve', + ], + 'created' => $faker->dateTime()->getTimestamp(), + 'modified' => $faker->dateTime()->getTimestamp() + ], + [ + 'id' => self::INBOX_INCOMING_CONNECTION_REQUEST_ID, + 'uuid' => $faker->uuid(), + 'scope' => 'LocalTool', + 'action' => 'IncomingConnectionRequest', + 'title' => 'Request for MISP Inter-connection', + 'origin' => 'http://127.0.0.1', + 'comment' => null, + 'description' => 'Handle Phase I of inter-connection when another cerebrate instance performs the request.', + 'user_id' => UsersFixture::USER_ORG_ADMIN_ID, + 'data' => [ + 'connectorName' => 'MispConnector', + 'cerebrateURL' => 'http://127.0.0.1', + 'local_tool_id' => 1, + 'remote_tool_id' => 1, + ], + 'created' => $faker->dateTime()->getTimestamp(), + 'modified' => $faker->dateTime()->getTimestamp() + ], + ]; + parent::init(); + } +} diff --git a/tests/TestCase/Api/Inbox/CreateInboxEntryApiTest.php b/tests/TestCase/Api/Inbox/CreateInboxEntryApiTest.php new file mode 100644 index 0000000..30a44c2 --- /dev/null +++ b/tests/TestCase/Api/Inbox/CreateInboxEntryApiTest.php @@ -0,0 +1,82 @@ +initializeValidator(APP . '../webroot/docs/openapi.yaml'); + } + + public function testAddUserRegistrationInbox(): void + { + $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + + // to avoid $this->request->clientIp() to return null + $_SERVER['REMOTE_ADDR'] = '::1'; + + $url = sprintf("%s/%s/%s", self::ENDPOINT, 'User', 'Registration'); + $this->post( + $url, + [ + 'email' => 'john@example.com', + 'password' => 'Password12345!' + ] + ); + + $this->assertResponseOk(); + $this->assertResponseContains('"email": "john@example.com"'); + $this->assertDbRecordExists( + 'Inbox', + [ + 'id' => 3, // hacky, but `data` is json string cannot verify the value because of the hashed password + 'scope' => 'User', + 'action' => 'Registration', + ] + ); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url, 'post'); + } + + public function testAddUserRegistrationInboxNotAllowedAsRegularUser(): void + { + $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); + + $url = sprintf("%s/%s/%s", self::ENDPOINT, 'User', 'Registration'); + $this->post( + $url, + [ + 'email' => 'john@example.com', + 'password' => 'Password12345!' + ] + ); + + $this->assertResponseCode(405); + $this->assertDbRecordNotExists('Inbox', ['id' => 3]); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url, 'post'); + } +} diff --git a/tests/TestCase/Api/Inbox/IndexInboxApiTest.php b/tests/TestCase/Api/Inbox/IndexInboxApiTest.php new file mode 100644 index 0000000..00c2cfd --- /dev/null +++ b/tests/TestCase/Api/Inbox/IndexInboxApiTest.php @@ -0,0 +1,46 @@ +initializeValidator(APP . '../webroot/docs/openapi.yaml'); + } + + public function testIndexInbox(): void + { + $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + $this->get(self::ENDPOINT); + + $this->assertResponseOk(); + $this->assertResponseContains(sprintf('"id": %d', InboxFixture::INBOX_USER_REGISTRATION_ID)); + $this->assertResponseContains(sprintf('"id": %d', InboxFixture::INBOX_INCOMING_CONNECTION_REQUEST_ID)); + // TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT); + } +} diff --git a/webroot/docs/openapi.yaml b/webroot/docs/openapi.yaml index d74ad68..59a0ecf 100644 --- a/webroot/docs/openapi.yaml +++ b/webroot/docs/openapi.yaml @@ -17,6 +17,8 @@ tags: description: "Organisations can be equivalent to legal entities or specific individual teams within such entities. Their purpose is to relate individuals to their affiliations and for release control of information using the Trust Circles." - name: Tags description: "Tags can be attached to entity to quickly classify them, allowing further filtering and searches." + - name: Inbox + description: "Inbox messages represent A list of requests to be manually processed." paths: /api/v1/individuals/index: @@ -369,6 +371,42 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" + /api/v1/inbox/index: + get: + summary: "Get inbox list" + operationId: getinbox + tags: + - Inbox + parameters: + - $ref: "#/components/parameters/quickFilter" + responses: + "200": + $ref: "#/components/responses/InboxListResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + + /api/v1/inbox/createEntry/User/Registration: + post: + summary: "Create user registration inbox entry" + operationId: createInboxEntry + tags: + - Inbox + requestBody: + $ref: "#/components/requestBodies/CreateUserRegistrationInboxEntryRequest" + responses: + "200": + $ref: "#/components/responses/CreateUserRegistrationInboxEntryResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + components: schemas: # General @@ -615,6 +653,109 @@ components: perm_org_admin: type: boolean + # Inbox + InboxScope: + type: string + enum: + - "User" + - "LocalTool" + + InboxAction: + type: string + enum: + - "Registration" + - "IncomingConnectionRequest" + - "AcceptedRequest" + - "DeclinedRequest" + + InboxTitle: + type: string + + InboxOrigin: + type: string + + InboxComment: + type: string + nullable: true + + InboxDescription: + type: string + nullable: true + + Inbox: + type: object + properties: + id: + $ref: "#/components/schemas/ID" + uuid: + $ref: "#/components/schemas/UUID" + scope: + $ref: "#/components/schemas/InboxScope" + action: + $ref: "#/components/schemas/InboxAction" + title: + $ref: "#/components/schemas/InboxTitle" + origin: + $ref: "#/components/schemas/InboxOrigin" + comment: + $ref: "#/components/schemas/InboxComment" + description: + $ref: "#/components/schemas/InboxDescription" + user_id: + $ref: "#/components/schemas/ID" + created: + $ref: "#/components/schemas/DateTime" + modified: + $ref: "#/components/schemas/DateTime" + + UserRegistrationInbox: + type: object + allOf: + - $ref: "#/components/schemas/Inbox" + - type: object + properties: + data: + type: object + properties: + email: + type: string + format: email + password: + type: string + user: + $ref: "#/components/schemas/User" + local_tool_connector_name: + type: string + nullable: true + + IncomingConnectionRequestInbox: + type: object + allOf: + - $ref: "#/components/schemas/Inbox" + - type: object + properties: + data: + type: object + properties: + connectorName: + type: string + enum: + - "MispConnector" + cerebrateURL: + type: string + example: "http://192.168.0.1" + local_tool_id: + type: integer + remote_tool_id: + type: integer + + InboxList: + type: array + items: + anyOf: + - $ref: "#/components/schemas/UserRegistrationInbox" + - $ref: "#/components/schemas/IncomingConnectionRequestInbox" + # Errors ApiError: type: object @@ -685,7 +826,7 @@ components: parameters: individualId: - name: userId + name: individualId in: path description: "Numeric ID of the User" required: true @@ -876,6 +1017,20 @@ components: description: "Stringified JSON array of the tag names to remove." example: '["red"]' + # Inbox + CreateUserRegistrationInboxEntryRequest: + description: "Create user registration inbox entry request" + content: + application/json: + schema: + type: object + properties: + email: + type: string + format: email + password: + type: string + responses: # Individuals IndividualResponse: @@ -937,6 +1092,52 @@ components: schema: $ref: "#/components/schemas/TagList" + # Inbox + UserRegistrationInboxResponse: + description: "User registration inbox response" + content: + application/json: + schema: + $ref: "#/components/schemas/UserRegistrationInbox" + + IncomingConnectionRequestInboxResponse: + description: "Incoming connection request inbox response" + content: + application/json: + schema: + $ref: "#/components/schemas/IncomingConnectionRequestInbox" + + InboxListResponse: + description: "Inbox list response" + content: + application/json: + schema: + $ref: "#/components/schemas/InboxList" + + CreateUserRegistrationInboxEntryResponse: + description: "Inbox response" + content: + application/json: + schema: + type: object + properties: + data: + allOf: + - $ref: "#/components/schemas/UserRegistrationInbox" + - properties: + local_tool_connector_name: + type: string + nullable: true + success: + type: boolean + message: + type: string + example: "User account creation requested. Please wait for an admin to approve your account." + errors: + type: array + items: + type: object + # Errors ApiErrorResponse: description: "Unexpected API error" From 2d05f9228dcfc4021c610dcd493c408e9b0bfe6b Mon Sep 17 00:00:00 2001 From: Luciano Righetti Date: Thu, 13 Jan 2022 16:57:05 +0100 Subject: [PATCH 108/150] add: some extra scopes and actions --- webroot/docs/openapi.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/webroot/docs/openapi.yaml b/webroot/docs/openapi.yaml index 59a0ecf..8cd48f1 100644 --- a/webroot/docs/openapi.yaml +++ b/webroot/docs/openapi.yaml @@ -659,6 +659,9 @@ components: enum: - "User" - "LocalTool" + - "Brood" + - "Proposal" + - "Synchronisation" InboxAction: type: string @@ -667,6 +670,10 @@ components: - "IncomingConnectionRequest" - "AcceptedRequest" - "DeclinedRequest" + - "Synchronisation" + - "OneWaySynchronization" + - "ProposalEdit" + - "DataExchange" InboxTitle: type: string From fa7316db3f0b5f1dc68be3ae934673ca3c54cd7e Mon Sep 17 00:00:00 2001 From: Luciano Righetti Date: Fri, 14 Jan 2022 14:43:21 +0100 Subject: [PATCH 109/150] add: add sharing groups api tests, extend openapi spec --- tests/Fixture/SharingGroupsFixture.php | 50 +++++ .../Api/Individuals/ViewIndividualApiTest.php | 45 ++++ .../DeleteSharingGroupApiTest.php | 60 ++++++ .../SharingGroups/EditSharingGroupApiTest.php | 83 ++++++++ .../IndexSharingGroupsApiTest.php | 46 +++++ .../SharingGroups/ViewSharingGroupApiTest.php | 46 +++++ webroot/docs/openapi.yaml | 194 +++++++++++++++++- 7 files changed, 523 insertions(+), 1 deletion(-) create mode 100644 tests/Fixture/SharingGroupsFixture.php create mode 100644 tests/TestCase/Api/Individuals/ViewIndividualApiTest.php create mode 100644 tests/TestCase/Api/SharingGroups/DeleteSharingGroupApiTest.php create mode 100644 tests/TestCase/Api/SharingGroups/EditSharingGroupApiTest.php create mode 100644 tests/TestCase/Api/SharingGroups/IndexSharingGroupsApiTest.php create mode 100644 tests/TestCase/Api/SharingGroups/ViewSharingGroupApiTest.php diff --git a/tests/Fixture/SharingGroupsFixture.php b/tests/Fixture/SharingGroupsFixture.php new file mode 100644 index 0000000..e12e021 --- /dev/null +++ b/tests/Fixture/SharingGroupsFixture.php @@ -0,0 +1,50 @@ +records = [ + [ + 'id' => self::SHARING_GROUP_A_ID, + 'uuid' => $faker->uuid(), + 'name' => 'Sharing Group A', + 'releasability' => 'Sharing Group A', + 'description' => 'Sharing Group A description', + 'organisation_id' => OrganisationsFixture::ORGANISATION_A_ID, + 'user_id' => UsersFixture::USER_ADMIN_ID, + 'active' => true, + 'local' => true, + 'created' => $faker->dateTime()->getTimestamp(), + 'modified' => $faker->dateTime()->getTimestamp() + ], + [ + 'id' => self::SHARING_GROUP_B_ID, + 'uuid' => $faker->uuid(), + 'name' => 'Sharing Group B', + 'releasability' => 'Sharing Group B', + 'description' => 'Sharing Group B description', + 'organisation_id' => OrganisationsFixture::ORGANISATION_B_ID, + 'user_id' => UsersFixture::USER_ADMIN_ID, + 'active' => true, + 'local' => true, + 'created' => $faker->dateTime()->getTimestamp(), + 'modified' => $faker->dateTime()->getTimestamp() + ], + ]; + parent::init(); + } +} diff --git a/tests/TestCase/Api/Individuals/ViewIndividualApiTest.php b/tests/TestCase/Api/Individuals/ViewIndividualApiTest.php new file mode 100644 index 0000000..4a0d4a4 --- /dev/null +++ b/tests/TestCase/Api/Individuals/ViewIndividualApiTest.php @@ -0,0 +1,45 @@ +initializeValidator(APP . '../webroot/docs/openapi.yaml'); + } + + public function testViewIndividualById(): void + { + $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + $url = sprintf('%s/%d', self::ENDPOINT, IndividualsFixture::INDIVIDUAL_ADMIN_ID); + $this->get($url); + + $this->assertResponseOk(); + $this->assertResponseContains(sprintf('"id": %d', IndividualsFixture::INDIVIDUAL_ADMIN_ID)); + // TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url); + } +} diff --git a/tests/TestCase/Api/SharingGroups/DeleteSharingGroupApiTest.php b/tests/TestCase/Api/SharingGroups/DeleteSharingGroupApiTest.php new file mode 100644 index 0000000..8b49f3b --- /dev/null +++ b/tests/TestCase/Api/SharingGroups/DeleteSharingGroupApiTest.php @@ -0,0 +1,60 @@ +initializeValidator(APP . '../webroot/docs/openapi.yaml'); + } + + public function testDeleteSharingGroup(): void + { + $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + $url = sprintf('%s/%d', self::ENDPOINT, SharingGroupsFixture::SHARING_GROUP_A_ID); + $this->delete($url); + + $this->assertResponseOk(); + $this->assertDbRecordNotExists('SharingGroups', ['id' => SharingGroupsFixture::SHARING_GROUP_A_ID]); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url, 'delete'); + $this->addWarning('TODO: CRUDComponent::delete() sets some view variables, does not take into account `isRest()`, fix it.'); + } + + public function testDeleteSharingGroupNotAllowedAsRegularUser(): void + { + $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); + $url = sprintf('%s/%d', self::ENDPOINT, SharingGroupsFixture::SHARING_GROUP_A_ID); + $this->delete($url); + + $this->assertResponseCode(405); + $this->assertDbRecordExists('SharingGroups', ['id' => SharingGroupsFixture::SHARING_GROUP_A_ID]); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url, 'delete'); + $this->addWarning('TODO: CRUDComponent::delete() sets some view variables, does not take into account `isRest()`, fix it.'); + } +} diff --git a/tests/TestCase/Api/SharingGroups/EditSharingGroupApiTest.php b/tests/TestCase/Api/SharingGroups/EditSharingGroupApiTest.php new file mode 100644 index 0000000..0cf6f05 --- /dev/null +++ b/tests/TestCase/Api/SharingGroups/EditSharingGroupApiTest.php @@ -0,0 +1,83 @@ +initializeValidator(APP . '../webroot/docs/openapi.yaml'); + } + + public function testEditSharingGroup(): void + { + $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + + $url = sprintf('%s/%d', self::ENDPOINT, SharingGroupsFixture::SHARING_GROUP_A_ID); + $this->put( + $url, + [ + 'name' => 'Test Sharing Group 4321', + ] + ); + + $this->assertResponseOk(); + $this->assertDbRecordExists( + 'SharingGroups', + [ + 'id' => SharingGroupsFixture::SHARING_GROUP_A_ID, + 'name' => 'Test Sharing Group 4321', + ] + ); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url, 'put'); + } + + public function testEditSharingGroupNotAllowedAsRegularUser(): void + { + $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); + + $url = sprintf('%s/%d', self::ENDPOINT, SharingGroupsFixture::SHARING_GROUP_B_ID); + $this->put( + $url, + [ + 'name' => 'Test Sharing Group 1234' + ] + ); + + $this->assertResponseCode(405); + $this->assertDbRecordNotExists( + 'SharingGroups', + [ + 'id' => SharingGroupsFixture::SHARING_GROUP_B_ID, + 'name' => 'Test Sharing Group 1234' + ] + ); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url, 'put'); + } +} diff --git a/tests/TestCase/Api/SharingGroups/IndexSharingGroupsApiTest.php b/tests/TestCase/Api/SharingGroups/IndexSharingGroupsApiTest.php new file mode 100644 index 0000000..85779aa --- /dev/null +++ b/tests/TestCase/Api/SharingGroups/IndexSharingGroupsApiTest.php @@ -0,0 +1,46 @@ +initializeValidator(APP . '../webroot/docs/openapi.yaml'); + } + + public function testIndexSharingGroups(): void + { + $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + $this->get(self::ENDPOINT); + + $this->assertResponseOk(); + $this->assertResponseContains(sprintf('"id": %d', SharingGroupsFixture::SHARING_GROUP_A_ID)); + $this->assertResponseContains(sprintf('"id": %d', SharingGroupsFixture::SHARING_GROUP_B_ID)); + // TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT); + } +} diff --git a/tests/TestCase/Api/SharingGroups/ViewSharingGroupApiTest.php b/tests/TestCase/Api/SharingGroups/ViewSharingGroupApiTest.php new file mode 100644 index 0000000..5f5e6d1 --- /dev/null +++ b/tests/TestCase/Api/SharingGroups/ViewSharingGroupApiTest.php @@ -0,0 +1,46 @@ +initializeValidator(APP . '../webroot/docs/openapi.yaml'); + } + + public function testVieSharingGroupById(): void + { + $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + $url = sprintf('%s/%d', self::ENDPOINT, SharingGroupsFixture::SHARING_GROUP_A_ID); + $this->get($url); + + $this->assertResponseOk(); + $this->assertResponseContains('"name": "Sharing Group A"'); + // TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url); + } +} diff --git a/webroot/docs/openapi.yaml b/webroot/docs/openapi.yaml index 8cd48f1..c8a8c70 100644 --- a/webroot/docs/openapi.yaml +++ b/webroot/docs/openapi.yaml @@ -19,6 +19,8 @@ tags: description: "Tags can be attached to entity to quickly classify them, allowing further filtering and searches." - name: Inbox description: "Inbox messages represent A list of requests to be manually processed." + - name: SharingGroups + description: "Sharing groups are distribution lists usable by tools that can exchange information with a list of trusted partners. Create recurring or ad hoc sharing groups and share them with the members of the sharing group." paths: /api/v1/individuals/index: @@ -39,6 +41,24 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" + /api/v1/individuals/view/{individualId}: + get: + summary: "Get individual by ID" + operationId: getIndividualById + tags: + - Individuals + parameters: + - $ref: "#/components/parameters/individualId" + responses: + "200": + $ref: "#/components/responses/IndividualResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + /api/v1/individuals/add: post: summary: "Add individual" @@ -131,7 +151,7 @@ paths: /api/v1/users/view/{userId}: get: - summary: "Get information of a user by id" + summary: "Get information of a user by ID" operationId: viewUserById tags: - Users @@ -407,6 +427,80 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" + /api/v1/sharingGroups/index: + get: + summary: "Get a sharing groups list" + operationId: getSharingGroups + tags: + - SharingGroups + parameters: + - $ref: "#/components/parameters/quickFilter" + responses: + "200": + $ref: "#/components/responses/SharingGroupListResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + + /api/v1/sharingGroups/view/{sharingGroupId}: + get: + summary: "Get sharing group by ID" + operationId: getSharingGroupById + tags: + - SharingGroups + parameters: + - $ref: "#/components/parameters/sharingGroupId" + responses: + "200": + $ref: "#/components/responses/SharingGroupResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + + /api/v1/sharingGroups/delete/{sharingGroupId}: + delete: + summary: "Delete sharing group by ID" + operationId: deleteSharingGroupById + tags: + - SharingGroups + parameters: + - $ref: "#/components/parameters/sharingGroupId" + responses: + "200": + $ref: "#/components/responses/SharingGroupResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + + /api/v1/sharingGroups/edit/{sharingGroupId}: + put: + summary: "Edit sharing group" + operationId: editSharingGroup + tags: + - SharingGroups + parameters: + - $ref: "#/components/parameters/sharingGroupId" + requestBody: + $ref: "#/components/requestBodies/EditSharingGroupRequest" + responses: + "200": + $ref: "#/components/responses/SharingGroupResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + components: schemas: # General @@ -763,6 +857,55 @@ components: - $ref: "#/components/schemas/UserRegistrationInbox" - $ref: "#/components/schemas/IncomingConnectionRequestInbox" + # SharingGroups + SharingGroupName: + type: string + + SharingGroupReleasability: + type: string + + SharingGroupDescription: + type: string + + SharingGroup: + type: object + properties: + id: + $ref: "#/components/schemas/ID" + uuid: + $ref: "#/components/schemas/UUID" + name: + $ref: "#/components/schemas/SharingGroupName" + releasability: + $ref: "#/components/schemas/SharingGroupReleasability" + description: + $ref: "#/components/schemas/SharingGroupDescription" + organisation_id: + $ref: "#/components/schemas/ID" + user_id: + $ref: "#/components/schemas/ID" + active: + type: boolean + local: + type: boolean + sharing_group_orgs: + type: array + items: + $ref: "#/components/schemas/Organisation" + user: + $ref: "#/components/schemas/User" + organisation: + $ref: "#/components/schemas/Organisation" + created: + $ref: "#/components/schemas/DateTime" + modified: + $ref: "#/components/schemas/DateTime" + + SharingGroupList: + type: array + items: + $ref: "#/components/schemas/SharingGroup" + # Errors ApiError: type: object @@ -856,6 +999,14 @@ components: schema: $ref: "#/components/schemas/ID" + sharingGroupId: + name: sharingGroupId + in: path + description: "Numeric ID of the Sharing Group" + required: true + schema: + $ref: "#/components/schemas/ID" + quickFilter: name: quickFilter in: query @@ -1038,6 +1189,31 @@ components: password: type: string + # SharingGroups + EditSharingGroupRequest: + required: true + content: + application/json: + schema: + type: object + properties: + uuid: + $ref: "#/components/schemas/UUID" + name: + $ref: "#/components/schemas/SharingGroupName" + releasability: + $ref: "#/components/schemas/SharingGroupReleasability" + description: + $ref: "#/components/schemas/SharingGroupDescription" + organisation_id: + $ref: "#/components/schemas/ID" + user_id: + $ref: "#/components/schemas/ID" + active: + type: boolean + local: + type: boolean + responses: # Individuals IndividualResponse: @@ -1144,6 +1320,22 @@ components: type: array items: type: object + # TODO: describe + + # SharingGroups + SharingGroupResponse: + description: "Sharing group response" + content: + application/json: + schema: + $ref: "#/components/schemas/SharingGroup" + + SharingGroupListResponse: + description: "Sharing groups list response" + content: + application/json: + schema: + $ref: "#/components/schemas/SharingGroupList" # Errors ApiErrorResponse: From 25ded7e3bfa4e970fd8bc07177ff92f32dedcd9b Mon Sep 17 00:00:00 2001 From: Luciano Righetti Date: Fri, 14 Jan 2022 17:43:53 +0100 Subject: [PATCH 110/150] add: more sharing groups api tests, add broods api tests, extend openapi spec --- tests/Fixture/BroodsFixture.php | 55 ++++ tests/Fixture/OrganisationsFixture.php | 1 - tests/Fixture/SharingGroupsFixture.php | 4 +- tests/TestCase/Api/Broods/AddBroodApiTest.php | 91 ++++++ .../Api/Broods/DeleteBroodsApiTest.php | 60 ++++ .../TestCase/Api/Broods/EditBroodApiTest.php | 83 ++++++ .../Api/Broods/IndexBroodsApiTest.php | 45 +++ .../TestCase/Api/Broods/ViewBroodApiTest.php | 46 +++ .../SharingGroups/AddSharingGroupApiTest.php | 90 ++++++ .../SharingGroups/ViewSharingGroupApiTest.php | 4 +- tests/bootstrap.php | 3 +- webroot/docs/openapi.yaml | 280 +++++++++++++++++- 12 files changed, 750 insertions(+), 12 deletions(-) create mode 100644 tests/Fixture/BroodsFixture.php create mode 100644 tests/TestCase/Api/Broods/AddBroodApiTest.php create mode 100644 tests/TestCase/Api/Broods/DeleteBroodsApiTest.php create mode 100644 tests/TestCase/Api/Broods/EditBroodApiTest.php create mode 100644 tests/TestCase/Api/Broods/IndexBroodsApiTest.php create mode 100644 tests/TestCase/Api/Broods/ViewBroodApiTest.php create mode 100644 tests/TestCase/Api/SharingGroups/AddSharingGroupApiTest.php diff --git a/tests/Fixture/BroodsFixture.php b/tests/Fixture/BroodsFixture.php new file mode 100644 index 0000000..adc035c --- /dev/null +++ b/tests/Fixture/BroodsFixture.php @@ -0,0 +1,55 @@ +records = [ + [ + 'id' => self::BROOD_A_ID, + 'uuid' => $faker->uuid(), + 'name' => 'Brood A', + 'url' => $faker->url, + 'description' => $faker->text, + 'organisation_id' => OrganisationsFixture::ORGANISATION_A_ID, + 'trusted' => true, + 'pull' => true, + 'skip_proxy' => true, + 'authkey' => self::BROOD_A_API_KEY, + 'created' => $faker->dateTime()->getTimestamp(), + 'modified' => $faker->dateTime()->getTimestamp() + ], + [ + 'id' => self::BROOD_B_ID, + 'uuid' => $faker->uuid(), + 'name' => 'Brood A', + 'url' => $faker->url, + 'description' => $faker->text, + 'organisation_id' => OrganisationsFixture::ORGANISATION_B_ID, + 'trusted' => true, + 'pull' => true, + 'skip_proxy' => true, + 'authkey' => self::BROOD_B_API_KEY, + 'created' => $faker->dateTime()->getTimestamp(), + 'modified' => $faker->dateTime()->getTimestamp() + ] + ]; + parent::init(); + } +} diff --git a/tests/Fixture/OrganisationsFixture.php b/tests/Fixture/OrganisationsFixture.php index 7b7439e..8531d0c 100644 --- a/tests/Fixture/OrganisationsFixture.php +++ b/tests/Fixture/OrganisationsFixture.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace App\Test\Fixture; use Cake\TestSuite\Fixture\TestFixture; -use Authentication\PasswordHasher\DefaultPasswordHasher; class OrganisationsFixture extends TestFixture { diff --git a/tests/Fixture/SharingGroupsFixture.php b/tests/Fixture/SharingGroupsFixture.php index e12e021..79665b6 100644 --- a/tests/Fixture/SharingGroupsFixture.php +++ b/tests/Fixture/SharingGroupsFixture.php @@ -22,7 +22,7 @@ class SharingGroupsFixture extends TestFixture 'id' => self::SHARING_GROUP_A_ID, 'uuid' => $faker->uuid(), 'name' => 'Sharing Group A', - 'releasability' => 'Sharing Group A', + 'releasability' => 'Sharing Group A releasability', 'description' => 'Sharing Group A description', 'organisation_id' => OrganisationsFixture::ORGANISATION_A_ID, 'user_id' => UsersFixture::USER_ADMIN_ID, @@ -35,7 +35,7 @@ class SharingGroupsFixture extends TestFixture 'id' => self::SHARING_GROUP_B_ID, 'uuid' => $faker->uuid(), 'name' => 'Sharing Group B', - 'releasability' => 'Sharing Group B', + 'releasability' => 'Sharing Group B releasability', 'description' => 'Sharing Group B description', 'organisation_id' => OrganisationsFixture::ORGANISATION_B_ID, 'user_id' => UsersFixture::USER_ADMIN_ID, diff --git a/tests/TestCase/Api/Broods/AddBroodApiTest.php b/tests/TestCase/Api/Broods/AddBroodApiTest.php new file mode 100644 index 0000000..099f3bd --- /dev/null +++ b/tests/TestCase/Api/Broods/AddBroodApiTest.php @@ -0,0 +1,91 @@ +initializeValidator(APP . '../webroot/docs/openapi.yaml'); + } + + public function testAddBrood(): void + { + $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + + $faker = \Faker\Factory::create(); + $uuid = $faker->uuid; + + $this->post( + self::ENDPOINT, + [ + 'uuid' => $uuid, + 'name' => 'Brood A', + 'url' => $faker->url, + 'description' => $faker->text, + 'organisation_id' => OrganisationsFixture::ORGANISATION_A_ID, + 'trusted' => true, + 'pull' => true, + 'skip_proxy' => true, + 'authkey' => $faker->sha1, + ] + ); + + $this->assertResponseOk(); + $this->assertResponseContains(sprintf('"uuid": "%s"', $uuid)); + $this->assertDbRecordExists('Broods', ['uuid' => $uuid]); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'post'); + } + + public function testAddBroodNotAllowedAsRegularUser(): void + { + $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); + + $faker = \Faker\Factory::create(); + $uuid = $faker->uuid; + + $this->post( + self::ENDPOINT, + [ + 'uuid' => $uuid, + 'name' => 'Brood A', + 'url' => $faker->url, + 'description' => $faker->text, + 'organisation_id' => OrganisationsFixture::ORGANISATION_A_ID, + 'trusted' => true, + 'pull' => true, + 'skip_proxy' => true, + 'authkey' => $faker->sha1, + ] + ); + + $this->assertResponseCode(405); + $this->assertDbRecordNotExists('Broods', ['uuid' => $uuid]); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'post'); + } +} diff --git a/tests/TestCase/Api/Broods/DeleteBroodsApiTest.php b/tests/TestCase/Api/Broods/DeleteBroodsApiTest.php new file mode 100644 index 0000000..1b8d2ce --- /dev/null +++ b/tests/TestCase/Api/Broods/DeleteBroodsApiTest.php @@ -0,0 +1,60 @@ +initializeValidator(APP . '../webroot/docs/openapi.yaml'); + } + + public function testDeleteBrood(): void + { + $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + $url = sprintf('%s/%d', self::ENDPOINT, BroodsFixture::BROOD_A_ID); + $this->delete($url); + + $this->assertResponseOk(); + $this->assertDbRecordNotExists('Broods', ['id' => BroodsFixture::BROOD_A_ID]); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url, 'delete'); + $this->addWarning('TODO: CRUDComponent::delete() sets some view variables, does not take into account `isRest()`, fix it.'); + } + + public function testDeleteBroodNotAllowedAsRegularUser(): void + { + $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); + $url = sprintf('%s/%d', self::ENDPOINT, BroodsFixture::BROOD_A_ID); + $this->delete($url); + + $this->assertResponseCode(405); + $this->assertDbRecordExists('Broods', ['id' => BroodsFixture::BROOD_A_ID]); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url, 'delete'); + $this->addWarning('TODO: CRUDComponent::delete() sets some view variables, does not take into account `isRest()`, fix it.'); + } +} diff --git a/tests/TestCase/Api/Broods/EditBroodApiTest.php b/tests/TestCase/Api/Broods/EditBroodApiTest.php new file mode 100644 index 0000000..bd15a39 --- /dev/null +++ b/tests/TestCase/Api/Broods/EditBroodApiTest.php @@ -0,0 +1,83 @@ +initializeValidator(APP . '../webroot/docs/openapi.yaml'); + } + + public function testEditBrood(): void + { + $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + + $url = sprintf('%s/%d', self::ENDPOINT, BroodsFixture::BROOD_A_ID); + $this->put( + $url, + [ + 'name' => 'Test Brood 4321', + ] + ); + + $this->assertResponseOk(); + $this->assertDbRecordExists( + 'Broods', + [ + 'id' => BroodsFixture::BROOD_A_ID, + 'name' => 'Test Brood 4321', + ] + ); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url, 'put'); + } + + public function testEditBroodNotAllowedAsRegularUser(): void + { + $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); + + $url = sprintf('%s/%d', self::ENDPOINT, BroodsFixture::BROOD_B_ID); + $this->put( + $url, + [ + 'name' => 'Test Brood 1234' + ] + ); + + $this->assertResponseCode(405); + $this->assertDbRecordNotExists( + 'Broods', + [ + 'id' => BroodsFixture::BROOD_B_ID, + 'name' => 'Test Brood 1234' + ] + ); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url, 'put'); + } +} diff --git a/tests/TestCase/Api/Broods/IndexBroodsApiTest.php b/tests/TestCase/Api/Broods/IndexBroodsApiTest.php new file mode 100644 index 0000000..97d0df8 --- /dev/null +++ b/tests/TestCase/Api/Broods/IndexBroodsApiTest.php @@ -0,0 +1,45 @@ +initializeValidator(APP . '../webroot/docs/openapi.yaml'); + } + + public function testIndexBroods(): void + { + $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + $this->get(self::ENDPOINT); + + $this->assertResponseOk(); + $this->assertResponseContains(sprintf('"id": %d', BroodsFixture::BROOD_A_ID)); + // TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT); + } +} diff --git a/tests/TestCase/Api/Broods/ViewBroodApiTest.php b/tests/TestCase/Api/Broods/ViewBroodApiTest.php new file mode 100644 index 0000000..b3f520d --- /dev/null +++ b/tests/TestCase/Api/Broods/ViewBroodApiTest.php @@ -0,0 +1,46 @@ +initializeValidator(APP . '../webroot/docs/openapi.yaml'); + } + + public function testViewBroodGroupById(): void + { + $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + $url = sprintf('%s/%d', self::ENDPOINT, BroodsFixture::BROOD_A_ID); + $this->get($url); + + $this->assertResponseOk(); + $this->assertResponseContains(sprintf('"id": %d', BroodsFixture::BROOD_A_ID)); + // TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url); + } +} diff --git a/tests/TestCase/Api/SharingGroups/AddSharingGroupApiTest.php b/tests/TestCase/Api/SharingGroups/AddSharingGroupApiTest.php new file mode 100644 index 0000000..45a79aa --- /dev/null +++ b/tests/TestCase/Api/SharingGroups/AddSharingGroupApiTest.php @@ -0,0 +1,90 @@ +initializeValidator(APP . '../webroot/docs/openapi.yaml'); + } + + public function testAddSharingGroup(): void + { + $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + + $faker = \Faker\Factory::create(); + $uuid = $faker->uuid; + + $this->post( + self::ENDPOINT, + [ + 'uuid' => $uuid, + 'name' => 'Test Sharing Group', + 'releasability' => 'Test Sharing Group releasability', + 'description' => 'Test Sharing Group description', + 'organisation_id' => OrganisationsFixture::ORGANISATION_A_ID, + 'user_id' => UsersFixture::USER_ADMIN_ID, + 'active' => true, + 'local' => true + ] + ); + + $this->assertResponseOk(); + $this->assertResponseContains(sprintf('"uuid": "%s"', $uuid)); + $this->assertDbRecordExists('SharingGroups', ['uuid' => $uuid]); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'post'); + } + + public function testAddSharingGroupNotAllowedAsRegularUser(): void + { + $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); + + $faker = \Faker\Factory::create(); + $uuid = $faker->uuid; + + $this->post( + self::ENDPOINT, + [ + 'uuid' => $uuid, + 'name' => 'Test Sharing Group', + 'releasability' => 'Sharing Group A', + 'description' => 'Sharing Group A description', + 'organisation_id' => OrganisationsFixture::ORGANISATION_A_ID, + 'user_id' => UsersFixture::USER_ADMIN_ID, + 'active' => true, + 'local' => true + ] + ); + + $this->assertResponseCode(405); + $this->assertDbRecordNotExists('SharingGroups', ['uuid' => $uuid]); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'post'); + } +} diff --git a/tests/TestCase/Api/SharingGroups/ViewSharingGroupApiTest.php b/tests/TestCase/Api/SharingGroups/ViewSharingGroupApiTest.php index 5f5e6d1..0e86fc7 100644 --- a/tests/TestCase/Api/SharingGroups/ViewSharingGroupApiTest.php +++ b/tests/TestCase/Api/SharingGroups/ViewSharingGroupApiTest.php @@ -32,14 +32,14 @@ class ViewSharingGroupApiTest extends TestCase $this->initializeValidator(APP . '../webroot/docs/openapi.yaml'); } - public function testVieSharingGroupById(): void + public function testViewSharingGroupById(): void { $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); $url = sprintf('%s/%d', self::ENDPOINT, SharingGroupsFixture::SHARING_GROUP_A_ID); $this->get($url); $this->assertResponseOk(); - $this->assertResponseContains('"name": "Sharing Group A"'); + $this->assertResponseContains(sprintf('"id": %d', SharingGroupsFixture::SHARING_GROUP_A_ID)); // TODO: $this->assertRequestMatchesOpenApiSpec(); $this->assertResponseMatchesOpenApiSpec($url); } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 6df9c27..c9a8b8c 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -56,6 +56,7 @@ session_id('cli'); // hacky way to skip migrations if (!in_array('skip-migrations', $_SERVER['argv'])) { + echo "[ * ] Running DB migrations, it may take some time ...\n"; $migrator = new Migrator(); $migrator->runMany([ ['connection' => 'test'], @@ -63,5 +64,5 @@ if (!in_array('skip-migrations', $_SERVER['argv'])) { ['plugin' => 'ADmad/SocialAuth', 'connection' => 'test'] ]); } else { - echo "[ * ] Skipping migrations ...\n"; + echo "[ * ] Skipping DB migrations ...\n"; } diff --git a/webroot/docs/openapi.yaml b/webroot/docs/openapi.yaml index c8a8c70..81ac9aa 100644 --- a/webroot/docs/openapi.yaml +++ b/webroot/docs/openapi.yaml @@ -21,6 +21,8 @@ tags: description: "Inbox messages represent A list of requests to be manually processed." - name: SharingGroups description: "Sharing groups are distribution lists usable by tools that can exchange information with a list of trusted partners. Create recurring or ad hoc sharing groups and share them with the members of the sharing group." + - name: Broods + description: "Cerebrate can connect to other Cerebrate instances to exchange trust information and to instrument interconnectivity between connected local tools. Each such Cerebrate instance with its connected tools is considered to be a brood." paths: /api/v1/individuals/index: @@ -66,7 +68,7 @@ paths: tags: - Users requestBody: - $ref: "#/components/requestBodies/AddIndividualRequest" + $ref: "#/components/requestBodies/CreateIndividualRequest" responses: "200": $ref: "#/components/responses/IndividualResponse" @@ -174,7 +176,7 @@ paths: tags: - Users requestBody: - $ref: "#/components/requestBodies/AddUserRequest" + $ref: "#/components/requestBodies/CreateUserRequest" responses: "200": $ref: "#/components/responses/UserResponse" @@ -248,7 +250,7 @@ paths: tags: - Organisations requestBody: - $ref: "#/components/requestBodies/AddOrganisationRequest" + $ref: "#/components/requestBodies/CreateOrganisationRequest" responses: "200": $ref: "#/components/responses/OrganisationResponse" @@ -445,6 +447,24 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" + /api/v1/sharingGroups/add: + post: + summary: "Add sharing group" + operationId: addSharingGroup + tags: + - SharingGroups + requestBody: + $ref: "#/components/requestBodies/CreateSharingGroupRequest" + responses: + "200": + $ref: "#/components/responses/IndividualResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + /api/v1/sharingGroups/view/{sharingGroupId}: get: summary: "Get sharing group by ID" @@ -501,6 +521,98 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" + /api/v1/broods/index: + get: + summary: "Get broods list" + operationId: getBroods + tags: + - Broods + parameters: + - $ref: "#/components/parameters/quickFilter" + responses: + "200": + $ref: "#/components/responses/BroodListResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + + /api/v1/broods/view/{broodId}: + get: + summary: "Get brood by ID" + operationId: getBroodById + tags: + - Broods + parameters: + - $ref: "#/components/parameters/broodId" + responses: + "200": + $ref: "#/components/responses/BroodResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + + /api/v1/broods/add: + post: + summary: "Add brood" + operationId: addBrood + tags: + - Broods + requestBody: + $ref: "#/components/requestBodies/CreateBroodRequest" + responses: + "200": + $ref: "#/components/responses/BroodResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + + /api/v1/broods/edit/{sharingGroupId}: + put: + summary: "Edit brood" + operationId: editBrood + tags: + - Broods + parameters: + - $ref: "#/components/parameters/broodId" + requestBody: + $ref: "#/components/requestBodies/EditBroodRequest" + responses: + "200": + $ref: "#/components/responses/BroodResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + + /api/v1/broods/delete/{broodId}: + delete: + summary: "Delete brood by ID" + operationId: deleteBroodById + tags: + - Broods + parameters: + - $ref: "#/components/parameters/broodId" + responses: + "200": + $ref: "#/components/responses/BroodResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + components: schemas: # General @@ -525,6 +637,9 @@ components: format: email example: "user@example.com" + AuthKey: + type: string + # Individuals IndividualFirstName: type: string @@ -906,6 +1021,59 @@ components: items: $ref: "#/components/schemas/SharingGroup" + # Broods + BroodName: + type: string + + BroodDescription: + type: string + + BroodUrl: + type: string + + BroodIsTrusted: + type: boolean + description: "Trusted upstream source" + + BroodIsPull: + type: boolean + description: "Enable pulling of trust information" + + Brood: + type: object + properties: + id: + $ref: "#/components/schemas/ID" + uuid: + $ref: "#/components/schemas/UUID" + name: + $ref: "#/components/schemas/BroodName" + url: + $ref: "#/components/schemas/BroodUrl" + description: + $ref: "#/components/schemas/BroodDescription" + organisation_id: + $ref: "#/components/schemas/ID" + trusted: + $ref: "#/components/schemas/BroodIsTrusted" + pull: + $ref: "#/components/schemas/BroodIsPull" + skip_proxy: + type: boolean + authkey: + $ref: "#/components/schemas/AuthKey" + created: + $ref: "#/components/schemas/DateTime" + modified: + $ref: "#/components/schemas/DateTime" + organisation: + $ref: "#/components/schemas/Organisation" + + BroodList: + type: array + items: + $ref: "#/components/schemas/Brood" + # Errors ApiError: type: object @@ -1007,6 +1175,14 @@ components: schema: $ref: "#/components/schemas/ID" + broodId: + name: broodId + in: path + description: "Numeric ID of the Brood" + required: true + schema: + $ref: "#/components/schemas/ID" + quickFilter: name: quickFilter in: query @@ -1027,7 +1203,7 @@ components: requestBodies: # Individuals - AddIndividualRequest: + CreateIndividualRequest: required: true content: application/json: @@ -1064,7 +1240,7 @@ components: $ref: "#/components/schemas/IndividualPosition" # Users - AddUserRequest: + CreateUserRequest: required: true content: application/json: @@ -1107,7 +1283,7 @@ components: type: string # Organisations - AddOrganisationRequest: + CreateOrganisationRequest: required: true content: application/json: @@ -1190,6 +1366,30 @@ components: type: string # SharingGroups + CreateSharingGroupRequest: + required: true + content: + application/json: + schema: + type: object + properties: + uuid: + $ref: "#/components/schemas/UUID" + name: + $ref: "#/components/schemas/SharingGroupName" + releasability: + $ref: "#/components/schemas/SharingGroupReleasability" + description: + $ref: "#/components/schemas/SharingGroupDescription" + organisation_id: + $ref: "#/components/schemas/ID" + user_id: + $ref: "#/components/schemas/ID" + active: + type: boolean + local: + type: boolean + EditSharingGroupRequest: required: true content: @@ -1214,6 +1414,59 @@ components: local: type: boolean + # Broods + CreateBroodRequest: + required: true + content: + application/json: + schema: + type: object + properties: + uuid: + $ref: "#/components/schemas/UUID" + name: + $ref: "#/components/schemas/BroodName" + url: + $ref: "#/components/schemas/BroodUrl" + description: + $ref: "#/components/schemas/BroodDescription" + organisation_id: + $ref: "#/components/schemas/ID" + trusted: + $ref: "#/components/schemas/BroodIsTrusted" + pull: + $ref: "#/components/schemas/BroodIsPull" + skip_proxy: + type: boolean + authkey: + $ref: "#/components/schemas/AuthKey" + + EditBroodRequest: + required: true + content: + application/json: + schema: + type: object + properties: + uuid: + $ref: "#/components/schemas/UUID" + name: + $ref: "#/components/schemas/BroodName" + url: + $ref: "#/components/schemas/BroodUrl" + description: + $ref: "#/components/schemas/BroodDescription" + organisation_id: + $ref: "#/components/schemas/ID" + trusted: + $ref: "#/components/schemas/BroodIsTrusted" + pull: + $ref: "#/components/schemas/BroodIsPull" + skip_proxy: + type: boolean + authkey: + $ref: "#/components/schemas/AuthKey" + responses: # Individuals IndividualResponse: @@ -1337,6 +1590,21 @@ components: schema: $ref: "#/components/schemas/SharingGroupList" + # Broods + BroodResponse: + description: "Brood response" + content: + application/json: + schema: + $ref: "#/components/schemas/Brood" + + BroodListResponse: + description: "Brood list response" + content: + application/json: + schema: + $ref: "#/components/schemas/BroodList" + # Errors ApiErrorResponse: description: "Unexpected API error" From caf48c9060ba27e13533e8d702a989bb72285a8e Mon Sep 17 00:00:00 2001 From: iglocska Date: Mon, 17 Jan 2022 09:19:53 +0100 Subject: [PATCH 111/150] fix: [ACL] proper error messages on user edit - don't just silently redirect to the own user editing if the user isn't authorised to modify another user --- src/Controller/UsersController.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Controller/UsersController.php b/src/Controller/UsersController.php index 36a69cf..3e389de 100644 --- a/src/Controller/UsersController.php +++ b/src/Controller/UsersController.php @@ -97,8 +97,16 @@ class UsersController extends AppController public function edit($id = false) { $currentUser = $this->ACL->getUser(); - if (empty($id) || (empty($currentUser['role']['perm_org_admin']) && empty($currentUser['role']['perm_admin']))) { + if (empty($id)) { $id = $currentUser['id']; + } else { + if ((empty($currentUser['role']['perm_org_admin']) && empty($currentUser['role']['perm_admin']))) { + if ($id !== $currentUser['id']) { + throw new MethodNotAllowedException(__('You are not authorised to edit that user.')); + } else { + $id = $currentUser['id']; + } + } } $params = [ From 95cb4536e1cb371d566b16afc2d06f0dc8723932 Mon Sep 17 00:00:00 2001 From: iglocska Date: Mon, 17 Jan 2022 09:22:06 +0100 Subject: [PATCH 112/150] fix: [tagging] error when trying to add a tag that doesn't exist yet - add default colour to circumvent the error --- plugins/Tags/src/Model/Behavior/TagBehavior.php | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/plugins/Tags/src/Model/Behavior/TagBehavior.php b/plugins/Tags/src/Model/Behavior/TagBehavior.php index b27ded9..5772d82 100644 --- a/plugins/Tags/src/Model/Behavior/TagBehavior.php +++ b/plugins/Tags/src/Model/Behavior/TagBehavior.php @@ -46,10 +46,10 @@ class TagBehavior extends Behavior $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')) { @@ -114,7 +114,6 @@ class TagBehavior extends Behavior $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']); @@ -131,7 +130,6 @@ class TagBehavior extends Behavior if (!$tag->isNew()) { continue; } - $existingTag = $this->getExistingTag($tag->name); if (!$existingTag) { continue; @@ -176,15 +174,14 @@ class TagBehavior extends Behavior $result[] = array_merge($common, ['id' => $existingTag->id]); continue; } - $result[] = array_merge( $common, [ 'name' => $tagIdentifier, + 'colour' => '#924da6' ] ); } - return $result; } @@ -312,7 +309,7 @@ class TagBehavior extends Behavior $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(); @@ -352,4 +349,4 @@ class TagBehavior extends Behavior return $query; } -} \ No newline at end of file +} From 12d7607aae9978c0380ee7dd4b5a96d31dd928bd Mon Sep 17 00:00:00 2001 From: iglocska Date: Mon, 17 Jan 2022 09:45:45 +0100 Subject: [PATCH 113/150] new: [encryption key] view added - was missing, despite links to it --- src/Controller/EncryptionKeysController.php | 12 +++++++ templates/EncryptionKeys/view.php | 32 +++++++++++++++++++ .../SingleViews/Fields/ownerField.php | 6 ++++ 3 files changed, 50 insertions(+) create mode 100644 templates/EncryptionKeys/view.php create mode 100644 templates/element/genericElements/SingleViews/Fields/ownerField.php diff --git a/src/Controller/EncryptionKeysController.php b/src/Controller/EncryptionKeysController.php index bafe8ce..57dfc4c 100644 --- a/src/Controller/EncryptionKeysController.php +++ b/src/Controller/EncryptionKeysController.php @@ -130,4 +130,16 @@ class EncryptionKeysController extends AppController $this->set('metaGroup', 'ContactDB'); $this->render('add'); } + + public function view($id = false) + { + $this->CRUD->view($id, [ + 'contain' => ['Individuals', 'Organisations'] + ]); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + $this->set('metaGroup', 'ContactDB'); + } } diff --git a/templates/EncryptionKeys/view.php b/templates/EncryptionKeys/view.php new file mode 100644 index 0000000..e92da92 --- /dev/null +++ b/templates/EncryptionKeys/view.php @@ -0,0 +1,32 @@ +element( + '/genericElements/SingleViews/single_view', + [ + 'data' => $entity, + 'fields' => [ + [ + 'key' => __('ID'), + 'path' => 'id' + ], + [ + 'key' => __('Type'), + 'path' => 'type' + ], + [ + 'key' => __('Owner'), + 'path' => 'owner_id', + 'owner_model_path' => 'owner_model', + 'type' => 'owner' + ], + [ + 'key' => __('Revoked'), + 'path' => 'revoked' + ], + + [ + 'key' => __('Key'), + 'path' => 'encryption_key' + ] + ] + ] +); diff --git a/templates/element/genericElements/SingleViews/Fields/ownerField.php b/templates/element/genericElements/SingleViews/Fields/ownerField.php new file mode 100644 index 0000000..4f213a7 --- /dev/null +++ b/templates/element/genericElements/SingleViews/Fields/ownerField.php @@ -0,0 +1,6 @@ +element('/genericElements/IndexTable/Fields/owner', [ + 'field' => $field, + 'row' => $data +]); +?> From aeaa833f64d65c1f66d1ec34f84c265635e50833 Mon Sep 17 00:00:00 2001 From: Sami Mokaddem Date: Mon, 17 Jan 2022 11:29:50 +0100 Subject: [PATCH 114/150] new: [CodeMirror] Shows a placeholder whenever the textarea is empty --- templates/layout/default.php | 1 + .../css/CodeMirror/codemirror-additional.css | 4 + .../CodeMirror/addon/display/placeholder.js | 78 +++++++++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 webroot/js/CodeMirror/addon/display/placeholder.js diff --git a/templates/layout/default.php b/templates/layout/default.php index 849592e..59d2efe 100644 --- a/templates/layout/default.php +++ b/templates/layout/default.php @@ -52,6 +52,7 @@ $sidebarOpen = $loggedUser->user_settings_by_name_with_fallback['ui.sidebar.expa Html->script('CodeMirror/addon/lint/json-lint') ?> Html->script('CodeMirror/addon/edit/matchbrackets') ?> Html->script('CodeMirror/addon/edit/closebrackets') ?> + Html->script('CodeMirror/addon/display/placeholder') ?> Html->css('CodeMirror/codemirror') ?> Html->css('CodeMirror/codemirror-additional') ?> Html->css('CodeMirror/addon/hint/show-hint') ?> diff --git a/webroot/css/CodeMirror/codemirror-additional.css b/webroot/css/CodeMirror/codemirror-additional.css index 0c62e18..4399c1a 100644 --- a/webroot/css/CodeMirror/codemirror-additional.css +++ b/webroot/css/CodeMirror/codemirror-additional.css @@ -26,4 +26,8 @@ .CodeMirror-hints { z-index: 1060 !important; /* Make sure hint is above modal */ +} + +.CodeMirror pre.CodeMirror-placeholder { + color: #999; } \ No newline at end of file diff --git a/webroot/js/CodeMirror/addon/display/placeholder.js b/webroot/js/CodeMirror/addon/display/placeholder.js new file mode 100644 index 0000000..d8e2dbd --- /dev/null +++ b/webroot/js/CodeMirror/addon/display/placeholder.js @@ -0,0 +1,78 @@ +// CodeMirror, copyright (c) by Marijn Haverbeke and others +// Distributed under an MIT license: https://codemirror.net/LICENSE + +(function (mod) { + if (typeof exports == "object" && typeof module == "object") // CommonJS + mod(require("../../lib/codemirror")); + else if (typeof define == "function" && define.amd) // AMD + define(["../../lib/codemirror"], mod); + else // Plain browser env + mod(CodeMirror); +})(function (CodeMirror) { + CodeMirror.defineOption("placeholder", "", function (cm, val, old) { + var prev = old && old != CodeMirror.Init; + if (val && !prev) { + cm.on("blur", onBlur); + cm.on("change", onChange); + cm.on("swapDoc", onChange); + CodeMirror.on(cm.getInputField(), "compositionupdate", cm.state.placeholderCompose = function () { onComposition(cm) }) + onChange(cm); + } else if (!val && prev) { + cm.off("blur", onBlur); + cm.off("change", onChange); + cm.off("swapDoc", onChange); + CodeMirror.off(cm.getInputField(), "compositionupdate", cm.state.placeholderCompose) + clearPlaceholder(cm); + var wrapper = cm.getWrapperElement(); + wrapper.className = wrapper.className.replace(" CodeMirror-empty", ""); + } + + if (val && !cm.hasFocus()) onBlur(cm); + }); + + function clearPlaceholder(cm) { + if (cm.state.placeholder) { + cm.state.placeholder.parentNode.removeChild(cm.state.placeholder); + cm.state.placeholder = null; + } + } + function setPlaceholder(cm) { + clearPlaceholder(cm); + var elt = cm.state.placeholder = document.createElement("pre"); + elt.style.cssText = "height: 0; overflow: visible"; + elt.style.direction = cm.getOption("direction"); + elt.className = "CodeMirror-placeholder CodeMirror-line-like"; + var placeHolder = cm.getOption("placeholder") + if (typeof placeHolder == "string") placeHolder = document.createTextNode(placeHolder) + elt.appendChild(placeHolder) + cm.display.lineSpace.insertBefore(elt, cm.display.lineSpace.firstChild); + } + + function onComposition(cm) { + setTimeout(function () { + var empty = false + if (cm.lineCount() == 1) { + var input = cm.getInputField() + empty = input.nodeName == "TEXTAREA" ? !cm.getLine(0).length + : !/[^\u200b]/.test(input.querySelector(".CodeMirror-line").textContent) + } + if (empty) setPlaceholder(cm) + else clearPlaceholder(cm) + }, 20) + } + + function onBlur(cm) { + if (isEmpty(cm)) setPlaceholder(cm); + } + function onChange(cm) { + var wrapper = cm.getWrapperElement(), empty = isEmpty(cm); + wrapper.className = wrapper.className.replace(" CodeMirror-empty", "") + (empty ? " CodeMirror-empty" : ""); + + if (empty) setPlaceholder(cm); + else clearPlaceholder(cm); + } + + function isEmpty(cm) { + return (cm.lineCount() === 1) && (cm.getLine(0) === ""); + } +}); From f18307b3cb476e5c727d8f211a27b29e06be1f2d Mon Sep 17 00:00:00 2001 From: Sami Mokaddem Date: Mon, 17 Jan 2022 11:30:26 +0100 Subject: [PATCH 115/150] chg: [localTools:local_tool_connectors] Added support of CodeMirror placeholder --- .../local_tool_connectors/MispConnector.php | 5 +++++ .../SkeletonConnectorExample.php | 16 ++++++++++++++++ src/Model/Table/LocalToolsTable.php | 3 ++- templates/LocalTools/add.php | 3 ++- 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/Lib/default/local_tool_connectors/MispConnector.php b/src/Lib/default/local_tool_connectors/MispConnector.php index 473573a..a021839 100644 --- a/src/Lib/default/local_tool_connectors/MispConnector.php +++ b/src/Lib/default/local_tool_connectors/MispConnector.php @@ -122,6 +122,11 @@ class MispConnector extends CommonConnectorTools 'type' => 'boolean' ], ]; + public $settingsPlaceholder = [ + 'url' => 'https://your.misp.intance', + 'authkey' => '', + 'skip_ssl' => '0', + ]; public function addSettingValidatorRules($validator) { diff --git a/src/Lib/default/local_tool_connectors/SkeletonConnectorExample.php b/src/Lib/default/local_tool_connectors/SkeletonConnectorExample.php index ce9b62d..362c514 100644 --- a/src/Lib/default/local_tool_connectors/SkeletonConnectorExample.php +++ b/src/Lib/default/local_tool_connectors/SkeletonConnectorExample.php @@ -46,6 +46,22 @@ class SkeletonConnector extends CommonConnectorTools 'redirect' => 'serverSettingsAction' ] ]; + public $settings = [ + 'url' => [ + 'type' => 'text' + ], + 'authkey' => [ + 'type' => 'text' + ], + 'skip_ssl' => [ + 'type' => 'boolean' + ], + ]; + public $settingsPlaceholder = [ + 'url' => 'https://your.url', + 'authkey' => '', + 'skip_ssl' => '0', + ]; public function health(Object $connection): array { diff --git a/src/Model/Table/LocalToolsTable.php b/src/Model/Table/LocalToolsTable.php index ac29bf9..64d6168 100644 --- a/src/Model/Table/LocalToolsTable.php +++ b/src/Model/Table/LocalToolsTable.php @@ -143,7 +143,8 @@ class LocalToolsTable extends AppTable 'connector' => $connector_type, 'connector_version' => $connector_class->version, 'connector_description' => $connector_class->description, - 'connector_settings' => $connector_class->settings ?? [] + 'connector_settings' => $connector_class->settings ?? [], + 'connector_settings_placeholder' => $connector_class->settingsPlaceholder ?? [], ]; if ($includeConnections) { $connector['connections'] = $this->healthCheck($connector_type, $connector_class); diff --git a/templates/LocalTools/add.php b/templates/LocalTools/add.php index 184f0e6..5197f21 100644 --- a/templates/LocalTools/add.php +++ b/templates/LocalTools/add.php @@ -22,7 +22,8 @@ 'codemirror' => [ 'height' => '10rem', 'hints' => $connectors[0]['connector_settings'] - ] + ], + 'placeholder' => json_encode($connectors[0]['connector_settings_placeholder'], JSON_FORCE_OBJECT | JSON_PRETTY_PRINT) ], [ 'field' => 'description', From 1b4c681a88f46f77da462ae89ad7d0e3d0347112 Mon Sep 17 00:00:00 2001 From: iglocska Date: Mon, 17 Jan 2022 12:47:48 +0100 Subject: [PATCH 116/150] new: [Outbox] entity added - to inherit the appModel functions --- src/Controller/LocalToolsController.php | 2 -- src/Model/Entity/Outbox.php | 11 +++++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 src/Model/Entity/Outbox.php diff --git a/src/Controller/LocalToolsController.php b/src/Controller/LocalToolsController.php index a0d31b9..4040c9b 100644 --- a/src/Controller/LocalToolsController.php +++ b/src/Controller/LocalToolsController.php @@ -355,10 +355,8 @@ class LocalToolsController extends AppController $params['target_tool_id'] = $postParams['target_tool_id']; $result = $this->LocalTools->encodeLocalConnection($params); // Send message to remote inbox - debug($result); } else { $target_tools = $this->LocalTools->findConnectable($local_tool); - debug($target_tools); if (empty($target_tools)) { throw new NotFoundException(__('No tools found to connect.')); } diff --git a/src/Model/Entity/Outbox.php b/src/Model/Entity/Outbox.php new file mode 100644 index 0000000..51304e6 --- /dev/null +++ b/src/Model/Entity/Outbox.php @@ -0,0 +1,11 @@ + Date: Mon, 17 Jan 2022 12:53:14 +0100 Subject: [PATCH 117/150] fix: [organisation] add/edit doesn't save URL --- templates/Organisations/add.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/Organisations/add.php b/templates/Organisations/add.php index 987809e..ab3a17e 100644 --- a/templates/Organisations/add.php +++ b/templates/Organisations/add.php @@ -17,7 +17,7 @@ 'type' => 'uuid' ), array( - 'field' => 'URL' + 'field' => 'url' ), array( 'field' => 'nationality' From 453c838dfe0a74592da5cd7490a3f117182e380d Mon Sep 17 00:00:00 2001 From: iglocska Date: Mon, 17 Jan 2022 13:15:26 +0100 Subject: [PATCH 118/150] fix: [placeholder removed] WiP functionality for local_tool->local_tool connections within the same brood temporarily removed - was never fully implemented --- src/Controller/Component/ACLComponent.php | 2 +- src/Controller/LocalToolsController.php | 2 ++ src/Model/Table/LocalToolsTable.php | 3 ++- templates/LocalTools/connector_index.php | 2 ++ 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Controller/Component/ACLComponent.php b/src/Controller/Component/ACLComponent.php index afa70ff..670a037 100644 --- a/src/Controller/Component/ACLComponent.php +++ b/src/Controller/Component/ACLComponent.php @@ -109,7 +109,7 @@ class ACLComponent extends Component 'batchAction' => ['perm_admin'], 'broodTools' => ['perm_admin'], 'connectionRequest' => ['perm_admin'], - 'connectLocal' => ['perm_admin'], + // 'connectLocal' => ['perm_admin'], 'delete' => ['perm_admin'], 'edit' => ['perm_admin'], 'exposedTools' => ['OR' => ['perm_admin', 'perm_sync']], diff --git a/src/Controller/LocalToolsController.php b/src/Controller/LocalToolsController.php index 4040c9b..12d9d62 100644 --- a/src/Controller/LocalToolsController.php +++ b/src/Controller/LocalToolsController.php @@ -340,6 +340,7 @@ class LocalToolsController extends AppController } } +/* public function connectLocal($local_tool_id) { $params = [ @@ -367,4 +368,5 @@ class LocalToolsController extends AppController ]); } } +*/ } diff --git a/src/Model/Table/LocalToolsTable.php b/src/Model/Table/LocalToolsTable.php index ac29bf9..20336e2 100644 --- a/src/Model/Table/LocalToolsTable.php +++ b/src/Model/Table/LocalToolsTable.php @@ -288,6 +288,7 @@ class LocalToolsTable extends AppTable return $jsonReply; } +/* public function findConnectable($local_tool): array { $connectors = $this->getInterconnectors($local_tool['connector']); @@ -297,8 +298,8 @@ class LocalToolsTable extends AppTable $validTargets[$connector['connects'][1]] = 1; } } - } +*/ public function fetchConnection($id): object { diff --git a/templates/LocalTools/connector_index.php b/templates/LocalTools/connector_index.php index e6ef205..f199d30 100644 --- a/templates/LocalTools/connector_index.php +++ b/templates/LocalTools/connector_index.php @@ -89,12 +89,14 @@ echo $this->element('genericElements/IndexTable/index_table', [ 'url_params_data_paths' => ['id'], 'icon' => 'eye' ], + /* [ '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', From 0328bfed4686d47c8b356d4c3ea5dab31ce73887 Mon Sep 17 00:00:00 2001 From: iglocska Date: Mon, 17 Jan 2022 13:20:34 +0100 Subject: [PATCH 119/150] fix: [inividuals] add shouldn't have the tagging options - can't tag that which does not exist yet --- templates/Individuals/add.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/templates/Individuals/add.php b/templates/Individuals/add.php index 935c2de..19bcf36 100644 --- a/templates/Individuals/add.php +++ b/templates/Individuals/add.php @@ -23,7 +23,8 @@ ), array( 'field' => 'tag_list', - 'type' => 'tags' + 'type' => 'tags', + 'requirements' => $this->request->getParam('action') === 'edit' ), ), 'metaTemplates' => empty($metaTemplates) ? [] : $metaTemplates, From e8f57dc40f21a78d0a4c79cf31579cb9afb2d024 Mon Sep 17 00:00:00 2001 From: iglocska Date: Mon, 17 Jan 2022 13:20:34 +0100 Subject: [PATCH 120/150] fix: [inividuals] add shouldn't have the tagging options - can't tag that which does not exist yet --- templates/Individuals/add.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/templates/Individuals/add.php b/templates/Individuals/add.php index 935c2de..19bcf36 100644 --- a/templates/Individuals/add.php +++ b/templates/Individuals/add.php @@ -23,7 +23,8 @@ ), array( 'field' => 'tag_list', - 'type' => 'tags' + 'type' => 'tags', + 'requirements' => $this->request->getParam('action') === 'edit' ), ), 'metaTemplates' => empty($metaTemplates) ? [] : $metaTemplates, From 1c81257b7596f55d91839f5b485dc8076538d143 Mon Sep 17 00:00:00 2001 From: Sami Mokaddem Date: Mon, 17 Jan 2022 15:22:52 +0100 Subject: [PATCH 121/150] fix: [helpers:bootstrap] Table's cell generator gets the correct row index --- src/View/Helper/BootstrapHelper.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/View/Helper/BootstrapHelper.php b/src/View/Helper/BootstrapHelper.php index 6d9275f..ee24495 100644 --- a/src/View/Helper/BootstrapHelper.php +++ b/src/View/Helper/BootstrapHelper.php @@ -637,13 +637,13 @@ class BoostrapTable extends BootstrapGeneric { ], ]); foreach ($this->items as $i => $row) { - $body .= $this->genRow($row); + $body .= $this->genRow($row, $i); } $body .= $this->closeNode('tbody'); return $body; } - private function genRow($row) + private function genRow($row, $rowIndex) { $html = $this->openNode('tr',[ 'class' => [ @@ -658,21 +658,21 @@ class BoostrapTable extends BootstrapGeneric { $key = $field; } $cellValue = Hash::get($row, $key); - $html .= $this->genCell($cellValue, $field, $row, $i); + $html .= $this->genCell($cellValue, $field, $row, $rowIndex); } } else { // indexed array - foreach ($row as $cellValue) { - $html .= $this->genCell($cellValue, $field, $row, $i); + foreach ($row as $i => $cellValue) { + $html .= $this->genCell($cellValue, 'index', $row, $rowIndex); } } $html .= $this->closeNode('tr'); return $html; } - private function genCell($value, $field=[], $row=[], $i=0) + private function genCell($value, $field=[], $row=[], $rowIndex=0) { if (isset($field['formatter'])) { - $cellContent = $field['formatter']($value, $row, $i); + $cellContent = $field['formatter']($value, $row, $rowIndex); } else if (isset($field['element'])) { $cellContent = $this->btHelper->getView()->element($field['element'], [ 'data' => [$value], From ef2827e87a43cf7360abd161633d54710fc73dec Mon Sep 17 00:00:00 2001 From: Sami Mokaddem Date: Mon, 17 Jan 2022 15:24:30 +0100 Subject: [PATCH 122/150] fix: [userSettings] Various permissions issues --- src/Controller/UserSettingsController.php | 64 ++++++++++++++++++++--- src/Model/Table/UserSettingsTable.php | 2 +- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/src/Controller/UserSettingsController.php b/src/Controller/UserSettingsController.php index 3d73707..d28f6ca 100644 --- a/src/Controller/UserSettingsController.php +++ b/src/Controller/UserSettingsController.php @@ -9,6 +9,8 @@ use \Cake\Database\Expression\QueryExpression; use Cake\Http\Exception\NotFoundException; use Cake\Http\Exception\MethodNotAllowedException; use Cake\Http\Exception\ForbiddenException; +use Cake\Http\Exception\UnauthorizedException; + class UserSettingsController extends AppController { @@ -19,8 +21,12 @@ class UserSettingsController extends AppController public function index() { $conditions = []; + $currentUser = $this->ACL->getUser(); + if (empty($currentUser['role']['perm_admin'])) { + $conditions['user_id'] = $currentUser->id; + } $this->CRUD->index([ - 'conditions' => [], + 'conditions' => $conditions, 'contain' => $this->containFields, 'filters' => $this->filterFields, 'quickFilters' => $this->quickFilterFields, @@ -39,6 +45,9 @@ class UserSettingsController extends AppController public function view($id) { + if (!$this->isLoggedUserAllowedToEdit($id)) { + throw new NotFoundException(__('Invalid {0}.', 'user setting')); + } $this->CRUD->view($id, [ 'contain' => ['Users'] ]); @@ -50,10 +59,13 @@ class UserSettingsController extends AppController public function add($user_id = false) { + $currentUser = $this->ACL->getUser(); $this->CRUD->add([ 'redirect' => ['action' => 'index', $user_id], - 'beforeSave' => function($data) use ($user_id) { - $data['user_id'] = $user_id; + 'beforeSave' => function ($data) use ($currentUser) { + if (empty($currentUser['role']['perm_admin'])) { + $data['user_id'] = $currentUser->id; + } return $data; } ]); @@ -61,10 +73,13 @@ class UserSettingsController extends AppController if (!empty($responsePayload)) { return $responsePayload; } + $allUsers = $this->UserSettings->Users->find('list', ['keyField' => 'id', 'valueField' => 'username'])->order(['username' => 'ASC']); + if (empty($currentUser['role']['perm_admin'])) { + $allUsers->where(['id' => $currentUser->id]); + $user_id = $currentUser->id; + } $dropdownData = [ - 'user' => $this->UserSettings->Users->find('list', [ - 'sort' => ['username' => 'asc'] - ]), + 'user' => $allUsers->all()->toArray(), ]; $this->set(compact('dropdownData')); $this->set('user_id', $user_id); @@ -75,6 +90,11 @@ class UserSettingsController extends AppController $entity = $this->UserSettings->find()->where([ 'id' => $id ])->first(); + + if (!$this->isLoggedUserAllowedToEdit($entity)) { + throw new NotFoundException(__('Invalid {0}.', 'user setting')); + } + $entity = $this->CRUD->edit($id, [ 'redirect' => ['action' => 'index', $entity->user_id] ]); @@ -94,6 +114,9 @@ class UserSettingsController extends AppController public function delete($id) { + if (!$this->isLoggedUserAllowedToEdit($id)) { + throw new NotFoundException(__('Invalid {0}.', 'user setting')); + } $this->CRUD->delete($id); $responsePayload = $this->CRUD->getResponsePayload(); if (!empty($responsePayload)) { @@ -160,7 +183,7 @@ class UserSettingsController extends AppController } } - public function getBookmarks($forSidebar=false) + public function getBookmarks($forSidebar = false) { $bookmarks = $this->UserSettings->getSettingByName($this->ACL->getUser(), $this->UserSettings->BOOKMARK_SETTING_NAME); $bookmarks = json_decode($bookmarks['value'], true); @@ -200,4 +223,29 @@ class UserSettingsController extends AppController $this->set('user_id', $this->ACL->getUser()->id); } -} \ No newline at end of file + /** + * isLoggedUserAllowedToEdit + * + * @param int|\App\Model\Entity\UserSetting $setting + * @return boolean + */ + private function isLoggedUserAllowedToEdit($setting): bool + { + $currentUser = $this->ACL->getUser(); + $isAllowed = false; + if (!empty($currentUser['role']['perm_admin'])) { + $isAllowed = true; + } else { + if (is_numeric($setting)) { + $setting = $this->UserSettings->find()->where([ + 'id' => $setting + ])->first(); + if (empty($setting)) { + return false; + } + } + $isAllowed = $setting->user_id == $currentUser->id; + } + return $isAllowed; + } +} diff --git a/src/Model/Table/UserSettingsTable.php b/src/Model/Table/UserSettingsTable.php index 4c7e708..bdfe535 100644 --- a/src/Model/Table/UserSettingsTable.php +++ b/src/Model/Table/UserSettingsTable.php @@ -11,7 +11,7 @@ use App\Settings\SettingsProvider\UserSettingsProvider; class UserSettingsTable extends AppTable { - protected $BOOKMARK_SETTING_NAME = 'ui.bookmarks'; + public $BOOKMARK_SETTING_NAME = 'ui.bookmarks'; public function initialize(array $config): void { From 98e8272810c91d0d81e59951df563e84a999f7ee Mon Sep 17 00:00:00 2001 From: Sami Mokaddem Date: Mon, 17 Jan 2022 15:29:58 +0100 Subject: [PATCH 123/150] fix: [ACL] Allow anyone to view encryption keys --- src/Controller/Component/ACLComponent.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Controller/Component/ACLComponent.php b/src/Controller/Component/ACLComponent.php index afa70ff..dbb618c 100644 --- a/src/Controller/Component/ACLComponent.php +++ b/src/Controller/Component/ACLComponent.php @@ -68,6 +68,7 @@ class ACLComponent extends Component 'view' => ['perm_admin'] ], 'EncryptionKeys' => [ + 'view' => ['*'], 'add' => ['*'], 'edit' => ['*'], 'delete' => ['*'], From 46870a4bcc53ed662ea669a2b364d051e03a20da Mon Sep 17 00:00:00 2001 From: Sami Mokaddem Date: Mon, 17 Jan 2022 15:45:51 +0100 Subject: [PATCH 124/150] fix: [organisation:add] Removed useless description field --- templates/Organisations/add.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/templates/Organisations/add.php b/templates/Organisations/add.php index ab3a17e..6278ff8 100644 --- a/templates/Organisations/add.php +++ b/templates/Organisations/add.php @@ -7,10 +7,6 @@ array( 'field' => 'name' ), - array( - 'field' => 'description', - 'type' => 'textarea' - ), array( 'field' => 'uuid', 'label' => 'UUID', From 49a3dd1623e41d802a661b49f30d87e1e5af91e7 Mon Sep 17 00:00:00 2001 From: Sami Mokaddem Date: Mon, 17 Jan 2022 15:55:55 +0100 Subject: [PATCH 125/150] chg: [instance] Added support of API response for 2 endpoints --- src/Controller/InstanceController.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/Controller/InstanceController.php b/src/Controller/InstanceController.php index 8479e5c..f136251 100644 --- a/src/Controller/InstanceController.php +++ b/src/Controller/InstanceController.php @@ -70,6 +70,12 @@ class InstanceController extends AppController usort($status, function($a, $b) { return strcmp($b['id'], $a['id']); }); + if ($this->ParamHandler->isRest()) { + return $this->RestResponse->viewData([ + 'status' => $status, + 'updateAvailables' => $migrationStatus['updateAvailables'], + ], 'json'); + } $this->set('status', $status); $this->set('updateAvailables', $migrationStatus['updateAvailables']); } @@ -140,6 +146,14 @@ class InstanceController extends AppController { $this->Settings = $this->getTableLocator()->get('Settings'); $all = $this->Settings->getSettings(true); + if ($this->ParamHandler->isRest()) { + return $this->RestResponse->viewData([ + 'settingsProvider' => $all['settingsProvider'], + 'settings' => $all['settings'], + 'settingsFlattened' => $all['settingsFlattened'], + 'notices' => $all['notices'], + ], 'json'); + } $this->set('settingsProvider', $all['settingsProvider']); $this->set('settings', $all['settings']); $this->set('settingsFlattened', $all['settingsFlattened']); From 299cb126dc664794f8fab314aaddf91ca56e383a Mon Sep 17 00:00:00 2001 From: Luciano Righetti Date: Mon, 17 Jan 2022 16:02:15 +0100 Subject: [PATCH 126/150] add: wiremock tests and boilerplate, update test readme, extend openapi spec --- .gitignore | 1 + composer.json | 11 ++- phpunit.xml.dist | 17 +++-- tests/Fixture/BroodsFixture.php | 17 +++++ tests/Helper/ApiTestTrait.php | 8 ++- tests/Helper/WireMockTestTrait.php | 49 +++++++++++++ tests/Helper/wiremock/start.sh | 38 ++++++++++ tests/Helper/wiremock/stop.sh | 23 ++++++ tests/README.md | 70 +++++++++++++++--- tests/TestCase/Api/Broods/AddBroodApiTest.php | 6 -- .../Api/Broods/DeleteBroodsApiTest.php | 6 -- .../TestCase/Api/Broods/EditBroodApiTest.php | 6 -- .../Api/Broods/IndexBroodsApiTest.php | 6 -- .../Api/Broods/TestBroodConnectionApiTest.php | 72 +++++++++++++++++++ .../TestCase/Api/Broods/ViewBroodApiTest.php | 6 -- .../Api/Inbox/CreateInboxEntryApiTest.php | 6 -- .../TestCase/Api/Inbox/IndexInboxApiTest.php | 6 -- .../Api/Individuals/AddIndividualApiTest.php | 6 -- .../Individuals/DeleteIndividualApiTest.php | 6 -- .../Api/Individuals/EditIndividualApiTest.php | 6 -- .../Individuals/IndexIndividualsApiTest.php | 6 -- .../Api/Individuals/ViewIndividualApiTest.php | 6 -- .../Organisations/AddOrganisationApiTest.php | 6 -- .../DeleteOrganisationApiTest.php | 6 -- .../Organisations/EditOrganisationApiTest.php | 6 -- .../IndexOrganisationsApiTest.php | 6 -- .../Organisations/TagOrganisationApiTest.php | 6 -- .../UntagOrganisationApiTest.php | 6 -- .../Organisations/ViewOrganisationApiTest.php | 6 -- .../SharingGroups/AddSharingGroupApiTest.php | 6 -- .../DeleteSharingGroupApiTest.php | 6 -- .../SharingGroups/EditSharingGroupApiTest.php | 6 -- .../IndexSharingGroupsApiTest.php | 6 -- .../SharingGroups/ViewSharingGroupApiTest.php | 6 -- tests/TestCase/Api/Tags/IndexTagsApiTest.php | 6 -- tests/TestCase/Api/Users/AddUserApiTest.php | 6 -- .../TestCase/Api/Users/DeleteUserApiTest.php | 6 -- tests/TestCase/Api/Users/EditUserApiTest.php | 6 -- .../TestCase/Api/Users/IndexUsersApiTest.php | 6 -- tests/TestCase/Api/Users/ViewUserApiTest.php | 6 -- tests/bootstrap.php | 4 +- webroot/docs/openapi.yaml | 54 ++++++++++++++ 42 files changed, 340 insertions(+), 204 deletions(-) create mode 100644 tests/Helper/WireMockTestTrait.php create mode 100644 tests/Helper/wiremock/start.sh create mode 100644 tests/Helper/wiremock/stop.sh create mode 100644 tests/TestCase/Api/Broods/TestBroodConnectionApiTest.php diff --git a/.gitignore b/.gitignore index ba05ac6..ce8fde9 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ webroot/theme/node_modules docker/run/ .phpunit.result.cache config.json +phpunit.xml diff --git a/composer.json b/composer.json index 4e1af85..f51bc6f 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,8 @@ "josegonzalez/dotenv": "^3.2", "league/openapi-psr7-validator": "^0.16.4", "phpunit/phpunit": "^8.5", - "psy/psysh": "@stable" + "psy/psysh": "@stable", + "wiremock-php/wiremock-php": "^2.32" }, "suggest": { "markstory/asset_compress": "An asset compression plugin which provides file concatenation and a flexible filter system for preprocessing and minification.", @@ -53,11 +54,15 @@ "cs-check": "phpcs --colors -p --standard=vendor/cakephp/cakephp-codesniffer/CakePHP src/ tests/", "cs-fix": "phpcbf --colors --standard=vendor/cakephp/cakephp-codesniffer/CakePHP src/ tests/", "stan": "phpstan analyse src/", - "test": "phpunit --colors=always" + "test": [ + "sh ./tests/Helper/wiremock/start.sh", + "phpunit", + "sh ./tests/Helper/wiremock/stop.sh" + ] }, "prefer-stable": true, "config": { "sort-packages": true }, "minimum-stability": "dev" -} +} \ No newline at end of file diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 7be6529..403e1e5 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,13 +1,12 @@ - + - - + + + + + + @@ -37,4 +36,4 @@ - + \ No newline at end of file diff --git a/tests/Fixture/BroodsFixture.php b/tests/Fixture/BroodsFixture.php index adc035c..c329e6a 100644 --- a/tests/Fixture/BroodsFixture.php +++ b/tests/Fixture/BroodsFixture.php @@ -16,6 +16,9 @@ class BroodsFixture extends TestFixture public const BROOD_B_ID = 2; public const BROOD_B_API_KEY = 'ae4f281df5a5d0ff3cad6371f76d5c29b6d953ec'; + public const BROOD_WIREMOCK_ID = 3; + public const BROOD_WIREMOCK_API_KEY = 'bfc63c07f74fa18b52d3cced97072cad00e51346'; + public function init(): void { $faker = \Faker\Factory::create(); @@ -48,6 +51,20 @@ class BroodsFixture extends TestFixture 'authkey' => self::BROOD_B_API_KEY, 'created' => $faker->dateTime()->getTimestamp(), 'modified' => $faker->dateTime()->getTimestamp() + ], + [ + 'id' => self::BROOD_WIREMOCK_ID, + 'uuid' => $faker->uuid(), + 'name' => 'wiremock', + 'url' => 'http://localhost:8080', + 'description' => $faker->text, + 'organisation_id' => OrganisationsFixture::ORGANISATION_B_ID, + 'trusted' => true, + 'pull' => true, + 'skip_proxy' => true, + 'authkey' => self::BROOD_WIREMOCK_API_KEY, + 'created' => $faker->dateTime()->getTimestamp(), + 'modified' => $faker->dateTime()->getTimestamp() ] ]; parent::init(); diff --git a/tests/Helper/ApiTestTrait.php b/tests/Helper/ApiTestTrait.php index 21b0c8b..8e20a36 100644 --- a/tests/Helper/ApiTestTrait.php +++ b/tests/Helper/ApiTestTrait.php @@ -24,6 +24,12 @@ trait ApiTestTrait /** @var ResponseValidator */ private $responseValidator; + public function setUp(): void + { + parent::setUp(); + $this->initializeOpenApiValidator($_ENV['OPENAPI_SPEC'] ?? APP . '../webroot/docs/openapi.yaml'); + } + public function setAuthToken(string $authToken): void { $this->_authToken = $authToken; @@ -51,7 +57,7 @@ trait ApiTestTrait * @param string $specFile * @return void */ - public function initializeValidator(string $specFile): void + public function initializeOpenApiValidator(string $specFile): void { $this->validator = (new ValidatorBuilder)->fromYamlFile($specFile); $this->requestValidator = $this->validator->getRequestValidator(); diff --git a/tests/Helper/WireMockTestTrait.php b/tests/Helper/WireMockTestTrait.php new file mode 100644 index 0000000..9b42f7e --- /dev/null +++ b/tests/Helper/WireMockTestTrait.php @@ -0,0 +1,49 @@ + */ + private $config = [ + 'hostname' => 'localhost', + 'port' => 8080 + ]; + + public function initializeWireMock(): void + { + $this->wiremock = WireMock::create( + $_ENV['WIREMOCK_HOST'] ?? $this->config['hostname'], + $_ENV['WIREMOCK_PORT'] ?? $this->config['port'] + ); + + if (!$this->wiremock->isAlive()) { + throw new Exception('Failed to connect to WireMock server.'); + } + + $this->clearWireMockStubs(); + } + + public function clearWireMockStubs(): void + { + $this->wiremock->resetToDefault(); + } + + public function getWireMock(): WireMock + { + return $this->wiremock; + } + + public function getWireMockBaseUrl(): string + { + return sprintf('http://%s:%s', $this->config['hostname'], $this->config['port']); + } +} diff --git a/tests/Helper/wiremock/start.sh b/tests/Helper/wiremock/start.sh new file mode 100644 index 0000000..60b8197 --- /dev/null +++ b/tests/Helper/wiremock/start.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +# Adapted from @rowanhill wiremock start.sh script +# https://github.com/rowanhill/wiremock-php/blob/master/wiremock/start.sh + +cd ./tmp/ + +instance=1 +port=8080 +if [ $# -gt 0 ]; then + instance=$1 + port=$2 +fi +pidFile=wiremock.$instance.pid +logFile=wiremock.$instance.log + +# Ensure WireMock isn't already running +if [ -e $pidFile ]; then + echo WireMock is already started: see process `cat $pidFile` 1>&2 + exit 0 +fi + +# Download the wiremock jar if we need it +if ! [ -e wiremock-standalone.jar ]; then + echo WireMock standalone JAR missing. Downloading. + curl https://repo1.maven.org/maven2/com/github/tomakehurst/wiremock-jre8-standalone/2.32.0/wiremock-jre8-standalone-2.32.0.jar -o wiremock-standalone.jar + status=$? + if [ ${status} -ne 0 ]; then + echo curl could not download WireMock JAR 1>&2 + exit ${status} + fi +fi + +# Start WireMock in standalone mode (in a background process) and save its output to a log +java -jar wiremock-standalone.jar --port $port --root-dir $instance --disable-banner &> $logFile 2>&1 & +pgrep -f wiremock-standalone.jar > $pidFile + +echo WireMock $instance started on port $port \ No newline at end of file diff --git a/tests/Helper/wiremock/stop.sh b/tests/Helper/wiremock/stop.sh new file mode 100644 index 0000000..f9e3f9e --- /dev/null +++ b/tests/Helper/wiremock/stop.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +# Adapted from @rowanhill wiremock stop.sh script +# https://github.com/rowanhill/wiremock-php/blob/master/wiremock/stop.sh + +cd ./tmp/ + +instance=1 +if [ $# -gt 0 ]; then + instance=$1 +fi +pidFile=wiremock.$instance.pid + + +if [ -e $pidFile ]; then + kill -9 `cat $pidFile` + rm $pidFile +else + echo WireMock is not started 2>&1 + exit 1 +fi + +echo WireMock $instance stopped \ No newline at end of file diff --git a/tests/README.md b/tests/README.md index 0486b3b..e080442 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,6 +1,5 @@ # Testing -## Configuration -1. Add a `cerebrate_test` database to the db: +1. Add a `cerebrate_test` database to the database: ```mysql CREATE DATABASE cerebrate_test; GRANT ALL PRIVILEGES ON cerebrate_test.* to cerebrate@localhost; @@ -28,12 +27,24 @@ QUIT; ``` ## Runing the tests - ``` $ composer install -$ vendor/bin/phpunit +$ composer test +> sh ./tests/Helper/wiremock/start.sh +WireMock 1 started on port 8080 +> phpunit +[ * ] Running DB migrations, it may take some time ... + +The WireMock server is started ..... +port: 8080 +enable-browser-proxying: false +disable-banner: true +no-request-journal: false +verbose: false + PHPUnit 8.5.22 by Sebastian Bergmann and contributors. + ..... 5 / 5 (100%) Time: 11.61 seconds, Memory: 26.00 MB @@ -51,12 +62,35 @@ Available suites: * `controller`: runs only controller tests * _to be continued ..._ -By default the database is re-generated before running the test suite, to skip this step and speed up the test run use the `-d skip-migrations` option: -``` -$ vendor/bin/phpunit -d skip-migrations +By default the database is re-generated before running the test suite, to skip this step and speed up the test run set the following env variable in `phpunit.xml`: +```xml + + ... + + ``` +## Extras +### WireMock +Some integration tests perform calls to external APIs, we use WireMock to mock the response of these API calls. -## Coverage +To download and run WireMock run the following script in a separate terminal: + ``` + sh ./tests/Helper/wiremock/start.sh + ``` + +You can also run WireMock with docker, check the official docs: http://wiremock.org/docs/docker/ + +> NOTE: When running the tests with `composer test` WireMock is automatically started and stoped after the tests finish. + +The default `hostname` and `port` for WireMock are set in `phpunit.xml` as environment variables: +```xml + + ... + + + +``` +### Coverage HTML: ``` $ vendor/bin/phpunit --coverage-html tmp/coverage @@ -66,3 +100,23 @@ XML: ``` $ vendor/bin/phpunit --verbose --coverage-clover=coverage.xml ``` + +### OpenAPI validation +API tests can assert the API response matches the OpenAPI specification, after the request add this line: + +```php +$this->assertResponseMatchesOpenApiSpec(self::ENDPOINT); +``` + +The default OpenAPI spec path is set in `phpunit.xml` as a environment variablea: +```xml + + ... + + +``` + +## TODO +- [ ] Validate API requests against the OpenAPI spec +- [ ] Validate response content matches / implement _seeResponseContainsJson()_ or equivalent +- [ ] Parse OpenAPI spec only once per test run, currently re-loading in every _TestCase::setUp()_ \ No newline at end of file diff --git a/tests/TestCase/Api/Broods/AddBroodApiTest.php b/tests/TestCase/Api/Broods/AddBroodApiTest.php index 099f3bd..14fb2c5 100644 --- a/tests/TestCase/Api/Broods/AddBroodApiTest.php +++ b/tests/TestCase/Api/Broods/AddBroodApiTest.php @@ -26,12 +26,6 @@ class AddBroodApiTest extends TestCase 'app.Broods' ]; - public function setUp(): void - { - parent::setUp(); - $this->initializeValidator(APP . '../webroot/docs/openapi.yaml'); - } - public function testAddBrood(): void { $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); diff --git a/tests/TestCase/Api/Broods/DeleteBroodsApiTest.php b/tests/TestCase/Api/Broods/DeleteBroodsApiTest.php index 1b8d2ce..94aa0a8 100644 --- a/tests/TestCase/Api/Broods/DeleteBroodsApiTest.php +++ b/tests/TestCase/Api/Broods/DeleteBroodsApiTest.php @@ -26,12 +26,6 @@ class DeleteBroodsApiTest extends TestCase 'app.Broods' ]; - public function setUp(): void - { - parent::setUp(); - $this->initializeValidator(APP . '../webroot/docs/openapi.yaml'); - } - public function testDeleteBrood(): void { $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); diff --git a/tests/TestCase/Api/Broods/EditBroodApiTest.php b/tests/TestCase/Api/Broods/EditBroodApiTest.php index bd15a39..d8fa01f 100644 --- a/tests/TestCase/Api/Broods/EditBroodApiTest.php +++ b/tests/TestCase/Api/Broods/EditBroodApiTest.php @@ -27,12 +27,6 @@ class EditBroodApiTest extends TestCase 'app.Broods' ]; - public function setUp(): void - { - parent::setUp(); - $this->initializeValidator(APP . '../webroot/docs/openapi.yaml'); - } - public function testEditBrood(): void { $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); diff --git a/tests/TestCase/Api/Broods/IndexBroodsApiTest.php b/tests/TestCase/Api/Broods/IndexBroodsApiTest.php index 97d0df8..856b151 100644 --- a/tests/TestCase/Api/Broods/IndexBroodsApiTest.php +++ b/tests/TestCase/Api/Broods/IndexBroodsApiTest.php @@ -26,12 +26,6 @@ class IndexBroodsApiTest extends TestCase 'app.Broods' ]; - public function setUp(): void - { - parent::setUp(); - $this->initializeValidator(APP . '../webroot/docs/openapi.yaml'); - } - public function testIndexBroods(): void { $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); diff --git a/tests/TestCase/Api/Broods/TestBroodConnectionApiTest.php b/tests/TestCase/Api/Broods/TestBroodConnectionApiTest.php new file mode 100644 index 0000000..1084131 --- /dev/null +++ b/tests/TestCase/Api/Broods/TestBroodConnectionApiTest.php @@ -0,0 +1,72 @@ +setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + $this->initializeWireMock(); + $this->mockCerebrateStatusResponse(); + + $url = sprintf('%s/%d', self::ENDPOINT, BroodsFixture::BROOD_WIREMOCK_ID); + $this->get($url); + + $this->getWireMock()->verify( + WireMock::getRequestedFor(WireMock::urlEqualTo('/instance/status.json')) + ->withHeader('Content-Type', WireMock::equalTo('application/json')) + ->withHeader('Authorization', WireMock::equalTo(BroodsFixture::BROOD_WIREMOCK_API_KEY)) + ); + + $this->assertResponseOk(); + $this->assertResponseContains('"user": "wiremock"'); + // TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url); + } + + private function mockCerebrateStatusResponse(): \WireMock\Stubbing\StubMapping + { + return $this->getWireMock()->stubFor( + WireMock::get(WireMock::urlEqualTo('/instance/status.json')) + ->willReturn(WireMock::aResponse() + ->withHeader('Content-Type', 'application/json') + ->withBody((string)json_encode([ + "version" => "0.1", + "application" => "Cerebrate", + "user" => [ + "id" => 1, + "username" => "wiremock", + "role" => [ + "id" => 1 + ] + ] + ]))) + ); + } +} diff --git a/tests/TestCase/Api/Broods/ViewBroodApiTest.php b/tests/TestCase/Api/Broods/ViewBroodApiTest.php index b3f520d..35bb957 100644 --- a/tests/TestCase/Api/Broods/ViewBroodApiTest.php +++ b/tests/TestCase/Api/Broods/ViewBroodApiTest.php @@ -26,12 +26,6 @@ class ViewBroodApiTest extends TestCase 'app.Broods' ]; - public function setUp(): void - { - parent::setUp(); - $this->initializeValidator(APP . '../webroot/docs/openapi.yaml'); - } - public function testViewBroodGroupById(): void { $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); diff --git a/tests/TestCase/Api/Inbox/CreateInboxEntryApiTest.php b/tests/TestCase/Api/Inbox/CreateInboxEntryApiTest.php index 30a44c2..39879f3 100644 --- a/tests/TestCase/Api/Inbox/CreateInboxEntryApiTest.php +++ b/tests/TestCase/Api/Inbox/CreateInboxEntryApiTest.php @@ -25,12 +25,6 @@ class CreateInboxEntryApiTest extends TestCase 'app.AuthKeys' ]; - public function setUp(): void - { - parent::setUp(); - $this->initializeValidator(APP . '../webroot/docs/openapi.yaml'); - } - public function testAddUserRegistrationInbox(): void { $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); diff --git a/tests/TestCase/Api/Inbox/IndexInboxApiTest.php b/tests/TestCase/Api/Inbox/IndexInboxApiTest.php index 00c2cfd..46f1b92 100644 --- a/tests/TestCase/Api/Inbox/IndexInboxApiTest.php +++ b/tests/TestCase/Api/Inbox/IndexInboxApiTest.php @@ -26,12 +26,6 @@ class IndexInboxApiTest extends TestCase 'app.AuthKeys' ]; - public function setUp(): void - { - parent::setUp(); - $this->initializeValidator(APP . '../webroot/docs/openapi.yaml'); - } - public function testIndexInbox(): void { $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); diff --git a/tests/TestCase/Api/Individuals/AddIndividualApiTest.php b/tests/TestCase/Api/Individuals/AddIndividualApiTest.php index 64855e8..0320c30 100644 --- a/tests/TestCase/Api/Individuals/AddIndividualApiTest.php +++ b/tests/TestCase/Api/Individuals/AddIndividualApiTest.php @@ -24,12 +24,6 @@ class AddIndividualApiTest extends TestCase 'app.AuthKeys' ]; - public function setUp(): void - { - parent::setUp(); - $this->initializeValidator(APP . '../webroot/docs/openapi.yaml'); - } - public function testAddIndividual(): void { $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); diff --git a/tests/TestCase/Api/Individuals/DeleteIndividualApiTest.php b/tests/TestCase/Api/Individuals/DeleteIndividualApiTest.php index c493c4b..e50cf63 100644 --- a/tests/TestCase/Api/Individuals/DeleteIndividualApiTest.php +++ b/tests/TestCase/Api/Individuals/DeleteIndividualApiTest.php @@ -25,12 +25,6 @@ class DeleteIndividualApiTest extends TestCase 'app.AuthKeys' ]; - public function setUp(): void - { - parent::setUp(); - $this->initializeValidator(APP . '../webroot/docs/openapi.yaml'); - } - public function testDeleteIndividual(): void { $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); diff --git a/tests/TestCase/Api/Individuals/EditIndividualApiTest.php b/tests/TestCase/Api/Individuals/EditIndividualApiTest.php index fcef7fd..642482c 100644 --- a/tests/TestCase/Api/Individuals/EditIndividualApiTest.php +++ b/tests/TestCase/Api/Individuals/EditIndividualApiTest.php @@ -25,12 +25,6 @@ class EditIndividualApiTest extends TestCase 'app.AuthKeys' ]; - public function setUp(): void - { - parent::setUp(); - $this->initializeValidator(APP . '../webroot/docs/openapi.yaml'); - } - public function testEditIndividualAsAdmin(): void { $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); diff --git a/tests/TestCase/Api/Individuals/IndexIndividualsApiTest.php b/tests/TestCase/Api/Individuals/IndexIndividualsApiTest.php index 55f6be1..d8254d1 100644 --- a/tests/TestCase/Api/Individuals/IndexIndividualsApiTest.php +++ b/tests/TestCase/Api/Individuals/IndexIndividualsApiTest.php @@ -25,12 +25,6 @@ class IndexIndividualsApiTest extends TestCase 'app.AuthKeys' ]; - public function setUp(): void - { - parent::setUp(); - $this->initializeValidator(APP . '../webroot/docs/openapi.yaml'); - } - public function testIndexIndividuals(): void { $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); diff --git a/tests/TestCase/Api/Individuals/ViewIndividualApiTest.php b/tests/TestCase/Api/Individuals/ViewIndividualApiTest.php index 4a0d4a4..5d47610 100644 --- a/tests/TestCase/Api/Individuals/ViewIndividualApiTest.php +++ b/tests/TestCase/Api/Individuals/ViewIndividualApiTest.php @@ -25,12 +25,6 @@ class ViewIndividualApiTest extends TestCase 'app.AuthKeys' ]; - public function setUp(): void - { - parent::setUp(); - $this->initializeValidator(APP . '../webroot/docs/openapi.yaml'); - } - public function testViewIndividualById(): void { $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); diff --git a/tests/TestCase/Api/Organisations/AddOrganisationApiTest.php b/tests/TestCase/Api/Organisations/AddOrganisationApiTest.php index a3f2585..d561292 100644 --- a/tests/TestCase/Api/Organisations/AddOrganisationApiTest.php +++ b/tests/TestCase/Api/Organisations/AddOrganisationApiTest.php @@ -24,12 +24,6 @@ class AddOrganisationApiTest extends TestCase 'app.AuthKeys' ]; - public function setUp(): void - { - parent::setUp(); - $this->initializeValidator(APP . '../webroot/docs/openapi.yaml'); - } - public function testAddOrganisation(): void { $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); diff --git a/tests/TestCase/Api/Organisations/DeleteOrganisationApiTest.php b/tests/TestCase/Api/Organisations/DeleteOrganisationApiTest.php index e16f57b..6a323fb 100644 --- a/tests/TestCase/Api/Organisations/DeleteOrganisationApiTest.php +++ b/tests/TestCase/Api/Organisations/DeleteOrganisationApiTest.php @@ -25,12 +25,6 @@ class DeleteOrganisationApiTest extends TestCase 'app.AuthKeys' ]; - public function setUp(): void - { - parent::setUp(); - $this->initializeValidator(APP . '../webroot/docs/openapi.yaml'); - } - public function testDeleteOrganisation(): void { $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); diff --git a/tests/TestCase/Api/Organisations/EditOrganisationApiTest.php b/tests/TestCase/Api/Organisations/EditOrganisationApiTest.php index d9cc7c6..75c032c 100644 --- a/tests/TestCase/Api/Organisations/EditOrganisationApiTest.php +++ b/tests/TestCase/Api/Organisations/EditOrganisationApiTest.php @@ -25,12 +25,6 @@ class EditOrganisationApiTest extends TestCase 'app.AuthKeys' ]; - public function setUp(): void - { - parent::setUp(); - $this->initializeValidator(APP . '../webroot/docs/openapi.yaml'); - } - public function testEditOrganisation(): void { $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); diff --git a/tests/TestCase/Api/Organisations/IndexOrganisationsApiTest.php b/tests/TestCase/Api/Organisations/IndexOrganisationsApiTest.php index ba7c255..709883a 100644 --- a/tests/TestCase/Api/Organisations/IndexOrganisationsApiTest.php +++ b/tests/TestCase/Api/Organisations/IndexOrganisationsApiTest.php @@ -25,12 +25,6 @@ class IndexOrganisationApiTest extends TestCase 'app.AuthKeys' ]; - public function setUp(): void - { - parent::setUp(); - $this->initializeValidator(APP . '../webroot/docs/openapi.yaml'); - } - public function testIndexOrganisations(): void { $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); diff --git a/tests/TestCase/Api/Organisations/TagOrganisationApiTest.php b/tests/TestCase/Api/Organisations/TagOrganisationApiTest.php index ed74c74..c79109a 100644 --- a/tests/TestCase/Api/Organisations/TagOrganisationApiTest.php +++ b/tests/TestCase/Api/Organisations/TagOrganisationApiTest.php @@ -28,12 +28,6 @@ class TagOrganisationApiTest extends TestCase 'app.AuthKeys' ]; - public function setUp(): void - { - parent::setUp(); - $this->initializeValidator(APP . '../webroot/docs/openapi.yaml'); - } - public function testTagOrganisation(): void { $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); diff --git a/tests/TestCase/Api/Organisations/UntagOrganisationApiTest.php b/tests/TestCase/Api/Organisations/UntagOrganisationApiTest.php index c8878d6..1aff58a 100644 --- a/tests/TestCase/Api/Organisations/UntagOrganisationApiTest.php +++ b/tests/TestCase/Api/Organisations/UntagOrganisationApiTest.php @@ -28,12 +28,6 @@ class UntagOrganisationApiTest extends TestCase 'app.AuthKeys' ]; - public function setUp(): void - { - parent::setUp(); - $this->initializeValidator(APP . '../webroot/docs/openapi.yaml'); - } - public function testUntagOrganisation(): void { $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); diff --git a/tests/TestCase/Api/Organisations/ViewOrganisationApiTest.php b/tests/TestCase/Api/Organisations/ViewOrganisationApiTest.php index 630fd92..7170e98 100644 --- a/tests/TestCase/Api/Organisations/ViewOrganisationApiTest.php +++ b/tests/TestCase/Api/Organisations/ViewOrganisationApiTest.php @@ -28,12 +28,6 @@ class ViewOrganisationApiTest extends TestCase 'app.AuthKeys' ]; - public function setUp(): void - { - parent::setUp(); - $this->initializeValidator(APP . '../webroot/docs/openapi.yaml'); - } - public function testViewOrganisationById(): void { $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); diff --git a/tests/TestCase/Api/SharingGroups/AddSharingGroupApiTest.php b/tests/TestCase/Api/SharingGroups/AddSharingGroupApiTest.php index 45a79aa..0b0c8ab 100644 --- a/tests/TestCase/Api/SharingGroups/AddSharingGroupApiTest.php +++ b/tests/TestCase/Api/SharingGroups/AddSharingGroupApiTest.php @@ -27,12 +27,6 @@ class AddSharingGroupApiTest extends TestCase 'app.SharingGroups' ]; - public function setUp(): void - { - parent::setUp(); - $this->initializeValidator(APP . '../webroot/docs/openapi.yaml'); - } - public function testAddSharingGroup(): void { $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); diff --git a/tests/TestCase/Api/SharingGroups/DeleteSharingGroupApiTest.php b/tests/TestCase/Api/SharingGroups/DeleteSharingGroupApiTest.php index 8b49f3b..c93bb05 100644 --- a/tests/TestCase/Api/SharingGroups/DeleteSharingGroupApiTest.php +++ b/tests/TestCase/Api/SharingGroups/DeleteSharingGroupApiTest.php @@ -26,12 +26,6 @@ class DeleteSharingGroupApiTest extends TestCase 'app.SharingGroups' ]; - public function setUp(): void - { - parent::setUp(); - $this->initializeValidator(APP . '../webroot/docs/openapi.yaml'); - } - public function testDeleteSharingGroup(): void { $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); diff --git a/tests/TestCase/Api/SharingGroups/EditSharingGroupApiTest.php b/tests/TestCase/Api/SharingGroups/EditSharingGroupApiTest.php index 0cf6f05..7de711c 100644 --- a/tests/TestCase/Api/SharingGroups/EditSharingGroupApiTest.php +++ b/tests/TestCase/Api/SharingGroups/EditSharingGroupApiTest.php @@ -27,12 +27,6 @@ class EditSharingGroupApiTest extends TestCase 'app.SharingGroups' ]; - public function setUp(): void - { - parent::setUp(); - $this->initializeValidator(APP . '../webroot/docs/openapi.yaml'); - } - public function testEditSharingGroup(): void { $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); diff --git a/tests/TestCase/Api/SharingGroups/IndexSharingGroupsApiTest.php b/tests/TestCase/Api/SharingGroups/IndexSharingGroupsApiTest.php index 85779aa..82f7255 100644 --- a/tests/TestCase/Api/SharingGroups/IndexSharingGroupsApiTest.php +++ b/tests/TestCase/Api/SharingGroups/IndexSharingGroupsApiTest.php @@ -26,12 +26,6 @@ class IndexSharingGroupsApiTest extends TestCase 'app.SharingGroups' ]; - public function setUp(): void - { - parent::setUp(); - $this->initializeValidator(APP . '../webroot/docs/openapi.yaml'); - } - public function testIndexSharingGroups(): void { $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); diff --git a/tests/TestCase/Api/SharingGroups/ViewSharingGroupApiTest.php b/tests/TestCase/Api/SharingGroups/ViewSharingGroupApiTest.php index 0e86fc7..3944122 100644 --- a/tests/TestCase/Api/SharingGroups/ViewSharingGroupApiTest.php +++ b/tests/TestCase/Api/SharingGroups/ViewSharingGroupApiTest.php @@ -26,12 +26,6 @@ class ViewSharingGroupApiTest extends TestCase 'app.SharingGroups' ]; - public function setUp(): void - { - parent::setUp(); - $this->initializeValidator(APP . '../webroot/docs/openapi.yaml'); - } - public function testViewSharingGroupById(): void { $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); diff --git a/tests/TestCase/Api/Tags/IndexTagsApiTest.php b/tests/TestCase/Api/Tags/IndexTagsApiTest.php index 137151f..330e93f 100644 --- a/tests/TestCase/Api/Tags/IndexTagsApiTest.php +++ b/tests/TestCase/Api/Tags/IndexTagsApiTest.php @@ -25,12 +25,6 @@ class IndexTagsApiTest extends TestCase 'app.AuthKeys' ]; - public function setUp(): void - { - parent::setUp(); - $this->initializeValidator(APP . '../webroot/docs/openapi.yaml'); - } - public function testIndexTags(): void { $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); diff --git a/tests/TestCase/Api/Users/AddUserApiTest.php b/tests/TestCase/Api/Users/AddUserApiTest.php index e60cf3a..396307e 100644 --- a/tests/TestCase/Api/Users/AddUserApiTest.php +++ b/tests/TestCase/Api/Users/AddUserApiTest.php @@ -27,12 +27,6 @@ class AddUserApiTest extends TestCase 'app.AuthKeys' ]; - public function setUp(): void - { - parent::setUp(); - $this->initializeValidator(APP . '../webroot/docs/openapi.yaml'); - } - public function testAddUser(): void { $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); diff --git a/tests/TestCase/Api/Users/DeleteUserApiTest.php b/tests/TestCase/Api/Users/DeleteUserApiTest.php index 69bd87c..50df2e5 100644 --- a/tests/TestCase/Api/Users/DeleteUserApiTest.php +++ b/tests/TestCase/Api/Users/DeleteUserApiTest.php @@ -27,12 +27,6 @@ class DeleteUserApiTest extends TestCase 'app.AuthKeys' ]; - public function setUp(): void - { - parent::setUp(); - $this->initializeValidator(APP . '../webroot/docs/openapi.yaml'); - } - public function testDeleteUser(): void { $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); diff --git a/tests/TestCase/Api/Users/EditUserApiTest.php b/tests/TestCase/Api/Users/EditUserApiTest.php index a8afa11..8d2810d 100644 --- a/tests/TestCase/Api/Users/EditUserApiTest.php +++ b/tests/TestCase/Api/Users/EditUserApiTest.php @@ -26,12 +26,6 @@ class EditUserApiTest extends TestCase 'app.AuthKeys' ]; - public function setUp(): void - { - parent::setUp(); - $this->initializeValidator(APP . '../webroot/docs/openapi.yaml'); - } - public function testEditUser(): void { $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); diff --git a/tests/TestCase/Api/Users/IndexUsersApiTest.php b/tests/TestCase/Api/Users/IndexUsersApiTest.php index 403a046..7d96936 100644 --- a/tests/TestCase/Api/Users/IndexUsersApiTest.php +++ b/tests/TestCase/Api/Users/IndexUsersApiTest.php @@ -25,12 +25,6 @@ class IndexUsersApiTest extends TestCase 'app.AuthKeys' ]; - public function setUp(): void - { - parent::setUp(); - $this->initializeValidator(APP . '../webroot/docs/openapi.yaml'); - } - public function testIndexUsers(): void { $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); diff --git a/tests/TestCase/Api/Users/ViewUserApiTest.php b/tests/TestCase/Api/Users/ViewUserApiTest.php index 99bdafe..d1d2c54 100644 --- a/tests/TestCase/Api/Users/ViewUserApiTest.php +++ b/tests/TestCase/Api/Users/ViewUserApiTest.php @@ -25,12 +25,6 @@ class ViewUserApiTest extends TestCase 'app.AuthKeys' ]; - public function setUp(): void - { - parent::setUp(); - $this->initializeValidator(APP . '../webroot/docs/openapi.yaml'); - } - public function testViewMyUser(): void { $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index c9a8b8c..34939c3 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -18,7 +18,6 @@ declare(strict_types=1); use Cake\Core\Configure; use Cake\Datasource\ConnectionManager; -use Cake\TestSuite\Fixture\SchemaLoader; use Migrations\TestSuite\Migrator; /** @@ -54,8 +53,7 @@ ConnectionManager::alias('test_debug_kit', 'debug_kit'); // has been written to. session_id('cli'); -// hacky way to skip migrations -if (!in_array('skip-migrations', $_SERVER['argv'])) { +if (!$_ENV['SKIP_DB_MIGRATIONS']) { echo "[ * ] Running DB migrations, it may take some time ...\n"; $migrator = new Migrator(); $migrator->runMany([ diff --git a/webroot/docs/openapi.yaml b/webroot/docs/openapi.yaml index 81ac9aa..f9418b9 100644 --- a/webroot/docs/openapi.yaml +++ b/webroot/docs/openapi.yaml @@ -613,6 +613,24 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" + /api/v1/broods/testConnection/{broodId}: + get: + summary: "Test brood connection by ID" + operationId: testBroodConnectionById + tags: + - Broods + parameters: + - $ref: "#/components/parameters/broodId" + responses: + "200": + $ref: "#/components/responses/TestBroodConnectionResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + components: schemas: # General @@ -714,6 +732,15 @@ components: $ref: "#/components/schemas/DateTime" organisation_id: $ref: "#/components/schemas/ID" + organisation: + $ref: "#/components/schemas/Organisation" + individual: + $ref: "#/components/schemas/Individual" + role: + $ref: "#/components/schemas/Role" + # user_settings: TODO + # user_settings_by_name: TODO + # user_settings_by_name_with_fallback: TODO UserList: type: array @@ -1605,6 +1632,33 @@ components: schema: $ref: "#/components/schemas/BroodList" + TestBroodConnectionResponse: + description: "Brood list response" + content: + application/json: + schema: + type: object + properties: + code: + type: integer + description: "HTTP status code" + example: 200 + response: + type: object + properties: + version: + type: string + example: "0.1" + application: + type: string + example: "Cerebrate" + user: + type: string + example: "sync" + ping: + type: number + format: float + # Errors ApiErrorResponse: description: "Unexpected API error" From 6d13d4aba0082a4bec862a8595ef3d50fe3574e8 Mon Sep 17 00:00:00 2001 From: iglocska Date: Mon, 17 Jan 2022 17:16:03 +0100 Subject: [PATCH 127/150] fix: [authkeys] tighten requirements to add authkeys for other org admins - site admin: can add to all - org admin: can add to all in org, except site admin - everyone else: can add to self only --- src/Controller/AuthKeysController.php | 28 ++++++++++++++++++---- src/Controller/Component/CRUDComponent.php | 3 +++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/Controller/AuthKeysController.php b/src/Controller/AuthKeysController.php index 9e8ac03..454b922 100644 --- a/src/Controller/AuthKeysController.php +++ b/src/Controller/AuthKeysController.php @@ -64,8 +64,30 @@ class AuthKeysController extends AppController public function add() { $this->set('metaGroup', $this->isAdmin ? 'Administration' : 'Cerebrate'); + $validUsers = []; + $userConditions = []; + $currentUser = $this->ACL->getUser(); + if (empty($currentUser['role']['perm_admin'])) { + if (empty($currentUser['role']['perm_org_admin'])) { + $userConditions['id'] = $currentUser['id']; + } else { + $role_ids = $this->Users->Roles->find()->where(['perm_admin' => 0])->all()->extract('id')->toList(); + $userConditions['role_id IN'] = $role_ids; + } + } + $users = $this->Users->find('list'); + if (!empty($userConditions)) { + $users->where($userConditions); + } + $users = $users->order(['username' => 'asc'])->all()->toList(); $this->CRUD->add([ - 'displayOnSuccess' => 'authkey_display' + 'displayOnSuccess' => 'authkey_display', + 'beforeSave' => function($data) use ($users) { + if (!in_array($data['user_id'], array_keys($users))) { + return false; + } + return $data; + } ]); $responsePayload = $this->CRUD->getResponsePayload([ 'displayOnSuccess' => 'authkey_display' @@ -75,9 +97,7 @@ class AuthKeysController extends AppController } $this->loadModel('Users'); $dropdownData = [ - 'user' => $this->Users->find('list', [ - 'sort' => ['username' => 'asc'] - ]) + 'user' => $users ]; $this->set(compact('dropdownData')); } diff --git a/src/Controller/Component/CRUDComponent.php b/src/Controller/Component/CRUDComponent.php index 4ebabff..4ebc674 100644 --- a/src/Controller/Component/CRUDComponent.php +++ b/src/Controller/Component/CRUDComponent.php @@ -175,6 +175,9 @@ class CRUDComponent extends Component $data = $this->Table->patchEntity($data, $input, $patchEntityParams); if (isset($params['beforeSave'])) { $data = $params['beforeSave']($data); + if ($data === false) { + throw new NotFoundException(__('Could not save {0} due to the input failing to meet expectations. Your input is bad and you should feel bad.', $this->ObjectAlias)); + } } $savedData = $this->Table->save($data); if ($savedData !== false) { From b80d778e1a42ef3bbb0e2fa1d05345c9772111d8 Mon Sep 17 00:00:00 2001 From: iglocska Date: Tue, 18 Jan 2022 00:17:47 +0100 Subject: [PATCH 128/150] fix: [encryption keys] tightened ACL across all CRUD functions --- src/Controller/EncryptionKeysController.php | 103 ++++++++++++-------- 1 file changed, 60 insertions(+), 43 deletions(-) diff --git a/src/Controller/EncryptionKeysController.php b/src/Controller/EncryptionKeysController.php index 57dfc4c..e04ebc0 100644 --- a/src/Controller/EncryptionKeysController.php +++ b/src/Controller/EncryptionKeysController.php @@ -39,7 +39,15 @@ class EncryptionKeysController extends AppController public function delete($id) { - $this->CRUD->delete($id); + $orgConditions = []; + $individualConditions = []; + $dropdownData = []; + $currentUser = $this->ACL->getUser(); + $params = []; + if (empty($currentUser['role']['perm_admin'])) { + $params = $this->buildBeforeSave($params, $currentUser, $orgConditions, $individualConditions, $dropdownData); + } + $this->CRUD->delete($id, $params); $responsePayload = $this->CRUD->getResponsePayload(); if (!empty($responsePayload)) { return $responsePayload; @@ -47,49 +55,38 @@ class EncryptionKeysController extends AppController $this->set('metaGroup', 'ContactDB'); } - public function add() + private function buildBeforeSave(array $params, $currentUser, array &$orgConditions, array &$individualConditions, array &$dropdownData): array { - $orgConditions = []; - $individualConditions = []; - $currentUser = $this->ACL->getUser(); - $params = ['redirect' => $this->referer()]; - if (empty($currentUser['role']['perm_admin'])) { - $orgConditions = [ - 'id' => $currentUser['organisation_id'] + $orgConditions = [ + 'id' => $currentUser['organisation_id'] + ]; + if (empty($currentUser['role']['perm_org_admin'])) { + $individualConditions = [ + 'id' => $currentUser['individual_id'] ]; - if (empty($currentUser['role']['perm_org_admin'])) { - $individualConditions = [ - 'id' => $currentUser['individual_id'] - ]; - } - $params['beforeSave'] = function($entity) use($currentUser) { - if ($entity['owner_model'] === 'organisation') { - $entity['owner_id'] = $currentUser['organisation_id']; + } + $params['beforeSave'] = function($entity) use($currentUser) { + if ($entity['owner_model'] === 'organisation') { + $entity['owner_id'] = $currentUser['organisation_id']; + } else { + if ($currentUser['role']['perm_org_admin']) { + $this->loadModel('Alignments'); + $validIndividuals = $this->Alignments->find('list', [ + 'keyField' => 'individual_id', + 'valueField' => 'id', + 'conditions' => ['organisation_id' => $currentUser['organisation_id']] + ])->toArray(); + if (!isset($validIndividuals[$entity['owner_id']])) { + throw new MethodNotAllowedException(__('Selected individual cannot be linked by the current user.')); + } } else { - if ($currentUser['role']['perm_org_admin']) { - $this->loadModel('Alignments'); - $validIndividuals = $this->Alignments->find('list', [ - 'keyField' => 'individual_id', - 'valueField' => 'id', - 'conditions' => ['organisation_id' => $currentUser['organisation_id']] - ])->toArray(); - if (!isset($validIndividuals[$entity['owner_id']])) { - throw new MethodNotAllowedException(__('Selected individual cannot be linked by the current user.')); - } - } else { - if ($entity['owner_id'] !== $currentUser['id']) { - throw new MethodNotAllowedException(__('Selected individual cannot be linked by the current user.')); - } + if ($entity['owner_id'] !== $currentUser['id']) { + throw new MethodNotAllowedException(__('Selected individual cannot be linked by the current user.')); } } - return $entity; - }; - } - $this->CRUD->add($params); - $responsePayload = $this->CRUD->getResponsePayload(); - if (!empty($responsePayload)) { - return $responsePayload; - } + } + return $entity; + }; $this->loadModel('Organisations'); $this->loadModel('Individuals'); $dropdownData = [ @@ -102,13 +99,35 @@ class EncryptionKeysController extends AppController 'conditions' => $individualConditions ]) ]; + return $params; + } + + public function add() + { + $orgConditions = []; + $individualConditions = []; + $dropdownData = []; + $currentUser = $this->ACL->getUser(); + $params = [ + 'redirect' => $this->referer() + ]; + if (empty($currentUser['role']['perm_admin'])) { + $params = $this->buildBeforeSave($params, $currentUser, $orgConditions, $individualConditions, $dropdownData); + } + $this->CRUD->add($params); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } $this->set(compact('dropdownData')); $this->set('metaGroup', 'ContactDB'); } public function edit($id = false) { - $conditions = []; + $orgConditions = []; + $individualConditions = []; + $dropdownData = []; $currentUser = $this->ACL->getUser(); $params = [ 'fields' => [ @@ -117,9 +136,7 @@ class EncryptionKeysController extends AppController 'redirect' => $this->referer() ]; if (empty($currentUser['role']['perm_admin'])) { - if (empty($currentUser['role']['perm_org_admin'])) { - - } + $params = $this->buildBeforeSave($params, $currentUser, $orgConditions, $individualConditions, $dropdownData); } $this->CRUD->edit($id, $params); $responsePayload = $this->CRUD->getResponsePayload(); From ec994b05edfda10585433544ee278d2d9eee1055 Mon Sep 17 00:00:00 2001 From: iglocska Date: Tue, 18 Jan 2022 00:20:53 +0100 Subject: [PATCH 129/150] chg: [user] edit restricted to password only for self --- src/Controller/UsersController.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Controller/UsersController.php b/src/Controller/UsersController.php index 3e389de..a5065db 100644 --- a/src/Controller/UsersController.php +++ b/src/Controller/UsersController.php @@ -119,12 +119,15 @@ class UsersController extends AppController 'password' ], 'fields' => [ - 'id', 'individual_id', 'username', 'disabled', 'password', 'confirm_password' + 'password', 'confirm_password' ] ]; if (!empty($this->ACL->getUser()['role']['perm_admin'])) { + $params['fields'][] = 'individual_id'; + $params['fields'][] = 'username'; $params['fields'][] = 'role_id'; $params['fields'][] = 'organisation_id'; + $params['fields'][] = 'disabled'; } $this->CRUD->edit($id, $params); $responsePayload = $this->CRUD->getResponsePayload(); From 5eeda6b6825c55d488645fa0aa7c71ac67450506 Mon Sep 17 00:00:00 2001 From: Sami Mokaddem Date: Tue, 18 Jan 2022 11:51:54 +0100 Subject: [PATCH 130/150] new: [localtool:commonConnectorTools] Added new logger for each local tools --- .../CommonConnectorTools.php | 31 +++++++++++++++++++ .../local_tool_connectors/MispConnector.php | 13 ++++++-- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/Lib/default/local_tool_connectors/CommonConnectorTools.php b/src/Lib/default/local_tool_connectors/CommonConnectorTools.php index 179a238..213db17 100644 --- a/src/Lib/default/local_tool_connectors/CommonConnectorTools.php +++ b/src/Lib/default/local_tool_connectors/CommonConnectorTools.php @@ -2,6 +2,8 @@ namespace CommonConnectorTools; use Cake\ORM\Locator\LocatorAwareTrait; +use Cake\Log\Log; +use Cake\Log\Engine\FileLog; class CommonConnectorTools { @@ -20,6 +22,35 @@ class CommonConnectorTools const STATE_CANCELLED = 'Request cancelled'; const STATE_DECLINED = 'Request declined by remote'; + public function __construct() + { + Log::setConfig("LocalToolDebug", [ + 'className' => FileLog::class, + 'path' => LOGS, + 'file' => "{$this->connectorName}-debug", + 'scopes' => [$this->connectorName], + 'levels' => ['notice', 'info', 'debug'], + ]); + Log::setConfig("LocalToolError", [ + 'className' => FileLog::class, + 'path' => LOGS, + 'file' => "{$this->connectorName}-error", + 'scopes' => [$this->connectorName], + 'levels' => ['warning', 'error', 'critical', 'alert', 'emergency'], + ]); + + } + + protected function logDebug($message) + { + Log::debug($message, [$this->connectorName]); + } + + protected function logError($message, $scope=[]) + { + Log::error($message, [$this->connectorName]); + } + public function addExposedFunction(string $functionName): void { $this->exposedFunctions[] = $functionName; diff --git a/src/Lib/default/local_tool_connectors/MispConnector.php b/src/Lib/default/local_tool_connectors/MispConnector.php index a021839..4b6c653 100644 --- a/src/Lib/default/local_tool_connectors/MispConnector.php +++ b/src/Lib/default/local_tool_connectors/MispConnector.php @@ -188,6 +188,7 @@ class MispConnector extends CommonConnectorTools $settings = json_decode($connection->settings, true); $http = $this->genHTTPClient($connection, $options); $url = sprintf('%s%s', $settings['url'], $relativeURL); + $this->logDebug(sprintf('%s %s %s', __('Posting data') . PHP_EOL, "POST {$url}" . PHP_EOL, json_encode($data))); return $http->post($url, $data, $options); } @@ -239,14 +240,18 @@ class MispConnector extends CommonConnectorTools if (!empty($params['softError'])) { return $response; } - throw new NotFoundException(__('Could not retrieve the requested resource.')); + $errorMsg = __('Could not post to the requested resource for `{0}`. Remote returned:', $url) . PHP_EOL . $response->getStringBody(); + $this->logError($errorMsg); + throw new NotFoundException($errorMsg); } } private function postData(string $url, array $params): Response { if (empty($params['connection'])) { - throw new NotFoundException(__('No connection object received.')); + $errorMsg = __('No connection object received.'); + $this->logError($errorMsg); + throw new NotFoundException($errorMsg); } $url = $this->urlAppendParams($url, $params); if (!is_string($params['body'])) { @@ -256,7 +261,9 @@ class MispConnector extends CommonConnectorTools if ($response->isOk()) { return $response; } else { - throw new NotFoundException(__('Could not post to the requested resource. Remote returned:') . PHP_EOL . $response->getStringBody()); + $errorMsg = __('Could not post to the requested resource for `{0}`. Remote returned:', $url) . PHP_EOL . $response->getStringBody(); + $this->logError($errorMsg); + throw new NotFoundException($errorMsg); } } From b1ad454db8eac8c639569af157ca933948f89a5c Mon Sep 17 00:00:00 2001 From: Luciano Righetti Date: Tue, 18 Jan 2022 14:29:27 +0100 Subject: [PATCH 131/150] add: api tests for /encryptionkeys, extend openapi spec --- tests/Fixture/EncryptionKeysFixture.php | 133 ++++++++++++++++++ .../DeleteEncryptionKeyApiTest.php | 54 +++++++ .../IndexEncryptionKeysApiTest.php | 40 ++++++ webroot/docs/openapi.yaml | 128 ++++++++++++++++- 4 files changed, 352 insertions(+), 3 deletions(-) create mode 100644 tests/Fixture/EncryptionKeysFixture.php create mode 100644 tests/TestCase/Api/EncryptionKeys/DeleteEncryptionKeyApiTest.php create mode 100644 tests/TestCase/Api/EncryptionKeys/IndexEncryptionKeysApiTest.php diff --git a/tests/Fixture/EncryptionKeysFixture.php b/tests/Fixture/EncryptionKeysFixture.php new file mode 100644 index 0000000..05b92f8 --- /dev/null +++ b/tests/Fixture/EncryptionKeysFixture.php @@ -0,0 +1,133 @@ +records = [ + [ + 'id' => self::ENCRYPTION_KEY_ORG_A_ID, + 'uuid' => $faker->uuid(), + 'type' => self::TYPE_PGP, + 'encryption_key' => $this->getPublicKey(self::KEY_TYPE_EDCH), + 'revoked' => false, + 'expires' => null, + 'owner_id' => OrganisationsFixture::ORGANISATION_A_ID, + 'owner_model' => 'Organisation', + 'created' => $faker->dateTime()->getTimestamp(), + 'modified' => $faker->dateTime()->getTimestamp() + ], + [ + 'id' => self::ENCRYPTION_KEY_ORG_B_ID, + 'uuid' => $faker->uuid(), + 'type' => self::TYPE_PGP, + 'encryption_key' => $this->getPublicKey(self::KEY_TYPE_EDCH), + 'revoked' => false, + 'expires' => null, + 'owner_id' => OrganisationsFixture::ORGANISATION_A_ID, + 'owner_model' => 'Organisation', + 'created' => $faker->dateTime()->getTimestamp(), + 'modified' => $faker->dateTime()->getTimestamp() + ], + ]; + parent::init(); + } + + public function getPublicKey(string $type): string + { + switch ($type) { + case self::KEY_TYPE_EDCH: + return <<setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + $url = sprintf('%s/%d', self::ENDPOINT, EncryptionKeysFixture::ENCRYPTION_KEY_ORG_A_ID); + $this->delete($url); + + $this->assertResponseOk(); + $this->assertDbRecordNotExists('EncryptionKeys', ['id' => EncryptionKeysFixture::ENCRYPTION_KEY_ORG_A_ID]); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url, 'delete'); + $this->addWarning('TODO: CRUDComponent::delete() sets some view variables, does not take into account `isRest()`, fix it.'); + } + + public function testDeleteEncryptionKeyNotAllowedAsRegularUser(): void + { + $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); + $url = sprintf('%s/%d', self::ENDPOINT, EncryptionKeysFixture::ENCRYPTION_KEY_ORG_B_ID); + $this->delete($url); + + $this->assertResponseCode(405); + $this->assertDbRecordExists('EncryptionKeys', ['id' => EncryptionKeysFixture::ENCRYPTION_KEY_ORG_B_ID]); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url, 'delete'); + $this->addWarning('TODO: CRUDComponent::delete() sets some view variables, does not take into account `isRest()`, fix it.'); + } +} diff --git a/tests/TestCase/Api/EncryptionKeys/IndexEncryptionKeysApiTest.php b/tests/TestCase/Api/EncryptionKeys/IndexEncryptionKeysApiTest.php new file mode 100644 index 0000000..0b7cb98 --- /dev/null +++ b/tests/TestCase/Api/EncryptionKeys/IndexEncryptionKeysApiTest.php @@ -0,0 +1,40 @@ +setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + $this->get(self::ENDPOINT); + + $this->assertResponseOk(); + $this->assertResponseContains(sprintf('"id": %d', EncryptionKeysFixture::ENCRYPTION_KEY_ORG_A_ID)); + // TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT); + } +} diff --git a/webroot/docs/openapi.yaml b/webroot/docs/openapi.yaml index f9418b9..c4852eb 100644 --- a/webroot/docs/openapi.yaml +++ b/webroot/docs/openapi.yaml @@ -23,6 +23,8 @@ tags: description: "Sharing groups are distribution lists usable by tools that can exchange information with a list of trusted partners. Create recurring or ad hoc sharing groups and share them with the members of the sharing group." - name: Broods description: "Cerebrate can connect to other Cerebrate instances to exchange trust information and to instrument interconnectivity between connected local tools. Each such Cerebrate instance with its connected tools is considered to be a brood." + - name: EncryptionKeys + description: "Assign encryption keys to the user, used to securely communicate or validate messages coming from the user." paths: /api/v1/individuals/index: @@ -575,7 +577,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/broods/edit/{sharingGroupId}: + /api/v1/broods/edit/{broodId}: put: summary: "Edit brood" operationId: editBrood @@ -631,6 +633,43 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" + # EncryptionKeys + /api/v1/encryptionKeys/index: + get: + summary: "Get encryption keys list" + operationId: getEncryptionKeys + tags: + - EncryptionKeys + parameters: + - $ref: "#/components/parameters/quickFilter" + responses: + "200": + $ref: "#/components/responses/EncryptionKeyListResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + + /api/v1/encryptionKeys/delete/{encryptionKeyId}: + delete: + summary: "Delete encryption key by ID" + operationId: deleteEncryptionKeyById + tags: + - EncryptionKeys + parameters: + - $ref: "#/components/parameters/encryptionKeyId" + responses: + "200": + $ref: "#/components/responses/EncryptionKeyResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + components: schemas: # General @@ -658,6 +697,18 @@ components: AuthKey: type: string + ModelName: + type: string + enum: + - "Organisation" + - "User" + - "Individual" + - "EncryptionKey" + - "Role" + - "Tag" + - "SharingGroup" + - "Brood" + # Individuals IndividualFirstName: type: string @@ -1089,18 +1140,66 @@ components: type: boolean authkey: $ref: "#/components/schemas/AuthKey" + organisation: + $ref: "#/components/schemas/Organisation" created: $ref: "#/components/schemas/DateTime" modified: $ref: "#/components/schemas/DateTime" - organisation: - $ref: "#/components/schemas/Organisation" BroodList: type: array items: $ref: "#/components/schemas/Brood" + # EncryptionKeys + EncryptionKeyType: + type: string + enum: + - "pgp" + - "smime" + + EncryptionKeyValue: + type: string + example: | + -----BEGIN PGP PUBLIC KEY BLOCK----- + ... + -----END PGP PUBLIC KEY BLOCK----- + + EncryptionKeyExpiration: + type: integer + description: "Timestamp or null of there is no expiration" + nullable: true + + EncryptionKey: + type: object + properties: + id: + $ref: "#/components/schemas/ID" + uuid: + $ref: "#/components/schemas/UUID" + type: + $ref: "#/components/schemas/EncryptionKeyType" + encryption_key: + $ref: "#/components/schemas/EncryptionKeyValue" + revoked: + type: boolean + expires: + $ref: "#/components/schemas/EncryptionKeyExpiration" + owner_id: + $ref: "#/components/schemas/ID" + owner_model: + $ref: "#/components/schemas/ModelName" + created: + $ref: "#/components/schemas/DateTime" + modified: + $ref: "#/components/schemas/DateTime" + + EncryptionKeyList: + type: array + items: + $ref: "#/components/schemas/EncryptionKey" + # Errors ApiError: type: object @@ -1210,6 +1309,14 @@ components: schema: $ref: "#/components/schemas/ID" + encryptionKeyId: + name: encryptionKeyId + in: path + description: "Numeric ID of the EncryptionKey" + required: true + schema: + $ref: "#/components/schemas/ID" + quickFilter: name: quickFilter in: query @@ -1659,6 +1766,21 @@ components: type: number format: float + # EncryptionKeys + EncryptionKeyResponse: + description: "Encryption key response" + content: + application/json: + schema: + $ref: "#/components/schemas/EncryptionKey" + + EncryptionKeyListResponse: + description: "Encryption key list response" + content: + application/json: + schema: + $ref: "#/components/schemas/EncryptionKeyList" + # Errors ApiErrorResponse: description: "Unexpected API error" From c35d67ebcae81a17d5c627b57bfba27c2dd87eb8 Mon Sep 17 00:00:00 2001 From: iglocska Date: Tue, 18 Jan 2022 14:59:41 +0100 Subject: [PATCH 132/150] fix: [encryption keys] functionality to filter orgs/individuals fixed - actually execute the query rather than just build it --- src/Controller/EncryptionKeysController.php | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/Controller/EncryptionKeysController.php b/src/Controller/EncryptionKeysController.php index e04ebc0..ff2c16c 100644 --- a/src/Controller/EncryptionKeysController.php +++ b/src/Controller/EncryptionKeysController.php @@ -90,14 +90,8 @@ class EncryptionKeysController extends AppController $this->loadModel('Organisations'); $this->loadModel('Individuals'); $dropdownData = [ - 'organisation' => $this->Organisations->find('list', [ - 'sort' => ['name' => 'asc'], - 'conditions' => $orgConditions - ]), - 'individual' => $this->Individuals->find('list', [ - 'sort' => ['email' => 'asc'], - 'conditions' => $individualConditions - ]) + 'organisation' => $this->Organisations->find('list')->order(['name' => 'asc'])->where($orgConditions)->all()->toArray(), + 'individual' => $this->Individuals->find('list')->order(['email' => 'asc'])->where($individualConditions)->all()->toArray() ]; return $params; } From 8cb24baf5f1a4f52f68c532a8e55068b948ac131 Mon Sep 17 00:00:00 2001 From: iglocska Date: Tue, 18 Jan 2022 15:35:55 +0100 Subject: [PATCH 133/150] fix: [ACL] tightening for delete functions - implemented beforeSave() function in the CRUD::delete() functionality - added correct handling for the organisation level encryption keys in the beforeSave constructor --- src/Controller/Component/CRUDComponent.php | 32 +++++++++--- src/Controller/EncryptionKeysController.php | 58 +++++++++++---------- 2 files changed, 54 insertions(+), 36 deletions(-) diff --git a/src/Controller/Component/CRUDComponent.php b/src/Controller/Component/CRUDComponent.php index 4ebc674..0adfcdf 100644 --- a/src/Controller/Component/CRUDComponent.php +++ b/src/Controller/Component/CRUDComponent.php @@ -442,6 +442,12 @@ class CRUDComponent extends Component if (empty($data)) { throw new NotFoundException(__('Invalid {0}.', $this->ObjectAlias)); } + if (isset($params['beforeSave'])) { + $data = $params['beforeSave']($data); + if ($data === false) { + throw new NotFoundException(__('Could not save {0} due to the input failing to meet expectations. Your input is bad and you should feel bad.', $this->ObjectAlias)); + } + } $this->Controller->set('id', $data['id']); $this->Controller->set('data', $data); $this->Controller->set('bulkEnabled', false); @@ -453,6 +459,7 @@ class CRUDComponent extends Component $isBulk = count($ids) > 1; $bulkSuccesses = 0; foreach ($ids as $id) { + $skipExecution = false; $data = $this->Table->find()->where([$this->Table->getAlias() . '.id' => $id]); if (!empty($params['conditions'])) { $data->where($params['conditions']); @@ -460,15 +467,24 @@ class CRUDComponent extends Component if (!empty($params['contain'])) { $data->contain($params['contain']); } - $data = $data->first(); - if (!empty($data)) { - $success = $this->Table->delete($data); - $success = true; - } else { - $success = false; + if (isset($params['beforeSave'])) { + $data = $params['beforeSave']($data); + if ($data === false) { + $skipExecution = true; + $success = false; + } } - if ($success) { - $bulkSuccesses++; + if (!$skipExecution) { + $data = $data->first(); + if (!empty($data)) { + $success = $this->Table->delete($data); + $success = true; + } else { + $success = false; + } + if ($success) { + $bulkSuccesses++; + } } } $message = $this->getMessageBasedOnResult( diff --git a/src/Controller/EncryptionKeysController.php b/src/Controller/EncryptionKeysController.php index ff2c16c..324decb 100644 --- a/src/Controller/EncryptionKeysController.php +++ b/src/Controller/EncryptionKeysController.php @@ -57,36 +57,40 @@ class EncryptionKeysController extends AppController private function buildBeforeSave(array $params, $currentUser, array &$orgConditions, array &$individualConditions, array &$dropdownData): array { - $orgConditions = [ - 'id' => $currentUser['organisation_id'] - ]; - if (empty($currentUser['role']['perm_org_admin'])) { - $individualConditions = [ - 'id' => $currentUser['individual_id'] + if (empty($currentUser['role']['perm_admin'])) { + $orgConditions = [ + 'id' => $currentUser['organisation_id'] ]; - } - $params['beforeSave'] = function($entity) use($currentUser) { - if ($entity['owner_model'] === 'organisation') { - $entity['owner_id'] = $currentUser['organisation_id']; - } else { - if ($currentUser['role']['perm_org_admin']) { - $this->loadModel('Alignments'); - $validIndividuals = $this->Alignments->find('list', [ - 'keyField' => 'individual_id', - 'valueField' => 'id', - 'conditions' => ['organisation_id' => $currentUser['organisation_id']] - ])->toArray(); - if (!isset($validIndividuals[$entity['owner_id']])) { - throw new MethodNotAllowedException(__('Selected individual cannot be linked by the current user.')); + if (empty($currentUser['role']['perm_org_admin'])) { + $individualConditions = [ + 'id' => $currentUser['individual_id'] + ]; + } + $params['beforeSave'] = function($entity) use($currentUser) { + if ($entity['owner_model'] === 'organisation') { + if ($entity['owner_id'] !== $currentUser['organisation_id']) { + throw new MethodNotAllowedException(__('Selected organisation cannot be linked by the current user.')); } } else { - if ($entity['owner_id'] !== $currentUser['id']) { - throw new MethodNotAllowedException(__('Selected individual cannot be linked by the current user.')); + if ($currentUser['role']['perm_org_admin']) { + $this->loadModel('Alignments'); + $validIndividuals = $this->Alignments->find('list', [ + 'keyField' => 'individual_id', + 'valueField' => 'id', + 'conditions' => ['organisation_id' => $currentUser['organisation_id']] + ])->toArray(); + if (!isset($validIndividuals[$entity['owner_id']])) { + throw new MethodNotAllowedException(__('Selected individual cannot be linked by the current user.')); + } + } else { + if ($entity['owner_id'] !== $currentUser['id']) { + throw new MethodNotAllowedException(__('Selected individual cannot be linked by the current user.')); + } } } - } - return $entity; - }; + return $entity; + }; + } $this->loadModel('Organisations'); $this->loadModel('Individuals'); $dropdownData = [ @@ -105,9 +109,7 @@ class EncryptionKeysController extends AppController $params = [ 'redirect' => $this->referer() ]; - if (empty($currentUser['role']['perm_admin'])) { - $params = $this->buildBeforeSave($params, $currentUser, $orgConditions, $individualConditions, $dropdownData); - } + $params = $this->buildBeforeSave($params, $currentUser, $orgConditions, $individualConditions, $dropdownData); $this->CRUD->add($params); $responsePayload = $this->CRUD->getResponsePayload(); if (!empty($responsePayload)) { From e6daa63064f532062b105612add26537b48d4d61 Mon Sep 17 00:00:00 2001 From: Luciano Righetti Date: Tue, 18 Jan 2022 16:11:00 +0100 Subject: [PATCH 134/150] add: more encription keys api endpoints covered --- tests/Fixture/EncryptionKeysFixture.php | 6 +- .../AddEncryptionKeyApiTest.php | 82 ++++++++++++++ .../EditEncryptionKeyApiTest.php | 76 +++++++++++++ .../ViewEncryptionKeyApiTest.php | 40 +++++++ webroot/docs/openapi.yaml | 100 ++++++++++++++++++ 5 files changed, 301 insertions(+), 3 deletions(-) create mode 100644 tests/TestCase/Api/EncryptionKeys/AddEncryptionKeyApiTest.php create mode 100644 tests/TestCase/Api/EncryptionKeys/EditEncryptionKeyApiTest.php create mode 100644 tests/TestCase/Api/EncryptionKeys/ViewEncryptionKeyApiTest.php diff --git a/tests/Fixture/EncryptionKeysFixture.php b/tests/Fixture/EncryptionKeysFixture.php index 05b92f8..a9d6c27 100644 --- a/tests/Fixture/EncryptionKeysFixture.php +++ b/tests/Fixture/EncryptionKeysFixture.php @@ -45,7 +45,7 @@ class EncryptionKeysFixture extends TestFixture 'encryption_key' => $this->getPublicKey(self::KEY_TYPE_EDCH), 'revoked' => false, 'expires' => null, - 'owner_id' => OrganisationsFixture::ORGANISATION_A_ID, + 'owner_id' => OrganisationsFixture::ORGANISATION_B_ID, 'owner_model' => 'Organisation', 'created' => $faker->dateTime()->getTimestamp(), 'modified' => $faker->dateTime()->getTimestamp() @@ -54,7 +54,7 @@ class EncryptionKeysFixture extends TestFixture parent::init(); } - public function getPublicKey(string $type): string + public static function getPublicKey(string $type): string { switch ($type) { case self::KEY_TYPE_EDCH: @@ -90,7 +90,7 @@ class EncryptionKeysFixture extends TestFixture } } - private function getPrivateKey(string $type): string + private static function getPrivateKey(string $type): string { switch ($type) { case self::KEY_TYPE_EDCH: diff --git a/tests/TestCase/Api/EncryptionKeys/AddEncryptionKeyApiTest.php b/tests/TestCase/Api/EncryptionKeys/AddEncryptionKeyApiTest.php new file mode 100644 index 0000000..b876800 --- /dev/null +++ b/tests/TestCase/Api/EncryptionKeys/AddEncryptionKeyApiTest.php @@ -0,0 +1,82 @@ +setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + + $faker = \Faker\Factory::create(); + $uuid = $faker->uuid; + + $this->post( + self::ENDPOINT, + [ + 'uuid' => $uuid, + 'type' => EncryptionKeysFixture::TYPE_PGP, + 'encryption_key' => EncryptionKeysFixture::getPublicKey(EncryptionKeysFixture::KEY_TYPE_EDCH), + 'revoked' => false, + 'expires' => null, + 'owner_id' => UsersFixture::USER_ADMIN_ID, + 'owner_model' => 'User' + ] + ); + + $this->assertResponseOk(); + $this->assertResponseContains(sprintf('"uuid": "%s"', $uuid)); + $this->assertDbRecordExists('EncryptionKeys', ['uuid' => $uuid]); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'post'); + } + + public function testAddAdminUserEncryptionKeyNotAllowedAsRegularUser(): void + { + $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); + + $faker = \Faker\Factory::create(); + $uuid = $faker->uuid; + + $this->post( + self::ENDPOINT, + [ + 'uuid' => $uuid, + 'type' => EncryptionKeysFixture::TYPE_PGP, + 'encryption_key' => EncryptionKeysFixture::getPublicKey(EncryptionKeysFixture::KEY_TYPE_EDCH), + 'revoked' => false, + 'expires' => null, + 'owner_id' => UsersFixture::USER_ADMIN_ID, + 'owner_model' => 'User' + ] + ); + + $this->assertResponseCode(405); + $this->assertDbRecordNotExists('EncryptionKeys', ['uuid' => $uuid]); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'post'); + } +} diff --git a/tests/TestCase/Api/EncryptionKeys/EditEncryptionKeyApiTest.php b/tests/TestCase/Api/EncryptionKeys/EditEncryptionKeyApiTest.php new file mode 100644 index 0000000..6525786 --- /dev/null +++ b/tests/TestCase/Api/EncryptionKeys/EditEncryptionKeyApiTest.php @@ -0,0 +1,76 @@ +setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + + $url = sprintf('%s/%d', self::ENDPOINT, EncryptionKeysFixture::ENCRYPTION_KEY_ORG_A_ID); + $this->put( + $url, + [ + 'revoked' => true, + ] + ); + + $this->assertResponseOk(); + $this->assertDbRecordExists( + 'EncryptionKeys', + [ + 'id' => EncryptionKeysFixture::ENCRYPTION_KEY_ORG_A_ID, + 'revoked' => true, + ] + ); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url, 'put'); + } + + public function testRevokeAdminEncryptionKeyNotAllowedAsRegularUser(): void + { + $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); + + $url = sprintf('%s/%d', self::ENDPOINT, EncryptionKeysFixture::ENCRYPTION_KEY_ORG_B_ID); + $this->put( + $url, + [ + 'revoked' => true + ] + ); + + $this->assertResponseCode(405); + $this->assertDbRecordNotExists( + 'EncryptionKeys', + [ + 'id' => EncryptionKeysFixture::ENCRYPTION_KEY_ORG_B_ID, + 'revoked' => true + ] + ); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url, 'put'); + } +} diff --git a/tests/TestCase/Api/EncryptionKeys/ViewEncryptionKeyApiTest.php b/tests/TestCase/Api/EncryptionKeys/ViewEncryptionKeyApiTest.php new file mode 100644 index 0000000..8e46119 --- /dev/null +++ b/tests/TestCase/Api/EncryptionKeys/ViewEncryptionKeyApiTest.php @@ -0,0 +1,40 @@ +setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + $url = sprintf('%s/%d', self::ENDPOINT, EncryptionKeysFixture::ENCRYPTION_KEY_ORG_A_ID); + $this->get($url); + + $this->assertResponseOk(); + $this->assertResponseContains(sprintf('"id": %d', EncryptionKeysFixture::ENCRYPTION_KEY_ORG_A_ID)); + // TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url); + } +} diff --git a/webroot/docs/openapi.yaml b/webroot/docs/openapi.yaml index c4852eb..d293ed5 100644 --- a/webroot/docs/openapi.yaml +++ b/webroot/docs/openapi.yaml @@ -652,6 +652,62 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" + /api/v1/encryptionKeys/view/{encryptionKeyId}: + get: + summary: "Get encryption key by ID" + operationId: getEncryptionKeyId + tags: + - EncryptionKeys + parameters: + - $ref: "#/components/parameters/encryptionKeyId" + responses: + "200": + $ref: "#/components/responses/EncryptionKeyResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + + /api/v1/encryptionKeys/add: + post: + summary: "Add encryption key" + operationId: addEncryptionKey + tags: + - EncryptionKeys + requestBody: + $ref: "#/components/requestBodies/CreateEncryptionKeyRequest" + responses: + "200": + $ref: "#/components/responses/EncryptionKeyResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + + /api/v1/encryptionKeys/edit/{encryptionKeyId}: + put: + summary: "Edit encryption key" + operationId: editEncryptionKey + tags: + - EncryptionKeys + parameters: + - $ref: "#/components/parameters/encryptionKeyId" + requestBody: + $ref: "#/components/requestBodies/EditEncryptionKeyRequest" + responses: + "200": + $ref: "#/components/responses/EncryptionKeyResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + /api/v1/encryptionKeys/delete/{encryptionKeyId}: delete: summary: "Delete encryption key by ID" @@ -1601,6 +1657,50 @@ components: authkey: $ref: "#/components/schemas/AuthKey" + CreateEncryptionKeyRequest: + required: true + content: + application/json: + schema: + type: object + properties: + uuid: + $ref: "#/components/schemas/UUID" + type: + $ref: "#/components/schemas/EncryptionKeyType" + encryption_key: + $ref: "#/components/schemas/EncryptionKeyValue" + revoked: + type: boolean + expires: + $ref: "#/components/schemas/EncryptionKeyExpiration" + owner_id: + $ref: "#/components/schemas/ID" + owner_model: + $ref: "#/components/schemas/ModelName" + + EditEncryptionKeyRequest: + required: true + content: + application/json: + schema: + type: object + properties: + uuid: + $ref: "#/components/schemas/UUID" + type: + $ref: "#/components/schemas/EncryptionKeyType" + encryption_key: + $ref: "#/components/schemas/EncryptionKeyValue" + revoked: + type: boolean + expires: + $ref: "#/components/schemas/EncryptionKeyExpiration" + owner_id: + $ref: "#/components/schemas/ID" + owner_model: + $ref: "#/components/schemas/ModelName" + responses: # Individuals IndividualResponse: From 9551f0b5b82288f4a4513a007926113cd7185d50 Mon Sep 17 00:00:00 2001 From: Luciano Righetti Date: Tue, 18 Jan 2022 16:15:45 +0100 Subject: [PATCH 135/150] fix: copy&paste --- tests/TestCase/Api/EncryptionKeys/EditEncryptionKeyApiTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/TestCase/Api/EncryptionKeys/EditEncryptionKeyApiTest.php b/tests/TestCase/Api/EncryptionKeys/EditEncryptionKeyApiTest.php index 6525786..56a25d3 100644 --- a/tests/TestCase/Api/EncryptionKeys/EditEncryptionKeyApiTest.php +++ b/tests/TestCase/Api/EncryptionKeys/EditEncryptionKeyApiTest.php @@ -10,7 +10,7 @@ use App\Test\Fixture\AuthKeysFixture; use App\Test\Fixture\EncryptionKeysFixture; use App\Test\Helper\ApiTestTrait; -class EditBroodApiTest extends TestCase +class EditEncryptionKeyApiTest extends TestCase { use IntegrationTestTrait; use ApiTestTrait; From eae8e62e5ec600f3bec23327c43da046558d1fb3 Mon Sep 17 00:00:00 2001 From: iglocska Date: Tue, 18 Jan 2022 16:24:24 +0100 Subject: [PATCH 136/150] fix: [CRUD] delete post message fix - correct order of execution for the beforesave command --- src/Controller/Component/CRUDComponent.php | 24 ++++++++-------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/src/Controller/Component/CRUDComponent.php b/src/Controller/Component/CRUDComponent.php index 0adfcdf..d1c78fc 100644 --- a/src/Controller/Component/CRUDComponent.php +++ b/src/Controller/Component/CRUDComponent.php @@ -467,24 +467,18 @@ class CRUDComponent extends Component if (!empty($params['contain'])) { $data->contain($params['contain']); } + $data = $data->first(); if (isset($params['beforeSave'])) { $data = $params['beforeSave']($data); - if ($data === false) { - $skipExecution = true; - $success = false; - } } - if (!$skipExecution) { - $data = $data->first(); - if (!empty($data)) { - $success = $this->Table->delete($data); - $success = true; - } else { - $success = false; - } - if ($success) { - $bulkSuccesses++; - } + if (!empty($data)) { + $success = $this->Table->delete($data); + $success = true; + } else { + $success = false; + } + if ($success) { + $bulkSuccesses++; } } $message = $this->getMessageBasedOnResult( From dbaa2ba7b320ce3f9511155ab7b9b960e26e67fc Mon Sep 17 00:00:00 2001 From: iglocska Date: Tue, 18 Jan 2022 16:56:38 +0100 Subject: [PATCH 137/150] fix: [encryption keys] several fixes - fix the user view to correctly point to the list of related encryption keys - fix the lookup on the index to be based on owner_model + owner_id combo - fix the filtering of the dropdown in the encryption key add form to only valid options --- src/Controller/EncryptionKeysController.php | 9 ++++++++- templates/Users/view.php | 4 ++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/Controller/EncryptionKeysController.php b/src/Controller/EncryptionKeysController.php index 324decb..803f180 100644 --- a/src/Controller/EncryptionKeysController.php +++ b/src/Controller/EncryptionKeysController.php @@ -14,7 +14,7 @@ use Cake\Error\Debugger; class EncryptionKeysController extends AppController { - public $filterFields = ['owner_model', 'organisation_id', 'individual_id', 'encryption_key']; + public $filterFields = ['owner_model', 'owner_id', 'encryption_key']; public $quickFilterFields = ['encryption_key']; public $containFields = ['Individuals', 'Organisations']; @@ -65,6 +65,13 @@ class EncryptionKeysController extends AppController $individualConditions = [ 'id' => $currentUser['individual_id'] ]; + } else { + $this->loadModel('Alignments'); + $individualConditions = ['id IN' => $this->Alignments->find('list', [ + 'keyField' => 'id', + 'valueField' => 'individual_id', + 'conditions' => ['organisation_id' => $currentUser['organisation_id']] + ])->toArray()]; } $params['beforeSave'] = function($entity) use($currentUser) { if ($entity['owner_model'] === 'organisation') { diff --git a/templates/Users/view.php b/templates/Users/view.php index 26c3c25..fbddf52 100644 --- a/templates/Users/view.php +++ b/templates/Users/view.php @@ -56,8 +56,8 @@ echo $this->element( 'title' => __('Authentication keys') ], [ - 'url' => '/EncryptionKeys/index?Users.id={{0}}', - 'url_params' => ['id'], + 'url' => '/EncryptionKeys/index?owner_id={{0}}', + 'url_params' => ['individual_id'], 'title' => __('Encryption keys') ], [ From 850eb0fb2d26008c3e01be490834b078b2ab5663 Mon Sep 17 00:00:00 2001 From: Luciano Righetti Date: Tue, 18 Jan 2022 17:39:41 +0100 Subject: [PATCH 138/150] add: cover authkeys api endpoints, extend openapi spec --- tests/Fixture/AuthKeysFixture.php | 13 ++ .../Api/AuthKeys/AddAuthKeyApiTest.php | 78 +++++++++ .../Api/AuthKeys/DeleteAuthKeyApiTest.php | 51 ++++++ .../Api/AuthKeys/IndexAuthKeysApiTest.php | 48 ++++++ ...oodsApiTest.php => DeleteBroodApiTest.php} | 4 +- .../DeleteEncryptionKeyApiTest.php | 2 - .../Individuals/DeleteIndividualApiTest.php | 2 - .../DeleteOrganisationApiTest.php | 2 - .../DeleteSharingGroupApiTest.php | 2 - .../TestCase/Api/Users/DeleteUserApiTest.php | 2 - tests/TestCase/Api/Users/EditUserApiTest.php | 2 + webroot/docs/openapi.yaml | 162 +++++++++++++++++- 12 files changed, 348 insertions(+), 20 deletions(-) create mode 100644 tests/TestCase/Api/AuthKeys/AddAuthKeyApiTest.php create mode 100644 tests/TestCase/Api/AuthKeys/DeleteAuthKeyApiTest.php create mode 100644 tests/TestCase/Api/AuthKeys/IndexAuthKeysApiTest.php rename tests/TestCase/Api/Broods/{DeleteBroodsApiTest.php => DeleteBroodApiTest.php} (83%) diff --git a/tests/Fixture/AuthKeysFixture.php b/tests/Fixture/AuthKeysFixture.php index 802ca5c..24034f3 100644 --- a/tests/Fixture/AuthKeysFixture.php +++ b/tests/Fixture/AuthKeysFixture.php @@ -11,9 +11,18 @@ class AuthKeysFixture extends TestFixture { public $connection = 'test'; + public const ADMIN_API_ID = 1; public const ADMIN_API_KEY = 'd033e22ae348aeb5660fc2140aec35850c4da997'; + + + public const SYNC_API_ID = 2; public const SYNC_API_KEY = '6b387ced110858dcbcda36edb044dc18f91a0894'; + + + public const ORG_ADMIN_API_ID = 3; public const ORG_ADMIN_API_KEY = '1c4685d281d478dbcebd494158024bc3539004d0'; + + public const REGULAR_USER_API_ID = 4; public const REGULAR_USER_API_KEY = '12dea96fec20593566ab75692c9949596833adc9'; public function init(): void @@ -23,6 +32,7 @@ class AuthKeysFixture extends TestFixture $this->records = [ [ + 'id' => self::ADMIN_API_ID, 'uuid' => $faker->uuid(), 'authkey' => $hasher->hash(self::ADMIN_API_KEY), 'authkey_start' => substr(self::ADMIN_API_KEY, 0, 4), @@ -34,6 +44,7 @@ class AuthKeysFixture extends TestFixture 'modified' => $faker->dateTime()->getTimestamp() ], [ + 'id' => self::SYNC_API_ID, 'uuid' => $faker->uuid(), 'authkey' => $hasher->hash(self::SYNC_API_KEY), 'authkey_start' => substr(self::SYNC_API_KEY, 0, 4), @@ -45,6 +56,7 @@ class AuthKeysFixture extends TestFixture 'modified' => $faker->dateTime()->getTimestamp() ], [ + 'id' => self::ORG_ADMIN_API_ID, 'uuid' => $faker->uuid(), 'authkey' => $hasher->hash(self::ORG_ADMIN_API_KEY), 'authkey_start' => substr(self::ORG_ADMIN_API_KEY, 0, 4), @@ -56,6 +68,7 @@ class AuthKeysFixture extends TestFixture 'modified' => $faker->dateTime()->getTimestamp() ], [ + 'id' => self::REGULAR_USER_API_ID, 'uuid' => $faker->uuid(), 'authkey' => $hasher->hash(self::REGULAR_USER_API_KEY), 'authkey_start' => substr(self::REGULAR_USER_API_KEY, 0, 4), diff --git a/tests/TestCase/Api/AuthKeys/AddAuthKeyApiTest.php b/tests/TestCase/Api/AuthKeys/AddAuthKeyApiTest.php new file mode 100644 index 0000000..810dad2 --- /dev/null +++ b/tests/TestCase/Api/AuthKeys/AddAuthKeyApiTest.php @@ -0,0 +1,78 @@ +setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + + $faker = \Faker\Factory::create(); + $uuid = $faker->uuid; + + $this->post( + self::ENDPOINT, + [ + 'uuid' => $uuid, + 'authkey' => $faker->sha1, + 'expiration' => 0, + 'user_id' => UsersFixture::USER_ADMIN_ID, + 'comment' => $faker->text + ] + ); + + $this->assertResponseOk(); + $this->assertResponseContains(sprintf('"uuid": "%s"', $uuid)); + $this->assertDbRecordExists('AuthKeys', ['uuid' => $uuid]); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'post'); + } + + public function testAddAdminAuthKeyNotAllowedAsRegularUser(): void + { + $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); + + $faker = \Faker\Factory::create(); + $uuid = $faker->uuid; + + + $this->post( + self::ENDPOINT, + [ + 'uuid' => $uuid, + 'authkey' => $faker->sha1, + 'expiration' => 0, + 'user_id' => UsersFixture::USER_ADMIN_ID, + 'comment' => $faker->text + ] + ); + + $this->assertResponseCode(404); + $this->addWarning('Should return 405 Method Not Allowed instead of 404 Not Found'); + $this->assertDbRecordNotExists('AuthKeys', ['uuid' => $uuid]); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'post'); + } +} diff --git a/tests/TestCase/Api/AuthKeys/DeleteAuthKeyApiTest.php b/tests/TestCase/Api/AuthKeys/DeleteAuthKeyApiTest.php new file mode 100644 index 0000000..21a22f6 --- /dev/null +++ b/tests/TestCase/Api/AuthKeys/DeleteAuthKeyApiTest.php @@ -0,0 +1,51 @@ +setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + $url = sprintf('%s/%d', self::ENDPOINT, AuthKeysFixture::ADMIN_API_ID); + $this->delete($url); + + $this->assertResponseOk(); + $this->assertDbRecordNotExists('AuthKeys', ['id' => AuthKeysFixture::ADMIN_API_ID]); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url, 'delete'); + } + + public function testDeleteOrgAdminAuthKeyNotAllowedAsRegularUser(): void + { + $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); + $url = sprintf('%s/%d', self::ENDPOINT, AuthKeysFixture::ORG_ADMIN_API_ID); + $this->delete($url); + + $this->assertResponseCode(405); + $this->assertDbRecordExists('AuthKeys', ['id' => AuthKeysFixture::ORG_ADMIN_API_ID]); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec($url, 'delete'); + } +} diff --git a/tests/TestCase/Api/AuthKeys/IndexAuthKeysApiTest.php b/tests/TestCase/Api/AuthKeys/IndexAuthKeysApiTest.php new file mode 100644 index 0000000..9fbe3e6 --- /dev/null +++ b/tests/TestCase/Api/AuthKeys/IndexAuthKeysApiTest.php @@ -0,0 +1,48 @@ +setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + $this->get(self::ENDPOINT); + + $this->assertResponseOk(); + $this->assertResponseContains(sprintf('"id": %d', AuthKeysFixture::ADMIN_API_ID)); + // TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT); + } + + public function testIndexDoesNotShowAdminAuthKeysAsRegularUser(): void + { + $this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY); + $this->get(self::ENDPOINT); + + $this->assertResponseOk(); + $this->assertResponseNotContains(sprintf('"id": %d', AuthKeysFixture::REGULAR_USER_API_KEY)); + // TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT); + } +} diff --git a/tests/TestCase/Api/Broods/DeleteBroodsApiTest.php b/tests/TestCase/Api/Broods/DeleteBroodApiTest.php similarity index 83% rename from tests/TestCase/Api/Broods/DeleteBroodsApiTest.php rename to tests/TestCase/Api/Broods/DeleteBroodApiTest.php index 94aa0a8..1b90bd6 100644 --- a/tests/TestCase/Api/Broods/DeleteBroodsApiTest.php +++ b/tests/TestCase/Api/Broods/DeleteBroodApiTest.php @@ -10,7 +10,7 @@ use App\Test\Fixture\AuthKeysFixture; use App\Test\Fixture\BroodsFixture; use App\Test\Helper\ApiTestTrait; -class DeleteBroodsApiTest extends TestCase +class DeleteBroodApiTest extends TestCase { use IntegrationTestTrait; use ApiTestTrait; @@ -36,7 +36,6 @@ class DeleteBroodsApiTest extends TestCase $this->assertDbRecordNotExists('Broods', ['id' => BroodsFixture::BROOD_A_ID]); //TODO: $this->assertRequestMatchesOpenApiSpec(); $this->assertResponseMatchesOpenApiSpec($url, 'delete'); - $this->addWarning('TODO: CRUDComponent::delete() sets some view variables, does not take into account `isRest()`, fix it.'); } public function testDeleteBroodNotAllowedAsRegularUser(): void @@ -49,6 +48,5 @@ class DeleteBroodsApiTest extends TestCase $this->assertDbRecordExists('Broods', ['id' => BroodsFixture::BROOD_A_ID]); //TODO: $this->assertRequestMatchesOpenApiSpec(); $this->assertResponseMatchesOpenApiSpec($url, 'delete'); - $this->addWarning('TODO: CRUDComponent::delete() sets some view variables, does not take into account `isRest()`, fix it.'); } } diff --git a/tests/TestCase/Api/EncryptionKeys/DeleteEncryptionKeyApiTest.php b/tests/TestCase/Api/EncryptionKeys/DeleteEncryptionKeyApiTest.php index 934d3e7..02ecd25 100644 --- a/tests/TestCase/Api/EncryptionKeys/DeleteEncryptionKeyApiTest.php +++ b/tests/TestCase/Api/EncryptionKeys/DeleteEncryptionKeyApiTest.php @@ -36,7 +36,6 @@ class DeleteEncryptionKeyApiTest extends TestCase $this->assertDbRecordNotExists('EncryptionKeys', ['id' => EncryptionKeysFixture::ENCRYPTION_KEY_ORG_A_ID]); //TODO: $this->assertRequestMatchesOpenApiSpec(); $this->assertResponseMatchesOpenApiSpec($url, 'delete'); - $this->addWarning('TODO: CRUDComponent::delete() sets some view variables, does not take into account `isRest()`, fix it.'); } public function testDeleteEncryptionKeyNotAllowedAsRegularUser(): void @@ -49,6 +48,5 @@ class DeleteEncryptionKeyApiTest extends TestCase $this->assertDbRecordExists('EncryptionKeys', ['id' => EncryptionKeysFixture::ENCRYPTION_KEY_ORG_B_ID]); //TODO: $this->assertRequestMatchesOpenApiSpec(); $this->assertResponseMatchesOpenApiSpec($url, 'delete'); - $this->addWarning('TODO: CRUDComponent::delete() sets some view variables, does not take into account `isRest()`, fix it.'); } } diff --git a/tests/TestCase/Api/Individuals/DeleteIndividualApiTest.php b/tests/TestCase/Api/Individuals/DeleteIndividualApiTest.php index e50cf63..32b4ba7 100644 --- a/tests/TestCase/Api/Individuals/DeleteIndividualApiTest.php +++ b/tests/TestCase/Api/Individuals/DeleteIndividualApiTest.php @@ -35,7 +35,6 @@ class DeleteIndividualApiTest extends TestCase $this->assertDbRecordNotExists('Individuals', ['id' => IndividualsFixture::INDIVIDUAL_A_ID]); //TODO: $this->assertRequestMatchesOpenApiSpec(); $this->assertResponseMatchesOpenApiSpec($url, 'delete'); - $this->addWarning('TODO: CRUDComponent::delete() sets some view variables, does not take into account `isRest()`, fix it.'); } public function testDeleteIndividualNotAllowedAsRegularUser(): void @@ -48,6 +47,5 @@ class DeleteIndividualApiTest extends TestCase $this->assertDbRecordExists('Individuals', ['id' => IndividualsFixture::INDIVIDUAL_ADMIN_ID]); //TODO: $this->assertRequestMatchesOpenApiSpec(); $this->assertResponseMatchesOpenApiSpec($url, 'delete'); - $this->addWarning('TODO: CRUDComponent::delete() sets some view variables, does not take into account `isRest()`, fix it.'); } } diff --git a/tests/TestCase/Api/Organisations/DeleteOrganisationApiTest.php b/tests/TestCase/Api/Organisations/DeleteOrganisationApiTest.php index 6a323fb..12b62d1 100644 --- a/tests/TestCase/Api/Organisations/DeleteOrganisationApiTest.php +++ b/tests/TestCase/Api/Organisations/DeleteOrganisationApiTest.php @@ -35,7 +35,6 @@ class DeleteOrganisationApiTest extends TestCase $this->assertDbRecordNotExists('Organisations', ['id' => OrganisationsFixture::ORGANISATION_B_ID]); //TODO: $this->assertRequestMatchesOpenApiSpec(); $this->assertResponseMatchesOpenApiSpec($url, 'delete'); - $this->addWarning('TODO: CRUDComponent::delete() sets some view variables, does not take into account `isRest()`, fix it.'); } public function testDeleteOrganisationNotAllowedAsRegularUser(): void @@ -48,6 +47,5 @@ class DeleteOrganisationApiTest extends TestCase $this->assertDbRecordExists('Organisations', ['id' => OrganisationsFixture::ORGANISATION_B_ID]); //TODO: $this->assertRequestMatchesOpenApiSpec(); $this->assertResponseMatchesOpenApiSpec($url, 'delete'); - $this->addWarning('TODO: CRUDComponent::delete() sets some view variables, does not take into account `isRest()`, fix it.'); } } diff --git a/tests/TestCase/Api/SharingGroups/DeleteSharingGroupApiTest.php b/tests/TestCase/Api/SharingGroups/DeleteSharingGroupApiTest.php index c93bb05..36379d4 100644 --- a/tests/TestCase/Api/SharingGroups/DeleteSharingGroupApiTest.php +++ b/tests/TestCase/Api/SharingGroups/DeleteSharingGroupApiTest.php @@ -36,7 +36,6 @@ class DeleteSharingGroupApiTest extends TestCase $this->assertDbRecordNotExists('SharingGroups', ['id' => SharingGroupsFixture::SHARING_GROUP_A_ID]); //TODO: $this->assertRequestMatchesOpenApiSpec(); $this->assertResponseMatchesOpenApiSpec($url, 'delete'); - $this->addWarning('TODO: CRUDComponent::delete() sets some view variables, does not take into account `isRest()`, fix it.'); } public function testDeleteSharingGroupNotAllowedAsRegularUser(): void @@ -49,6 +48,5 @@ class DeleteSharingGroupApiTest extends TestCase $this->assertDbRecordExists('SharingGroups', ['id' => SharingGroupsFixture::SHARING_GROUP_A_ID]); //TODO: $this->assertRequestMatchesOpenApiSpec(); $this->assertResponseMatchesOpenApiSpec($url, 'delete'); - $this->addWarning('TODO: CRUDComponent::delete() sets some view variables, does not take into account `isRest()`, fix it.'); } } diff --git a/tests/TestCase/Api/Users/DeleteUserApiTest.php b/tests/TestCase/Api/Users/DeleteUserApiTest.php index 50df2e5..48f9069 100644 --- a/tests/TestCase/Api/Users/DeleteUserApiTest.php +++ b/tests/TestCase/Api/Users/DeleteUserApiTest.php @@ -37,7 +37,6 @@ class DeleteUserApiTest extends TestCase $this->assertDbRecordNotExists('Users', ['id' => UsersFixture::USER_REGULAR_USER_ID]); //TODO: $this->assertRequestMatchesOpenApiSpec(); $this->assertResponseMatchesOpenApiSpec($url, 'delete'); - $this->addWarning('TODO: CRUDComponent::delete() sets some view variables, does not take into account `isRest()`, fix it.'); } public function testDeleteUserNotAllowedAsRegularUser(): void @@ -50,6 +49,5 @@ class DeleteUserApiTest extends TestCase $this->assertDbRecordExists('Users', ['id' => UsersFixture::USER_ORG_ADMIN_ID]); //TODO: $this->assertRequestMatchesOpenApiSpec(); $this->assertResponseMatchesOpenApiSpec($url, 'delete'); - $this->addWarning('TODO: CRUDComponent::delete() sets some view variables, does not take into account `isRest()`, fix it.'); } } diff --git a/tests/TestCase/Api/Users/EditUserApiTest.php b/tests/TestCase/Api/Users/EditUserApiTest.php index 8d2810d..39673ee 100644 --- a/tests/TestCase/Api/Users/EditUserApiTest.php +++ b/tests/TestCase/Api/Users/EditUserApiTest.php @@ -57,6 +57,7 @@ class EditUserApiTest extends TestCase ] ); + $this->assertResponseOk(); $this->assertDbRecordNotExists('Users', [ 'id' => UsersFixture::USER_REGULAR_USER_ID, 'role_id' => RolesFixture::ROLE_ADMIN_ID @@ -75,6 +76,7 @@ class EditUserApiTest extends TestCase ] ); + $this->assertResponseOk(); $this->assertDbRecordExists('Users', [ 'id' => UsersFixture::USER_REGULAR_USER_ID, 'username' => 'test' diff --git a/webroot/docs/openapi.yaml b/webroot/docs/openapi.yaml index d293ed5..076af9f 100644 --- a/webroot/docs/openapi.yaml +++ b/webroot/docs/openapi.yaml @@ -25,6 +25,8 @@ tags: description: "Cerebrate can connect to other Cerebrate instances to exchange trust information and to instrument interconnectivity between connected local tools. Each such Cerebrate instance with its connected tools is considered to be a brood." - name: EncryptionKeys description: "Assign encryption keys to the user, used to securely communicate or validate messages coming from the user." + - name: AuthKeys + 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." paths: /api/v1/individuals/index: @@ -726,6 +728,61 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" + # AuthKeys + /api/v1/authKeys/index: + get: + summary: "Get auth keys list" + operationId: getAuthKeys + tags: + - AuthKeys + parameters: + - $ref: "#/components/parameters/quickFilter" + responses: + "200": + $ref: "#/components/responses/AuthKeyListResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + + /api/v1/authKeys/add: + post: + summary: "Add auth keys" + operationId: addAuthKey + tags: + - AuthKeys + requestBody: + $ref: "#/components/requestBodies/CreateAuthKeyRequest" + responses: + "200": + $ref: "#/components/responses/AuthKeyResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + + /api/v1/authKeys/delete/{authKeyId}: + delete: + summary: "Delete auth key by ID" + operationId: deleteAuthKeyById + tags: + - AuthKeys + parameters: + - $ref: "#/components/parameters/authKeyId" + responses: + "200": + $ref: "#/components/responses/AuthKeyResponse" + "403": + $ref: "#/components/responses/UnauthorizedApiErrorResponse" + "405": + $ref: "#/components/responses/MethodNotAllowedApiErrorResponse" + default: + $ref: "#/components/responses/ApiErrorResponse" + components: schemas: # General @@ -750,9 +807,6 @@ components: format: email example: "user@example.com" - AuthKey: - type: string - ModelName: type: string enum: @@ -1195,7 +1249,7 @@ components: skip_proxy: type: boolean authkey: - $ref: "#/components/schemas/AuthKey" + $ref: "#/components/schemas/AuthKeyRaw" organisation: $ref: "#/components/schemas/Organisation" created: @@ -1224,7 +1278,7 @@ components: EncryptionKeyExpiration: type: integer - description: "Timestamp or null of there is no expiration" + description: "UNIX timestamp or null of there is no expiration" nullable: true EncryptionKey: @@ -1256,6 +1310,58 @@ components: items: $ref: "#/components/schemas/EncryptionKey" + # AuthKeys + AuthKeyRaw: + type: string + + AuthKeyHashed: + type: string + + AuthKeyExpiration: + type: integer + description: "0 or UNIX timestamp" + example: 0 + + AuthKeyCreatedAt: + type: integer + description: "UNIX timestamp" + + AuthKeyComment: + type: string + + AuthKey: + type: object + properties: + id: + $ref: "#/components/schemas/ID" + uuid: + $ref: "#/components/schemas/UUID" + authkey: + $ref: "#/components/schemas/AuthKeyHashed" + authkey_start: + type: string + example: abcd + authkey_end: + type: string + example: abcd + created: + $ref: "#/components/schemas/AuthKeyCreatedAt" + expiration: + $ref: "#/components/schemas/AuthKeyExpiration" + type: integer + description: "0 or UNIX timestamp" + user_id: + $ref: "#/components/schemas/ID" + comment: + $ref: "#/components/schemas/AuthKeyComment" + user: + $ref: "#/components/schemas/User" + + AuthKeyList: + type: array + items: + $ref: "#/components/schemas/AuthKey" + # Errors ApiError: type: object @@ -1373,6 +1479,14 @@ components: schema: $ref: "#/components/schemas/ID" + authKeyId: + name: authKeyId + in: path + description: "Numeric ID of the AuthKey" + required: true + schema: + $ref: "#/components/schemas/ID" + quickFilter: name: quickFilter in: query @@ -1629,7 +1743,7 @@ components: skip_proxy: type: boolean authkey: - $ref: "#/components/schemas/AuthKey" + $ref: "#/components/schemas/AuthKeyRaw" EditBroodRequest: required: true @@ -1655,7 +1769,7 @@ components: skip_proxy: type: boolean authkey: - $ref: "#/components/schemas/AuthKey" + $ref: "#/components/schemas/AuthKeyRaw" CreateEncryptionKeyRequest: required: true @@ -1701,6 +1815,25 @@ components: owner_model: $ref: "#/components/schemas/ModelName" + # AuthKeys + CreateAuthKeyRequest: + required: true + content: + application/json: + schema: + type: object + properties: + uuid: + $ref: "#/components/schemas/UUID" + authkey: + $ref: "#/components/schemas/AuthKeyRaw" + expiration: + $ref: "#/components/schemas/AuthKeyExpiration" + user_id: + $ref: "#/components/schemas/ID" + comment: + $ref: "#/components/schemas/AuthKeyComment" + responses: # Individuals IndividualResponse: @@ -1881,6 +2014,21 @@ components: schema: $ref: "#/components/schemas/EncryptionKeyList" + # AuthKeys + AuthKeyResponse: + description: "Auth key response" + content: + application/json: + schema: + $ref: "#/components/schemas/AuthKey" + + AuthKeyListResponse: + description: "Auth key list response" + content: + application/json: + schema: + $ref: "#/components/schemas/AuthKeyList" + # Errors ApiErrorResponse: description: "Unexpected API error" From f75d0829d1a98e2c686beb89d993df5ac824883f Mon Sep 17 00:00:00 2001 From: iglocska Date: Tue, 18 Jan 2022 17:52:59 +0100 Subject: [PATCH 139/150] fix: [user edit] fixed for non admins --- src/Controller/UsersController.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Controller/UsersController.php b/src/Controller/UsersController.php index a5065db..9ffb2fe 100644 --- a/src/Controller/UsersController.php +++ b/src/Controller/UsersController.php @@ -7,6 +7,7 @@ use Cake\Utility\Text; use Cake\ORM\TableRegistry; use \Cake\Database\Expression\QueryExpression; use Cake\Http\Exception\UnauthorizedException; +use Cake\Http\Exception\MethodNotAllowedException; use Cake\Core\Configure; class UsersController extends AppController @@ -100,11 +101,10 @@ class UsersController extends AppController if (empty($id)) { $id = $currentUser['id']; } else { + $id = intval($id); if ((empty($currentUser['role']['perm_org_admin']) && empty($currentUser['role']['perm_admin']))) { if ($id !== $currentUser['id']) { throw new MethodNotAllowedException(__('You are not authorised to edit that user.')); - } else { - $id = $currentUser['id']; } } } From 20cc6017d0472c33ffddfe496db9cf93f2e40fad Mon Sep 17 00:00:00 2001 From: Sami Mokaddem Date: Wed, 19 Jan 2022 09:04:10 +0100 Subject: [PATCH 140/150] fix: [localTool:CommonConnector] Ensure one logger per connector --- .../CommonConnectorTools.php | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/src/Lib/default/local_tool_connectors/CommonConnectorTools.php b/src/Lib/default/local_tool_connectors/CommonConnectorTools.php index 213db17..d01e9d9 100644 --- a/src/Lib/default/local_tool_connectors/CommonConnectorTools.php +++ b/src/Lib/default/local_tool_connectors/CommonConnectorTools.php @@ -24,21 +24,24 @@ class CommonConnectorTools public function __construct() { - Log::setConfig("LocalToolDebug", [ - 'className' => FileLog::class, - 'path' => LOGS, - 'file' => "{$this->connectorName}-debug", - 'scopes' => [$this->connectorName], - 'levels' => ['notice', 'info', 'debug'], - ]); - Log::setConfig("LocalToolError", [ - 'className' => FileLog::class, - 'path' => LOGS, - 'file' => "{$this->connectorName}-error", - 'scopes' => [$this->connectorName], - 'levels' => ['warning', 'error', 'critical', 'alert', 'emergency'], - ]); - + if (empty(Log::getConfig("LocalToolDebug{$this->connectorName}"))) { + Log::setConfig("LocalToolDebug{$this->connectorName}", [ + 'className' => FileLog::class, + 'path' => LOGS, + 'file' => "{$this->connectorName}-debug", + 'scopes' => [$this->connectorName], + 'levels' => ['notice', 'info', 'debug'], + ]); + } + if (empty(Log::getConfig("LocalToolError{$this->connectorName}"))) { + Log::setConfig("LocalToolError{$this->connectorName}", [ + 'className' => FileLog::class, + 'path' => LOGS, + 'file' => "{$this->connectorName}-error", + 'scopes' => [$this->connectorName], + 'levels' => ['warning', 'error', 'critical', 'alert', 'emergency'], + ]); + } } protected function logDebug($message) From 1d7fc00a650d08cc0a0f77dd4f8458edafd4736f Mon Sep 17 00:00:00 2001 From: Sami Mokaddem Date: Wed, 19 Jan 2022 09:33:57 +0100 Subject: [PATCH 141/150] chg: [layout:header-profile] Improved spacing --- templates/element/layouts/header/header-profile.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/templates/element/layouts/header/header-profile.php b/templates/element/layouts/header/header-profile.php index 1f148d6..146aa3e 100644 --- a/templates/element/layouts/header/header-profile.php +++ b/templates/element/layouts/header/header-profile.php @@ -11,7 +11,10 @@ use Cake\Routing\Router;

SocialProvider->getIcon($this->request->getAttribute('identity')) ?> - [] request->getAttribute('identity')['username']) ?> + + [] + request->getAttribute('identity')['username']) ?> +
From 5eca1a916052c23f304eb6410a1d2cdfcc6a624d Mon Sep 17 00:00:00 2001 From: Luciano Righetti Date: Wed, 19 Jan 2022 10:45:51 +0100 Subject: [PATCH 142/150] add: change password via api test, add helper methods to ApiTestTrait. --- tests/Helper/ApiTestTrait.php | 30 +++++++ .../Api/Users/ChangePasswordApiTest.php | 78 +++++++++++++++++++ tests/TestCase/Api/Users/EditUserApiTest.php | 20 +---- 3 files changed, 109 insertions(+), 19 deletions(-) create mode 100644 tests/TestCase/Api/Users/ChangePasswordApiTest.php diff --git a/tests/Helper/ApiTestTrait.php b/tests/Helper/ApiTestTrait.php index 8e20a36..56728f4 100644 --- a/tests/Helper/ApiTestTrait.php +++ b/tests/Helper/ApiTestTrait.php @@ -129,4 +129,34 @@ trait ApiTestTrait } $this->assertEmpty($record); } + + /** + * Parses the response body and returns the decoded JSON + * + * @return void + * @throws \Exception + * + * @see https://book.cakephp.org/4/en/orm-query-builder.html + */ + public function getJsonResponseAsArray(): array + { + if ($this->_response->getHeaders()['Content-Type'][0] !== 'application/json') { + throw new \Exception('The response is not a JSON response'); + } + + return json_decode((string)$this->_response->getBody(), true); + } + + /** + * Gets a database records as an array + * + * @param string $table The table name + * @param array $conditions The conditions to check + * @return array + * @throws \Cake\Datasource\Exception\RecordNotFoundException + */ + public function getRecordFromDb(string $table, array $conditions): array + { + return $this->getTableLocator()->get($table)->find()->where($conditions)->first()->toArray(); + } } diff --git a/tests/TestCase/Api/Users/ChangePasswordApiTest.php b/tests/TestCase/Api/Users/ChangePasswordApiTest.php new file mode 100644 index 0000000..f1c1b82 --- /dev/null +++ b/tests/TestCase/Api/Users/ChangePasswordApiTest.php @@ -0,0 +1,78 @@ +initializeOpenApiValidator($_ENV['OPENAPI_SPEC'] ?? APP . '../webroot/docs/openapi.yaml'); + + $this->collection = new ComponentRegistry(); + $this->auth = new FormAuthenticate($this->collection, [ + 'userModel' => 'Users', + ]); + } + + public function testChangePasswordOwnUser(): void + { + $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); + $newPassword = 'Test12345678!'; + + $this->put( + self::ENDPOINT, + [ + 'password' => $newPassword, + ] + ); + + $this->assertResponseOk(); + //TODO: $this->assertRequestMatchesOpenApiSpec(); + $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'put'); + + // Test new password with form login + $request = new ServerRequest([ + 'url' => 'users/login', + 'post' => [ + 'username' => UsersFixture::USER_REGULAR_USER_USERNAME, + 'password' => $newPassword + ], + ]); + $result = $this->auth->authenticate($request, new Response()); + + $this->assertEquals(UsersFixture::USER_REGULAR_USER_ID, $result['id']); + $this->assertEquals(UsersFixture::USER_REGULAR_USER_USERNAME, $result['username']); + } +} diff --git a/tests/TestCase/Api/Users/EditUserApiTest.php b/tests/TestCase/Api/Users/EditUserApiTest.php index 39673ee..d52aea9 100644 --- a/tests/TestCase/Api/Users/EditUserApiTest.php +++ b/tests/TestCase/Api/Users/EditUserApiTest.php @@ -10,6 +10,7 @@ use App\Test\Fixture\AuthKeysFixture; use App\Test\Fixture\UsersFixture; use App\Test\Fixture\RolesFixture; use App\Test\Helper\ApiTestTrait; +use Authentication\PasswordHasher\DefaultPasswordHasher; class EditUserApiTest extends TestCase { @@ -65,23 +66,4 @@ class EditUserApiTest extends TestCase //TODO: $this->assertRequestMatchesOpenApiSpec(); $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'put'); } - - public function testEditSelfUser(): void - { - $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); - $this->put( - self::ENDPOINT, - [ - 'username' => 'test', - ] - ); - - $this->assertResponseOk(); - $this->assertDbRecordExists('Users', [ - 'id' => UsersFixture::USER_REGULAR_USER_ID, - 'username' => 'test' - ]); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'put'); - } } From 5a06a97c321437e6e99e7d94c1e58cc866ece319 Mon Sep 17 00:00:00 2001 From: Sami Mokaddem Date: Wed, 19 Jan 2022 14:04:13 +0100 Subject: [PATCH 143/150] new: [doc] Added prerequisites document --- documentation/prerequisites.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 documentation/prerequisites.md diff --git a/documentation/prerequisites.md b/documentation/prerequisites.md new file mode 100644 index 0000000..98da39d --- /dev/null +++ b/documentation/prerequisites.md @@ -0,0 +1,17 @@ +# Prerequisites based on usecases + +This document list the requirements that have to be met in order to perform the desired usecase. + +## Connect a local tool to cerebrate +- **Networking**: The *cerebrate* application must be able to contact the local tool service. That means the address and the port of the local must be reachable by *cerebrate*. +- **User permissions**: Depends on the actions performed by Cerebrate on the local tool. + - Example: For a standard MISP configuration, a simple user with the `user` role is enough for Cerebrate to pass the health check. + +## Conect two cerebrate instances together +- **Networking**: The two *cerebrate* applications must be able to contact each others. That means the address and the port of both tools must be reachable by the other one. +- **User permissions**: No specific role or set of permission is required. Any user role can be used. + +## Connect two local tools through cerebrate +- **Networking**: The two *cerebrate* applications must be able to contact each others. That means the address and the port of both tools must be reachable by the other one. This also applies to both the local tools. +- **User permissions**: Depends on the actions performed by Cerebrate on the local tool. + - Example: For a standard MISP configuration, in order to have two instance connected, the API key used by *cebrate* to orchestrate the inter-connection must belong to a user having the `site-admin` permission flag. This is essential as only the `site-admin` permission allows to create synchronisation links between MISP instances. \ No newline at end of file From c7ccff5e1e22e279a460b0ef463d432da7080ab8 Mon Sep 17 00:00:00 2001 From: Sami Mokaddem Date: Wed, 19 Jan 2022 14:19:55 +0100 Subject: [PATCH 144/150] fix: [doc] Typo in text --- documentation/prerequisites.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/prerequisites.md b/documentation/prerequisites.md index 98da39d..7b020ad 100644 --- a/documentation/prerequisites.md +++ b/documentation/prerequisites.md @@ -3,7 +3,7 @@ This document list the requirements that have to be met in order to perform the desired usecase. ## Connect a local tool to cerebrate -- **Networking**: The *cerebrate* application must be able to contact the local tool service. That means the address and the port of the local must be reachable by *cerebrate*. +- **Networking**: The *cerebrate* application must be able to contact the local tool service. That means the address and the port of the local tool must be reachable by *cerebrate*. - **User permissions**: Depends on the actions performed by Cerebrate on the local tool. - Example: For a standard MISP configuration, a simple user with the `user` role is enough for Cerebrate to pass the health check. From d488f010512f5c85a46bc617f42c301442255e7c Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 19 Jan 2022 14:39:03 +0100 Subject: [PATCH 145/150] fix: [authkey] add fixed - incorrectly potentially filter out valid options when adding a key by a regular user --- src/Controller/AuthKeysController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Controller/AuthKeysController.php b/src/Controller/AuthKeysController.php index 454b922..9ed43c3 100644 --- a/src/Controller/AuthKeysController.php +++ b/src/Controller/AuthKeysController.php @@ -79,7 +79,7 @@ class AuthKeysController extends AppController if (!empty($userConditions)) { $users->where($userConditions); } - $users = $users->order(['username' => 'asc'])->all()->toList(); + $users = $users->order(['username' => 'asc'])->all()->toArray(); $this->CRUD->add([ 'displayOnSuccess' => 'authkey_display', 'beforeSave' => function($data) use ($users) { From 7ed87ff0f2c660ea18604f394bac9f6900b8490c Mon Sep 17 00:00:00 2001 From: Luciano Righetti Date: Wed, 19 Jan 2022 15:15:49 +0100 Subject: [PATCH 146/150] chg: refactor ApiTestTrait to reduce code duplication, enforce openapi spec validations --- tests/Helper/ApiTestTrait.php | 164 ++++++++++++++++-- tests/README.md | 6 + .../Api/AuthKeys/AddAuthKeyApiTest.php | 6 - .../Api/AuthKeys/DeleteAuthKeyApiTest.php | 8 +- .../Api/AuthKeys/IndexAuthKeysApiTest.php | 6 - tests/TestCase/Api/Broods/AddBroodApiTest.php | 6 - .../Api/Broods/DeleteBroodApiTest.php | 6 - .../TestCase/Api/Broods/EditBroodApiTest.php | 6 - .../Api/Broods/IndexBroodsApiTest.php | 4 - .../Api/Broods/TestBroodConnectionApiTest.php | 4 - .../TestCase/Api/Broods/ViewBroodApiTest.php | 4 - .../AddEncryptionKeyApiTest.php | 6 - .../DeleteEncryptionKeyApiTest.php | 4 - .../EditEncryptionKeyApiTest.php | 6 - .../IndexEncryptionKeysApiTest.php | 4 - .../ViewEncryptionKeyApiTest.php | 4 - .../Api/Inbox/CreateInboxEntryApiTest.php | 6 - .../TestCase/Api/Inbox/IndexInboxApiTest.php | 4 - .../Api/Individuals/AddIndividualApiTest.php | 36 ++-- .../Individuals/DeleteIndividualApiTest.php | 6 - .../Api/Individuals/EditIndividualApiTest.php | 6 - .../Individuals/IndexIndividualsApiTest.php | 4 - .../Api/Individuals/ViewIndividualApiTest.php | 4 - .../Organisations/AddOrganisationApiTest.php | 6 - .../DeleteOrganisationApiTest.php | 5 - .../Organisations/EditOrganisationApiTest.php | 6 - .../IndexOrganisationsApiTest.php | 4 - .../Organisations/TagOrganisationApiTest.php | 6 - .../UntagOrganisationApiTest.php | 6 - .../Organisations/ViewOrganisationApiTest.php | 5 - .../SharingGroups/AddSharingGroupApiTest.php | 6 - .../DeleteSharingGroupApiTest.php | 6 - .../SharingGroups/EditSharingGroupApiTest.php | 7 - .../IndexSharingGroupsApiTest.php | 4 - .../SharingGroups/ViewSharingGroupApiTest.php | 4 - tests/TestCase/Api/Tags/IndexTagsApiTest.php | 5 - tests/TestCase/Api/Users/AddUserApiTest.php | 6 - .../Api/Users/ChangePasswordApiTest.php | 4 - .../TestCase/Api/Users/DeleteUserApiTest.php | 8 - tests/TestCase/Api/Users/EditUserApiTest.php | 7 - .../TestCase/Api/Users/IndexUsersApiTest.php | 4 - tests/TestCase/Api/Users/ViewUserApiTest.php | 6 - webroot/docs/openapi.yaml | 6 +- 43 files changed, 172 insertions(+), 249 deletions(-) diff --git a/tests/Helper/ApiTestTrait.php b/tests/Helper/ApiTestTrait.php index 56728f4..2060063 100644 --- a/tests/Helper/ApiTestTrait.php +++ b/tests/Helper/ApiTestTrait.php @@ -4,25 +4,45 @@ declare(strict_types=1); namespace App\Test\Helper; +use Cake\TestSuite\IntegrationTestTrait; use Cake\Http\Exception\NotImplementedException; +use Cake\Http\ServerRequestFactory; +use Cake\Http\ServerRequest; use \League\OpenAPIValidation\PSR7\ValidatorBuilder; use \League\OpenAPIValidation\PSR7\RequestValidator; use \League\OpenAPIValidation\PSR7\ResponseValidator; use \League\OpenAPIValidation\PSR7\OperationAddress; +use PHPUnit\Exception as PHPUnitException; +/** + * Trait ApiTestTrait + * + * @package App\Test\TestCase\Helper + */ trait ApiTestTrait { + use IntegrationTestTrait { + IntegrationTestTrait::_buildRequest as _buildRequestOriginal; + IntegrationTestTrait::_sendRequest as _sendRequestOriginal; + } + /** @var string */ protected $_authToken = ''; /** @var ValidatorBuilder */ - private $validator; + private $_validator; /** @var RequestValidator */ - private $requestValidator; + private $_requestValidator; /** @var ResponseValidator */ - private $responseValidator; + private $_responseValidator; + + /** @var ServerRequest */ + protected $_psrRequest; + + /* @var boolean */ + protected $_skipOpenApiValidations = false; public function setUp(): void { @@ -40,11 +60,22 @@ trait ApiTestTrait $this->configRequest([ 'headers' => [ 'Accept' => 'application/json', - 'Authorization' => $this->_authToken + 'Authorization' => $this->_authToken, + 'Content-Type' => 'application/json' ] ]); } + /** + * Skip OpenAPI validations. + * + * @return void + */ + public function skipOpenApiValidations(): void + { + $this->_skipOpenApiValidations = true; + } + public function assertResponseContainsArray(array $expected): void { $responseArray = json_decode((string)$this->_response->getBody(), true); @@ -59,22 +90,19 @@ trait ApiTestTrait */ public function initializeOpenApiValidator(string $specFile): void { - $this->validator = (new ValidatorBuilder)->fromYamlFile($specFile); - $this->requestValidator = $this->validator->getRequestValidator(); - $this->responseValidator = $this->validator->getResponseValidator(); + $this->_validator = (new ValidatorBuilder)->fromYamlFile($specFile); + $this->_requestValidator = $this->_validator->getRequestValidator(); + $this->_responseValidator = $this->_validator->getResponseValidator(); } /** * Validates the API request against the OpenAPI spec * - * @param string $path The path to the API endpoint - * @param string $method The HTTP method used to call the endpoint * @return void */ - public function assertRequestMatchesOpenApiSpec(string $endpoint, string $method = 'get'): void + public function assertRequestMatchesOpenApiSpec(): void { - // TODO: find a workaround to create a PSR-7 request object for validation - throw NotImplementedException("Unfortunately cakephp does not save the PSR-7 request object in the test context"); + $this->_requestValidator->validate($this->_psrRequest); } /** @@ -87,7 +115,7 @@ trait ApiTestTrait public function assertResponseMatchesOpenApiSpec(string $endpoint, string $method = 'get'): void { $address = new OperationAddress($endpoint, $method); - $this->responseValidator->validate($address, $this->_response); + $this->_responseValidator->validate($address, $this->_response); } /** @@ -111,7 +139,7 @@ trait ApiTestTrait } /** - * Validates a record do notexists in the database + * Validates a record do not exists in the database * * @param string $table The table name * @param array $conditions The conditions to check @@ -133,10 +161,8 @@ trait ApiTestTrait /** * Parses the response body and returns the decoded JSON * - * @return void + * @return array * @throws \Exception - * - * @see https://book.cakephp.org/4/en/orm-query-builder.html */ public function getJsonResponseAsArray(): array { @@ -159,4 +185,108 @@ trait ApiTestTrait { return $this->getTableLocator()->get($table)->find()->where($conditions)->first()->toArray(); } + + /** + * This method intercepts IntegrationTestTrait::_buildRequest() + * in the quest to get a PSR-7 request object and saves it for + * later inspection, also validates it against the OpenAPI spec. + * @see \Cake\TestSuite\IntegrationTestTrait::_buildRequest() + * + * @param string $url The URL + * @param string $method The HTTP method + * @param array|string $data The request data. + * @return array The request context + */ + protected function _buildRequest(string $url, $method, $data = []): array + { + $spec = $this->_buildRequestOriginal($url, $method, $data); + + $this->_psrRequest = $this->_createPsr7RequestFromSpec($spec); + + // Validate request against OpenAPI spec + if (!$this->_skipOpenApiValidations) { + try { + $this->assertRequestMatchesOpenApiSpec(); + } catch (\Exception $exception) { + $this->fail($exception->getMessage()); + } + } else { + $this->addWarning( + sprintf( + 'OpenAPI spec validations skipped for request [%s]%s.', + $this->_psrRequest->getMethod(), + $this->_psrRequest->getPath() + ) + ); + } + + return $spec; + } + + /** + * This method intercepts IntegrationTestTrait::_buildRequest() + * and validates the response against the OpenAPI spec. + * + * @see \Cake\TestSuite\IntegrationTestTrait::_sendRequest() + * + * @param array|string $url The URL + * @param string $method The HTTP method + * @param array|string $data The request data. + * @return void + * @throws \PHPUnit\Exception|\Throwable + */ + protected function _sendRequest($url, $method, $data = []): void + { + // Adding Content-Type: application/json $this->configRequest() prevents this from happening somehow + if (in_array($method, ['POST', 'PATCH', 'PUT']) && $this->_request['headers']['Content-Type'] === 'application/json') { + $data = json_encode($data); + } + + $this->_sendRequestOriginal($url, $method, $data); + + // Validate response against OpenAPI spec + if (!$this->_skipOpenApiValidations) { + $this->assertResponseMatchesOpenApiSpec( + $this->_psrRequest->getPath(), + strtolower($this->_psrRequest->getMethod()) + ); + } else { + $this->addWarning( + sprintf( + 'OpenAPI spec validations skipped for response of [%s]%s.', + $this->_psrRequest->getMethod(), + $this->_psrRequest->getPath() + ) + ); + } + } + + /** + * Create a PSR-7 request from the request spec. + * @see \Cake\TestSuite\MiddlewareDispatcher::_createRequest() + * + * @param array $spec The request spec. + * @return \Cake\Http\ServerRequest + */ + private function _createPsr7RequestFromSpec(array $spec): ServerRequest + { + if (isset($spec['input'])) { + $spec['post'] = []; + $spec['environment']['CAKEPHP_INPUT'] = $spec['input']; + } + $environment = array_merge( + array_merge($_SERVER, ['REQUEST_URI' => $spec['url']]), + $spec['environment'] + ); + if (strpos($environment['PHP_SELF'], 'phpunit') !== false) { + $environment['PHP_SELF'] = '/'; + } + return ServerRequestFactory::fromGlobals( + $environment, + $spec['query'], + $spec['post'], + $spec['cookies'], + $spec['files'] + ); + } } diff --git a/tests/README.md b/tests/README.md index e080442..d5f4385 100644 --- a/tests/README.md +++ b/tests/README.md @@ -115,6 +115,12 @@ The default OpenAPI spec path is set in `phpunit.xml` as a environment variablea ``` + +### Debugging tests +``` +$ export XDEBUG_CONFIG="idekey=IDEKEY" +$ phpunit +``` ## TODO - [ ] Validate API requests against the OpenAPI spec diff --git a/tests/TestCase/Api/AuthKeys/AddAuthKeyApiTest.php b/tests/TestCase/Api/AuthKeys/AddAuthKeyApiTest.php index 810dad2..32a79c4 100644 --- a/tests/TestCase/Api/AuthKeys/AddAuthKeyApiTest.php +++ b/tests/TestCase/Api/AuthKeys/AddAuthKeyApiTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; use App\Test\Fixture\UsersFixture; @@ -12,7 +11,6 @@ use App\Test\Helper\ApiTestTrait; class AddAuthKeyApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/authKeys/add'; @@ -46,8 +44,6 @@ class AddAuthKeyApiTest extends TestCase $this->assertResponseOk(); $this->assertResponseContains(sprintf('"uuid": "%s"', $uuid)); $this->assertDbRecordExists('AuthKeys', ['uuid' => $uuid]); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'post'); } public function testAddAdminAuthKeyNotAllowedAsRegularUser(): void @@ -72,7 +68,5 @@ class AddAuthKeyApiTest extends TestCase $this->assertResponseCode(404); $this->addWarning('Should return 405 Method Not Allowed instead of 404 Not Found'); $this->assertDbRecordNotExists('AuthKeys', ['uuid' => $uuid]); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'post'); } } diff --git a/tests/TestCase/Api/AuthKeys/DeleteAuthKeyApiTest.php b/tests/TestCase/Api/AuthKeys/DeleteAuthKeyApiTest.php index 21a22f6..539ab29 100644 --- a/tests/TestCase/Api/AuthKeys/DeleteAuthKeyApiTest.php +++ b/tests/TestCase/Api/AuthKeys/DeleteAuthKeyApiTest.php @@ -4,15 +4,12 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; -use App\Test\Fixture\BroodsFixture; use App\Test\Helper\ApiTestTrait; class DeleteAuthKeyApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/authKeys/delete'; @@ -33,19 +30,16 @@ class DeleteAuthKeyApiTest extends TestCase $this->assertResponseOk(); $this->assertDbRecordNotExists('AuthKeys', ['id' => AuthKeysFixture::ADMIN_API_ID]); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url, 'delete'); } public function testDeleteOrgAdminAuthKeyNotAllowedAsRegularUser(): void { $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); $url = sprintf('%s/%d', self::ENDPOINT, AuthKeysFixture::ORG_ADMIN_API_ID); + $this->delete($url); $this->assertResponseCode(405); $this->assertDbRecordExists('AuthKeys', ['id' => AuthKeysFixture::ORG_ADMIN_API_ID]); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url, 'delete'); } } diff --git a/tests/TestCase/Api/AuthKeys/IndexAuthKeysApiTest.php b/tests/TestCase/Api/AuthKeys/IndexAuthKeysApiTest.php index 9fbe3e6..43a3390 100644 --- a/tests/TestCase/Api/AuthKeys/IndexAuthKeysApiTest.php +++ b/tests/TestCase/Api/AuthKeys/IndexAuthKeysApiTest.php @@ -4,14 +4,12 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; use App\Test\Helper\ApiTestTrait; class IndexAuthKeysApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/authKeys/index'; @@ -31,8 +29,6 @@ class IndexAuthKeysApiTest extends TestCase $this->assertResponseOk(); $this->assertResponseContains(sprintf('"id": %d', AuthKeysFixture::ADMIN_API_ID)); - // TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT); } public function testIndexDoesNotShowAdminAuthKeysAsRegularUser(): void @@ -42,7 +38,5 @@ class IndexAuthKeysApiTest extends TestCase $this->assertResponseOk(); $this->assertResponseNotContains(sprintf('"id": %d', AuthKeysFixture::REGULAR_USER_API_KEY)); - // TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT); } } diff --git a/tests/TestCase/Api/Broods/AddBroodApiTest.php b/tests/TestCase/Api/Broods/AddBroodApiTest.php index 14fb2c5..3da884c 100644 --- a/tests/TestCase/Api/Broods/AddBroodApiTest.php +++ b/tests/TestCase/Api/Broods/AddBroodApiTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\OrganisationsFixture; use App\Test\Fixture\AuthKeysFixture; @@ -12,7 +11,6 @@ use App\Test\Helper\ApiTestTrait; class AddBroodApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/broods/add'; @@ -51,8 +49,6 @@ class AddBroodApiTest extends TestCase $this->assertResponseOk(); $this->assertResponseContains(sprintf('"uuid": "%s"', $uuid)); $this->assertDbRecordExists('Broods', ['uuid' => $uuid]); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'post'); } public function testAddBroodNotAllowedAsRegularUser(): void @@ -79,7 +75,5 @@ class AddBroodApiTest extends TestCase $this->assertResponseCode(405); $this->assertDbRecordNotExists('Broods', ['uuid' => $uuid]); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'post'); } } diff --git a/tests/TestCase/Api/Broods/DeleteBroodApiTest.php b/tests/TestCase/Api/Broods/DeleteBroodApiTest.php index 1b90bd6..c54e47a 100644 --- a/tests/TestCase/Api/Broods/DeleteBroodApiTest.php +++ b/tests/TestCase/Api/Broods/DeleteBroodApiTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; use App\Test\Fixture\BroodsFixture; @@ -12,7 +11,6 @@ use App\Test\Helper\ApiTestTrait; class DeleteBroodApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/broods/delete'; @@ -34,8 +32,6 @@ class DeleteBroodApiTest extends TestCase $this->assertResponseOk(); $this->assertDbRecordNotExists('Broods', ['id' => BroodsFixture::BROOD_A_ID]); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url, 'delete'); } public function testDeleteBroodNotAllowedAsRegularUser(): void @@ -46,7 +42,5 @@ class DeleteBroodApiTest extends TestCase $this->assertResponseCode(405); $this->assertDbRecordExists('Broods', ['id' => BroodsFixture::BROOD_A_ID]); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url, 'delete'); } } diff --git a/tests/TestCase/Api/Broods/EditBroodApiTest.php b/tests/TestCase/Api/Broods/EditBroodApiTest.php index d8fa01f..9bf3077 100644 --- a/tests/TestCase/Api/Broods/EditBroodApiTest.php +++ b/tests/TestCase/Api/Broods/EditBroodApiTest.php @@ -7,13 +7,11 @@ namespace App\Test\TestCase\Api\Users; use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; -use App\Test\Fixture\OrganisationsFixture; use App\Test\Fixture\BroodsFixture; use App\Test\Helper\ApiTestTrait; class EditBroodApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/broods/edit'; @@ -47,8 +45,6 @@ class EditBroodApiTest extends TestCase 'name' => 'Test Brood 4321', ] ); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url, 'put'); } public function testEditBroodNotAllowedAsRegularUser(): void @@ -71,7 +67,5 @@ class EditBroodApiTest extends TestCase 'name' => 'Test Brood 1234' ] ); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url, 'put'); } } diff --git a/tests/TestCase/Api/Broods/IndexBroodsApiTest.php b/tests/TestCase/Api/Broods/IndexBroodsApiTest.php index 856b151..598898d 100644 --- a/tests/TestCase/Api/Broods/IndexBroodsApiTest.php +++ b/tests/TestCase/Api/Broods/IndexBroodsApiTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; use App\Test\Fixture\BroodsFixture; @@ -12,7 +11,6 @@ use App\Test\Helper\ApiTestTrait; class IndexBroodsApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/users/index'; @@ -33,7 +31,5 @@ class IndexBroodsApiTest extends TestCase $this->assertResponseOk(); $this->assertResponseContains(sprintf('"id": %d', BroodsFixture::BROOD_A_ID)); - // TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT); } } diff --git a/tests/TestCase/Api/Broods/TestBroodConnectionApiTest.php b/tests/TestCase/Api/Broods/TestBroodConnectionApiTest.php index 1084131..31c2d48 100644 --- a/tests/TestCase/Api/Broods/TestBroodConnectionApiTest.php +++ b/tests/TestCase/Api/Broods/TestBroodConnectionApiTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; use App\Test\Fixture\BroodsFixture; @@ -14,7 +13,6 @@ use \WireMock\Client\WireMock; class TestBroodConnectionApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; use WireMockTestTrait; @@ -46,8 +44,6 @@ class TestBroodConnectionApiTest extends TestCase $this->assertResponseOk(); $this->assertResponseContains('"user": "wiremock"'); - // TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url); } private function mockCerebrateStatusResponse(): \WireMock\Stubbing\StubMapping diff --git a/tests/TestCase/Api/Broods/ViewBroodApiTest.php b/tests/TestCase/Api/Broods/ViewBroodApiTest.php index 35bb957..c357d90 100644 --- a/tests/TestCase/Api/Broods/ViewBroodApiTest.php +++ b/tests/TestCase/Api/Broods/ViewBroodApiTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; use App\Test\Fixture\BroodsFixture; @@ -12,7 +11,6 @@ use App\Test\Helper\ApiTestTrait; class ViewBroodApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/broods/view'; @@ -34,7 +32,5 @@ class ViewBroodApiTest extends TestCase $this->assertResponseOk(); $this->assertResponseContains(sprintf('"id": %d', BroodsFixture::BROOD_A_ID)); - // TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url); } } diff --git a/tests/TestCase/Api/EncryptionKeys/AddEncryptionKeyApiTest.php b/tests/TestCase/Api/EncryptionKeys/AddEncryptionKeyApiTest.php index b876800..1adf5d9 100644 --- a/tests/TestCase/Api/EncryptionKeys/AddEncryptionKeyApiTest.php +++ b/tests/TestCase/Api/EncryptionKeys/AddEncryptionKeyApiTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; use App\Test\Fixture\EncryptionKeysFixture; @@ -13,7 +12,6 @@ use App\Test\Helper\ApiTestTrait; class AddEncryptionKeyApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/encryptionKeys/add'; @@ -50,8 +48,6 @@ class AddEncryptionKeyApiTest extends TestCase $this->assertResponseOk(); $this->assertResponseContains(sprintf('"uuid": "%s"', $uuid)); $this->assertDbRecordExists('EncryptionKeys', ['uuid' => $uuid]); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'post'); } public function testAddAdminUserEncryptionKeyNotAllowedAsRegularUser(): void @@ -76,7 +72,5 @@ class AddEncryptionKeyApiTest extends TestCase $this->assertResponseCode(405); $this->assertDbRecordNotExists('EncryptionKeys', ['uuid' => $uuid]); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'post'); } } diff --git a/tests/TestCase/Api/EncryptionKeys/DeleteEncryptionKeyApiTest.php b/tests/TestCase/Api/EncryptionKeys/DeleteEncryptionKeyApiTest.php index 02ecd25..4bf0174 100644 --- a/tests/TestCase/Api/EncryptionKeys/DeleteEncryptionKeyApiTest.php +++ b/tests/TestCase/Api/EncryptionKeys/DeleteEncryptionKeyApiTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; use App\Test\Fixture\EncryptionKeysFixture; @@ -12,7 +11,6 @@ use App\Test\Helper\ApiTestTrait; class DeleteEncryptionKeyApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/encryptionKeys/delete'; @@ -46,7 +44,5 @@ class DeleteEncryptionKeyApiTest extends TestCase $this->assertResponseCode(405); $this->assertDbRecordExists('EncryptionKeys', ['id' => EncryptionKeysFixture::ENCRYPTION_KEY_ORG_B_ID]); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url, 'delete'); } } diff --git a/tests/TestCase/Api/EncryptionKeys/EditEncryptionKeyApiTest.php b/tests/TestCase/Api/EncryptionKeys/EditEncryptionKeyApiTest.php index 56a25d3..300a969 100644 --- a/tests/TestCase/Api/EncryptionKeys/EditEncryptionKeyApiTest.php +++ b/tests/TestCase/Api/EncryptionKeys/EditEncryptionKeyApiTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; use App\Test\Fixture\EncryptionKeysFixture; @@ -12,7 +11,6 @@ use App\Test\Helper\ApiTestTrait; class EditEncryptionKeyApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/encryptionKeys/edit'; @@ -46,8 +44,6 @@ class EditEncryptionKeyApiTest extends TestCase 'revoked' => true, ] ); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url, 'put'); } public function testRevokeAdminEncryptionKeyNotAllowedAsRegularUser(): void @@ -70,7 +66,5 @@ class EditEncryptionKeyApiTest extends TestCase 'revoked' => true ] ); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url, 'put'); } } diff --git a/tests/TestCase/Api/EncryptionKeys/IndexEncryptionKeysApiTest.php b/tests/TestCase/Api/EncryptionKeys/IndexEncryptionKeysApiTest.php index 0b7cb98..9aec4c5 100644 --- a/tests/TestCase/Api/EncryptionKeys/IndexEncryptionKeysApiTest.php +++ b/tests/TestCase/Api/EncryptionKeys/IndexEncryptionKeysApiTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; use App\Test\Fixture\EncryptionKeysFixture; @@ -12,7 +11,6 @@ use App\Test\Helper\ApiTestTrait; class IndexEncryptionKeysApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/encryptionKeys/index'; @@ -34,7 +32,5 @@ class IndexEncryptionKeysApiTest extends TestCase $this->assertResponseOk(); $this->assertResponseContains(sprintf('"id": %d', EncryptionKeysFixture::ENCRYPTION_KEY_ORG_A_ID)); - // TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT); } } diff --git a/tests/TestCase/Api/EncryptionKeys/ViewEncryptionKeyApiTest.php b/tests/TestCase/Api/EncryptionKeys/ViewEncryptionKeyApiTest.php index 8e46119..39c6519 100644 --- a/tests/TestCase/Api/EncryptionKeys/ViewEncryptionKeyApiTest.php +++ b/tests/TestCase/Api/EncryptionKeys/ViewEncryptionKeyApiTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; use App\Test\Fixture\EncryptionKeysFixture; @@ -12,7 +11,6 @@ use App\Test\Helper\ApiTestTrait; class ViewEncryptionKeyApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/encryptionKeys/view'; @@ -34,7 +32,5 @@ class ViewEncryptionKeyApiTest extends TestCase $this->assertResponseOk(); $this->assertResponseContains(sprintf('"id": %d', EncryptionKeysFixture::ENCRYPTION_KEY_ORG_A_ID)); - // TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url); } } diff --git a/tests/TestCase/Api/Inbox/CreateInboxEntryApiTest.php b/tests/TestCase/Api/Inbox/CreateInboxEntryApiTest.php index 39879f3..9c34aa0 100644 --- a/tests/TestCase/Api/Inbox/CreateInboxEntryApiTest.php +++ b/tests/TestCase/Api/Inbox/CreateInboxEntryApiTest.php @@ -4,14 +4,12 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; use App\Test\Helper\ApiTestTrait; class CreateInboxEntryApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/inbox/createEntry'; @@ -51,8 +49,6 @@ class CreateInboxEntryApiTest extends TestCase 'action' => 'Registration', ] ); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url, 'post'); } public function testAddUserRegistrationInboxNotAllowedAsRegularUser(): void @@ -70,7 +66,5 @@ class CreateInboxEntryApiTest extends TestCase $this->assertResponseCode(405); $this->assertDbRecordNotExists('Inbox', ['id' => 3]); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url, 'post'); } } diff --git a/tests/TestCase/Api/Inbox/IndexInboxApiTest.php b/tests/TestCase/Api/Inbox/IndexInboxApiTest.php index 46f1b92..da99710 100644 --- a/tests/TestCase/Api/Inbox/IndexInboxApiTest.php +++ b/tests/TestCase/Api/Inbox/IndexInboxApiTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; use App\Test\Fixture\InboxFixture; @@ -12,7 +11,6 @@ use App\Test\Helper\ApiTestTrait; class IndexInboxApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/inbox/index'; @@ -34,7 +32,5 @@ class IndexInboxApiTest extends TestCase $this->assertResponseOk(); $this->assertResponseContains(sprintf('"id": %d', InboxFixture::INBOX_USER_REGISTRATION_ID)); $this->assertResponseContains(sprintf('"id": %d', InboxFixture::INBOX_INCOMING_CONNECTION_REQUEST_ID)); - // TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT); } } diff --git a/tests/TestCase/Api/Individuals/AddIndividualApiTest.php b/tests/TestCase/Api/Individuals/AddIndividualApiTest.php index 0320c30..cced4e4 100644 --- a/tests/TestCase/Api/Individuals/AddIndividualApiTest.php +++ b/tests/TestCase/Api/Individuals/AddIndividualApiTest.php @@ -4,14 +4,12 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; use App\Test\Helper\ApiTestTrait; class AddIndividualApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/individuals/add'; @@ -40,26 +38,22 @@ class AddIndividualApiTest extends TestCase $this->assertResponseOk(); $this->assertResponseContains('"email": "john@example.com"'); $this->assertDbRecordExists('Individuals', ['email' => 'john@example.com']); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'post'); } - public function testAddUserNotAllowedAsRegularUser(): void - { - $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); - $this->post( - self::ENDPOINT, - [ - 'email' => 'john@example.com', - 'first_name' => 'John', - 'last_name' => 'Doe', - 'position' => 'Security Analyst' - ] - ); + // public function testAddUserNotAllowedAsRegularUser(): void + // { + // $this->setAuthToken(AuthKeysFixture::REGULAR_USER_API_KEY); + // $this->post( + // self::ENDPOINT, + // [ + // 'email' => 'john@example.com', + // 'first_name' => 'John', + // 'last_name' => 'Doe', + // 'position' => 'Security Analyst' + // ] + // ); - $this->assertResponseCode(405); - $this->assertDbRecordNotExists('Individuals', ['email' => 'john@example.com']); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'post'); - } + // $this->assertResponseCode(405); + // $this->assertDbRecordNotExists('Individuals', ['email' => 'john@example.com']); + // } } diff --git a/tests/TestCase/Api/Individuals/DeleteIndividualApiTest.php b/tests/TestCase/Api/Individuals/DeleteIndividualApiTest.php index 32b4ba7..0768b59 100644 --- a/tests/TestCase/Api/Individuals/DeleteIndividualApiTest.php +++ b/tests/TestCase/Api/Individuals/DeleteIndividualApiTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; use App\Test\Fixture\IndividualsFixture; @@ -12,7 +11,6 @@ use App\Test\Helper\ApiTestTrait; class DeleteIndividualApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/individuals/delete'; @@ -33,8 +31,6 @@ class DeleteIndividualApiTest extends TestCase $this->assertResponseOk(); $this->assertDbRecordNotExists('Individuals', ['id' => IndividualsFixture::INDIVIDUAL_A_ID]); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url, 'delete'); } public function testDeleteIndividualNotAllowedAsRegularUser(): void @@ -45,7 +41,5 @@ class DeleteIndividualApiTest extends TestCase $this->assertResponseCode(405); $this->assertDbRecordExists('Individuals', ['id' => IndividualsFixture::INDIVIDUAL_ADMIN_ID]); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url, 'delete'); } } diff --git a/tests/TestCase/Api/Individuals/EditIndividualApiTest.php b/tests/TestCase/Api/Individuals/EditIndividualApiTest.php index 642482c..cdff2d9 100644 --- a/tests/TestCase/Api/Individuals/EditIndividualApiTest.php +++ b/tests/TestCase/Api/Individuals/EditIndividualApiTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; use App\Test\Fixture\IndividualsFixture; @@ -12,7 +11,6 @@ use App\Test\Helper\ApiTestTrait; class EditIndividualApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/individuals/edit'; @@ -41,8 +39,6 @@ class EditIndividualApiTest extends TestCase 'id' => IndividualsFixture::INDIVIDUAL_REGULAR_USER_ID, 'email' => 'foo@bar.com' ]); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url, 'put'); } public function testEditAnyIndividualNotAllowedAsRegularUser(): void @@ -61,7 +57,5 @@ class EditIndividualApiTest extends TestCase 'id' => IndividualsFixture::INDIVIDUAL_ADMIN_ID, 'email' => 'foo@bar.com' ]); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url, 'put'); } } diff --git a/tests/TestCase/Api/Individuals/IndexIndividualsApiTest.php b/tests/TestCase/Api/Individuals/IndexIndividualsApiTest.php index d8254d1..29da85f 100644 --- a/tests/TestCase/Api/Individuals/IndexIndividualsApiTest.php +++ b/tests/TestCase/Api/Individuals/IndexIndividualsApiTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; use App\Test\Fixture\IndividualsFixture; @@ -12,7 +11,6 @@ use App\Test\Helper\ApiTestTrait; class IndexIndividualsApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/individuals/index'; @@ -32,7 +30,5 @@ class IndexIndividualsApiTest extends TestCase $this->assertResponseOk(); $this->assertResponseContains(sprintf('"id": %d', IndividualsFixture::INDIVIDUAL_ADMIN_ID)); - // TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT); } } diff --git a/tests/TestCase/Api/Individuals/ViewIndividualApiTest.php b/tests/TestCase/Api/Individuals/ViewIndividualApiTest.php index 5d47610..8589f1f 100644 --- a/tests/TestCase/Api/Individuals/ViewIndividualApiTest.php +++ b/tests/TestCase/Api/Individuals/ViewIndividualApiTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; use App\Test\Fixture\IndividualsFixture; @@ -12,7 +11,6 @@ use App\Test\Helper\ApiTestTrait; class ViewIndividualApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/individuals/view'; @@ -33,7 +31,5 @@ class ViewIndividualApiTest extends TestCase $this->assertResponseOk(); $this->assertResponseContains(sprintf('"id": %d', IndividualsFixture::INDIVIDUAL_ADMIN_ID)); - // TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url); } } diff --git a/tests/TestCase/Api/Organisations/AddOrganisationApiTest.php b/tests/TestCase/Api/Organisations/AddOrganisationApiTest.php index d561292..bde14a0 100644 --- a/tests/TestCase/Api/Organisations/AddOrganisationApiTest.php +++ b/tests/TestCase/Api/Organisations/AddOrganisationApiTest.php @@ -4,14 +4,12 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; use App\Test\Helper\ApiTestTrait; class AddOrganisationApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/organisations/add'; @@ -47,8 +45,6 @@ class AddOrganisationApiTest extends TestCase $this->assertResponseOk(); $this->assertResponseContains(sprintf('"uuid": "%s"', $uuid)); $this->assertDbRecordExists('Organisations', ['uuid' => $uuid]); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'post'); } public function testAddOrganisationNotAllowedAsRegularUser(): void @@ -73,7 +69,5 @@ class AddOrganisationApiTest extends TestCase $this->assertResponseCode(405); $this->assertDbRecordNotExists('Organisations', ['uuid' => $uuid]); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'post'); } } diff --git a/tests/TestCase/Api/Organisations/DeleteOrganisationApiTest.php b/tests/TestCase/Api/Organisations/DeleteOrganisationApiTest.php index 12b62d1..9e3ae3c 100644 --- a/tests/TestCase/Api/Organisations/DeleteOrganisationApiTest.php +++ b/tests/TestCase/Api/Organisations/DeleteOrganisationApiTest.php @@ -12,7 +12,6 @@ use App\Test\Helper\ApiTestTrait; class DeleteOrganisationApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/organisations/delete'; @@ -33,8 +32,6 @@ class DeleteOrganisationApiTest extends TestCase $this->assertResponseOk(); $this->assertDbRecordNotExists('Organisations', ['id' => OrganisationsFixture::ORGANISATION_B_ID]); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url, 'delete'); } public function testDeleteOrganisationNotAllowedAsRegularUser(): void @@ -45,7 +42,5 @@ class DeleteOrganisationApiTest extends TestCase $this->assertResponseCode(405); $this->assertDbRecordExists('Organisations', ['id' => OrganisationsFixture::ORGANISATION_B_ID]); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url, 'delete'); } } diff --git a/tests/TestCase/Api/Organisations/EditOrganisationApiTest.php b/tests/TestCase/Api/Organisations/EditOrganisationApiTest.php index 75c032c..4587808 100644 --- a/tests/TestCase/Api/Organisations/EditOrganisationApiTest.php +++ b/tests/TestCase/Api/Organisations/EditOrganisationApiTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; use App\Test\Fixture\OrganisationsFixture; @@ -12,7 +11,6 @@ use App\Test\Helper\ApiTestTrait; class EditOrganisationApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/organisations/edit'; @@ -45,8 +43,6 @@ class EditOrganisationApiTest extends TestCase 'name' => 'Test Organisation 4321', ] ); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url, 'put'); } public function testEditOrganisationNotAllowedAsRegularUser(): void @@ -69,7 +65,5 @@ class EditOrganisationApiTest extends TestCase 'name' => 'Test Organisation 1234' ] ); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url, 'put'); } } diff --git a/tests/TestCase/Api/Organisations/IndexOrganisationsApiTest.php b/tests/TestCase/Api/Organisations/IndexOrganisationsApiTest.php index 709883a..23a68c9 100644 --- a/tests/TestCase/Api/Organisations/IndexOrganisationsApiTest.php +++ b/tests/TestCase/Api/Organisations/IndexOrganisationsApiTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; use App\Test\Fixture\OrganisationsFixture; @@ -12,7 +11,6 @@ use App\Test\Helper\ApiTestTrait; class IndexOrganisationApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/organisations/index'; @@ -33,7 +31,5 @@ class IndexOrganisationApiTest extends TestCase $this->assertResponseOk(); $this->assertResponseContains(sprintf('"id": %d', OrganisationsFixture::ORGANISATION_A_ID)); $this->assertResponseContains(sprintf('"id": %d', OrganisationsFixture::ORGANISATION_B_ID)); - // TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT); } } diff --git a/tests/TestCase/Api/Organisations/TagOrganisationApiTest.php b/tests/TestCase/Api/Organisations/TagOrganisationApiTest.php index c79109a..d22f31a 100644 --- a/tests/TestCase/Api/Organisations/TagOrganisationApiTest.php +++ b/tests/TestCase/Api/Organisations/TagOrganisationApiTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; use App\Test\Fixture\OrganisationsFixture; @@ -13,7 +12,6 @@ use App\Test\Helper\ApiTestTrait; class TagOrganisationApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/organisations/tag'; @@ -49,8 +47,6 @@ class TagOrganisationApiTest extends TestCase 'fk_model' => 'Organisations' ] ); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url, 'post'); } public function testTagOrganisationNotAllowedAsRegularUser(): void @@ -74,7 +70,5 @@ class TagOrganisationApiTest extends TestCase 'fk_model' => 'Organisations' ] ); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url, 'post'); } } diff --git a/tests/TestCase/Api/Organisations/UntagOrganisationApiTest.php b/tests/TestCase/Api/Organisations/UntagOrganisationApiTest.php index 1aff58a..6cbb55d 100644 --- a/tests/TestCase/Api/Organisations/UntagOrganisationApiTest.php +++ b/tests/TestCase/Api/Organisations/UntagOrganisationApiTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; use App\Test\Fixture\OrganisationsFixture; @@ -13,7 +12,6 @@ use App\Test\Helper\ApiTestTrait; class UntagOrganisationApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/organisations/untag'; @@ -49,8 +47,6 @@ class UntagOrganisationApiTest extends TestCase 'fk_model' => 'Organisations' ] ); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url, 'post'); } public function testUntagOrganisationNotAllowedAsRegularUser(): void @@ -74,7 +70,5 @@ class UntagOrganisationApiTest extends TestCase 'fk_model' => 'Organisations' ] ); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url, 'post'); } } diff --git a/tests/TestCase/Api/Organisations/ViewOrganisationApiTest.php b/tests/TestCase/Api/Organisations/ViewOrganisationApiTest.php index 7170e98..5e51698 100644 --- a/tests/TestCase/Api/Organisations/ViewOrganisationApiTest.php +++ b/tests/TestCase/Api/Organisations/ViewOrganisationApiTest.php @@ -4,16 +4,13 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; use App\Test\Fixture\OrganisationsFixture; -use App\Test\Fixture\UsersFixture; use App\Test\Helper\ApiTestTrait; class ViewOrganisationApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/organisations/view'; @@ -36,7 +33,5 @@ class ViewOrganisationApiTest extends TestCase $this->assertResponseOk(); $this->assertResponseContains('"name": "Organisation A"'); - // TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url); } } diff --git a/tests/TestCase/Api/SharingGroups/AddSharingGroupApiTest.php b/tests/TestCase/Api/SharingGroups/AddSharingGroupApiTest.php index 0b0c8ab..2843687 100644 --- a/tests/TestCase/Api/SharingGroups/AddSharingGroupApiTest.php +++ b/tests/TestCase/Api/SharingGroups/AddSharingGroupApiTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\OrganisationsFixture; use App\Test\Fixture\UsersFixture; @@ -13,7 +12,6 @@ use App\Test\Helper\ApiTestTrait; class AddSharingGroupApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/sharingGroups/add'; @@ -51,8 +49,6 @@ class AddSharingGroupApiTest extends TestCase $this->assertResponseOk(); $this->assertResponseContains(sprintf('"uuid": "%s"', $uuid)); $this->assertDbRecordExists('SharingGroups', ['uuid' => $uuid]); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'post'); } public function testAddSharingGroupNotAllowedAsRegularUser(): void @@ -78,7 +74,5 @@ class AddSharingGroupApiTest extends TestCase $this->assertResponseCode(405); $this->assertDbRecordNotExists('SharingGroups', ['uuid' => $uuid]); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'post'); } } diff --git a/tests/TestCase/Api/SharingGroups/DeleteSharingGroupApiTest.php b/tests/TestCase/Api/SharingGroups/DeleteSharingGroupApiTest.php index 36379d4..b9d4a0c 100644 --- a/tests/TestCase/Api/SharingGroups/DeleteSharingGroupApiTest.php +++ b/tests/TestCase/Api/SharingGroups/DeleteSharingGroupApiTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; use App\Test\Fixture\SharingGroupsFixture; @@ -12,7 +11,6 @@ use App\Test\Helper\ApiTestTrait; class DeleteSharingGroupApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/sharingGroups/delete'; @@ -34,8 +32,6 @@ class DeleteSharingGroupApiTest extends TestCase $this->assertResponseOk(); $this->assertDbRecordNotExists('SharingGroups', ['id' => SharingGroupsFixture::SHARING_GROUP_A_ID]); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url, 'delete'); } public function testDeleteSharingGroupNotAllowedAsRegularUser(): void @@ -46,7 +42,5 @@ class DeleteSharingGroupApiTest extends TestCase $this->assertResponseCode(405); $this->assertDbRecordExists('SharingGroups', ['id' => SharingGroupsFixture::SHARING_GROUP_A_ID]); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url, 'delete'); } } diff --git a/tests/TestCase/Api/SharingGroups/EditSharingGroupApiTest.php b/tests/TestCase/Api/SharingGroups/EditSharingGroupApiTest.php index 7de711c..9f861e8 100644 --- a/tests/TestCase/Api/SharingGroups/EditSharingGroupApiTest.php +++ b/tests/TestCase/Api/SharingGroups/EditSharingGroupApiTest.php @@ -4,16 +4,13 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; -use App\Test\Fixture\OrganisationsFixture; use App\Test\Fixture\SharingGroupsFixture; use App\Test\Helper\ApiTestTrait; class EditSharingGroupApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/sharingGroups/edit'; @@ -47,8 +44,6 @@ class EditSharingGroupApiTest extends TestCase 'name' => 'Test Sharing Group 4321', ] ); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url, 'put'); } public function testEditSharingGroupNotAllowedAsRegularUser(): void @@ -71,7 +66,5 @@ class EditSharingGroupApiTest extends TestCase 'name' => 'Test Sharing Group 1234' ] ); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url, 'put'); } } diff --git a/tests/TestCase/Api/SharingGroups/IndexSharingGroupsApiTest.php b/tests/TestCase/Api/SharingGroups/IndexSharingGroupsApiTest.php index 82f7255..2cbded2 100644 --- a/tests/TestCase/Api/SharingGroups/IndexSharingGroupsApiTest.php +++ b/tests/TestCase/Api/SharingGroups/IndexSharingGroupsApiTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; use App\Test\Fixture\SharingGroupsFixture; @@ -12,7 +11,6 @@ use App\Test\Helper\ApiTestTrait; class IndexSharingGroupsApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/sharingGroups/index'; @@ -34,7 +32,5 @@ class IndexSharingGroupsApiTest extends TestCase $this->assertResponseOk(); $this->assertResponseContains(sprintf('"id": %d', SharingGroupsFixture::SHARING_GROUP_A_ID)); $this->assertResponseContains(sprintf('"id": %d', SharingGroupsFixture::SHARING_GROUP_B_ID)); - // TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT); } } diff --git a/tests/TestCase/Api/SharingGroups/ViewSharingGroupApiTest.php b/tests/TestCase/Api/SharingGroups/ViewSharingGroupApiTest.php index 3944122..0bbfbf5 100644 --- a/tests/TestCase/Api/SharingGroups/ViewSharingGroupApiTest.php +++ b/tests/TestCase/Api/SharingGroups/ViewSharingGroupApiTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; use App\Test\Fixture\SharingGroupsFixture; @@ -12,7 +11,6 @@ use App\Test\Helper\ApiTestTrait; class ViewSharingGroupApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/sharingGroups/view'; @@ -34,7 +32,5 @@ class ViewSharingGroupApiTest extends TestCase $this->assertResponseOk(); $this->assertResponseContains(sprintf('"id": %d', SharingGroupsFixture::SHARING_GROUP_A_ID)); - // TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url); } } diff --git a/tests/TestCase/Api/Tags/IndexTagsApiTest.php b/tests/TestCase/Api/Tags/IndexTagsApiTest.php index 330e93f..962750f 100644 --- a/tests/TestCase/Api/Tags/IndexTagsApiTest.php +++ b/tests/TestCase/Api/Tags/IndexTagsApiTest.php @@ -4,15 +4,12 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; -use App\Test\Fixture\UsersFixture; use App\Test\Helper\ApiTestTrait; class IndexTagsApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/tags/index'; @@ -35,7 +32,5 @@ class IndexTagsApiTest extends TestCase $this->assertResponseContains('"name": "red"'); $this->assertResponseContains('"name": "green"'); $this->assertResponseContains('"name": "blue"'); - // TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT); } } diff --git a/tests/TestCase/Api/Users/AddUserApiTest.php b/tests/TestCase/Api/Users/AddUserApiTest.php index 396307e..6741b9b 100644 --- a/tests/TestCase/Api/Users/AddUserApiTest.php +++ b/tests/TestCase/Api/Users/AddUserApiTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; use App\Test\Fixture\UsersFixture; @@ -14,7 +13,6 @@ use App\Test\Helper\ApiTestTrait; class AddUserApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/users/add'; @@ -45,8 +43,6 @@ class AddUserApiTest extends TestCase $this->assertResponseOk(); $this->assertResponseContains('"username": "test"'); $this->assertDbRecordExists('Users', ['username' => 'test']); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'post'); } public function testAddUserNotAllowedAsRegularUser(): void @@ -66,7 +62,5 @@ class AddUserApiTest extends TestCase $this->assertResponseCode(405); $this->assertDbRecordNotExists('Users', ['username' => 'test']); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'post'); } } diff --git a/tests/TestCase/Api/Users/ChangePasswordApiTest.php b/tests/TestCase/Api/Users/ChangePasswordApiTest.php index f1c1b82..fca8339 100644 --- a/tests/TestCase/Api/Users/ChangePasswordApiTest.php +++ b/tests/TestCase/Api/Users/ChangePasswordApiTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; use App\Test\Fixture\UsersFixture; @@ -16,7 +15,6 @@ use Cake\Controller\ComponentRegistry; class ChangePasswordApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/users/edit'; @@ -59,8 +57,6 @@ class ChangePasswordApiTest extends TestCase ); $this->assertResponseOk(); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'put'); // Test new password with form login $request = new ServerRequest([ diff --git a/tests/TestCase/Api/Users/DeleteUserApiTest.php b/tests/TestCase/Api/Users/DeleteUserApiTest.php index 48f9069..4e99963 100644 --- a/tests/TestCase/Api/Users/DeleteUserApiTest.php +++ b/tests/TestCase/Api/Users/DeleteUserApiTest.php @@ -4,17 +4,13 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; use App\Test\Fixture\UsersFixture; -use App\Test\Fixture\OrganisationsFixture; -use App\Test\Fixture\RolesFixture; use App\Test\Helper\ApiTestTrait; class DeleteUserApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/users/delete'; @@ -35,8 +31,6 @@ class DeleteUserApiTest extends TestCase $this->assertResponseOk(); $this->assertDbRecordNotExists('Users', ['id' => UsersFixture::USER_REGULAR_USER_ID]); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url, 'delete'); } public function testDeleteUserNotAllowedAsRegularUser(): void @@ -47,7 +41,5 @@ class DeleteUserApiTest extends TestCase $this->assertResponseCode(405); $this->assertDbRecordExists('Users', ['id' => UsersFixture::USER_ORG_ADMIN_ID]); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url, 'delete'); } } diff --git a/tests/TestCase/Api/Users/EditUserApiTest.php b/tests/TestCase/Api/Users/EditUserApiTest.php index d52aea9..93a0df8 100644 --- a/tests/TestCase/Api/Users/EditUserApiTest.php +++ b/tests/TestCase/Api/Users/EditUserApiTest.php @@ -4,17 +4,14 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; use App\Test\Fixture\UsersFixture; use App\Test\Fixture\RolesFixture; use App\Test\Helper\ApiTestTrait; -use Authentication\PasswordHasher\DefaultPasswordHasher; class EditUserApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/users/edit'; @@ -44,8 +41,6 @@ class EditUserApiTest extends TestCase 'id' => UsersFixture::USER_REGULAR_USER_ID, 'role_id' => RolesFixture::ROLE_ORG_ADMIN_ID ]); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url, 'put'); } public function testEditRoleNotAllowedAsRegularUser(): void @@ -63,7 +58,5 @@ class EditUserApiTest extends TestCase 'id' => UsersFixture::USER_REGULAR_USER_ID, 'role_id' => RolesFixture::ROLE_ADMIN_ID ]); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT, 'put'); } } diff --git a/tests/TestCase/Api/Users/IndexUsersApiTest.php b/tests/TestCase/Api/Users/IndexUsersApiTest.php index 7d96936..7303224 100644 --- a/tests/TestCase/Api/Users/IndexUsersApiTest.php +++ b/tests/TestCase/Api/Users/IndexUsersApiTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; use App\Test\Fixture\UsersFixture; @@ -12,7 +11,6 @@ use App\Test\Helper\ApiTestTrait; class IndexUsersApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/users/index'; @@ -32,7 +30,5 @@ class IndexUsersApiTest extends TestCase $this->assertResponseOk(); $this->assertResponseContains(sprintf('"username": "%s"', UsersFixture::USER_ADMIN_USERNAME)); - // TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT); } } diff --git a/tests/TestCase/Api/Users/ViewUserApiTest.php b/tests/TestCase/Api/Users/ViewUserApiTest.php index d1d2c54..576f2f8 100644 --- a/tests/TestCase/Api/Users/ViewUserApiTest.php +++ b/tests/TestCase/Api/Users/ViewUserApiTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Test\TestCase\Api\Users; -use Cake\TestSuite\IntegrationTestTrait; use Cake\TestSuite\TestCase; use App\Test\Fixture\AuthKeysFixture; use App\Test\Fixture\UsersFixture; @@ -12,7 +11,6 @@ use App\Test\Helper\ApiTestTrait; class ViewUserApiTest extends TestCase { - use IntegrationTestTrait; use ApiTestTrait; protected const ENDPOINT = '/api/v1/users/view'; @@ -32,8 +30,6 @@ class ViewUserApiTest extends TestCase $this->assertResponseOk(); $this->assertResponseContains(sprintf('"username": "%s"', UsersFixture::USER_ADMIN_USERNAME)); - // TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec(self::ENDPOINT); } public function testViewUserById(): void @@ -44,7 +40,5 @@ class ViewUserApiTest extends TestCase $this->assertResponseOk(); $this->assertResponseContains(sprintf('"username": "%s"', UsersFixture::USER_REGULAR_USER_USERNAME)); - // TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url); } } diff --git a/webroot/docs/openapi.yaml b/webroot/docs/openapi.yaml index 076af9f..b584bfc 100644 --- a/webroot/docs/openapi.yaml +++ b/webroot/docs/openapi.yaml @@ -70,7 +70,7 @@ paths: summary: "Add individual" operationId: addIndividual tags: - - Users + - Individuals requestBody: $ref: "#/components/requestBodies/CreateIndividualRequest" responses: @@ -1517,11 +1517,11 @@ components: uuid: $ref: "#/components/schemas/UUID" email: - $ref: "#/components/schemas/IndividualLastName" + $ref: "#/components/schemas/Email" first_name: $ref: "#/components/schemas/IndividualFirstName" last_name: - type: boolean + $ref: "#/components/schemas/IndividualLastName" position: $ref: "#/components/schemas/IndividualPosition" From 862ea58e490cedb64708288d19efc7df315514c6 Mon Sep 17 00:00:00 2001 From: Luciano Righetti Date: Wed, 19 Jan 2022 15:18:29 +0100 Subject: [PATCH 147/150] fix: assertions are already executed --- .../TestCase/Api/EncryptionKeys/DeleteEncryptionKeyApiTest.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/TestCase/Api/EncryptionKeys/DeleteEncryptionKeyApiTest.php b/tests/TestCase/Api/EncryptionKeys/DeleteEncryptionKeyApiTest.php index 4bf0174..b089c91 100644 --- a/tests/TestCase/Api/EncryptionKeys/DeleteEncryptionKeyApiTest.php +++ b/tests/TestCase/Api/EncryptionKeys/DeleteEncryptionKeyApiTest.php @@ -32,8 +32,6 @@ class DeleteEncryptionKeyApiTest extends TestCase $this->assertResponseOk(); $this->assertDbRecordNotExists('EncryptionKeys', ['id' => EncryptionKeysFixture::ENCRYPTION_KEY_ORG_A_ID]); - //TODO: $this->assertRequestMatchesOpenApiSpec(); - $this->assertResponseMatchesOpenApiSpec($url, 'delete'); } public function testDeleteEncryptionKeyNotAllowedAsRegularUser(): void From b1f63f190c343efd3af3d66896c7c4df6644410c Mon Sep 17 00:00:00 2001 From: Luciano Righetti Date: Wed, 19 Jan 2022 16:08:50 +0100 Subject: [PATCH 148/150] chg: move openapi validator initialization to tests/bootstrap.php so its only parsed once. --- tests/Helper/ApiTestTrait.php | 24 ++++++++++--------- .../Api/Users/ChangePasswordApiTest.php | 2 +- tests/bootstrap.php | 8 +++++++ 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/tests/Helper/ApiTestTrait.php b/tests/Helper/ApiTestTrait.php index 2060063..a55268c 100644 --- a/tests/Helper/ApiTestTrait.php +++ b/tests/Helper/ApiTestTrait.php @@ -5,14 +5,14 @@ declare(strict_types=1); namespace App\Test\Helper; use Cake\TestSuite\IntegrationTestTrait; -use Cake\Http\Exception\NotImplementedException; +use Cake\Core\Configure; use Cake\Http\ServerRequestFactory; use Cake\Http\ServerRequest; +use Cake\Http\Exception\NotImplementedException; use \League\OpenAPIValidation\PSR7\ValidatorBuilder; use \League\OpenAPIValidation\PSR7\RequestValidator; use \League\OpenAPIValidation\PSR7\ResponseValidator; use \League\OpenAPIValidation\PSR7\OperationAddress; -use PHPUnit\Exception as PHPUnitException; /** * Trait ApiTestTrait @@ -47,7 +47,7 @@ trait ApiTestTrait public function setUp(): void { parent::setUp(); - $this->initializeOpenApiValidator($_ENV['OPENAPI_SPEC'] ?? APP . '../webroot/docs/openapi.yaml'); + $this->initializeOpenApiValidator(); } public function setAuthToken(string $authToken): void @@ -83,16 +83,18 @@ trait ApiTestTrait } /** - * Parse the OpenAPI specification and create a validator + * Load OpenAPI specification validator * - * @param string $specFile * @return void */ - public function initializeOpenApiValidator(string $specFile): void + public function initializeOpenApiValidator(): void { - $this->_validator = (new ValidatorBuilder)->fromYamlFile($specFile); - $this->_requestValidator = $this->_validator->getRequestValidator(); - $this->_responseValidator = $this->_validator->getResponseValidator(); + if (!$this->_skipOpenApiValidations) { + $this->_validator = Configure::read('App.OpenAPIValidator'); + if ($this->_validator === null) { + throw new \Exception('OpenAPI validator is not configured'); + } + } } /** @@ -102,7 +104,7 @@ trait ApiTestTrait */ public function assertRequestMatchesOpenApiSpec(): void { - $this->_requestValidator->validate($this->_psrRequest); + $this->_validator->getRequestValidator()->validate($this->_psrRequest); } /** @@ -115,7 +117,7 @@ trait ApiTestTrait public function assertResponseMatchesOpenApiSpec(string $endpoint, string $method = 'get'): void { $address = new OperationAddress($endpoint, $method); - $this->_responseValidator->validate($address, $this->_response); + $this->_validator->getResponseValidator()->validate($address, $this->_response); } /** diff --git a/tests/TestCase/Api/Users/ChangePasswordApiTest.php b/tests/TestCase/Api/Users/ChangePasswordApiTest.php index fca8339..8dcd517 100644 --- a/tests/TestCase/Api/Users/ChangePasswordApiTest.php +++ b/tests/TestCase/Api/Users/ChangePasswordApiTest.php @@ -36,7 +36,7 @@ class ChangePasswordApiTest extends TestCase public function setUp(): void { parent::setUp(); - $this->initializeOpenApiValidator($_ENV['OPENAPI_SPEC'] ?? APP . '../webroot/docs/openapi.yaml'); + $this->initializeOpenApiValidator(); $this->collection = new ComponentRegistry(); $this->auth = new FormAuthenticate($this->collection, [ diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 34939c3..7523c28 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -19,6 +19,10 @@ declare(strict_types=1); use Cake\Core\Configure; use Cake\Datasource\ConnectionManager; use Migrations\TestSuite\Migrator; +use \League\OpenAPIValidation\PSR7\ValidatorBuilder; +use \League\OpenAPIValidation\PSR7\RequestValidator; +use \League\OpenAPIValidation\PSR7\ResponseValidator; +use \League\OpenAPIValidation\PSR7\OperationAddress; /** * Test runner bootstrap. @@ -64,3 +68,7 @@ if (!$_ENV['SKIP_DB_MIGRATIONS']) { } else { echo "[ * ] Skipping DB migrations ...\n"; } + +$specFile = $_ENV['OPENAPI_SPEC'] ?? APP . '../webroot/docs/openapi.yaml'; +// Initialize OpenAPI spec validator +Configure::write('App.OpenAPIValidator', (new ValidatorBuilder)->fromYamlFile($specFile)); From 01b4bc9bac7d0804ef26e526e92fa8f9bcfc59ab Mon Sep 17 00:00:00 2001 From: Luciano Righetti Date: Wed, 19 Jan 2022 16:13:37 +0100 Subject: [PATCH 149/150] chg: remove todo section --- tests/README.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/README.md b/tests/README.md index d5f4385..434f84a 100644 --- a/tests/README.md +++ b/tests/README.md @@ -121,8 +121,3 @@ The default OpenAPI spec path is set in `phpunit.xml` as a environment variablea $ export XDEBUG_CONFIG="idekey=IDEKEY" $ phpunit ``` - -## TODO -- [ ] Validate API requests against the OpenAPI spec -- [ ] Validate response content matches / implement _seeResponseContainsJson()_ or equivalent -- [ ] Parse OpenAPI spec only once per test run, currently re-loading in every _TestCase::setUp()_ \ No newline at end of file From 25b7d167f1d0d8a335e727a876764c0c160f2a9e Mon Sep 17 00:00:00 2001 From: Luciano Righetti Date: Wed, 19 Jan 2022 16:22:44 +0100 Subject: [PATCH 150/150] chg: remove the /api/v1 prefix for api endpoints --- config/routes.php | 35 +++----- .../Api/AuthKeys/AddAuthKeyApiTest.php | 2 +- .../Api/AuthKeys/DeleteAuthKeyApiTest.php | 2 +- .../Api/AuthKeys/IndexAuthKeysApiTest.php | 2 +- tests/TestCase/Api/Broods/AddBroodApiTest.php | 2 +- .../Api/Broods/DeleteBroodApiTest.php | 2 +- .../TestCase/Api/Broods/EditBroodApiTest.php | 2 +- .../Api/Broods/IndexBroodsApiTest.php | 2 +- .../Api/Broods/TestBroodConnectionApiTest.php | 2 +- .../TestCase/Api/Broods/ViewBroodApiTest.php | 2 +- .../AddEncryptionKeyApiTest.php | 2 +- .../DeleteEncryptionKeyApiTest.php | 2 +- .../EditEncryptionKeyApiTest.php | 2 +- .../IndexEncryptionKeysApiTest.php | 2 +- .../ViewEncryptionKeyApiTest.php | 2 +- .../Api/Inbox/CreateInboxEntryApiTest.php | 2 +- .../TestCase/Api/Inbox/IndexInboxApiTest.php | 2 +- .../Api/Individuals/AddIndividualApiTest.php | 2 +- .../Individuals/DeleteIndividualApiTest.php | 2 +- .../Api/Individuals/EditIndividualApiTest.php | 2 +- .../Individuals/IndexIndividualsApiTest.php | 2 +- .../Api/Individuals/ViewIndividualApiTest.php | 2 +- .../Organisations/AddOrganisationApiTest.php | 2 +- .../DeleteOrganisationApiTest.php | 2 +- .../Organisations/EditOrganisationApiTest.php | 2 +- .../IndexOrganisationsApiTest.php | 2 +- .../Organisations/TagOrganisationApiTest.php | 2 +- .../UntagOrganisationApiTest.php | 2 +- .../Organisations/ViewOrganisationApiTest.php | 2 +- .../SharingGroups/AddSharingGroupApiTest.php | 2 +- .../DeleteSharingGroupApiTest.php | 2 +- .../SharingGroups/EditSharingGroupApiTest.php | 2 +- .../IndexSharingGroupsApiTest.php | 2 +- .../SharingGroups/ViewSharingGroupApiTest.php | 2 +- tests/TestCase/Api/Tags/IndexTagsApiTest.php | 2 +- tests/TestCase/Api/Users/AddUserApiTest.php | 2 +- .../Api/Users/ChangePasswordApiTest.php | 2 +- .../TestCase/Api/Users/DeleteUserApiTest.php | 2 +- tests/TestCase/Api/Users/EditUserApiTest.php | 2 +- .../TestCase/Api/Users/IndexUsersApiTest.php | 2 +- tests/TestCase/Api/Users/ViewUserApiTest.php | 2 +- webroot/docs/openapi.yaml | 90 +++++++++---------- 42 files changed, 96 insertions(+), 109 deletions(-) diff --git a/config/routes.php b/config/routes.php index d51121a..ac1ab26 100644 --- a/config/routes.php +++ b/config/routes.php @@ -92,27 +92,14 @@ $routes->prefix('Open', function (RouteBuilder $routes) { $routes->fallbacks(DashedRoute::class); }); -// API routes -$routes->scope('/api', function (RouteBuilder $routes) { - // $routes->applyMiddleware('ratelimit', 'auth.api'); - $routes->scope('/v1', function (RouteBuilder $routes) { - // $routes->applyMiddleware('v1compat'); - $routes->setExtensions(['json']); - - // Generic API route - $routes->connect('/{controller}/{action}/*'); - - // Tags plugin routes - $routes->plugin( - 'tags', - ['path' => '/tags'], - function ($routes) { - $routes->setRouteClass(DashedRoute::class); - $routes->connect( - '/{action}/*', - ['controller' => 'Tags'] - ); - } - ); - }); -}); +/* + * If you need a different set of middleware or none at all, + * open new scope and define routes there. + * + * ``` + * $routes->scope('/api', function (RouteBuilder $builder) { + * // No $builder->applyMiddleware() here. + * // Connect API actions here. + * }); + * ``` + */ diff --git a/tests/TestCase/Api/AuthKeys/AddAuthKeyApiTest.php b/tests/TestCase/Api/AuthKeys/AddAuthKeyApiTest.php index 32a79c4..ca305e8 100644 --- a/tests/TestCase/Api/AuthKeys/AddAuthKeyApiTest.php +++ b/tests/TestCase/Api/AuthKeys/AddAuthKeyApiTest.php @@ -13,7 +13,7 @@ class AddAuthKeyApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/authKeys/add'; + protected const ENDPOINT = '/authKeys/add'; protected $fixtures = [ 'app.Organisations', diff --git a/tests/TestCase/Api/AuthKeys/DeleteAuthKeyApiTest.php b/tests/TestCase/Api/AuthKeys/DeleteAuthKeyApiTest.php index 539ab29..a621f37 100644 --- a/tests/TestCase/Api/AuthKeys/DeleteAuthKeyApiTest.php +++ b/tests/TestCase/Api/AuthKeys/DeleteAuthKeyApiTest.php @@ -12,7 +12,7 @@ class DeleteAuthKeyApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/authKeys/delete'; + protected const ENDPOINT = '/authKeys/delete'; protected $fixtures = [ 'app.Organisations', diff --git a/tests/TestCase/Api/AuthKeys/IndexAuthKeysApiTest.php b/tests/TestCase/Api/AuthKeys/IndexAuthKeysApiTest.php index 43a3390..0712480 100644 --- a/tests/TestCase/Api/AuthKeys/IndexAuthKeysApiTest.php +++ b/tests/TestCase/Api/AuthKeys/IndexAuthKeysApiTest.php @@ -12,7 +12,7 @@ class IndexAuthKeysApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/authKeys/index'; + protected const ENDPOINT = '/authKeys/index'; protected $fixtures = [ 'app.Organisations', diff --git a/tests/TestCase/Api/Broods/AddBroodApiTest.php b/tests/TestCase/Api/Broods/AddBroodApiTest.php index 3da884c..f064f61 100644 --- a/tests/TestCase/Api/Broods/AddBroodApiTest.php +++ b/tests/TestCase/Api/Broods/AddBroodApiTest.php @@ -13,7 +13,7 @@ class AddBroodApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/broods/add'; + protected const ENDPOINT = '/broods/add'; protected $fixtures = [ 'app.Organisations', diff --git a/tests/TestCase/Api/Broods/DeleteBroodApiTest.php b/tests/TestCase/Api/Broods/DeleteBroodApiTest.php index c54e47a..420bf01 100644 --- a/tests/TestCase/Api/Broods/DeleteBroodApiTest.php +++ b/tests/TestCase/Api/Broods/DeleteBroodApiTest.php @@ -13,7 +13,7 @@ class DeleteBroodApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/broods/delete'; + protected const ENDPOINT = '/broods/delete'; protected $fixtures = [ 'app.Organisations', diff --git a/tests/TestCase/Api/Broods/EditBroodApiTest.php b/tests/TestCase/Api/Broods/EditBroodApiTest.php index 9bf3077..ad5d70b 100644 --- a/tests/TestCase/Api/Broods/EditBroodApiTest.php +++ b/tests/TestCase/Api/Broods/EditBroodApiTest.php @@ -14,7 +14,7 @@ class EditBroodApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/broods/edit'; + protected const ENDPOINT = '/broods/edit'; protected $fixtures = [ 'app.Organisations', diff --git a/tests/TestCase/Api/Broods/IndexBroodsApiTest.php b/tests/TestCase/Api/Broods/IndexBroodsApiTest.php index 598898d..d70bc53 100644 --- a/tests/TestCase/Api/Broods/IndexBroodsApiTest.php +++ b/tests/TestCase/Api/Broods/IndexBroodsApiTest.php @@ -13,7 +13,7 @@ class IndexBroodsApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/users/index'; + protected const ENDPOINT = '/users/index'; protected $fixtures = [ 'app.Organisations', diff --git a/tests/TestCase/Api/Broods/TestBroodConnectionApiTest.php b/tests/TestCase/Api/Broods/TestBroodConnectionApiTest.php index 31c2d48..ee1117f 100644 --- a/tests/TestCase/Api/Broods/TestBroodConnectionApiTest.php +++ b/tests/TestCase/Api/Broods/TestBroodConnectionApiTest.php @@ -16,7 +16,7 @@ class TestBroodConnectionApiTest extends TestCase use ApiTestTrait; use WireMockTestTrait; - protected const ENDPOINT = '/api/v1/broods/testConnection'; + protected const ENDPOINT = '/broods/testConnection'; protected $fixtures = [ 'app.Organisations', diff --git a/tests/TestCase/Api/Broods/ViewBroodApiTest.php b/tests/TestCase/Api/Broods/ViewBroodApiTest.php index c357d90..bd9e5a7 100644 --- a/tests/TestCase/Api/Broods/ViewBroodApiTest.php +++ b/tests/TestCase/Api/Broods/ViewBroodApiTest.php @@ -13,7 +13,7 @@ class ViewBroodApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/broods/view'; + protected const ENDPOINT = '/broods/view'; protected $fixtures = [ 'app.Organisations', diff --git a/tests/TestCase/Api/EncryptionKeys/AddEncryptionKeyApiTest.php b/tests/TestCase/Api/EncryptionKeys/AddEncryptionKeyApiTest.php index 1adf5d9..00cc377 100644 --- a/tests/TestCase/Api/EncryptionKeys/AddEncryptionKeyApiTest.php +++ b/tests/TestCase/Api/EncryptionKeys/AddEncryptionKeyApiTest.php @@ -14,7 +14,7 @@ class AddEncryptionKeyApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/encryptionKeys/add'; + protected const ENDPOINT = '/encryptionKeys/add'; protected $fixtures = [ 'app.Organisations', diff --git a/tests/TestCase/Api/EncryptionKeys/DeleteEncryptionKeyApiTest.php b/tests/TestCase/Api/EncryptionKeys/DeleteEncryptionKeyApiTest.php index b089c91..6ae8143 100644 --- a/tests/TestCase/Api/EncryptionKeys/DeleteEncryptionKeyApiTest.php +++ b/tests/TestCase/Api/EncryptionKeys/DeleteEncryptionKeyApiTest.php @@ -13,7 +13,7 @@ class DeleteEncryptionKeyApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/encryptionKeys/delete'; + protected const ENDPOINT = '/encryptionKeys/delete'; protected $fixtures = [ 'app.Organisations', diff --git a/tests/TestCase/Api/EncryptionKeys/EditEncryptionKeyApiTest.php b/tests/TestCase/Api/EncryptionKeys/EditEncryptionKeyApiTest.php index 300a969..2636fc1 100644 --- a/tests/TestCase/Api/EncryptionKeys/EditEncryptionKeyApiTest.php +++ b/tests/TestCase/Api/EncryptionKeys/EditEncryptionKeyApiTest.php @@ -13,7 +13,7 @@ class EditEncryptionKeyApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/encryptionKeys/edit'; + protected const ENDPOINT = '/encryptionKeys/edit'; protected $fixtures = [ 'app.Organisations', diff --git a/tests/TestCase/Api/EncryptionKeys/IndexEncryptionKeysApiTest.php b/tests/TestCase/Api/EncryptionKeys/IndexEncryptionKeysApiTest.php index 9aec4c5..844336d 100644 --- a/tests/TestCase/Api/EncryptionKeys/IndexEncryptionKeysApiTest.php +++ b/tests/TestCase/Api/EncryptionKeys/IndexEncryptionKeysApiTest.php @@ -13,7 +13,7 @@ class IndexEncryptionKeysApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/encryptionKeys/index'; + protected const ENDPOINT = '/encryptionKeys/index'; protected $fixtures = [ 'app.Organisations', diff --git a/tests/TestCase/Api/EncryptionKeys/ViewEncryptionKeyApiTest.php b/tests/TestCase/Api/EncryptionKeys/ViewEncryptionKeyApiTest.php index 39c6519..de324fb 100644 --- a/tests/TestCase/Api/EncryptionKeys/ViewEncryptionKeyApiTest.php +++ b/tests/TestCase/Api/EncryptionKeys/ViewEncryptionKeyApiTest.php @@ -13,7 +13,7 @@ class ViewEncryptionKeyApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/encryptionKeys/view'; + protected const ENDPOINT = '/encryptionKeys/view'; protected $fixtures = [ 'app.Organisations', diff --git a/tests/TestCase/Api/Inbox/CreateInboxEntryApiTest.php b/tests/TestCase/Api/Inbox/CreateInboxEntryApiTest.php index 9c34aa0..8434d62 100644 --- a/tests/TestCase/Api/Inbox/CreateInboxEntryApiTest.php +++ b/tests/TestCase/Api/Inbox/CreateInboxEntryApiTest.php @@ -12,7 +12,7 @@ class CreateInboxEntryApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/inbox/createEntry'; + protected const ENDPOINT = '/inbox/createEntry'; protected $fixtures = [ 'app.Inbox', diff --git a/tests/TestCase/Api/Inbox/IndexInboxApiTest.php b/tests/TestCase/Api/Inbox/IndexInboxApiTest.php index da99710..ae9c039 100644 --- a/tests/TestCase/Api/Inbox/IndexInboxApiTest.php +++ b/tests/TestCase/Api/Inbox/IndexInboxApiTest.php @@ -13,7 +13,7 @@ class IndexInboxApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/inbox/index'; + protected const ENDPOINT = '/inbox/index'; protected $fixtures = [ 'app.Inbox', diff --git a/tests/TestCase/Api/Individuals/AddIndividualApiTest.php b/tests/TestCase/Api/Individuals/AddIndividualApiTest.php index cced4e4..fcff319 100644 --- a/tests/TestCase/Api/Individuals/AddIndividualApiTest.php +++ b/tests/TestCase/Api/Individuals/AddIndividualApiTest.php @@ -12,7 +12,7 @@ class AddIndividualApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/individuals/add'; + protected const ENDPOINT = '/individuals/add'; protected $fixtures = [ 'app.Organisations', diff --git a/tests/TestCase/Api/Individuals/DeleteIndividualApiTest.php b/tests/TestCase/Api/Individuals/DeleteIndividualApiTest.php index 0768b59..e5657aa 100644 --- a/tests/TestCase/Api/Individuals/DeleteIndividualApiTest.php +++ b/tests/TestCase/Api/Individuals/DeleteIndividualApiTest.php @@ -13,7 +13,7 @@ class DeleteIndividualApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/individuals/delete'; + protected const ENDPOINT = '/individuals/delete'; protected $fixtures = [ 'app.Organisations', diff --git a/tests/TestCase/Api/Individuals/EditIndividualApiTest.php b/tests/TestCase/Api/Individuals/EditIndividualApiTest.php index cdff2d9..c888bba 100644 --- a/tests/TestCase/Api/Individuals/EditIndividualApiTest.php +++ b/tests/TestCase/Api/Individuals/EditIndividualApiTest.php @@ -13,7 +13,7 @@ class EditIndividualApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/individuals/edit'; + protected const ENDPOINT = '/individuals/edit'; protected $fixtures = [ 'app.Organisations', diff --git a/tests/TestCase/Api/Individuals/IndexIndividualsApiTest.php b/tests/TestCase/Api/Individuals/IndexIndividualsApiTest.php index 29da85f..e5c92ce 100644 --- a/tests/TestCase/Api/Individuals/IndexIndividualsApiTest.php +++ b/tests/TestCase/Api/Individuals/IndexIndividualsApiTest.php @@ -13,7 +13,7 @@ class IndexIndividualsApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/individuals/index'; + protected const ENDPOINT = '/individuals/index'; protected $fixtures = [ 'app.Organisations', diff --git a/tests/TestCase/Api/Individuals/ViewIndividualApiTest.php b/tests/TestCase/Api/Individuals/ViewIndividualApiTest.php index 8589f1f..d4b94d9 100644 --- a/tests/TestCase/Api/Individuals/ViewIndividualApiTest.php +++ b/tests/TestCase/Api/Individuals/ViewIndividualApiTest.php @@ -13,7 +13,7 @@ class ViewIndividualApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/individuals/view'; + protected const ENDPOINT = '/individuals/view'; protected $fixtures = [ 'app.Organisations', diff --git a/tests/TestCase/Api/Organisations/AddOrganisationApiTest.php b/tests/TestCase/Api/Organisations/AddOrganisationApiTest.php index bde14a0..5a47554 100644 --- a/tests/TestCase/Api/Organisations/AddOrganisationApiTest.php +++ b/tests/TestCase/Api/Organisations/AddOrganisationApiTest.php @@ -12,7 +12,7 @@ class AddOrganisationApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/organisations/add'; + protected const ENDPOINT = '/organisations/add'; protected $fixtures = [ 'app.Organisations', diff --git a/tests/TestCase/Api/Organisations/DeleteOrganisationApiTest.php b/tests/TestCase/Api/Organisations/DeleteOrganisationApiTest.php index 9e3ae3c..efdaa5c 100644 --- a/tests/TestCase/Api/Organisations/DeleteOrganisationApiTest.php +++ b/tests/TestCase/Api/Organisations/DeleteOrganisationApiTest.php @@ -14,7 +14,7 @@ class DeleteOrganisationApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/organisations/delete'; + protected const ENDPOINT = '/organisations/delete'; protected $fixtures = [ 'app.Organisations', diff --git a/tests/TestCase/Api/Organisations/EditOrganisationApiTest.php b/tests/TestCase/Api/Organisations/EditOrganisationApiTest.php index 4587808..6d14f3c 100644 --- a/tests/TestCase/Api/Organisations/EditOrganisationApiTest.php +++ b/tests/TestCase/Api/Organisations/EditOrganisationApiTest.php @@ -13,7 +13,7 @@ class EditOrganisationApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/organisations/edit'; + protected const ENDPOINT = '/organisations/edit'; protected $fixtures = [ 'app.Organisations', diff --git a/tests/TestCase/Api/Organisations/IndexOrganisationsApiTest.php b/tests/TestCase/Api/Organisations/IndexOrganisationsApiTest.php index 23a68c9..a22e0f4 100644 --- a/tests/TestCase/Api/Organisations/IndexOrganisationsApiTest.php +++ b/tests/TestCase/Api/Organisations/IndexOrganisationsApiTest.php @@ -13,7 +13,7 @@ class IndexOrganisationApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/organisations/index'; + protected const ENDPOINT = '/organisations/index'; protected $fixtures = [ 'app.Organisations', diff --git a/tests/TestCase/Api/Organisations/TagOrganisationApiTest.php b/tests/TestCase/Api/Organisations/TagOrganisationApiTest.php index d22f31a..f8bd194 100644 --- a/tests/TestCase/Api/Organisations/TagOrganisationApiTest.php +++ b/tests/TestCase/Api/Organisations/TagOrganisationApiTest.php @@ -14,7 +14,7 @@ class TagOrganisationApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/organisations/tag'; + protected const ENDPOINT = '/organisations/tag'; protected $fixtures = [ 'app.TagsTags', diff --git a/tests/TestCase/Api/Organisations/UntagOrganisationApiTest.php b/tests/TestCase/Api/Organisations/UntagOrganisationApiTest.php index 6cbb55d..59f1bea 100644 --- a/tests/TestCase/Api/Organisations/UntagOrganisationApiTest.php +++ b/tests/TestCase/Api/Organisations/UntagOrganisationApiTest.php @@ -14,7 +14,7 @@ class UntagOrganisationApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/organisations/untag'; + protected const ENDPOINT = '/organisations/untag'; protected $fixtures = [ 'app.TagsTags', diff --git a/tests/TestCase/Api/Organisations/ViewOrganisationApiTest.php b/tests/TestCase/Api/Organisations/ViewOrganisationApiTest.php index 5e51698..a9a728b 100644 --- a/tests/TestCase/Api/Organisations/ViewOrganisationApiTest.php +++ b/tests/TestCase/Api/Organisations/ViewOrganisationApiTest.php @@ -13,7 +13,7 @@ class ViewOrganisationApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/organisations/view'; + protected const ENDPOINT = '/organisations/view'; protected $fixtures = [ 'app.TagsTags', diff --git a/tests/TestCase/Api/SharingGroups/AddSharingGroupApiTest.php b/tests/TestCase/Api/SharingGroups/AddSharingGroupApiTest.php index 2843687..cbfebbb 100644 --- a/tests/TestCase/Api/SharingGroups/AddSharingGroupApiTest.php +++ b/tests/TestCase/Api/SharingGroups/AddSharingGroupApiTest.php @@ -14,7 +14,7 @@ class AddSharingGroupApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/sharingGroups/add'; + protected const ENDPOINT = '/sharingGroups/add'; protected $fixtures = [ 'app.Organisations', diff --git a/tests/TestCase/Api/SharingGroups/DeleteSharingGroupApiTest.php b/tests/TestCase/Api/SharingGroups/DeleteSharingGroupApiTest.php index b9d4a0c..e2d1dc5 100644 --- a/tests/TestCase/Api/SharingGroups/DeleteSharingGroupApiTest.php +++ b/tests/TestCase/Api/SharingGroups/DeleteSharingGroupApiTest.php @@ -13,7 +13,7 @@ class DeleteSharingGroupApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/sharingGroups/delete'; + protected const ENDPOINT = '/sharingGroups/delete'; protected $fixtures = [ 'app.Organisations', diff --git a/tests/TestCase/Api/SharingGroups/EditSharingGroupApiTest.php b/tests/TestCase/Api/SharingGroups/EditSharingGroupApiTest.php index 9f861e8..07dff5b 100644 --- a/tests/TestCase/Api/SharingGroups/EditSharingGroupApiTest.php +++ b/tests/TestCase/Api/SharingGroups/EditSharingGroupApiTest.php @@ -13,7 +13,7 @@ class EditSharingGroupApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/sharingGroups/edit'; + protected const ENDPOINT = '/sharingGroups/edit'; protected $fixtures = [ 'app.Organisations', diff --git a/tests/TestCase/Api/SharingGroups/IndexSharingGroupsApiTest.php b/tests/TestCase/Api/SharingGroups/IndexSharingGroupsApiTest.php index 2cbded2..5286af2 100644 --- a/tests/TestCase/Api/SharingGroups/IndexSharingGroupsApiTest.php +++ b/tests/TestCase/Api/SharingGroups/IndexSharingGroupsApiTest.php @@ -13,7 +13,7 @@ class IndexSharingGroupsApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/sharingGroups/index'; + protected const ENDPOINT = '/sharingGroups/index'; protected $fixtures = [ 'app.Organisations', diff --git a/tests/TestCase/Api/SharingGroups/ViewSharingGroupApiTest.php b/tests/TestCase/Api/SharingGroups/ViewSharingGroupApiTest.php index 0bbfbf5..06ceb93 100644 --- a/tests/TestCase/Api/SharingGroups/ViewSharingGroupApiTest.php +++ b/tests/TestCase/Api/SharingGroups/ViewSharingGroupApiTest.php @@ -13,7 +13,7 @@ class ViewSharingGroupApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/sharingGroups/view'; + protected const ENDPOINT = '/sharingGroups/view'; protected $fixtures = [ 'app.Organisations', diff --git a/tests/TestCase/Api/Tags/IndexTagsApiTest.php b/tests/TestCase/Api/Tags/IndexTagsApiTest.php index 962750f..4b13b67 100644 --- a/tests/TestCase/Api/Tags/IndexTagsApiTest.php +++ b/tests/TestCase/Api/Tags/IndexTagsApiTest.php @@ -12,7 +12,7 @@ class IndexTagsApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/tags/index'; + protected const ENDPOINT = '/tags/index'; protected $fixtures = [ 'app.TagsTags', diff --git a/tests/TestCase/Api/Users/AddUserApiTest.php b/tests/TestCase/Api/Users/AddUserApiTest.php index 6741b9b..3437d29 100644 --- a/tests/TestCase/Api/Users/AddUserApiTest.php +++ b/tests/TestCase/Api/Users/AddUserApiTest.php @@ -15,7 +15,7 @@ class AddUserApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/users/add'; + protected const ENDPOINT = '/users/add'; protected $fixtures = [ 'app.Organisations', diff --git a/tests/TestCase/Api/Users/ChangePasswordApiTest.php b/tests/TestCase/Api/Users/ChangePasswordApiTest.php index 8dcd517..201cb74 100644 --- a/tests/TestCase/Api/Users/ChangePasswordApiTest.php +++ b/tests/TestCase/Api/Users/ChangePasswordApiTest.php @@ -17,7 +17,7 @@ class ChangePasswordApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/users/edit'; + protected const ENDPOINT = '/users/edit'; /** @var \Cake\Auth\FormAuthenticate */ protected $auth; diff --git a/tests/TestCase/Api/Users/DeleteUserApiTest.php b/tests/TestCase/Api/Users/DeleteUserApiTest.php index 4e99963..9288b9a 100644 --- a/tests/TestCase/Api/Users/DeleteUserApiTest.php +++ b/tests/TestCase/Api/Users/DeleteUserApiTest.php @@ -13,7 +13,7 @@ class DeleteUserApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/users/delete'; + protected const ENDPOINT = '/users/delete'; protected $fixtures = [ 'app.Organisations', diff --git a/tests/TestCase/Api/Users/EditUserApiTest.php b/tests/TestCase/Api/Users/EditUserApiTest.php index 93a0df8..dd685b9 100644 --- a/tests/TestCase/Api/Users/EditUserApiTest.php +++ b/tests/TestCase/Api/Users/EditUserApiTest.php @@ -14,7 +14,7 @@ class EditUserApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/users/edit'; + protected const ENDPOINT = '/users/edit'; protected $fixtures = [ 'app.Organisations', diff --git a/tests/TestCase/Api/Users/IndexUsersApiTest.php b/tests/TestCase/Api/Users/IndexUsersApiTest.php index 7303224..c30462b 100644 --- a/tests/TestCase/Api/Users/IndexUsersApiTest.php +++ b/tests/TestCase/Api/Users/IndexUsersApiTest.php @@ -13,7 +13,7 @@ class IndexUsersApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/users/index'; + protected const ENDPOINT = '/users/index'; protected $fixtures = [ 'app.Organisations', diff --git a/tests/TestCase/Api/Users/ViewUserApiTest.php b/tests/TestCase/Api/Users/ViewUserApiTest.php index 576f2f8..e3ab847 100644 --- a/tests/TestCase/Api/Users/ViewUserApiTest.php +++ b/tests/TestCase/Api/Users/ViewUserApiTest.php @@ -13,7 +13,7 @@ class ViewUserApiTest extends TestCase { use ApiTestTrait; - protected const ENDPOINT = '/api/v1/users/view'; + protected const ENDPOINT = '/users/view'; protected $fixtures = [ 'app.Organisations', diff --git a/webroot/docs/openapi.yaml b/webroot/docs/openapi.yaml index b584bfc..8077ce7 100644 --- a/webroot/docs/openapi.yaml +++ b/webroot/docs/openapi.yaml @@ -29,7 +29,7 @@ tags: 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." paths: - /api/v1/individuals/index: + /individuals/index: get: summary: "Get individuals list" operationId: getIndividuals @@ -47,7 +47,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/individuals/view/{individualId}: + /individuals/view/{individualId}: get: summary: "Get individual by ID" operationId: getIndividualById @@ -65,7 +65,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/individuals/add: + /individuals/add: post: summary: "Add individual" operationId: addIndividual @@ -83,7 +83,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/individuals/edit/{individualId}: + /individuals/edit/{individualId}: put: summary: "Edit individual" operationId: editIndividual @@ -103,7 +103,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/individuals/delete/{individualId}: + /individuals/delete/{individualId}: delete: summary: "Delete individual by ID" operationId: deleteIndividualById @@ -121,7 +121,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/users/index: + /users/index: get: summary: "Get users list" operationId: getUsers @@ -139,7 +139,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/users/view: + /users/view: get: summary: "Get information about the current user" operationId: viewUserMe @@ -155,7 +155,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/users/view/{userId}: + /users/view/{userId}: get: summary: "Get information of a user by ID" operationId: viewUserById @@ -173,7 +173,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/users/add: + /users/add: post: summary: "Add user" operationId: addUser @@ -191,7 +191,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/users/edit: + /users/edit: put: summary: "Edit current user" operationId: editUser @@ -209,7 +209,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/users/edit/{userId}: + /users/edit/{userId}: put: summary: "Edit current user" operationId: editUserById @@ -229,7 +229,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/users/delete/{userId}: + /users/delete/{userId}: delete: summary: "Delete user by ID" operationId: deleteUserById @@ -247,7 +247,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/organisations/add: + /organisations/add: post: summary: "Add organisation" operationId: addOrganisation @@ -265,7 +265,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/organisations/edit/{organisationId}: + /organisations/edit/{organisationId}: put: summary: "Edit organisation" operationId: editOrganisation @@ -285,7 +285,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/organisations/index: + /organisations/index: get: summary: "Get organisations" operationId: getOrganisations @@ -303,7 +303,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/organisations/view/{organisationId}: + /organisations/view/{organisationId}: get: summary: "View organisation by ID" operationId: getOrganisationById @@ -321,7 +321,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/organisations/delete/{organisationId}: + /organisations/delete/{organisationId}: delete: summary: "Delete organisation by ID" operationId: deleteOrganisationById @@ -339,7 +339,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/organisations/tag/{organisationId}: + /organisations/tag/{organisationId}: post: summary: "Tag organisation by ID" operationId: tagOrganisationById @@ -359,7 +359,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/organisations/untag/{organisationId}: + /organisations/untag/{organisationId}: post: summary: "Remove organisation tag by ID" operationId: untagOrganisationById @@ -379,7 +379,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/tags/index: + /tags/index: get: summary: "Get tags list" operationId: getTags @@ -397,7 +397,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/inbox/index: + /inbox/index: get: summary: "Get inbox list" operationId: getinbox @@ -415,7 +415,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/inbox/createEntry/User/Registration: + /inbox/createEntry/User/Registration: post: summary: "Create user registration inbox entry" operationId: createInboxEntry @@ -433,7 +433,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/sharingGroups/index: + /sharingGroups/index: get: summary: "Get a sharing groups list" operationId: getSharingGroups @@ -451,7 +451,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/sharingGroups/add: + /sharingGroups/add: post: summary: "Add sharing group" operationId: addSharingGroup @@ -469,7 +469,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/sharingGroups/view/{sharingGroupId}: + /sharingGroups/view/{sharingGroupId}: get: summary: "Get sharing group by ID" operationId: getSharingGroupById @@ -487,7 +487,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/sharingGroups/delete/{sharingGroupId}: + /sharingGroups/delete/{sharingGroupId}: delete: summary: "Delete sharing group by ID" operationId: deleteSharingGroupById @@ -505,7 +505,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/sharingGroups/edit/{sharingGroupId}: + /sharingGroups/edit/{sharingGroupId}: put: summary: "Edit sharing group" operationId: editSharingGroup @@ -525,7 +525,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/broods/index: + /broods/index: get: summary: "Get broods list" operationId: getBroods @@ -543,7 +543,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/broods/view/{broodId}: + /broods/view/{broodId}: get: summary: "Get brood by ID" operationId: getBroodById @@ -561,7 +561,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/broods/add: + /broods/add: post: summary: "Add brood" operationId: addBrood @@ -579,7 +579,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/broods/edit/{broodId}: + /broods/edit/{broodId}: put: summary: "Edit brood" operationId: editBrood @@ -599,7 +599,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/broods/delete/{broodId}: + /broods/delete/{broodId}: delete: summary: "Delete brood by ID" operationId: deleteBroodById @@ -617,7 +617,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/broods/testConnection/{broodId}: + /broods/testConnection/{broodId}: get: summary: "Test brood connection by ID" operationId: testBroodConnectionById @@ -636,7 +636,7 @@ paths: $ref: "#/components/responses/ApiErrorResponse" # EncryptionKeys - /api/v1/encryptionKeys/index: + /encryptionKeys/index: get: summary: "Get encryption keys list" operationId: getEncryptionKeys @@ -654,7 +654,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/encryptionKeys/view/{encryptionKeyId}: + /encryptionKeys/view/{encryptionKeyId}: get: summary: "Get encryption key by ID" operationId: getEncryptionKeyId @@ -672,7 +672,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/encryptionKeys/add: + /encryptionKeys/add: post: summary: "Add encryption key" operationId: addEncryptionKey @@ -690,7 +690,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/encryptionKeys/edit/{encryptionKeyId}: + /encryptionKeys/edit/{encryptionKeyId}: put: summary: "Edit encryption key" operationId: editEncryptionKey @@ -710,7 +710,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/encryptionKeys/delete/{encryptionKeyId}: + /encryptionKeys/delete/{encryptionKeyId}: delete: summary: "Delete encryption key by ID" operationId: deleteEncryptionKeyById @@ -729,7 +729,7 @@ paths: $ref: "#/components/responses/ApiErrorResponse" # AuthKeys - /api/v1/authKeys/index: + /authKeys/index: get: summary: "Get auth keys list" operationId: getAuthKeys @@ -747,7 +747,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/authKeys/add: + /authKeys/add: post: summary: "Add auth keys" operationId: addAuthKey @@ -765,7 +765,7 @@ paths: default: $ref: "#/components/responses/ApiErrorResponse" - /api/v1/authKeys/delete/{authKeyId}: + /authKeys/delete/{authKeyId}: delete: summary: "Delete auth key by ID" operationId: deleteAuthKeyById @@ -1374,7 +1374,7 @@ components: type: string url: type: string - example: "/api/v1/users" + example: "/users" code: type: integer example: 500 @@ -1391,7 +1391,7 @@ components: example: "Authentication failed. Please make sure you pass the API key of an API enabled user along in the Authorization header." url: type: string - example: "/api/v1/users" + example: "/users" code: type: integer example: 403 @@ -1408,7 +1408,7 @@ components: example: "You do not have permission to use this functionality." url: type: string - example: "/api/v1/users/index" + example: "/users/index" code: type: integer example: 405 @@ -1425,7 +1425,7 @@ components: example: "Invalid user" url: type: string - example: "/api/v1/users/users/view/1234" + example: "/users/users/view/1234" code: type: integer example: 404