Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
44971f8
Merge branch 'release/2.5.0' into main
matthieu2607 May 28, 2024
4369e6b
Merge branch 'develop'
matthieu2607 May 28, 2024
dc195bb
chore: require Sulu 3, Symfony 7, PHP 8.2
Jun 7, 2026
c1c9f38
refactor: convert Setting entity to PHP 8 attributes
Jun 7, 2026
abd71c9
fix: drop rest-routing-bundle, add getLocale for Sulu 3
Jun 7, 2026
587ffa8
feat: add explicit admin REST routes for Sulu 3
Jun 7, 2026
e7a5cab
docs: update install route snippet for Sulu 3
Jun 7, 2026
7e422ed
fix: add TreeBuilder return type for Symfony 7 compat
Jun 7, 2026
1eb8723
fix: initialize auditable timestamps for unsaved Setting (Sulu 3)
Jun 7, 2026
634d16d
chore: update tarteaucitron to v1.33.0 for Google Consent Mode v2
Jun 7, 2026
e0880a7
feat: add Integration entities and provider defaults helper
Jun 7, 2026
9f82306
refactor: drop v1 provider fields from Setting (moved to integrations)
Jun 7, 2026
e2131c7
feat: add integration list + form metadata
Jun 7, 2026
58c60e9
feat: add locale-aware Integration CRUD controller
Jun 7, 2026
f89afe5
feat: register integration controller, routes, list dir and resource
Jun 7, 2026
55ea846
feat: add integrations list tab and full-page localized forms
Jun 7, 2026
fa36559
feat: add admin translations for integrations (en/de)
Jun 7, 2026
438bb74
feat: render integrations config and boot consent runtime
Jun 7, 2026
875724e
feat: add v1 settings migration command
Jun 7, 2026
5d963c6
fix: add :locale placeholder to localized integrations list URL
Jun 8, 2026
5843aeb
fix: declare add/delete permission types for integrations CRUD
Jun 8, 2026
607bebb
docs: document integrations usage, frontend API and v1 upgrade
Jun 8, 2026
37473a6
fix: add German translations for v1 settings labels
Jun 8, 2026
78ee5f8
docs: fix install steps (assets:install, dual-kernel console, render,…
Jun 8, 2026
ef7066c
docs: add v2 integrations screenshots to readme
Jun 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
/.idea/
vendor

.DS_Store
14 changes: 7 additions & 7 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@
}
],
"require": {
"php": "^8.0",
"sulu/sulu": "^2.4",
"symfony/config": "^5.0 || ^6.0",
"symfony/dependency-injection": "^5.0 || ^6.0",
"symfony/framework-bundle": "^5.0 || ^6.0",
"symfony/http-foundation": "^5.0 || ^6.0",
"symfony/http-kernel": "^5.0 || ^6.0"
"php": "^8.2",
"sulu/sulu": "^3.0",
"symfony/config": "^6.4 || ^7.0",
"symfony/dependency-injection": "^6.4 || ^7.0",
"symfony/framework-bundle": "^6.4 || ^7.0",
"symfony/http-foundation": "^6.4 || ^7.0",
"symfony/http-kernel": "^6.4 || ^7.0"
},
"require-dev": {
"dantleech/phpcr-migrations-bundle": "^1.3",
Expand Down
Binary file added docs/images/settings_details.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/settings_integrations.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/settings_integrations_detail.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
142 changes: 126 additions & 16 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ It also allows you to manage the consent banner by using the [Tarteaucitron](htt

## Requirements

* PHP >= 8.0
* Sulu >= 2.5.*
* Symfony >= 5.4
* PHP >= 8.2
* Sulu >= 3.0.*
* Symfony >= 6.4
* Composer

## Installation
Expand All @@ -43,48 +43,158 @@ Enable the bundle by adding it to the list of registered bundles in the `config/
```

### Update schema (for dev environnement)

Sulu uses two kernels, so run the schema update on the admin console:

```shell script
bin/console do:sch:up --force
bin/adminconsole doctrine:schema:update --force
```

### Publish the assets

The bundle ships the Tarteaucitron library and the consent runtime (`gdpr-consent.js`).
Publish them to your `public/` directory, otherwise the banner cannot load:

```shell script
bin/adminconsole assets:install --symlink
```

## Bundle Config

Define the Admin Api Route in `routes_admin.yaml`
Import the bundle's Admin API routes in `routes_admin.yaml`
```yaml
gdpr.setting_api:
type: rest
gdpr_admin_api:
resource: '@GDPRBundle/Resources/config/routing_admin.yaml'
prefix: /admin/api
resource: pixel_gdpr.settings_route_controller
name_prefix: gdpr.
```

### Render the banner

Call the `gdpr_script()` Twig function in the `<head>` of your layout (e.g. `master.html.twig`).
It renders nothing until you enable "Use cookies management?" in the settings.

```twig
<head>
{# ... #}
{{ gdpr_script() }}
</head>
```

### Permissions

The bundle registers the `gdpr_settings.settings` security context. Grant your role **View**,
**Add**, **Edit** and **Delete** for it under *Settings → Roles*, otherwise the GDPR settings and
integrations are hidden or read‑only.

## Upgrading from v1 (single tracker → integrations)

v2 replaces the fixed provider fields (single Google Analytics code, etc.) with the
**Integrations** list. If you have v1 data, migrate it **before** the schema drops the old columns:

```shell script
# 1. create the new tables WITHOUT dropping the legacy columns yet
bin/adminconsole doctrine:schema:update --dump-sql # review
# run only the "CREATE TABLE gdpr_integration ..." statements, or use a migration

# 2. copy the legacy tracking codes into integrations
bin/adminconsole gdpr:integrations:migrate-settings # --locales=de,en

# 3. now let the schema drop the legacy columns and add foreign keys
bin/adminconsole doctrine:schema:update --force
```

On a fresh install (no v1 data) just run `bin/adminconsole doctrine:schema:update --force`.

Because the Integrations list adds create/delete operations to the existing
`gdpr_settings.settings` security context, grant **Add** and **Delete** for that context to your
role under *Settings → Roles* after upgrading (an existing context does not auto‑grant newly added
permission types).

## Use
The bundle is only composed of the settings, which make the management of the GDPR very easy.

To use the GDPR management of the bundle, just check the "Use cookies management?". All the other options should be display.

The **Services** section will take care to manage the different cookies scripts.

The **Parameters** section will help you manage the Tarteaucitron banner, which displays the consent banner.
There are plenty of parameters, so don't hesitate to visit the repository of Tarteaucitron.

![GDPR settings](docs/images/settings_details.png)

The **Integrations** tab is where you add the individual scripts/services that the banner asks
consent for (see below).

## Integrations

Each tracker, script or embed you want to gate behind consent is configured as an **integration**
on the **Integrations** tab of the GDPR settings. The list is localized — use the language switcher
to edit the texts shown in the banner per language.

![Integrations list](docs/images/settings_integrations.png)

Click **Add** (or a row) to open the full‑page form. The **Type** field decides which other fields
are shown:

![Integration form](docs/images/settings_integrations_detail.png)

| Type | What it does | Fields |
|------|--------------|--------|
| **Preconfigured provider** | Wires a known provider into Tarteaucitron for you. | *Provider* (Google Analytics, Google Tag Manager, Google Ads, Bing Ads, Facebook Pixel) + *Tracking ID* |
| **Custom inline script** | Runs the pasted JavaScript when the integration is accepted. | *Inline script* |
| **Custom external JS** | Injects `<script src="…">` when the integration is accepted. | *Script URL* |
| **Manual (event only)** | Stores no code — only emits the accept/reject event so you can run your own code (e.g. a map you built yourself). | – |

Common fields for every type:

* **Key** — a unique slug (e.g. `googlemaps`). It is the Tarteaucitron service key **and** the
suffix of the JavaScript events (`gdpr:accept:<key>`).
* **Consent Mode v2 categories** — the Google Consent Mode v2 signals that are set to `granted`
when the integration is accepted (`analytics_storage`, `ad_storage`, …). Preconfigured providers
get sensible defaults.
* **Enabled** — only enabled integrations are rendered on the frontend.
* **Title / Description** — the localized texts shown in the consent banner.

### Reacting to consent on the frontend

The bundle exposes a small JavaScript API so your own code can react when an integration is
accepted or rejected. This is the recommended way to gate a **Manual** integration:

```js
// run your code once the visitor accepts the "googlemaps" integration
window.gdpr.onAccept('googlemaps', function () {
initMyMap();
});

window.gdpr.onReject('googlemaps', function () {
// optional clean-up
});
```

`onAccept` callbacks registered *after* the visitor already accepted are fired immediately. The same
signals are also dispatched as DOM events on `document`, if you prefer listening to those:

```js
document.addEventListener('gdpr:accept:googlemaps', function (e) { /* e.detail.key === 'googlemaps' */ });
document.addEventListener('gdpr:reject:googlemaps', function () { /* … */ });
```

### Twig extension
The bundle comes with two twig functions:

**gdpr_settings()**: returns the settings of the bundle. No parameters are required.
**gdpr_settings()**: returns the banner settings of the bundle. No parameters are required.

Example of use:
```twig
{% set gdprSettings = gdrp_settings() %}
{{ gdprSettings.useCookiesHandleing }}
{% set gdprSettings = gdpr_settings() %}
{{ gdprSettings.useCookieHandling }}
```

**gdprScripts()**: renders the consent banner. No parameters are required.
**gdpr_script()**: renders the consent banner, the enabled integrations and the consent runtime.
No parameters are required — it reads the current request locale for the banner texts. Place it in
the `<head>` of your layout.

Example of use:
```twig
{{ gdpr_scripts() }}
{{ gdpr_script() }}
```

## Contributing
Expand Down
59 changes: 59 additions & 0 deletions src/Admin/SettingAdmin.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Pixel\GDPRBundle\Admin;

use Pixel\GDPRBundle\Entity\Integration;
use Pixel\GDPRBundle\Entity\Setting;
use Sulu\Bundle\AdminBundle\Admin\Admin;
use Sulu\Bundle\AdminBundle\Admin\Navigation\NavigationItem;
Expand All @@ -18,6 +19,11 @@ class SettingAdmin extends Admin
{
public const TAB_VIEW = "gdpr.settings";
public const FORM_VIEW = "gdpr.settings.form";
public const INTEGRATIONS_LIST_VIEW = "gdpr.settings.integrations";
public const INTEGRATION_ADD_FORM_VIEW = "gdpr.integration.add_form";
public const INTEGRATION_ADD_DETAILS_VIEW = "gdpr.integration.add_form.details";
public const INTEGRATION_EDIT_FORM_VIEW = "gdpr.integration.edit_form";
public const INTEGRATION_EDIT_DETAILS_VIEW = "gdpr.integration.edit_form.details";

private ViewBuilderFactoryInterface $viewBuilderFactory;
private SecurityCheckerInterface $securityChecker;
Expand Down Expand Up @@ -56,6 +62,57 @@ public function configureViews(ViewCollection $viewCollection): void
->addToolbarActions([new ToolbarAction("sulu_admin.save")])
->setParent(static::TAB_VIEW)
);

$locales = ['de', 'en'];

// Integrations list as a second tab of the settings view.
$viewCollection->add(
$this->viewBuilderFactory->createListViewBuilder(static::INTEGRATIONS_LIST_VIEW, "/integrations/:locale")
->setResourceKey(Integration::RESOURCE_KEY)
->setListKey(Integration::LIST_KEY)
->setTabTitle("gdpr_settings.integrations")
->addListAdapters(["table"])
->addLocales($locales)
->setDefaultLocale($locales[0])
->setAddView(static::INTEGRATION_ADD_FORM_VIEW)
->setEditView(static::INTEGRATION_EDIT_FORM_VIEW)
->addToolbarActions([new ToolbarAction("sulu_admin.add"), new ToolbarAction("sulu_admin.delete")])
->setParent(static::TAB_VIEW)
);

// Full-page add form (new screen, localized).
$viewCollection->add(
$this->viewBuilderFactory->createResourceTabViewBuilder(static::INTEGRATION_ADD_FORM_VIEW, "/integrations/:locale/add")
->setResourceKey(Integration::RESOURCE_KEY)
->addLocales($locales)
->setBackView(static::INTEGRATIONS_LIST_VIEW)
);
$viewCollection->add(
$this->viewBuilderFactory->createFormViewBuilder(static::INTEGRATION_ADD_DETAILS_VIEW, "/details")
->setResourceKey(Integration::RESOURCE_KEY)
->setFormKey(Integration::FORM_KEY)
->setTabTitle("sulu_admin.details")
->addToolbarActions([new ToolbarAction("sulu_admin.save")])
->setEditView(static::INTEGRATION_EDIT_FORM_VIEW)
->setParent(static::INTEGRATION_ADD_FORM_VIEW)
);

// Full-page edit form (new screen, localized).
$viewCollection->add(
$this->viewBuilderFactory->createResourceTabViewBuilder(static::INTEGRATION_EDIT_FORM_VIEW, "/integrations/:locale/:id")
->setResourceKey(Integration::RESOURCE_KEY)
->addLocales($locales)
->setBackView(static::INTEGRATIONS_LIST_VIEW)
->setTitleProperty("serviceKey")
);
$viewCollection->add(
$this->viewBuilderFactory->createFormViewBuilder(static::INTEGRATION_EDIT_DETAILS_VIEW, "/details")
->setResourceKey(Integration::RESOURCE_KEY)
->setFormKey(Integration::FORM_KEY)
->setTabTitle("sulu_admin.details")
->addToolbarActions([new ToolbarAction("sulu_admin.save"), new ToolbarAction("sulu_admin.delete")])
->setParent(static::INTEGRATION_EDIT_FORM_VIEW)
);
}
}

