Skip to content

Commit

Permalink
Double-Opt-In Feature (#470)
Browse files Browse the repository at this point in the history
  • Loading branch information
solverat authored Aug 22, 2024
1 parent 0fd8cd6 commit 7fbad6a
Show file tree
Hide file tree
Showing 50 changed files with 1,354 additions and 125 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ Nothing to tell here, it's just [Symfony](https://symfony.com/doc/current/templa
## Further Information
- [Usage (Rendering Types, Configuration)](docs/0_Usage.md)
- [Headless Mode](docs/1_HeadlessMode.md)
- [SPAM Protection (Honeypot, reCAPTCHA)](docs/03_SpamProtection.md)
- [SPAM Protection](docs/03_SpamProtection.md)
- [Double-Opt-In Feature](docs/03_SpamProtection.md)
- [Output Workflows](docs/OutputWorkflow/0_Usage.md)
- [API Channel](docs/OutputWorkflow/09_ApiChannel.md)
- [Email Channel](docs/OutputWorkflow/10_EmailChannel.md)
Expand Down
2 changes: 2 additions & 0 deletions UPGRADE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Upgrade Notes

## 5.1.0
- **[SECURITY FEATURE]** Double-Opt-In Feature, read more about it [here](/docs/.md)
- If you're using a custom form theme, please include the `instructions` type (`{% use '@FormBuilder/form/theme/type/instructions.html.twig' %}`)
- **[SECURITY FEATURE]** Add [friendly captcha field](/docs/03_SpamProtection.md#friendly-captcha)
- **[SECURITY FEATURE]** Add [cloudflare turnstile](/docs/03_SpamProtection.md#cloudflare-turnstile)
- **[BUGFIX]** Use Pimcore AdminUserTranslator for Editable Dialog Box [#450](https://github.com/dachcom-digital/pimcore-formbuilder/issues/450)
Expand Down
49 changes: 49 additions & 0 deletions config/doctrine/model/DoubleOptInSession.orm.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
FormBuilderBundle\Model\DoubleOptInSession:
type: entity
table: formbuilder_double_opt_in_session
indexes:
token_form:
columns: [ token, form_definition, applied ]
id:
token:
unique: true
column: token
type: uuid
generator:
strategy: CUSTOM
customIdGenerator:
class: Symfony\Bridge\Doctrine\IdGenerator\UuidGenerator
fields:
email:
column: email
type: string
nullable: false
length: 190
additionalData:
column: additional_data
type: array
nullable: true
dispatchLocation:
column: dispatch_location
type: text
nullable: true
applied:
column: applied
type: boolean
options:
default: 0
creationDate:
column: creationDate
type: datetime
nullable: false
manyToOne:
formDefinition:
targetEntity: FormBuilderBundle\Model\FormDefinition
orphanRemoval: true
joinColumn:
name: form_definition
referencedColumnName: id
onDelete: CASCADE
uniqueConstraints:
email_form_definition:
columns: [email, form_definition, applied]
4 changes: 0 additions & 4 deletions config/doctrine/model/FormDefinition.orm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,6 @@ FormBuilderBundle\Model\FormDefinition:
modifiedBy:
column: modifiedBy
type: integer
mailLayout:
column: mailLayout
type: object
nullable: true
configuration:
column: configuration
type: object
Expand Down
19 changes: 17 additions & 2 deletions config/install/sql/install.sql
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ CREATE TABLE IF NOT EXISTS `formbuilder_forms` (
`modificationDate` datetime NOT NULL,
`createdBy` int(11) NOT NULL,
`modifiedBy` int(11) NOT NULL,
`mailLayout` longtext COMMENT '(DC2Type:object)',
`configuration` longtext COMMENT '(DC2Type:object)',
`conditionalLogic` longtext COMMENT '(DC2Type:object)',
`fields` longtext COMMENT '(DC2Type:form_builder_fields)',
Expand Down Expand Up @@ -37,4 +36,20 @@ CREATE TABLE IF NOT EXISTS `formbuilder_output_workflow_channel` (
UNIQUE KEY `ow_name` (`output_workflow`,`name`),
KEY `IDX_CEC462362C75DDDC` (`output_workflow`),
CONSTRAINT `FK_CEC462362C75DDDC` FOREIGN KEY (`output_workflow`) REFERENCES `formbuilder_output_workflow` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC;

CREATE TABLE IF NOT EXISTS `formbuilder_double_opt_in_session` (
`token` binary(16) NOT NULL COMMENT '(DC2Type:uuid)' PRIMARY KEY,
`form_definition` int NULL,
`email` varchar(190) NOT NULL,
`additional_data` longtext NULL COMMENT '(DC2Type:array)',
`dispatch_location` longtext NULL,
`applied` tinyint(1) DEFAULT 0 NOT null,
`creationDate` datetime NOT NULL,
CONSTRAINT email_form_definition UNIQUE (email, form_definition, applied),
CONSTRAINT FK_88815C4F61F7634C FOREIGN KEY (form_definition) REFERENCES formbuilder_forms (id) ON DELETE CASCADE
);

create index IDX_88815C4F61F7634C on formbuilder_double_opt_in_session (form_definition);
create index token_form on formbuilder_double_opt_in_session (token, form_definition, applied);

3 changes: 2 additions & 1 deletion config/install/translations/frontend.csv
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,5 @@
"form_builder.dynamic_multi_file.remove","Remove file","Datei entfernen"
"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."
"form_builder.form.container.repeater.min","%label%: You need to add at least %items% items.","%label%: Es werden mindestens %items% Einträge benötigt."
"form_builder.form.container.repeater.max","%label%: Only %items% item(s) allowed.","%label%: Maximal %items% Einträge erlaubt."
"form_builder.form.container.repeater.max","%label%: Only %items% item(s) allowed.","%label%: Maximal %items% Einträge erlaubt."
"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."
6 changes: 6 additions & 0 deletions config/services/double_opt_in.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
services:

_defaults:
autowire: true
autoconfigure: true
public: true
13 changes: 13 additions & 0 deletions config/services/forms/forms.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ services:
tags:
- { name: form.type }

FormBuilderBundle\Form\Type\InstructionsType:
public: false
tags:
- { name: form.type }

FormBuilderBundle\Form\Type\SnippetType:
autowire: true
public: false
Expand Down Expand Up @@ -96,3 +101,11 @@ services:
tags:
- { name: form.type }


#
# Double-Opt-In

FormBuilderBundle\Form\Type\DoubleOptIn\DoubleOptInType:
public: false
tags:
- { name: form.type }
5 changes: 4 additions & 1 deletion config/services/manager.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,7 @@ services:
FormBuilderBundle\Manager\PresetManager: ~

# manager: template (form themes, type templates)
FormBuilderBundle\Manager\TemplateManager: ~
FormBuilderBundle\Manager\TemplateManager: ~

# manager: double-opt-in
FormBuilderBundle\Manager\DoubleOptInManager: ~
5 changes: 4 additions & 1 deletion config/services/repository.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,7 @@ services:
FormBuilderBundle\Repository\FormDefinitionRepository: ~

FormBuilderBundle\Repository\OutputWorkflowRepositoryInterface: '@FormBuilderBundle\Repository\OutputWorkflowRepository'
FormBuilderBundle\Repository\OutputWorkflowRepository: ~
FormBuilderBundle\Repository\OutputWorkflowRepository: ~

FormBuilderBundle\Repository\DoubleOptInSessionRepositoryInterface: '@FormBuilderBundle\Repository\DoubleOptInSessionRepository'
FormBuilderBundle\Repository\DoubleOptInSessionRepository: ~
4 changes: 4 additions & 0 deletions config/services/runtime_data.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,7 @@ services:

FormBuilderBundle\Form\RuntimeData\FormRuntimeDataAllocator: ~
FormBuilderBundle\Form\RuntimeData\FormRuntimeDataAllocatorInterface: '@FormBuilderBundle\Form\RuntimeData\FormRuntimeDataAllocator'

FormBuilderBundle\Form\RuntimeData\Provider\DoubleOptInSessionDataProvider:
tags:
- { name: form_builder.runtime_data_provider }
3 changes: 3 additions & 0 deletions docs/03_SpamProtection.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Spam Protection

## Double-Opt-In
Read more about the double-opt-in feature [here](./04_DoubleOptIn.md).

## HoneyPot
The Honeypot Field is enabled by default. You can disable it via [configuration flags](100_ConfigurationFlags.md).

Expand Down
38 changes: 38 additions & 0 deletions docs/04_DoubleOptIn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Double-Opt-In
![image](https://github.com/user-attachments/assets/aa4f1f24-607c-4ed3-aa72-2d9d91fddf12)

When enabled, a user must confirm its email identity via confirmation before the real form shows up.

This feature is disabled by default.

```yaml
form_builder:

double_opt_in:

# enable the feature
enabled: true

# redeem_mode:
# choose between "delete" or "devalue"
# - "delete" (default): The double-opt-in session token gets deleted, after the form submission was successful
# - "devalue": The double-opt-in session token only gets redeemed but not deleted, after the form submission was successful.
redeem_mode: 'delete'

expiration:
# delete open sessions after 24 hours (default). If you set it to 0, no sessions will be deleted ever.
open_sessions: 24
# delete redeemed session after x hours (default 0, which means: disabled)
redeemed_sessions: 0
```
## Extending Double-Opt-In Form
By default, the `DoubleOptInType` form type only contains a `emailAddress` field to keep users effort small.
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).

Additional Info:
- `emailAddress` is required and you're not allowed to remove it
- Additional fields will be stored as array in the DoubleOptInSession in `additionalData`

## Trash-Mail Protection
TBD
114 changes: 105 additions & 9 deletions public/js/extjs/_form/tab/configPanel.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ Formbuilder.extjs.formPanel.config = Class.create({
this.formConditionalsStructured = formData.conditional_logic;
this.formConditionalsStore = formData.conditional_logic_store;
this.formFields = formData.fields;
this.doubleOptIn = formData.double_opt_in;
this.availableFormFields = formData.fields_structure;
this.availableContainerTypes = formData.container_types;
this.availableConstraints = formData.validation_constraints;
Expand Down Expand Up @@ -500,10 +501,12 @@ Formbuilder.extjs.formPanel.config = Class.create({
return el.submitValue === undefined || el.submitValue === true;
});

for (var i = 0; i < items.length; i++) {
if (typeof items[i].getValue === 'function') {
var val = items[i].getValue(),
fieldName = items[i].name;
Ext.Array.each(items, function (item, index) {
if (typeof item.getValue === 'function') {

var val = item.getValue(),
fieldName = item.name;

if (fieldName) {

if (fieldName === 'name') {
Expand All @@ -519,6 +522,13 @@ Formbuilder.extjs.formPanel.config = Class.create({
}
}
}
}.bind(this));

// parse form config
this.formConfig = DataObjectParser.transpose(this.formConfig).data();

if (this.formConfig.doubleOptIn && this.formConfig.doubleOptIn.enabled === false) {
this.formConfig.doubleOptIn = {enabled: false}
}

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

getRootPanel: function () {

var methodStore = new Ext.data.ArrayStore({
var doubleOptInLocalizedField,
methodStore = new Ext.data.ArrayStore({
fields: ['value', 'label'],
data: [['post', 'POST'], ['get', 'GET']]
}),
Expand All @@ -658,8 +669,8 @@ Formbuilder.extjs.formPanel.config = Class.create({
listeners: {
load: function (store) {
store.insert(0, {
id : 'all',
name : t('form_builder_email_csv_export_mail_type_all')
id: 'all',
name: t('form_builder_email_csv_export_mail_type_all')
});
}
}
Expand All @@ -675,6 +686,92 @@ Formbuilder.extjs.formPanel.config = Class.create({
),
clBuilder = new Formbuilder.extjs.conditionalLogic.builder(this.formConditionalsStructured, this.formConditionalsStore, this);

if (this.doubleOptIn.enabled === true) {

doubleOptInLocalizedField = new Formbuilder.extjs.types.localizedField(function (locale) {

var hrefField = new Formbuilder.extjs.types.href({
label: t('form_builder_form.double_opt_in.mail_template'),
id: 'doubleOptIn.mailTemplate.' + locale,
config: {
types: ['document'],
subtypes: {document: ['email']}
}
},
this.formConfig.doubleOptIn && this.formConfig.doubleOptIn.mailTemplate && this.formConfig.doubleOptIn.mailTemplate[locale]
? this.formConfig.doubleOptIn.mailTemplate[locale]
: null,
null
);

return hrefField.getHref();

}.bind(this), true);

this.doubleOptInPanel = new Ext.form.FieldSet({
title: t('form_builder_form.double_opt_in'),
collapsible: false,
autoHeight: true,
width: '100%',
style: 'margin-top: 20px;',
submitValue: false,
defaults: {
labelWidth: 160
},
items: [
{
xtype: 'checkbox',
name: 'doubleOptIn.enabled',
fieldLabel: t('form_builder_form.double_opt_in.enable'),
inputValue: true,
uncheckedValue: false,
value: this.formConfig.doubleOptIn ? this.formConfig.doubleOptIn.enabled : false,
listeners: {
change: function (cb, value) {

var containerField = cb.nextSibling();

containerField.setHidden(!value);
containerField.query('textfield[name="doubleOptIn.confirmationMessage"]')[0].allowBlank = !value

}.bind(this)
}
},
{
xtype: 'container',
hidden: !this.formConfig.doubleOptIn || this.formConfig.doubleOptIn.enabled === false,
items: [
{
fieldLabel: false,
xtype: 'displayfield',
style: 'display:block !important; margin-bottom:15px !important; font-weight: 300;',
value: t('form_builder_form.double_opt_in.description')
},
{
xtype: 'textfield',
name: 'doubleOptIn.instructionNote',
fieldLabel: t('form_builder_form.double_opt_in.double_opt_in_instruction_note'),
value: this.formConfig.doubleOptIn ? this.formConfig.doubleOptIn.instructionNote : null,
allowBlank: true,
width: '100%',
inputAttrTpl: ' data-qwidth="250" data-qalign="br-r?" data-qtrackMouse="false" data-qtip="' + t('form_builder_type_field_base.translatable_field') + '"',
},
{
xtype: 'textfield',
name: 'doubleOptIn.confirmationMessage',
fieldLabel: t('form_builder_form.double_opt_in.confirmation_message'),
value: this.formConfig.doubleOptIn ? this.formConfig.doubleOptIn.confirmationMessage : null,
allowBlank: true,
width: '100%',
inputAttrTpl: ' data-qwidth="250" data-qalign="br-r?" data-qtrackMouse="false" data-qtip="' + t('form_builder_type_field_base.translatable_field') + '"',
},
doubleOptInLocalizedField.getField()
]
}
]
});
}

this.metaDataPanel = keyValueRepeater.getRepeater();

// add conditional logic field
Expand Down Expand Up @@ -792,11 +889,10 @@ Formbuilder.extjs.formPanel.config = Class.create({
checked: this.formConfig.useAjax === undefined,
value: this.formConfig.useAjax
},

this.metaDataPanel,
this.clBuilder,
this.doubleOptInPanel ? this.doubleOptInPanel : null,
this.exportPanel

]
});

Expand Down
Loading

0 comments on commit 7fbad6a

Please sign in to comment.