diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index a8a611e13..ba80535b4 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -4,7 +4,7 @@ runs: using: 'composite' steps: - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 18 @@ -23,7 +23,7 @@ runs: shell: bash run: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT - - uses: actions/cache@v3 + - uses: actions/cache@v4 name: Setup pnpm cache with: path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} @@ -34,7 +34,3 @@ runs: - name: Install dependencies shell: bash run: pnpm i - - - name: Check formatting - shell: bash - run: pnpm test:format diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 83587e7ac..256e3f6df 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -8,10 +8,10 @@ on: jobs: test: name: Deploy - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 diff --git a/.github/workflows/estimate.yml b/.github/workflows/estimate.yml index d492e2c0c..8b6b76fc4 100644 --- a/.github/workflows/estimate.yml +++ b/.github/workflows/estimate.yml @@ -6,7 +6,7 @@ jobs: estimate: name: Estimate if: vars.JIRA_PROJECT_ID != '' - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - name: Start Check Run id: check @@ -27,7 +27,7 @@ jobs: run: npm install -g @amazeelabs/estimator - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 diff --git a/.github/workflows/high_content_volume.yml b/.github/workflows/high_content_volume.yml index 88bbda211..b3474552a 100644 --- a/.github/workflows/high_content_volume.yml +++ b/.github/workflows/high_content_volume.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 1 diff --git a/.github/workflows/merge_dev_to_stage.yml b/.github/workflows/merge_dev_to_stage.yml index 55ca2f714..78edc2398 100644 --- a/.github/workflows/merge_dev_to_stage.yml +++ b/.github/workflows/merge_dev_to_stage.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: 'dev' diff --git a/.github/workflows/tag_release.yml b/.github/workflows/tag_release.yml index 3d2c2dcf5..6d60fa34a 100644 --- a/.github/workflows/tag_release.yml +++ b/.github/workflows/tag_release.yml @@ -15,11 +15,11 @@ jobs: id: date run: echo "::set-output name=date::$(date +'%Y-%m-%d/%H/%M/%S')" - name: Checkout branch "prod" - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: ref: prod - name: Create Git tag for PR - uses: actions/github-script@v4 + uses: actions/github-script@v7 with: script: | github.git.createRef({ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d23873f19..edf0ade21 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,7 +5,7 @@ on: jobs: test: name: Test - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - name: Init check if: ${{ github.repository != 'AmazeeLabs/silverback-template'}} @@ -14,7 +14,7 @@ jobs: instructions.' && false - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 @@ -38,7 +38,7 @@ jobs: TURBO_TEAM: 'local' - name: Upload Playwright report - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: failure() with: name: playwright-report @@ -56,7 +56,7 @@ jobs: fi - name: Publish to Chromatic - uses: chromaui/action@v1 + uses: chromaui/action@v11 with: token: ${{ secrets.GITHUB_TOKEN }} projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} @@ -94,13 +94,13 @@ jobs: update_dashboard: name: Update dashboard if: github.ref == 'refs/heads/release' && vars.JIRA_PROJECT_ID != '' - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - name: Install estimator run: npm install -g @amazeelabs/estimator - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 @@ -118,7 +118,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 1 - name: Setup Ruby diff --git a/.github/workflows/test_without_turbo_cache.yml b/.github/workflows/test_without_turbo_cache.yml index 757b757c0..7faf17889 100644 --- a/.github/workflows/test_without_turbo_cache.yml +++ b/.github/workflows/test_without_turbo_cache.yml @@ -5,7 +5,7 @@ on: jobs: test: name: Test - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - name: Init check if: ${{ github.repository != 'AmazeeLabs/silverback-template'}} @@ -14,7 +14,7 @@ jobs: instructions.' && false - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 @@ -35,7 +35,7 @@ jobs: TURBO_TEAM: 'local' - name: Upload Playwright report - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: failure() with: name: playwright-report diff --git a/.github/workflows/write-dashboard.yml b/.github/workflows/write-dashboard.yml index 418c9c555..01186ab98 100644 --- a/.github/workflows/write-dashboard.yml +++ b/.github/workflows/write-dashboard.yml @@ -6,13 +6,13 @@ jobs: write_dashboard: name: Write dashboard history if: github.ref == 'refs/heads/release' && vars.JIRA_PROJECT_ID != '' - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - name: Install estimator run: npm install -g @amazeelabs/estimator - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 000000000..3e0c21ea6 --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,15 @@ + + + + + sqlite.xerial + true + org.sqlite.JDBC + jdbc:sqlite:$PROJECT_DIR$/apps/cms/web/sites/default/files/.sqlite + + + + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/prettier.xml b/.idea/prettier.xml index 0d96645cd..a3175fe71 100644 --- a/.idea/prettier.xml +++ b/.idea/prettier.xml @@ -1,8 +1,9 @@ + - + \ No newline at end of file diff --git a/.lagoon/Dockerfile b/.lagoon/Dockerfile index db5094463..5c0858c05 100644 --- a/.lagoon/Dockerfile +++ b/.lagoon/Dockerfile @@ -60,6 +60,7 @@ COPY --from=builder /tmp/.deploy/cms /app WORKDIR /app ENV WEBROOT=web +ENV PHP_MEMORY_LIMIT=2048 # ==================================================================================================== # PHP IMAGE @@ -73,6 +74,7 @@ COPY --from=cli /app /app WORKDIR /app ENV WEBROOT=web +ENV PHP_MEMORY_LIMIT=2048 # ==================================================================================================== # NGINX IMAGE diff --git a/INIT.md b/INIT.md index 886ba6cfb..3cfc2e1e2 100644 --- a/INIT.md +++ b/INIT.md @@ -74,17 +74,34 @@ replace( 'PROJECT_NAME=example', 'PROJECT_NAME=' + process.env.PROJECT_NAME_MACHINE, ); -const clientSecret = randomString(32); +const publisherClientSecret = randomString(32); replace( ['apps/cms/.lagoon.env', 'apps/website/.lagoon.env'], 'PUBLISHER_OAUTH2_CLIENT_SECRET=REPLACE_ME', - 'PUBLISHER_OAUTH2_CLIENT_SECRET=' + clientSecret, + 'PUBLISHER_OAUTH2_CLIENT_SECRET=' + publisherClientSecret, ); -const sessionSecret = randomString(32); +const publisherSessionSecret = randomString(32); replace( ['apps/website/.lagoon.env'], 'PUBLISHER_OAUTH2_SESSION_SECRET=REPLACE_ME', - 'PUBLISHER_OAUTH2_SESSION_SECRET=' + sessionSecret, + 'PUBLISHER_OAUTH2_SESSION_SECRET=' + publisherSessionSecret, +); +const previewClientSecret = randomString(32); +replace( + ['apps/cms/.lagoon.env'], + 'PREVIEW_OAUTH2_CLIENT_SECRET=REPLACE_ME', + 'PREVIEW_OAUTH2_CLIENT_SECRET=' + previewClientSecret, +); +replace( + ['apps/preview/.lagoon.env'], + 'OAUTH2_CLIENT_SECRET=REPLACE_ME', + 'OAUTH2_CLIENT_SECRET=' + previewClientSecret, +); +const previewSessionSecret = randomString(32); +replace( + ['apps/preview/.lagoon.env'], + 'OAUTH2_SESSION_SECRET=REPLACE_ME', + 'OAUTH2_SESSION_SECRET=' + previewSessionSecret, ); // Template's prod domain is special. replace( diff --git a/apps/cms/.lagoon.env b/apps/cms/.lagoon.env index 911f7c798..3939d749c 100644 --- a/apps/cms/.lagoon.env +++ b/apps/cms/.lagoon.env @@ -5,3 +5,4 @@ PREVIEW_URL="https://preview.${LAGOON_ENVIRONMENT}.${LAGOON_PROJECT}.ch4.amazee. # Used to set the original client secret. PUBLISHER_OAUTH2_CLIENT_SECRET=REPLACE_ME +PREVIEW_OAUTH2_CLIENT_SECRET=REPLACE_ME diff --git a/apps/cms/composer.json b/apps/cms/composer.json index 895c30606..f6a331297 100644 --- a/apps/cms/composer.json +++ b/apps/cms/composer.json @@ -44,6 +44,7 @@ "amazeelabs/silverback_gutenberg": "^2.4.8", "amazeelabs/silverback_iframe": "^1.3.5", "amazeelabs/silverback_iframe_theme": "^1.3.0", + "amazeelabs/silverback_preview_link": "^1.6", "amazeelabs/silverback_publisher_monitor": "^2.3.2", "amazeelabs/silverback_translations": "^1.0.4", "composer/installers": "^2.2", @@ -103,12 +104,12 @@ "drupal/userprotect": { "Fix site install": "https://www.drupal.org/files/issues/2023-07-28/3349663-8.patch" }, - "amazeelabs/silverback_gatsby": { - "Autosave preview": "./patches/fetch-entity.patch" - }, "drupal/gutenberg": { "Gutenberg enabled hook": "https://www.drupal.org/files/issues/2024-05-07/gutenberg_enabled_hook_3445677-2.patch", "Remove !important from sidebar": "./patches/gutenberg_remove-important-sidebar.patch" + }, + "drupal/graphql": { + "Check if translation exists when loading an entity by its uuid": "./patches/graphql_load_by_uuid_translation_check.patch" } }, "patchLevel": { diff --git a/apps/cms/composer.lock b/apps/cms/composer.lock index fc8dfcf18..b91bff8c0 100644 --- a/apps/cms/composer.lock +++ b/apps/cms/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "95a349756daa5580656cc1f2a42f8649", + "content-hash": "8fa136f788cce1474b561f9f2f6d29af", "packages": [ { "name": "amazeeio/drupal_integrations", @@ -345,16 +345,16 @@ }, { "name": "amazeelabs/silverback_autosave", - "version": "1.2.0", + "version": "1.3.0", "source": { "type": "git", "url": "https://github.com/AmazeeLabs/silverback_autosave.git", - "reference": "8208ddba6d5da916bf5649b7e0e53efdf491bb7a" + "reference": "c65e743f64c96598aec3b8bf1874cc2fd31fd534" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/AmazeeLabs/silverback_autosave/zipball/8208ddba6d5da916bf5649b7e0e53efdf491bb7a", - "reference": "8208ddba6d5da916bf5649b7e0e53efdf491bb7a", + "url": "https://api.github.com/repos/AmazeeLabs/silverback_autosave/zipball/c65e743f64c96598aec3b8bf1874cc2fd31fd534", + "reference": "c65e743f64c96598aec3b8bf1874cc2fd31fd534", "shasum": "" }, "type": "drupal-module", @@ -365,9 +365,9 @@ "description": "Adds autosave feature on forms.", "support": { "issues": "https://github.com/AmazeeLabs/silverback_autosave/issues", - "source": "https://github.com/AmazeeLabs/silverback_autosave/tree/1.2.0" + "source": "https://github.com/AmazeeLabs/silverback_autosave/tree/1.3.0" }, - "time": "2024-04-25T13:14:00+00:00" + "time": "2024-07-11T08:04:07+00:00" }, { "name": "amazeelabs/silverback_campaign_urls", @@ -603,6 +603,38 @@ }, "time": "2024-01-26T16:14:08+00:00" }, + { + "name": "amazeelabs/silverback_preview_link", + "version": "1.6.4", + "source": { + "type": "git", + "url": "https://github.com/AmazeeLabs/silverback_preview_link.git", + "reference": "0087294dd4764a2cbb027f5a5a89008d53cf600d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/AmazeeLabs/silverback_preview_link/zipball/0087294dd4764a2cbb027f5a5a89008d53cf600d", + "reference": "0087294dd4764a2cbb027f5a5a89008d53cf600d", + "shasum": "" + }, + "require": { + "chillerlan/php-qrcode": "^5", + "drupal/dynamic_entity_reference": "^3 || ^4", + "php": ">=8.2" + }, + "type": "drupal-module", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0+" + ], + "description": "Decoupled preview with access token.", + "homepage": "https://github.com/AmazeeLabs/silverback-mono/tree/development/packages/composer/amazeelabs/silverback_preview_link#readme", + "support": { + "issues": "https://github.com/AmazeeLabs/silverback_preview_link/issues", + "source": "https://github.com/AmazeeLabs/silverback_preview_link/tree/1.6.4" + }, + "time": "2024-08-20T20:05:58+00:00" + }, { "name": "amazeelabs/silverback_publisher_monitor", "version": "2.3.12", @@ -773,6 +805,165 @@ }, "time": "2023-10-21T12:57:05+00:00" }, + { + "name": "chillerlan/php-qrcode", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/chillerlan/php-qrcode.git", + "reference": "da5bdb82c8755f54de112b271b402aaa8df53269" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/chillerlan/php-qrcode/zipball/da5bdb82c8755f54de112b271b402aaa8df53269", + "reference": "da5bdb82c8755f54de112b271b402aaa8df53269", + "shasum": "" + }, + "require": { + "chillerlan/php-settings-container": "^2.1.4 || ^3.1", + "ext-mbstring": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "chillerlan/php-authenticator": "^4.1 || ^5.1", + "phan/phan": "^5.4", + "phpmd/phpmd": "^2.15", + "phpunit/phpunit": "^9.6", + "setasign/fpdf": "^1.8.2", + "squizlabs/php_codesniffer": "^3.8" + }, + "suggest": { + "chillerlan/php-authenticator": "Yet another Google authenticator! Also creates URIs for mobile apps.", + "setasign/fpdf": "Required to use the QR FPDF output.", + "simple-icons/simple-icons": "SVG icons that you can use to embed as logos in the QR Code" + }, + "type": "library", + "autoload": { + "psr-4": { + "chillerlan\\QRCode\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT", + "Apache-2.0" + ], + "authors": [ + { + "name": "Kazuhiko Arase", + "homepage": "https://github.com/kazuhikoarase/qrcode-generator" + }, + { + "name": "ZXing Authors", + "homepage": "https://github.com/zxing/zxing" + }, + { + "name": "Ashot Khanamiryan", + "homepage": "https://github.com/khanamiryan/php-qrcode-detector-decoder" + }, + { + "name": "Smiley", + "email": "smiley@chillerlan.net", + "homepage": "https://github.com/codemasher" + }, + { + "name": "Contributors", + "homepage": "https://github.com/chillerlan/php-qrcode/graphs/contributors" + } + ], + "description": "A QR code generator and reader with a user friendly API. PHP 7.4+", + "homepage": "https://github.com/chillerlan/php-qrcode", + "keywords": [ + "phpqrcode", + "qr", + "qr code", + "qr-reader", + "qrcode", + "qrcode-generator", + "qrcode-reader" + ], + "support": { + "docs": "https://php-qrcode.readthedocs.io", + "issues": "https://github.com/chillerlan/php-qrcode/issues", + "source": "https://github.com/chillerlan/php-qrcode" + }, + "funding": [ + { + "url": "https://www.paypal.com/donate?hosted_button_id=WLYUNAT9ZTJZ4", + "type": "custom" + }, + { + "url": "https://ko-fi.com/codemasher", + "type": "ko_fi" + } + ], + "time": "2024-02-27T14:37:26+00:00" + }, + { + "name": "chillerlan/php-settings-container", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/chillerlan/php-settings-container.git", + "reference": "8f93648fac8e6bacac8e00a8d325eba4950295e6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/chillerlan/php-settings-container/zipball/8f93648fac8e6bacac8e00a8d325eba4950295e6", + "reference": "8f93648fac8e6bacac8e00a8d325eba4950295e6", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^8.1" + }, + "require-dev": { + "phan/phan": "^5.4", + "phpmd/phpmd": "^2.15", + "phpunit/phpunit": "^10.5", + "squizlabs/php_codesniffer": "^3.9" + }, + "type": "library", + "autoload": { + "psr-4": { + "chillerlan\\Settings\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Smiley", + "email": "smiley@chillerlan.net", + "homepage": "https://github.com/codemasher" + } + ], + "description": "A container class for immutable settings objects. Not a DI container.", + "homepage": "https://github.com/chillerlan/php-settings-container", + "keywords": [ + "Settings", + "configuration", + "container", + "helper" + ], + "support": { + "issues": "https://github.com/chillerlan/php-settings-container/issues", + "source": "https://github.com/chillerlan/php-settings-container" + }, + "funding": [ + { + "url": "https://www.paypal.com/donate?hosted_button_id=WLYUNAT9ZTJZ4", + "type": "custom" + }, + { + "url": "https://ko-fi.com/codemasher", + "type": "ko_fi" + } + ], + "time": "2024-03-02T20:07:15+00:00" + }, { "name": "cloudinary/cloudinary_php", "version": "2.12.0", @@ -2958,6 +3149,69 @@ "#media": "http://drupal.slack.com" } }, + { + "name": "drupal/dynamic_entity_reference", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://git.drupalcode.org/project/dynamic_entity_reference.git", + "reference": "3.2.0" + }, + "dist": { + "type": "zip", + "url": "https://ftp.drupal.org/files/projects/dynamic_entity_reference-3.2.0.zip", + "reference": "3.2.0", + "shasum": "a265f7f0723fb085bd729e7624d644475900cf94" + }, + "require": { + "drupal/core": "^10 || ^11", + "php": ">=8.1" + }, + "require-dev": { + "drupal/diff": "*", + "mglaman/phpstan-drupal": "^1.1", + "phpstan/phpstan": "^1.1", + "phpstan/phpstan-deprecation-rules": "^1.0" + }, + "type": "drupal-module", + "extra": { + "drupal": { + "version": "3.2.0", + "datestamp": "1706486865", + "security-coverage": { + "status": "covered", + "message": "Covered by Drupal's security advisory policy" + } + } + }, + "notification-url": "https://packages.drupal.org/8/downloads", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "Lee Rowlands", + "homepage": "https://www.drupal.org/u/larowlan", + "role": "Maintainer" + }, + { + "name": "Jibran Ijaz", + "homepage": "https://www.drupal.org/u/jibran", + "role": "Maintainer" + }, + { + "name": "larowlan", + "homepage": "https://www.drupal.org/user/395439" + } + ], + "description": "Provides a field that allows an entity-reference field to reference more than one entity type.", + "homepage": "http://drupal.org/project/dynamic_entity_reference", + "support": { + "source": "http://cgit.drupalcode.org/dynamic_entity_reference", + "issues": "http://drupal.org/project/dynamic_entity_reference", + "irc": "irc://irc.freenode.org/drupal-contribute" + } + }, { "name": "drupal/entity_usage", "version": "2.0.0-beta12", @@ -15999,8 +16253,5 @@ "php": "^8.2 <8.3" }, "platform-dev": [], - "platform-overrides": { - "php": "8.2" - }, "plugin-api-version": "2.6.0" } diff --git a/apps/cms/config/sync/core.extension.yml b/apps/cms/config/sync/core.extension.yml index 6d0ecd9c1..13dd0ec1c 100644 --- a/apps/cms/config/sync/core.extension.yml +++ b/apps/cms/config/sync/core.extension.yml @@ -20,6 +20,7 @@ module: dblog: 0 default_content: 0 dropzonejs: 0 + dynamic_entity_reference: 0 dynamic_page_cache: 0 editor: 0 entity_create_split: 0 @@ -72,6 +73,7 @@ module: silverback_graphql_persisted: 0 silverback_gutenberg: 0 silverback_iframe: 0 + silverback_preview_link: 0 silverback_publisher_monitor: 0 silverback_translations: 0 simple_oauth: 0 diff --git a/apps/cms/config/sync/linkit.linkit_profile.teaser_list.yml b/apps/cms/config/sync/linkit.linkit_profile.teaser_list.yml new file mode 100644 index 000000000..ac46e9edf --- /dev/null +++ b/apps/cms/config/sync/linkit.linkit_profile.teaser_list.yml @@ -0,0 +1,21 @@ +uuid: a50edb79-f43d-4388-9606-59750a92d720 +langcode: en +status: true +dependencies: + module: + - node +label: 'Teaser list' +id: teaser_list +description: 'Used to search items to be displayed in a teaser list, in the Gutenberg editor.' +matchers: + c42abd9a-5bed-4578-84f7-a4778a062699: + id: 'silverback:entity:node' + uuid: c42abd9a-5bed-4578-84f7-a4778a062699 + settings: + include_unpublished: 0 + metadata: '' + bundles: { } + group_by_bundle: 0 + substitution_type: canonical + limit: '100' + weight: 0 diff --git a/apps/cms/config/sync/silverback_preview_link.settings.yml b/apps/cms/config/sync/silverback_preview_link.settings.yml new file mode 100644 index 000000000..0974bf8db --- /dev/null +++ b/apps/cms/config/sync/silverback_preview_link.settings.yml @@ -0,0 +1,6 @@ +_core: + default_config_hash: XAotqrdAEia8K6UQBOR89_AxeqJJ4Q2ovs2Hx0Ro1xg +enabled_entity_types: + node: { } +expiry_seconds: 86400 +multiple_entities: false diff --git a/apps/cms/config/sync/user.role.administrator.yml b/apps/cms/config/sync/user.role.administrator.yml index 4660fb037..421eb824a 100644 --- a/apps/cms/config/sync/user.role.administrator.yml +++ b/apps/cms/config/sync/user.role.administrator.yml @@ -10,6 +10,7 @@ dependencies: - content_moderation - content_translation - dropzonejs + - entity_usage - environment_indicator - file - filter @@ -38,6 +39,7 @@ is_admin: null permissions: - 'access administration pages' - 'access content overview' + - 'access entity usage statistics' - 'access environment indicator' - 'access media overview' - 'access publisher' diff --git a/apps/cms/config/sync/user.role.editor.yml b/apps/cms/config/sync/user.role.editor.yml index 2914cb680..e3292f397 100644 --- a/apps/cms/config/sync/user.role.editor.yml +++ b/apps/cms/config/sync/user.role.editor.yml @@ -10,6 +10,7 @@ dependencies: - content_moderation - content_translation - dropzonejs + - entity_usage - environment_indicator - file - filter @@ -33,6 +34,7 @@ is_admin: null permissions: - 'access administration pages' - 'access content overview' + - 'access entity usage statistics' - 'access environment indicator' - 'access media overview' - 'access shortcuts' diff --git a/apps/cms/config/sync/views.view.content_hub.yml b/apps/cms/config/sync/views.view.content_hub.yml index 5ea530f9e..25709b7ce 100644 --- a/apps/cms/config/sync/views.view.content_hub.yml +++ b/apps/cms/config/sync/views.view.content_hub.yml @@ -232,6 +232,55 @@ display: default_group: All default_group_multiple: { } group_items: { } + uuid: + id: uuid + table: node + field: uuid + relationship: none + group_type: group + admin_label: 'Exclude UUIDs' + entity_type: node + entity_field: uuid + plugin_id: string + operator: not_regular_expression + value: '' + group: 1 + exposed: true + expose: + operator_id: uuid_op + label: 'Exclude UUIDs' + description: '' + use_operator: false + operator: uuid_op + operator_limit_selection: false + operator_list: { } + identifier: excludeIds + required: false + remember: false + multiple: false + remember_roles: + authenticated: authenticated + anonymous: '0' + super_admin: '0' + administrator: '0' + gatsby_build: '0' + editor: '0' + placeholder: '' + is_grouped: false + group_info: + label: UUID + description: null + identifier: uuid + optional: true + widget: select + multiple: false + remember: false + default_group: All + default_group_multiple: { } + group_items: + 1: { } + 2: { } + 3: { } filter_groups: operator: AND groups: diff --git a/apps/cms/patches/fetch-entity.patch b/apps/cms/patches/fetch-entity.patch deleted file mode 100644 index b3a5aeaba..000000000 --- a/apps/cms/patches/fetch-entity.patch +++ /dev/null @@ -1,40 +0,0 @@ -diff --git a/src/Plugin/GraphQL/DataProducer/FetchEntity.php b/src/Plugin/GraphQL/DataProducer/FetchEntity.php -index ffef6a8..7c84154 100644 ---- a/src/Plugin/GraphQL/DataProducer/FetchEntity.php -+++ b/src/Plugin/GraphQL/DataProducer/FetchEntity.php -@@ -250,6 +250,35 @@ class FetchEntity extends DataProducerPluginBase implements ContainerFactoryPlug - } - } - -+ // Autosave: get autosaved values. -+ if (\Drupal::service('module_handler')->moduleExists('silverback_autosave')) { -+ $context->mergeCacheMaxAge(0); -+ // @todo Add DI to both. -+ $service = \Drupal::service('silverback_autosave.entity_form_storage'); -+ /** -+ * Quick and dirty because this causes leaked metadata error. -+ * $entityForm = \Drupal::service('entity.form_builder')->getForm($entity, 'edit'); -+ * $form_id = $entityForm['form_id']['#value']; -+ */ -+ $form_id = "{$entity->getEntityTypeId()}_{$entity->bundle()}_edit_form"; -+ $autosaved_state = $service->getEntityAndFormState($form_id, $entity->getEntityTypeId(), $entity->id(), $entity->language()->getId(), \Drupal::currentUser()->id()); -+ /** @var \Drupal\Core\Entity\EntityInterface $autosaved_entity */ -+ $autosaved_entity = $autosaved_state['entity'] ?? NULL; -+ /** @var \Drupal\Core\Form\FormStateInterface $autosaved_form_state */ -+ $autosaved_form_state = $autosaved_state['form_state'] ?? []; -+ if ($autosaved_entity && !empty($autosaved_form_state)) { -+ $current_user_input = $autosaved_form_state->getUserInput(); -+ foreach ($autosaved_entity->getFields() as $name => $field) { -+ if (in_array($name, ['title', 'body']) || str_starts_with($name, 'field_')) { -+ if (isset($current_user_input[$name])) { -+ $field->setValue($current_user_input[$name]); -+ } -+ } -+ } -+ return $autosaved_entity; -+ } -+ } -+ - return $entity; - }); - } diff --git a/apps/cms/patches/graphql_load_by_uuid_translation_check.patch b/apps/cms/patches/graphql_load_by_uuid_translation_check.patch new file mode 100644 index 000000000..ba9d03d8d --- /dev/null +++ b/apps/cms/patches/graphql_load_by_uuid_translation_check.patch @@ -0,0 +1,15 @@ +diff --git a/src/Plugin/GraphQL/DataProducer/Entity/EntityLoadByUuid.php b/src/Plugin/GraphQL/DataProducer/Entity/EntityLoadByUuid.php +index 10e2d405..5d1e9b1d 100644 +--- a/src/Plugin/GraphQL/DataProducer/Entity/EntityLoadByUuid.php ++++ b/src/Plugin/GraphQL/DataProducer/Entity/EntityLoadByUuid.php +@@ -165,7 +165,9 @@ class EntityLoadByUuid extends DataProducerPluginBase implements ContainerFactor + + // Get the correct translation. + if (isset($language) && $language != $entity->language()->getId() && $entity instanceof TranslatableInterface) { +- $entity = $entity->getTranslation($language); ++ if ($entity->hasTranslation($language)) { ++ $entity = $entity->getTranslation($language); ++ } + $entity->addCacheContexts(["static:language:{$language}"]); + } + diff --git a/apps/preview/.lagoon.env b/apps/preview/.lagoon.env index ecef2dae1..4d5c19085 100644 --- a/apps/preview/.lagoon.env +++ b/apps/preview/.lagoon.env @@ -1,2 +1,12 @@ PROJECT_NAME=example DRUPAL_URL="https://nginx.${LAGOON_ENVIRONMENT}.${LAGOON_PROJECT}.ch4.amazee.io" + +# Authentication with OAuth2 +AUTHENTICATION_TYPE=oauth2 +OAUTH2_CLIENT_SECRET=REPLACE_ME +OAUTH2_SESSION_SECRET=REPLACE_ME + +# Authentication with Basic Auth +#AUTHENTICATION_TYPE=basic +#BASIC_AUTH_USER=preview +#BASIC_AUTH_PASSWORD=preview diff --git a/apps/preview/.lagoon.env.prod b/apps/preview/.lagoon.env.prod index 364cf1a4d..453e60079 100644 --- a/apps/preview/.lagoon.env.prod +++ b/apps/preview/.lagoon.env.prod @@ -1 +1,2 @@ DRUPAL_URL="https://example.cms.amazeelabs.dev" +OAUTH2_ENVIRONMENT_TYPE=production diff --git a/apps/preview/package.json b/apps/preview/package.json index 567df2948..102158d3e 100644 --- a/apps/preview/package.json +++ b/apps/preview/package.json @@ -15,20 +15,29 @@ "@custom/schema": "workspace:*", "@custom/ui": "workspace:*", "@custom/cms": "workspace:*", + "cookie-parser": "^1.4.6", "express": "^4.19.2", + "express-basic-auth": "^1.2.1", + "express-session": "^1.18.0", "express-ws": "^5.0.2", + "memorystore": "^1.6.7", + "node-fetch": "^3.3.2", "react": "^18.2.0", "react-dom": "^18.2.0", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "simple-oauth2": "^5.1.0" }, "devDependencies": { "@swc/cli": "^0.1.63", "@swc/core": "^1.3.102", + "@types/cookie-parser": "^1.4.7", "@types/express": "^4.17.21", + "@types/express-session": "^1.18.0", "@types/express-ws": "^3.0.4", "@types/node": "^20.11.17", "@types/react": "^18.2.46", "@types/react-dom": "^18.2.18", + "@types/simple-oauth2": "^5.0.7", "@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/parser": "^7.2.0", "@vitejs/plugin-react-swc": "^3.5.0", diff --git a/apps/preview/server/index.ts b/apps/preview/server/index.ts index bbbd868eb..7bf4cd628 100644 --- a/apps/preview/server/index.ts +++ b/apps/preview/server/index.ts @@ -2,6 +2,22 @@ import express from 'express'; import expressWs from 'express-ws'; import { Subject } from 'rxjs'; +import { + getAuthenticationMiddleware, + isSessionRequired, +} from './utils/authentication.js'; +import { getConfig } from './utils/config.js'; +import { + getOAuth2AuthorizeUrl, + getPersistedAccessToken, + hasPreviewAccess, + initializeSession, + isAuthenticated, + oAuth2AuthorizationCodeClient, + persistAccessToken, + stateMatches, +} from './utils/oAuth2.js'; + const expressServer = express(); const expressWsInstance = expressWs(expressServer); const { app } = expressWsInstance; @@ -9,6 +25,13 @@ const { app } = expressWsInstance; const updates$ = new Subject(); app.use(express.json()); +// A session is only needed for OAuth2. +if (isSessionRequired()) { + initializeSession(expressServer); +} +// Authentication middleware based on the configuration. +const authMiddleware = getAuthenticationMiddleware(); + app.get('/endpoint.js', (_, res) => { res.send( `window.DRUPAL_URL = ${JSON.stringify( @@ -17,7 +40,6 @@ app.get('/endpoint.js', (_, res) => { ); }); -// TODO: Protect endpoints and preview with Drupal authentication. app.post('/__preview', (req, res) => { updates$.next(req.body || {}); res.json(true); @@ -31,11 +53,105 @@ app.ws('/__preview', (ws) => { ws.on('close', sub.unsubscribe); }); -app.get('/__preview/*', (req, _, next) => { +app.get('/__preview/*', authMiddleware, (req, _, next) => { req.url = '/'; next(); }); +// --------------------------------------------------------------------------- +// OAuth2 routes +// --------------------------------------------------------------------------- + +// Fallback route for login. Is used if there is no origin cookie. +app.get('/oauth/login', async (req, res) => { + if (await isAuthenticated(req)) { + const accessPreview = await hasPreviewAccess(req); + if (accessPreview) { + res.send('Preview access is granted.'); + } else { + res.send( + 'Preview access is not granted. Contact your site administrator. Log out', + ); + } + } else { + res.cookie('origin', req.path).send('Log in'); + } +}); + +// Redirects to authentication provider. +app.get('/oauth', (req, res) => { + const client = oAuth2AuthorizationCodeClient(); + if (!client) { + throw new Error('Missing OAuth2 client.'); + } + const authorizationUri = getOAuth2AuthorizeUrl(client, req); + res.redirect(authorizationUri); +}); + +// Callback from authentication provider. +app.get('/oauth/callback', async (req, res) => { + const oAuth2Config = getConfig().oAuth2; + if (!oAuth2Config) { + throw new Error('Missing OAuth2 configuration.'); + } + + const client = oAuth2AuthorizationCodeClient(); + if (!client) { + throw new Error('Missing OAuth2 client.'); + } + + // Check if the state matches. + if (!stateMatches(req)) { + return res.status(500).json('State does not match.'); + } + + const { code } = req.query; + const options = { + code, + scope: oAuth2Config.scope, + // Do not include redirect_uri, makes Drupal simple_oauth fail. + // Returns 400 Bad Request. + //redirect_uri: 'http://127.0.0.1:7777/callback', + }; + + try { + // @ts-ignore options due to missing redirect_uri. + const accessToken = await client.getToken(options); + console.log('/oauth/callback accessToken', accessToken); + persistAccessToken(accessToken, req); + + if (req.cookies.origin) { + res.redirect(req.cookies.origin); + } else { + res.redirect('/oauth/login'); + } + } catch (error) { + console.error(error); + return ( + res + .status(500) + // @ts-ignore + .json(`Authentication failed with error: ${error.message}`) + ); + } +}); + +// Removes the session. +app.get('/oauth/logout', async (req, res) => { + const accessToken = getPersistedAccessToken(req); + if (!accessToken) { + return res.status(401).send('No token found.'); + } + + // Requires this Drupal patch + // https://www.drupal.org/project/simple_oauth/issues/2945273 + // await accessToken.revokeAll(); + req.session.destroy(function (err) { + console.log('Remove session', err); + }); + res.redirect('/oauth/login'); +}); + app.use(express.static('./dist')); const isLagoon = !!process.env.LAGOON; diff --git a/apps/preview/server/utils/authentication.ts b/apps/preview/server/utils/authentication.ts new file mode 100644 index 000000000..c3d3cf537 --- /dev/null +++ b/apps/preview/server/utils/authentication.ts @@ -0,0 +1,57 @@ +import { NextFunction, Request, RequestHandler, Response } from 'express'; +import basicAuth from 'express-basic-auth'; + +import { getConfig } from './config.js'; +import { oAuth2AuthCodeMiddleware } from './oAuth2.js'; + +/** + * Returns the Express authentication middleware based on the configuration. + * + * Favours OAuth2, then Basic Auth, then falling back to no auth + * if not configured (= grant access). + */ +export const getAuthenticationMiddleware = (): RequestHandler => + ((): RequestHandler => { + const config = getConfig(); + switch (config.authenticationType) { + case 'oauth2': + if (config.oAuth2) { + return oAuth2AuthCodeMiddleware; + } else { + console.error('Missing OAuth2 configuration.'); + } + break; + case 'basic': + if (config.basicAuth) { + return basicAuth({ + users: { [config.basicAuth.username]: config.basicAuth.password }, + challenge: true, + }); + } else { + console.error('Missing basic auth configuration.'); + } + break; + case 'noauth': + break; + default: + console.error('Unknown authentication type.'); + break; + } + + return (req: Request, res: Response, next: NextFunction): void => next(); + })(); + +/** + * Checks if a session is required based on the configuration. + */ +export const isSessionRequired = (): boolean => { + let result = false; + if (getConfig().oAuth2) { + const oAuth2Config = getConfig().oAuth2; + if (!oAuth2Config) { + throw new Error('Missing OAuth2 configuration.'); + } + result = true; + } + return result; +}; diff --git a/apps/preview/server/utils/config.ts b/apps/preview/server/utils/config.ts new file mode 100644 index 000000000..3d23b3bde --- /dev/null +++ b/apps/preview/server/utils/config.ts @@ -0,0 +1,47 @@ +export type PreviewConfig = { + authenticationType: string; // 'oauth2' | 'basic' | 'noauth'; + drupalHost: string; + /** + * Basic auth. + */ + basicAuth?: { + username: string; + password: string; + }; + /** + * OAuth2. + */ + oAuth2?: { + clientId: string; + clientSecret: string; + scope: string; + tokenHost: string; + tokenPath: string; + authorizePath: string; + sessionSecret: string; + environmentType?: string; // 'development' | 'production'; + }; +}; + +export const getConfig = (): PreviewConfig => { + return { + authenticationType: process.env.AUTHENTICATION_TYPE || 'noauth', + drupalHost: process.env.DRUPAL_URL || 'http://127.0.0.1:8888', + basicAuth: { + username: process.env.BASIC_AUTH_USER || 'test', + password: process.env.BASIC_AUTH_PASSWORD || 'test', + }, + oAuth2: { + clientId: process.env.OAUTH2_CLIENT_ID || 'preview', + clientSecret: process.env.OAUTH2_CLIENT_SECRET || 'preview', + scope: process.env.OAUTH2_SCOPE || 'preview', + tokenHost: process.env.DRUPAL_URL || 'http://127.0.0.1:8888', + tokenPath: process.env.OAUTH2_TOKEN_PATH || '/oauth/token', + authorizePath: + process.env.OAUTH2_AUTHORIZE_PATH || + '/oauth/authorize?response_type=code', + sessionSecret: process.env.OAUTH2_SESSION_SECRET || 'banana', + environmentType: process.env.OAUTH2_ENVIRONMENT_TYPE || 'development', + }, + }; +}; diff --git a/apps/preview/server/utils/oAuth2.ts b/apps/preview/server/utils/oAuth2.ts new file mode 100644 index 000000000..6fcc238df --- /dev/null +++ b/apps/preview/server/utils/oAuth2.ts @@ -0,0 +1,330 @@ +import cookieParser from 'cookie-parser'; +import crypto from 'crypto'; +import { + Express, + NextFunction, + Request, + RequestHandler, + Response, +} from 'express'; +import session from 'express-session'; +import createMemoryStore from 'memorystore'; +import fetch from 'node-fetch'; +import { AccessToken, AuthorizationCode } from 'simple-oauth2'; + +import { getConfig } from './config.js'; + +declare module 'express-session' { + interface SessionData { + tokenString: string; + state: string; + } +} + +// In seconds +export const SESSION_MAX_AGE = 300; +export const ACCESS_TOKEN_EXPIRATION_TIME = 300; + +const ENCRYPTION_KEY = + process.env.ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); + +/** + * Returns the Authorization Code middleware. + * + * This should be favoured in most cases when using OAuth2. + */ +export const oAuth2AuthCodeMiddleware: RequestHandler = ((): RequestHandler => { + return async (req: Request, res: Response, next: NextFunction) => { + // Drupal's OAuth can be skipped if there is + // a valid preview link token for the entity. + const { preview_access_token, nid, entity_type_id, lang } = req.query; + if (preview_access_token && nid) { + const config = getConfig(); + const previewAccess = await fetch( + `${config.drupalHost}/preview/link-access`, + { + method: 'POST', + body: JSON.stringify({ + entity_id: nid, + // @todo we need to pass the entity type ID for other entity types. + // But then also need to change the nid parameter to entity_id. + entity_type_id: entity_type_id || 'node', + langcode: lang || 'en', + preview_access_token: preview_access_token, + }), + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + // Skip, otherwise continue with OAuth. + if (previewAccess.status === 200) { + return next(); + } + } + + if (await isAuthenticated(req)) { + const accessPreview = await hasPreviewAccess(req); + if (accessPreview) { + return next(); + } else { + res + .status(403) + .send( + 'Your user account does not have Preview access. You might contact your site administrator.', + ); + } + } else { + res.cookie('origin', req.originalUrl).redirect('/oauth'); + } + }; +})(); + +export const initializeSession = (server: Express): void => { + const oAuth2Config = getConfig().oAuth2; + if (!oAuth2Config) { + throw new Error('Missing OAuth2 configuration.'); + } + server.use(cookieParser()); + + const sessionMaxAgeInMilliseconds = SESSION_MAX_AGE * 1000; + const MemoryStore = createMemoryStore(session); + + const config = { + secret: + oAuth2Config.sessionSecret || crypto.randomBytes(64).toString('hex'), + resave: true, // seems to be needed for MemoryStore + saveUninitialized: false, + cookie: { maxAge: sessionMaxAgeInMilliseconds }, + // Keep it simple, use production safe memory store, + // not the one provided by express-session. + // Other available stores + // https://expressjs.com/en/resources/middleware/session.html#compatible-session-stores + store: new MemoryStore({ + checkPeriod: sessionMaxAgeInMilliseconds, // prune expired entries + }), + }; + + if (oAuth2Config.environmentType === 'production') { + server.set('trust proxy', 1); // trust first proxy + // @ts-ignore + config.cookie.secure = true; // serve secure cookies + } + + server.use(session(config)); +}; + +export const oAuth2AuthorizationCodeClient = (): AuthorizationCode | null => { + const oAuth2Config = getConfig().oAuth2; + if (!oAuth2Config) { + return null; + } + return new AuthorizationCode({ + client: { + id: oAuth2Config.clientId, + secret: oAuth2Config.clientSecret, + }, + auth: { + tokenHost: oAuth2Config.tokenHost, + tokenPath: oAuth2Config.tokenPath, + authorizePath: oAuth2Config.authorizePath, + }, + }); +}; + +export const persistAccessToken = (token: AccessToken, req: Request): void => { + req.session.tokenString = encrypt(JSON.stringify(token)); +}; + +export const getPersistedAccessToken = (req: Request): AccessToken | null => { + const client = oAuth2AuthorizationCodeClient(); + if (!client) { + throw new Error('Missing OAuth2 client.'); + } + if (req.session.tokenString) { + const decryptedToken = decrypt(req.session.tokenString); + if (!decryptedToken) { + throw new Error('Failed to decrypt token.'); + } + return client.createToken(JSON.parse(decryptedToken)); + } else { + return null; + } +}; + +const encrypt = (text: string): string => { + if (!ENCRYPTION_KEY) { + throw new Error('Missing encryption key.'); + } + + const oAuth2Config = getConfig().oAuth2; + if (!oAuth2Config) { + throw new Error('Missing OAuth2 configuration.'); + } + + try { + const iv = crypto.randomBytes(16); + const key = crypto + .createHash('sha256') + .update(ENCRYPTION_KEY) + .digest('base64') + .substring(0, 32); + const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); + let encrypted = cipher.update(text); + encrypted = Buffer.concat([encrypted, cipher.final()]); + return iv.toString('hex') + ':' + encrypted.toString('hex'); + } catch (error) { + throw new Error('Encryption failed.'); + } +}; + +const decrypt = (encryptedText: string): string => { + if (!ENCRYPTION_KEY) { + throw new Error('Missing encryption key.'); + } + + try { + const textParts = encryptedText.split(':'); + // @ts-ignore + const iv = Buffer.from(textParts.shift(), 'hex'); + + const encryptedData = Buffer.from(textParts.join(':'), 'hex'); + const key = crypto + .createHash('sha256') + .update(ENCRYPTION_KEY) + .digest('base64') + .substring(0, 32); + const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); + + const decrypted = decipher.update(encryptedData); + const decryptedText = Buffer.concat([decrypted, decipher.final()]); + return decryptedText.toString(); + } catch (error) { + throw new Error('Decryption failed.'); + } +}; + +export const getOAuth2AuthorizeUrl = ( + client: AuthorizationCode, + req: Request, +): string => { + const oAuth2Config = getConfig().oAuth2; + if (!oAuth2Config) { + throw new Error('Missing OAuth2 configuration.'); + } + + const state = crypto.randomBytes(32).toString('hex'); + persistState(state, req); + const encodedState = Buffer.from(state).toString('base64'); + return client.authorizeURL({ + // Set on the OAuth2 provider. + //redirect_uri: callbackUrl, + // https://auth0.com/docs/secure/attack-protection/state-parameters + state: encodedState, + }); +}; + +export const persistState = (state: string, req: Request): void => { + req.session.state = state; +}; + +export const stateMatches = (req: Request): boolean => { + const persistedState = req.session.state; + if (persistedState === undefined) { + return false; + } + const encodedState = req.query.state as string; + if (encodedState === undefined) { + return false; + } + const decodedState = Buffer.from(encodedState, 'base64').toString('ascii'); + return persistedState === decodedState; +}; + +export const isAuthenticated = async (req: Request): Promise => { + const oAuth2Config = getConfig().oAuth2; + if (!oAuth2Config) { + throw new Error('Missing OAuth2 configuration.'); + } + + let result = false; + let accessToken = getPersistedAccessToken(req); + if (accessToken) { + if (!accessToken.expired(ACCESS_TOKEN_EXPIRATION_TIME)) { + result = true; + } else { + try { + accessToken = await accessToken.refresh(); + persistAccessToken(accessToken, req); + result = true; + } catch (error) { + console.error('Error refreshing access token: ', error); + } + } + } else { + console.log('No access token.'); + } + + return result; +}; + +export const hasPreviewAccess = async (req: Request): Promise => { + const oAuth2Config = getConfig().oAuth2; + if (!oAuth2Config) { + throw new Error('Missing OAuth2 configuration.'); + } + + const accessToken = getPersistedAccessToken(req); + if (!accessToken) { + throw new Error('Missing access token.'); + } + + const previewAccess = await fetch( + `${oAuth2Config.tokenHost}/preview/access`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken.token.access_token}`, + }, + }, + ); + + const status = await previewAccess.status; + return status === 200; +}; + +/** + * User info. + * + * Can be used for debugging purposes. + */ +export const getUserInfo = async (req: Request): Promise => { + const accessToken = getPersistedAccessToken(req); + if (!accessToken) { + throw new Error('Missing access token.'); + } + try { + const userInfoResponse = await fetch( + 'http://127.0.0.1:8888/oauth/userinfo', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken.token.access_token}`, + }, + }, + ); + const status = await userInfoResponse.status; + console.debug('User info status', status); + if (status === 200) { + const json = await userInfoResponse.json(); + console.debug('User info', json); + } + return status === 200; + } catch (error) { + console.error('Error fetching user info', error); + } + + return false; +}; diff --git a/packages/drupal/custom/custom.deploy.php b/packages/drupal/custom/custom.deploy.php index 29177aad4..c9cd3390c 100644 --- a/packages/drupal/custom/custom.deploy.php +++ b/packages/drupal/custom/custom.deploy.php @@ -1,58 +1,111 @@ $redirect_base_url_env_var, + ] + ) + ); } - $clientSecret = getenv('PUBLISHER_OAUTH2_CLIENT_SECRET'); + $clientSecret = getenv($client_secret_env_var); if (!$clientSecret) { - throw new \Exception('PUBLISHER_OAUTH2_CLIENT_SECRET environment variable is not set. It is required to setup the Publisher OAuth Consumer.'); + throw new \Exception( + t('@ENV_VAR environment variable is not set. It is required to create the OAuth Consumer.', + [ + '@ENV_VAR' => $client_secret_env_var, + ] + ) + ); } $consumersStorage = $entityTypeManager->getStorage('consumer'); $existingConsumers = $consumersStorage->loadMultiple(); - $hasPublisherConsumer = FALSE; + $consumerExists = FALSE; /** @var \Drupal\consumers\Entity\ConsumerInterface $consumer */ - foreach($existingConsumers as $consumer) { + foreach ($existingConsumers as $consumer) { // As a side effect, delete the default consumer. // It is installed by the Consumers module. + // We don't use in the template. if ($consumer->getClientId() === 'default_consumer') { $consumer->delete(); } - if ($consumer->getClientId() === 'publisher') { - $hasPublisherConsumer = TRUE; + if ($consumer->getClientId() === $client_id) { + $consumerExists = TRUE; } } - // Create the Publisher Consumer if it does not exist. - if (!$hasPublisherConsumer) { - $oAuthCallback = $publisherUrl . '/oauth/callback'; + // Create the Consumer if it does not exist. + if (!$consumerExists) { + $oAuthCallback = $redirectBaseUrl . '/oauth/callback'; $consumersStorage->create([ - 'label' => 'Publisher', - 'client_id' => 'publisher', - 'is_default' => TRUE, + 'label' => $label, + 'client_id' => $client_id, + 'is_default' => FALSE, 'secret' => $clientSecret, 'redirect' => $oAuthCallback, ])->save(); - return t('Created Publisher OAuth Consumer.'); + return t('Created @consumer OAuth Consumer.', ['@consumer' => $label]); } - return t('Publisher OAuth Consumer already exists.'); + return t('@consumer OAuth Consumer already exists.', ['@consumer' => $label]); } diff --git a/packages/drupal/custom/custom.module b/packages/drupal/custom/custom.module index 07ac7bc44..baac13c42 100644 --- a/packages/drupal/custom/custom.module +++ b/packages/drupal/custom/custom.module @@ -225,6 +225,12 @@ function custom_form_views_exposed_form_alter(&$form, FormStateInterface $form_s // Not applicable $form['langcode']['#options']['zxx'], ); + // The `excludeIds` filter contains a list of UUIDs, and this might exceed the + // 128 characters limit of the field. To avoid this, we increase the maxlength + // of the exposed filter to 2048. + if (!empty($form['excludeIds'])) { + $form['excludeIds']['#maxlength'] = 2048; + } } /** diff --git a/packages/drupal/custom/directives.graphql b/packages/drupal/custom/directives.graphql new file mode 100644 index 000000000..c80242397 --- /dev/null +++ b/packages/drupal/custom/directives.graphql @@ -0,0 +1,19 @@ +""" +Loads a given entity by its uuid. + +Provided by the "custom" module. +Implemented in "Drupal\custom\Plugin\GraphQL\Directive\EntityLoadByUUID". +""" +directive @loadByUUID( + type: String + uuid: String + operation: String +) repeatable on FIELD_DEFINITION | SCALAR | UNION | ENUM | INTERFACE | OBJECT + +""" +Resolves the parent value + +Provided by the "custom" module. +Implemented in "Drupal\custom\Plugin\GraphQL\Directive\ParentValue". +""" +directive @resolveParent repeatable on FIELD_DEFINITION | SCALAR | UNION | ENUM | INTERFACE | OBJECT diff --git a/packages/drupal/custom/src/Plugin/GraphQL/Directive/EntityLoadByUUID.php b/packages/drupal/custom/src/Plugin/GraphQL/Directive/EntityLoadByUUID.php new file mode 100644 index 000000000..c0b6f759c --- /dev/null +++ b/packages/drupal/custom/src/Plugin/GraphQL/Directive/EntityLoadByUUID.php @@ -0,0 +1,45 @@ +produce('entity_load_by_uuid') + ->map('type', $builder->fromValue($arguments['type'])) + ->map('access_operation', $builder->fromValue($arguments['operation'] ?? 'view')) + ->map('language', $builder->fromContext('document_language')) + ->map('uuid', $this->argumentResolver($arguments['uuid'], $builder)); + } + +} diff --git a/packages/drupal/custom/src/Plugin/GraphQL/Directive/ParentValue.php b/packages/drupal/custom/src/Plugin/GraphQL/Directive/ParentValue.php new file mode 100644 index 000000000..b0734ccb0 --- /dev/null +++ b/packages/drupal/custom/src/Plugin/GraphQL/Directive/ParentValue.php @@ -0,0 +1,21 @@ +fromParent(); + } + +} diff --git a/packages/drupal/gutenberg_blocks/.eslintignore b/packages/drupal/gutenberg_blocks/.eslintignore index f20cdcff6..44de66f72 100644 --- a/packages/drupal/gutenberg_blocks/.eslintignore +++ b/packages/drupal/gutenberg_blocks/.eslintignore @@ -1,3 +1,3 @@ .turbo -js +dist node_modules diff --git a/packages/drupal/gutenberg_blocks/.gitignore b/packages/drupal/gutenberg_blocks/.gitignore index 4f754a126..f5cbc0c2b 100644 --- a/packages/drupal/gutenberg_blocks/.gitignore +++ b/packages/drupal/gutenberg_blocks/.gitignore @@ -1,3 +1,3 @@ node_modules -js/gutenberg_blocks.mjs -js/gutenberg_blocks.umd.js +dist/gutenberg_blocks.mjs +dist/gutenberg_blocks.umd.js diff --git a/packages/drupal/gutenberg_blocks/README.md b/packages/drupal/gutenberg_blocks/README.md index 713856e00..4cced52ab 100644 --- a/packages/drupal/gutenberg_blocks/README.md +++ b/packages/drupal/gutenberg_blocks/README.md @@ -4,9 +4,9 @@ To create a custom Gutenberg you must: -1. Create a `.tsx` file in `src/blocks`, using one of the existing examples as a +1. Create a `.tsx` file in `js/blocks`, using one of the existing examples as a starting point. -2. Include the file within `src/index.ts` - this file is used to generate the +2. Include the file within `js/index.ts` - this file is used to generate the javascript file included by the Drupal module. 3. Clear the cache if necessary and you should be able to add your new block within the Gutenberg editor. @@ -20,9 +20,9 @@ create a new block based on a GraphQL type. pnpm gutenberg:generate ``` -This will create a new block in the `src/blocks` directory, with the necessary +This will create a new block in the `js/blocks` directory, with the necessary fields and attributes already defined. You will still need to add the block to -`src/index.ts` and clear the cache to see the new block in the Gutenberg editor +`js/index.ts` and clear the cache to see the new block in the Gutenberg editor after running this command. ### Icons @@ -178,7 +178,6 @@ import { registerBlockType } from 'wordpress__blocks'; import { InnerBlocks } from 'wordpress__block-editor'; import { useSelect } from 'wordpress__data'; -// @ts-ignore const __ = Drupal.t; const MAX_BLOCKS: number = 1; diff --git a/packages/drupal/gutenberg_blocks/css/edit.css b/packages/drupal/gutenberg_blocks/css/edit.css index 7dc32cdc3..68f0871f5 100644 --- a/packages/drupal/gutenberg_blocks/css/edit.css +++ b/packages/drupal/gutenberg_blocks/css/edit.css @@ -22,7 +22,8 @@ display: block; position: relative; margin: 40px 0; - border-left: 34px solid #666666; + border-left-width: 34px; + border-left-style: solid; padding-left: 10px; min-height: 250px; } @@ -129,3 +130,185 @@ .gutenberg__editor .drupal-preview-sidebar--header button { margin-right: 5px; } + +.gutenberg__editor .drupal-preview-sidebar--header select { + min-width: 350px; + position: relative; + appearance: none; + -webkit-appearance: none; + padding: 0.455em 6em 0.575em 1em; + background-color: #fff; + border: 1px solid #caced1; + border-radius: 0.25rem; + color: #000; + cursor: pointer; +} + +/* Preview select */ +.gutenberg__editor .drupal-preview-sidebar--header select::before, +.gutenberg__editor .drupal-preview-sidebar--header select::after { + --size: 0.3rem; + content: ''; + position: absolute; + right: 1rem; + pointer-events: none; +} + +.gutenberg__editor .drupal-preview-sidebar--header select::before { + border-left: var(--size) solid transparent; + border-right: var(--size) solid transparent; + border-bottom: var(--size) solid black; + top: 40%; +} + +.gutenberg__editor .drupal-preview-sidebar--header select::after { + border-left: var(--size) solid transparent; + border-right: var(--size) solid transparent; + border-top: var(--size) solid black; + top: 55%; +} + +.\!border-stone-500 { + --tw-border-opacity: 1; + border-color: rgb(120 113 108 / var(--tw-border-opacity)) !important; +} + +.bg-stone-500 { + --tw-bg-opacity: 1; + background-color: rgb(120 113 108 / var(--tw-bg-opacity)); +} + +.text-white { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity)); +} + +.\[\&\>div\]\:flex > div { + display: flex; +} + +.\[\&\>div\]\:flex-col-reverse > div { + flex-direction: column-reverse; +} + +.\[\&\>div\]\:gap-4 > div { + gap: 1rem; +} + +.\[\&\>div\>label\]\:w-4 > div > label { + width: 1rem; +} + +.\[\&\>div\>label\]\:mb-0 > div > label { + margin-bottom: 0; +} + +.\[\&\>div\>label\]\:my-auto > div > label { + margin-top: auto; + margin-bottom: auto; +} + +.font-extralight { + font-weight: 200; +} + +.text-gray-600 { + --tw-text-opacity: 1; + color: rgb(75 85 99 / var(--tw-text-opacity)); +} + +.text-neutral-500 { + --tw-text-opacity: 1; + color: rgb(115 115 115 / var(--tw-text-opacity)); +} + +.text-sm { + font-size: 0.875rem; + line-height: 1.25rem; +} + +.text-xs { + font-size: 0.75rem; + line-height: 1rem; +} + +.flex { + display: flex; +} + +.flex-col { + flex-direction: column; +} + +.items-start { + align-items: flex-start; +} + +.gap-4 { + gap: 1rem; +} + +.p-0 { + padding: 0; +} + +.\!m-0 { + margin: 0 !important; +} + +summary::marker { + content: none; +} + +.relative { + position: relative; +} + +.inline-block { + display: inline-block; +} + +.ease-in { + transition-timing-function: cubic-bezier(0.4, 0, 1, 1); +} + +.w-\[var\(--space-m\)\] { + width: var(--space-m); +} + +.h-\[var\(--space-m\)\] { + height: var(--space-m); +} + +.mt-\[calc\(var\(--space-m\)\/-2\)\] { + margin-top: calc(var(--space-m) / -2); +} + +.transition-transform { + transition-property: transform; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.rotate-90 { + --tw-rotate: 90deg; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) + rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) + scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.duration-\[var\(--details-transform-transition-duration\)\] { + transition-duration: var(--details-transform-transition-duration); +} + +details[open] > summary svg { + transform: rotate(-90deg); +} + +.grid-cols-\[34px\2c 1fr\] { + grid-template-columns: 34px 1fr; +} + +.col-start-2 { + grid-column-start: 2; +} diff --git a/packages/drupal/gutenberg_blocks/gutenberg_blocks.libraries.yml b/packages/drupal/gutenberg_blocks/gutenberg_blocks.libraries.yml index 87a7ad6d9..8ddc4bb6c 100644 --- a/packages/drupal/gutenberg_blocks/gutenberg_blocks.libraries.yml +++ b/packages/drupal/gutenberg_blocks/gutenberg_blocks.libraries.yml @@ -1,6 +1,6 @@ edit: js: - js/gutenberg_blocks.umd.js: {} + dist/gutenberg_blocks.umd.js: {} css: theme: css/edit.css: {} diff --git a/packages/drupal/gutenberg_blocks/gutenberg_blocks.module b/packages/drupal/gutenberg_blocks/gutenberg_blocks.module index 61d7a515b..045357faa 100644 --- a/packages/drupal/gutenberg_blocks/gutenberg_blocks.module +++ b/packages/drupal/gutenberg_blocks/gutenberg_blocks.module @@ -27,6 +27,7 @@ function gutenberg_blocks_form_node_form_alter(&$form, FormStateInterface $form_ /** @var \Drupal\node\NodeInterface $node */ $node = $form_state->getFormObject()->getEntity(); if (Utils::getGutenbergFields($node)) { + // Perhaps move in Silverback preview link module. $form['actions']['preview_link'] = [ '#type' => 'link', '#title' => t('Preview'), @@ -42,9 +43,14 @@ function gutenberg_blocks_form_node_form_alter(&$form, FormStateInterface $form_ /** @var \Drupal\silverback_external_preview\ExternalPreviewLink $externalPreviewLink */ $externalPreviewLink = \Drupal::service('silverback_external_preview.external_preview_link'); $previewUrl = $externalPreviewLink->createPreviewUrlFromEntity($node)->toString(); + $langcode = \Drupal::languageManager()->getCurrentLanguage(LanguageInterface::TYPE_CONTENT)->getId(); $form['#attached']['drupalSettings']['preview'] = [ 'previewUrl' => $previewUrl, + // @todo link template does not work out here, check why. + //'previewTokenUrl' => $node->toUrl('preview-link-generate')->toString(), + 'previewTokenUrl' => '/' . $langcode . '/node/' . $node->id() . '/generate-preview-link', ]; + $form['#attached']['library'][] = 'core/drupal.dialog.ajax'; // Load all open webforms and populate them into drupalSettings. $languageManager = \Drupal::languageManager(); diff --git a/packages/drupal/gutenberg_blocks/src/blocks/accordion-item-text.tsx b/packages/drupal/gutenberg_blocks/js/blocks/accordion-item-text.tsx similarity index 94% rename from packages/drupal/gutenberg_blocks/src/blocks/accordion-item-text.tsx rename to packages/drupal/gutenberg_blocks/js/blocks/accordion-item-text.tsx index aaf3f4a6d..9c6b2aed0 100644 --- a/packages/drupal/gutenberg_blocks/src/blocks/accordion-item-text.tsx +++ b/packages/drupal/gutenberg_blocks/js/blocks/accordion-item-text.tsx @@ -9,8 +9,8 @@ import { registerBlockType } from 'wordpress__blocks'; import { PanelBody, SelectControl } from 'wordpress__components'; import { compose, withState } from 'wordpress__compose'; -// @ts-ignore const { t: __ } = Drupal; + // @ts-ignore registerBlockType('custom/accordion-item-text', { title: 'Accordion Item Text', @@ -53,7 +53,7 @@ registerBlockType('custom/accordion-item-text', { /> -
+
{__('Accordion Item Text')}
diff --git a/packages/drupal/gutenberg_blocks/js/blocks/accordion.tsx b/packages/drupal/gutenberg_blocks/js/blocks/accordion.tsx new file mode 100644 index 000000000..ea934733b --- /dev/null +++ b/packages/drupal/gutenberg_blocks/js/blocks/accordion.tsx @@ -0,0 +1,70 @@ +import { InnerBlocks, InspectorControls } from 'wordpress__block-editor'; +import { registerBlockType } from 'wordpress__blocks'; +import { PanelBody, SelectControl } from 'wordpress__components'; + +const { t: __ } = Drupal; + +enum HeadingLevels { + H2 = 'h2', + H3 = 'h3', + H4 = 'h4', + H5 = 'h5', +} + +registerBlockType('custom/accordion', { + title: __('Accordion'), + icon: 'menu', + category: 'layout', + attributes: { + headingLevel: { + type: 'string', + default: HeadingLevels.H2, + }, + }, + edit: (props) => { + const { setAttributes } = props; + + return ( + <> + + + { + setAttributes({ headingLevel }); + }} + /> + + + +
+
{__('Accordion')}
+ +
+ + ); + }, + save: () => , +}); diff --git a/packages/drupal/gutenberg_blocks/js/blocks/conditional.tsx b/packages/drupal/gutenberg_blocks/js/blocks/conditional.tsx new file mode 100644 index 000000000..c282591df --- /dev/null +++ b/packages/drupal/gutenberg_blocks/js/blocks/conditional.tsx @@ -0,0 +1,254 @@ +import clsx from 'clsx'; +import React, { PropsWithChildren } from 'react'; +import { InnerBlocks, InspectorControls } from 'wordpress__block-editor'; +import { registerBlockType } from 'wordpress__blocks'; +import { + BaseControl, + PanelBody, + PanelRow, + TextControl, +} from 'wordpress__components'; + +const { t: __ } = Drupal; + +type ConditionsType = { + [key: string]: { + label: string; + visible: boolean; + template: JSX.Element; + }; +}; + +const blockTitle = __('Conditional content'); + +registerBlockType(`custom/conditional`, { + title: blockTitle, + category: 'layout', + icon: 'category', + // Allow the block only at the root level to avoid GraphQL fragment recursion. + parent: ['custom/content'], + attributes: { + displayFrom: { + type: 'string', + default: '', + }, + displayTo: { + type: 'string', + default: '', + }, + purpose: { + type: 'string', + default: '', + }, + }, + edit(props) { + const { attributes, setAttributes } = props; + + const displayFrom = attributes.displayFrom as string | undefined; + const displayTo = attributes.displayTo as string | undefined; + const purpose = (attributes.purpose as string) || ''; + + // Same logic as in BlockConditional.tsx + const active = { + scheduledDisplay: [ + displayFrom + ? new Date(displayFrom).getTime() <= new Date().getTime() + : true, + displayTo ? new Date(displayTo).getTime() > new Date().getTime() : true, + ].every(Boolean), + }; + const isActive = Object.values(active).every(Boolean); + + const conditions: ConditionsType = { + scheduledDisplay: { + label: '⏱️ ' + __('Scheduled display'), + visible: !!(displayFrom || displayTo), + template: ( + <> + {displayFrom ? ( + <> + {__('From')}:{' '} + {new Date(displayFrom).toLocaleString()} + + ) : null} + {displayFrom && displayTo ? ' ' : null} + {displayTo ? ( + <> + {__('To')}:{' '} + {new Date(displayTo).toLocaleString()} + + ) : null} + + ), + }, + device: { + label: '📱 ' + __('Device'), + visible: false, + template: <>{'Mobile only.'}, + }, + }; + + const hasConditions = Object.values(conditions) + .filter(({ visible }) => visible) + .some(Boolean); + + const conditionsSummary = hasConditions ? ( + Object.entries(conditions) + .filter(([, value]) => !!value) + .filter(([, { visible }]) => visible) + .map(([, { label, template }]) => ( + <> +
{label}
+ {template} + + )) + ) : ( + <>{'ℹ️ ' + __('No conditions set')} + ); + + return ( + <> + +
{conditionsSummary}
+
+ +
+
+ + + + setAttributes({ purpose: value })} + help={__( + 'The value is not exposed to the frontend and serves to identify the reason of the conditional content (e.g. Summer Campaign).', + )} + /> + + + + + + div]:flex [&>div]:gap-4 [&>div>label]:w-4 [&>div>label]:my-auto !m-0' + } + > + { + setAttributes({ + displayFrom: event.target.value + ? localToIsoTime(event.target.value) + : '', + }); + }} + /> + + div]:flex [&>div]:gap-4 [&>div>label]:w-4 [&>div>label]:mb-0 [&>div>label]:my-auto !m-0' + } + > + { + setAttributes({ + displayTo: event.target.value + ? localToIsoTime(event.target.value) + : '', + }); + }} + /> + + + +