Expand All @@ -69,7 +126,9 @@ public function getSecurityContexts()
"Setting" => [
Setting::SECURITY_CONTEXT => [
PermissionTypes::VIEW,
PermissionTypes::ADD,
PermissionTypes::EDIT,
PermissionTypes::DELETE,
],
],
],
Expand Down
97 changes: 97 additions & 0 deletions src/Command/MigrateSettingsCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?php

declare(strict_types=1);

namespace Pixel\GDPRBundle\Command;

use Doctrine\ORM\EntityManagerInterface;
use Pixel\GDPRBundle\Entity\Integration;
use Pixel\GDPRBundle\Provider\IntegrationDefaults;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(
name: 'gdpr:integrations:migrate-settings',
description: 'Migrate legacy v1 provider tracking codes into integrations.',
)]
class MigrateSettingsCommand extends Command
{
/** legacy column => provider */
private const LEGACY_MAP = [
'google_analytics_gtag_js' => 'gtag',
'google_tag_manager' => 'googletagmanager',
'google_ads' => 'googleads',
'bing_ads' => 'bingads',
'pixel_facebook' => 'facebookpixel',
];

public function __construct(private EntityManagerInterface $entityManager)
{
parent::__construct();
}

protected function configure(): void
{
$this->addOption('locales', null, InputOption::VALUE_REQUIRED, 'Comma-separated locales for titles', 'de,en');
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$locales = \array_filter(\explode(',', (string) $input->getOption('locales')));

$connection = $this->entityManager->getConnection();
$columns = \implode(', ', \array_keys(self::LEGACY_MAP));

try {
$row = $connection->fetchAssociative("SELECT {$columns} FROM gdpr_settings LIMIT 1");
} catch (\Throwable $e) {
$io->warning('No legacy gdpr_settings columns found; nothing to migrate.');

return Command::SUCCESS;
}

if (false === $row) {
$io->note('No gdpr_settings row; nothing to migrate.');

return Command::SUCCESS;
}

$repository = $this->entityManager->getRepository(Integration::class);
$created = 0;

foreach (self::LEGACY_MAP as $column => $provider) {
$value = $row[$column] ?? null;
if (null === $value || '' === \trim((string) $value)) {
continue;
}
if (null !== $repository->findOneBy(['serviceKey' => $provider])) {
$io->note(sprintf('Integration "%s" already exists, skipping.', $provider));
continue;
}

$integration = new Integration();
$integration->setServiceKey($provider);
$integration->setType(IntegrationDefaults::TYPE_PRECONFIGURED);
$integration->setProvider($provider);
$integration->setTrackingId((string) $value);
$integration->setConsentCategories(IntegrationDefaults::categoriesForProvider($provider));
$integration->setEnabled(true);
foreach ($locales as $locale) {
$integration->getOrCreateTranslation($locale)->setTitle(IntegrationDefaults::displayName($provider));
}
$this->entityManager->persist($integration);
++$created;
$io->writeln(sprintf('Created integration <info>%s</info> (%s).', $provider, $value));
}

$this->entityManager->flush();
$io->success(sprintf('Migration complete: %d integration(s) created.', $created));

return Command::SUCCESS;
}
}
Loading