Skip to content

Commit b6d3348

Browse files
committed
Double-Opt-In Feature
1 parent 0fd8cd6 commit b6d3348

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+1336
-123
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ Nothing to tell here, it's just [Symfony](https://symfony.com/doc/current/templa
5353
## Further Information
5454
- [Usage (Rendering Types, Configuration)](docs/0_Usage.md)
5555
- [Headless Mode](docs/1_HeadlessMode.md)
56-
- [SPAM Protection (Honeypot, reCAPTCHA)](docs/03_SpamProtection.md)
56+
- [SPAM Protection](docs/03_SpamProtection.md)
57+
- [Double-Opt-In Feature](docs/03_SpamProtection.md)
5758
- [Output Workflows](docs/OutputWorkflow/0_Usage.md)
5859
- [API Channel](docs/OutputWorkflow/09_ApiChannel.md)
5960
- [Email Channel](docs/OutputWorkflow/10_EmailChannel.md)

UPGRADE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Upgrade Notes
22

33
## 5.1.0
4+
- **[SECURITY FEATURE]** Double-Opt-In Feature, read more about it [here](/docs/.md)
45
- **[SECURITY FEATURE]** Add [friendly captcha field](/docs/03_SpamProtection.md#friendly-captcha)
56
- **[SECURITY FEATURE]** Add [cloudflare turnstile](/docs/03_SpamProtection.md#cloudflare-turnstile)
67
- **[BUGFIX]** Use Pimcore AdminUserTranslator for Editable Dialog Box [#450](https://github.com/dachcom-digital/pimcore-formbuilder/issues/450)
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
FormBuilderBundle\Model\DoubleOptInSession:
2+
type: entity
3+
table: formbuilder_double_opt_in_session
4+
indexes:
5+
token_form:
6+
columns: [ token, form_definition, applied ]
7+
id:
8+
token:
9+
unique: true
10+
column: token
11+
type: uuid
12+
generator:
13+
strategy: CUSTOM
14+
customIdGenerator:
15+
class: Symfony\Bridge\Doctrine\IdGenerator\UuidGenerator
16+
fields:
17+
email:
18+
column: email
19+
type: string
20+
nullable: false
21+
length: 190
22+
additionalData:
23+
column: additional_data
24+
type: array
25+
nullable: true
26+
dispatchLocation:
27+
column: dispatch_location
28+
type: text
29+
nullable: true
30+
applied:
31+
column: applied
32+
type: boolean
33+
options:
34+
default: 0
35+
creationDate:
36+
column: creationDate
37+
type: datetime
38+
nullable: false
39+
manyToOne:
40+
formDefinition:
41+
targetEntity: FormBuilderBundle\Model\FormDefinition
42+
orphanRemoval: true
43+
joinColumn:
44+
name: form_definition
45+
referencedColumnName: id
46+
onDelete: CASCADE
47+
uniqueConstraints:
48+
email_form_definition:
49+
columns: [email, form_definition, applied]

config/doctrine/model/FormDefinition.orm.yml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,6 @@ FormBuilderBundle\Model\FormDefinition:
3333
modifiedBy:
3434
column: modifiedBy
3535
type: integer
36-
mailLayout:
37-
column: mailLayout
38-
type: object
39-
nullable: true
4036
configuration:
4137
column: configuration
4238
type: object

config/install/translations/frontend.csv

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,5 @@
3636
"form_builder.dynamic_multi_file.remove","Remove file","Datei entfernen"
3737
"form_builder.dynamic_multi_file.global.cannot_destroy_active_instance","This uploader is currently active or has some unprocessed files. in case there are some uploaded files, please remove them first.","Ein Uploader ist derzeit in dieser Sektion aktiv oder es wurden bereits Daten verarbeitet. Falls es bereits hochgeladene Dateien gibt, entfernen Sie diese bitte zuerst."
3838
"form_builder.form.container.repeater.min","%label%: You need to add at least %items% items.","%label%: Es werden mindestens %items% Einträge benötigt."
39-
"form_builder.form.container.repeater.max","%label%: Only %items% item(s) allowed.","%label%: Maximal %items% Einträge erlaubt."
39+
"form_builder.form.container.repeater.max","%label%: Only %items% item(s) allowed.","%label%: Maximal %items% Einträge erlaubt."
40+
"form_builder.form.double_opt_in.duplicate_session","Double-Opt-In Session for the given address has already been created.","Ein Zugang wurde für diese Adresse bereits erstellt."

config/services/double_opt_in.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
services:
2+
3+
_defaults:
4+
autowire: true
5+
autoconfigure: true
6+
public: true

config/services/forms/forms.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ services:
5252
tags:
5353
- { name: form.type }
5454

55+
FormBuilderBundle\Form\Type\InstructionsType:
56+
public: false
57+
tags:
58+
- { name: form.type }
59+
5560
FormBuilderBundle\Form\Type\SnippetType:
5661
autowire: true
5762
public: false
@@ -96,3 +101,11 @@ services:
96101
tags:
97102
- { name: form.type }
98103

104+
105+
#
106+
# Double-Opt-In
107+
108+
FormBuilderBundle\Form\Type\DoubleOptIn\DoubleOptInType:
109+
public: false
110+
tags:
111+
- { name: form.type }

config/services/manager.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,7 @@ services:
1515
FormBuilderBundle\Manager\PresetManager: ~
1616

1717
# manager: template (form themes, type templates)
18-
FormBuilderBundle\Manager\TemplateManager: ~
18+
FormBuilderBundle\Manager\TemplateManager: ~
19+
20+
# manager: double-opt-in
21+
FormBuilderBundle\Manager\DoubleOptInManager: ~

config/services/repository.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,7 @@ services:
99
FormBuilderBundle\Repository\FormDefinitionRepository: ~
1010

1111
FormBuilderBundle\Repository\OutputWorkflowRepositoryInterface: '@FormBuilderBundle\Repository\OutputWorkflowRepository'
12-
FormBuilderBundle\Repository\OutputWorkflowRepository: ~
12+
FormBuilderBundle\Repository\OutputWorkflowRepository: ~
13+
14+
FormBuilderBundle\Repository\DoubleOptInSessionRepositoryInterface: '@FormBuilderBundle\Repository\DoubleOptInSessionRepository'
15+
FormBuilderBundle\Repository\DoubleOptInSessionRepository: ~

config/services/runtime_data.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,7 @@ services:
99

1010
FormBuilderBundle\Form\RuntimeData\FormRuntimeDataAllocator: ~
1111
FormBuilderBundle\Form\RuntimeData\FormRuntimeDataAllocatorInterface: '@FormBuilderBundle\Form\RuntimeData\FormRuntimeDataAllocator'
12+
13+
FormBuilderBundle\Form\RuntimeData\Provider\DoubleOptInSessionDataProvider:
14+
tags:
15+
- { name: form_builder.runtime_data_provider }

docs/03_SpamProtection.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Spam Protection
22

3+
## Double-Opt-In
4+
Read more about the double-opt-in feature [here](./04_DoubleOptIn.md).
5+
36
## HoneyPot
47
The Honeypot Field is enabled by default. You can disable it via [configuration flags](100_ConfigurationFlags.md).
58

docs/04_DoubleOptIn.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Double-Opt-In
2+
![image](https://github.com/user-attachments/assets/aa4f1f24-607c-4ed3-aa72-2d9d91fddf12)
3+
4+
When enabled, a user must confirm its email identity via confirmation before the real form shows up.
5+
6+
This feature is disabled by default.
7+
8+
```yaml
9+
form_builder:
10+
11+
double_opt_in:
12+
13+
# enable the feature
14+
enabled: true
15+
16+
# redeem_mode:
17+
# choose between "delete" or "devalue"
18+
# - "delete" (default): The double-opt-in session token gets deleted, after the form submission was successful
19+
# - "devalue": The double-opt-in session token only gets redeemed but not deleted, after the form submission was successful.
20+
redeem_mode: 'delete'
21+
22+
expiration:
23+
# delete open sessions after 24 hours (default). If you set it to 0, no sessions will be deleted ever.
24+
open_sessions: 24
25+
# delete redeemed session after x hours (default 0, which means: disabled)
26+
redeemed_sessions: 0
27+
```
28+
29+
## Extending Double-Opt-In Form
30+
By default, the `DoubleOptInType` form type only contains a `emailAddress` field to keep users effort small.
31+
If you want to extend the form, you may want to use a symfony [form extension](https://symfony.com/doc/current/form/create_form_type_extension.html).
32+
33+
Additional Info:
34+
- `emailAddress` is required and you're not allowed to remove it
35+
- Additional fields will be stored as array in the DoubleOptInSession in `additionalData`
36+
37+
## Trash-Mail Protection
38+
TBD

public/js/extjs/_form/tab/configPanel.js

Lines changed: 105 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ Formbuilder.extjs.formPanel.config = Class.create({
4949
this.formConditionalsStructured = formData.conditional_logic;
5050
this.formConditionalsStore = formData.conditional_logic_store;
5151
this.formFields = formData.fields;
52+
this.doubleOptIn = formData.double_opt_in;
5253
this.availableFormFields = formData.fields_structure;
5354
this.availableContainerTypes = formData.container_types;
5455
this.availableConstraints = formData.validation_constraints;
@@ -500,10 +501,12 @@ Formbuilder.extjs.formPanel.config = Class.create({
500501
return el.submitValue === undefined || el.submitValue === true;
501502
});
502503

503-
for (var i = 0; i < items.length; i++) {
504-
if (typeof items[i].getValue === 'function') {
505-
var val = items[i].getValue(),
506-
fieldName = items[i].name;
504+
Ext.Array.each(items, function (item, index) {
505+
if (typeof item.getValue === 'function') {
506+
507+
var val = item.getValue(),
508+
fieldName = item.name;
509+
507510
if (fieldName) {
508511

509512
if (fieldName === 'name') {
@@ -519,6 +522,13 @@ Formbuilder.extjs.formPanel.config = Class.create({
519522
}
520523
}
521524
}
525+
}.bind(this));
526+
527+
// parse form config
528+
this.formConfig = DataObjectParser.transpose(this.formConfig).data();
529+
530+
if (this.formConfig.doubleOptIn && this.formConfig.doubleOptIn.enabled === false) {
531+
this.formConfig.doubleOptIn = {enabled: false}
522532
}
523533

524534
// parse conditional logic to add them later again
@@ -632,7 +642,8 @@ Formbuilder.extjs.formPanel.config = Class.create({
632642

633643
getRootPanel: function () {
634644

635-
var methodStore = new Ext.data.ArrayStore({
645+
var doubleOptInLocalizedField,
646+
methodStore = new Ext.data.ArrayStore({
636647
fields: ['value', 'label'],
637648
data: [['post', 'POST'], ['get', 'GET']]
638649
}),
@@ -658,8 +669,8 @@ Formbuilder.extjs.formPanel.config = Class.create({
658669
listeners: {
659670
load: function (store) {
660671
store.insert(0, {
661-
id : 'all',
662-
name : t('form_builder_email_csv_export_mail_type_all')
672+
id: 'all',
673+
name: t('form_builder_email_csv_export_mail_type_all')
663674
});
664675
}
665676
}
@@ -675,6 +686,92 @@ Formbuilder.extjs.formPanel.config = Class.create({
675686
),
676687
clBuilder = new Formbuilder.extjs.conditionalLogic.builder(this.formConditionalsStructured, this.formConditionalsStore, this);
677688

689+
if (this.doubleOptIn.enabled === true) {
690+
691+
doubleOptInLocalizedField = new Formbuilder.extjs.types.localizedField(function (locale) {
692+
693+
var hrefField = new Formbuilder.extjs.types.href({
694+
label: t('form_builder_form.double_opt_in.mail_template'),
695+
id: 'doubleOptIn.mailTemplate.' + locale,
696+
config: {
697+
types: ['document'],
698+
subtypes: {document: ['email']}
699+
}
700+
},
701+
this.formConfig.doubleOptIn && this.formConfig.doubleOptIn.mailTemplate && this.formConfig.doubleOptIn.mailTemplate[locale]
702+
? this.formConfig.doubleOptIn.mailTemplate[locale]
703+
: null,
704+
null
705+
);
706+
707+
return hrefField.getHref();
708+
709+
}.bind(this), true);
710+
711+
this.doubleOptInPanel = new Ext.form.FieldSet({
712+
title: t('form_builder_form.double_opt_in'),
713+
collapsible: false,
714+
autoHeight: true,
715+
width: '100%',
716+
style: 'margin-top: 20px;',
717+
submitValue: false,
718+
defaults: {
719+
labelWidth: 160
720+
},
721+
items: [
722+
{
723+
xtype: 'checkbox',
724+
name: 'doubleOptIn.enabled',
725+
fieldLabel: t('form_builder_form.double_opt_in.enable'),
726+
inputValue: true,
727+
uncheckedValue: false,
728+
value: this.formConfig.doubleOptIn ? this.formConfig.doubleOptIn.enabled : false,
729+
listeners: {
730+
change: function (cb, value) {
731+
732+
var containerField = cb.nextSibling();
733+
734+
containerField.setHidden(!value);
735+
containerField.query('textfield[name="doubleOptIn.confirmationMessage"]')[0].allowBlank = !value
736+
737+
}.bind(this)
738+
}
739+
},
740+
{
741+
xtype: 'container',
742+
hidden: !this.formConfig.doubleOptIn || this.formConfig.doubleOptIn.enabled === false,
743+
items: [
744+
{
745+
fieldLabel: false,
746+
xtype: 'displayfield',
747+
style: 'display:block !important; margin-bottom:15px !important; font-weight: 300;',
748+
value: t('form_builder_form.double_opt_in.description')
749+
},
750+
{
751+
xtype: 'textfield',
752+
name: 'doubleOptIn.instructionNote',
753+
fieldLabel: t('form_builder_form.double_opt_in.double_opt_in_instruction_note'),
754+
value: this.formConfig.doubleOptIn ? this.formConfig.doubleOptIn.instructionNote : null,
755+
allowBlank: true,
756+
width: '100%',
757+
inputAttrTpl: ' data-qwidth="250" data-qalign="br-r?" data-qtrackMouse="false" data-qtip="' + t('form_builder_type_field_base.translatable_field') + '"',
758+
},
759+
{
760+
xtype: 'textfield',
761+
name: 'doubleOptIn.confirmationMessage',
762+
fieldLabel: t('form_builder_form.double_opt_in.confirmation_message'),
763+
value: this.formConfig.doubleOptIn ? this.formConfig.doubleOptIn.confirmationMessage : null,
764+
allowBlank: true,
765+
width: '100%',
766+
inputAttrTpl: ' data-qwidth="250" data-qalign="br-r?" data-qtrackMouse="false" data-qtip="' + t('form_builder_type_field_base.translatable_field') + '"',
767+
},
768+
doubleOptInLocalizedField.getField()
769+
]
770+
}
771+
]
772+
});
773+
}
774+
678775
this.metaDataPanel = keyValueRepeater.getRepeater();
679776

680777
// add conditional logic field
@@ -792,11 +889,10 @@ Formbuilder.extjs.formPanel.config = Class.create({
792889
checked: this.formConfig.useAjax === undefined,
793890
value: this.formConfig.useAjax
794891
},
795-
796892
this.metaDataPanel,
797893
this.clBuilder,
894+
this.doubleOptInPanel ? this.doubleOptInPanel : null,
798895
this.exportPanel
799-
800896
]
801897
});
802898

src/Assembler/FormAssembler.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use FormBuilderBundle\Event\FormAssembleEvent;
77
use FormBuilderBundle\Form\RuntimeData\FormRuntimeDataAllocatorInterface;
88
use FormBuilderBundle\FormBuilderEvents;
9+
use FormBuilderBundle\Manager\DoubleOptInManager;
910
use FormBuilderBundle\Resolver\FormOptionsResolver;
1011
use FormBuilderBundle\Manager\FormDefinitionManager;
1112
use FormBuilderBundle\Model\FormDefinitionInterface;
@@ -18,6 +19,7 @@ public function __construct(
1819
protected EventDispatcherInterface $eventDispatcher,
1920
protected FrontendFormBuilder $frontendFormBuilder,
2021
protected FormDefinitionManager $formDefinitionManager,
22+
protected DoubleOptInManager $doubleOptInManager,
2123
protected FormRuntimeDataAllocatorInterface $formRuntimeDataAllocator
2224
) {
2325
}
@@ -121,9 +123,13 @@ public function buildForm(
121123
$formAttributes = $optionsResolver->getFormAttributes();
122124
$useCsrfProtection = $optionsResolver->useCsrfProtection();
123125

124-
$formRuntimeDataCollector = $this->formRuntimeDataAllocator->allocate($formDefinition, $systemRuntimeData);
126+
$formRuntimeDataCollector = $this->formRuntimeDataAllocator->allocate($formDefinition, $systemRuntimeData, $headless);
125127
$formRuntimeData = $formRuntimeDataCollector->getData();
126128

129+
if ($this->doubleOptInManager->requiresDoubleOptInForm($formDefinition, $formRuntimeData)) {
130+
return $this->frontendFormBuilder->buildDoubleOptInForm($formDefinition, $formAttributes, $headless, $useCsrfProtection);
131+
}
132+
127133
if ($headless === true) {
128134
return $this->frontendFormBuilder->buildHeadlessForm($formDefinition, $formRuntimeData, $formAttributes, $formData, $useCsrfProtection);
129135
}

0 commit comments

Comments
 (0)