+ {__('Time zone') + + ': ' + + Intl.DateTimeFormat().resolvedOptions().timeZone} +

+
+
+
+ + ); + }, + + save() { + return ; + }, +}); + +const localToIsoTime = (localTime: string) => { + return new Date(localTime).toISOString(); +}; + +const isoToLocalTime = (isoTime: string) => { + const date = new Date(isoTime); + date.setMinutes(date.getMinutes() - date.getTimezoneOffset()); + return date.toISOString().slice(0, 16); +}; + +const CollapsibleContainer = ({ + children, + label, + title, + isActive, +}: PropsWithChildren<{ label: string; title: string; isActive: boolean }>) => { + return ( + <> +
+ + + + + + + {title} + + +
+
{label}
+
{children}
+
+
+ + ); +}; diff --git a/packages/drupal/gutenberg_blocks/src/blocks/content.tsx b/packages/drupal/gutenberg_blocks/js/blocks/content.tsx similarity index 97% rename from packages/drupal/gutenberg_blocks/src/blocks/content.tsx rename to packages/drupal/gutenberg_blocks/js/blocks/content.tsx index 1aff9b0ac..d5aa26b2a 100644 --- a/packages/drupal/gutenberg_blocks/src/blocks/content.tsx +++ b/packages/drupal/gutenberg_blocks/js/blocks/content.tsx @@ -1,7 +1,6 @@ import { InnerBlocks } from 'wordpress__block-editor'; import { registerBlockType } from 'wordpress__blocks'; -// @ts-ignore const { t: __ } = Drupal; const style = { diff --git a/packages/drupal/gutenberg_blocks/src/blocks/cta.tsx b/packages/drupal/gutenberg_blocks/js/blocks/cta.tsx similarity index 99% rename from packages/drupal/gutenberg_blocks/src/blocks/cta.tsx rename to packages/drupal/gutenberg_blocks/js/blocks/cta.tsx index 5b70375d3..b2f11af6b 100644 --- a/packages/drupal/gutenberg_blocks/src/blocks/cta.tsx +++ b/packages/drupal/gutenberg_blocks/js/blocks/cta.tsx @@ -9,10 +9,7 @@ import { registerBlockType } from 'wordpress__blocks'; import { PanelBody, SelectControl, ToggleControl } from 'wordpress__components'; import { compose, withState } from 'wordpress__compose'; -// @ts-ignore const { t: __ } = Drupal; - -// @ts-ignore const { setPlainTextAttribute } = silverbackGutenbergUtils; const ArrowRightIcon = () => ( diff --git a/packages/drupal/gutenberg_blocks/src/blocks/demo-block.tsx b/packages/drupal/gutenberg_blocks/js/blocks/demo-block.tsx similarity index 98% rename from packages/drupal/gutenberg_blocks/src/blocks/demo-block.tsx rename to packages/drupal/gutenberg_blocks/js/blocks/demo-block.tsx index d35d6354d..4c0958f0b 100644 --- a/packages/drupal/gutenberg_blocks/src/blocks/demo-block.tsx +++ b/packages/drupal/gutenberg_blocks/js/blocks/demo-block.tsx @@ -7,8 +7,8 @@ import { dispatch } from 'wordpress__data'; import { DrupalMediaEntity } from '../utils/drupal-media'; -// @ts-ignore const { t: __ } = Drupal; + // @ts-ignore registerBlockType('custom/demo-block', { title: 'Demo Block', @@ -47,7 +47,7 @@ registerBlockType('custom/demo-block', {

Block settings

-
+
{__('Demo Block')}
string }; - -declare const drupalSettings: { - path: { - baseUrl: string; - pathPrefix: string; - }; - customGutenbergBlocks: { - forms: Array<{ - id: string; - url: string; - label: string; - }>; - }; -}; - const { t: __ } = Drupal; registerBlockType(`custom/form`, { @@ -53,7 +37,7 @@ registerBlockType(`custom/form`, { /> -
+
{__('Form')}
{props.attributes.formId ? (