Skip to content

Commit

Permalink
Feat/add optional policies (#51)
Browse files Browse the repository at this point in the history
* feat: added 'optional' field to fof-terms-policies table
feat: added 'is_accepted' field to fof-terms-policy-user table
feat: added settings to opt out of consents in user profile
feat: added functionality that allows users to decline policies that were declared optional

* feat: accept policies really accepted by user instead of all

* feat: added 'additionalInfo' model attribute
feat: added 'ExtensionData' component

* feat: changes in policy consents in profile settings now save automatically
feat: changed 'ExtensionData' component, so it allows other extensions to sync with fof/terms
fix: synced new model field name to its DB column

* refactor: minor changes in 'ExtensionData' and 'PolicyEdit'

feat: started writing guide on how to integrate fof/terms with other extensions in README.md

* refactor: simplified addManagePoliciesOption code
feat: added translations
feat: improved ExtensionData component
feat: added recipe for integrating fof/terms with other extensions in README
fix: additionalInfo is now properly saved into DB
chore: removed redundant imports and comments

* Apply fixes from StyleCI

* refactor: changed additionalInfo column name into additional_info

* fix: fixed bug that caused inappropriate behaviour when deleting policies.

* style: formatted README.md

* fix: optional policies that were not accepted during registration are now saved properly.

* chore: added phpstan ignore to User::$fofTermsPoliciesState.

* Update resources/locale/en.yml

Co-authored-by: Davide Iadeluca <[email protected]>

* Update resources/locale/en.yml

Co-authored-by: Davide Iadeluca <[email protected]>

* chore: minor changes in addManagePoliciesOption.js

* chore: renamed migration files

* chore: revert file permissions

* chore: reverted file permissions
chore: removed accidently added files

* chore: fixed migration files' names

* chore: reset js/dist folder changes

* Delete js/dist/forum.js.map

* Delete js/dist/admin.js

* Delete js/dist/forum.js

* Delete js/dist/admin.js.map

* chore: sync fork and reset dist and package.json files

* style: reformat addManagePoliciesOption.js

* remove var_dump

* style: one array access method

* Apply fixes from StyleCI

* style: use link component

* fix: replace JSON column with longText for compatibility

* refactor: simplify migrations

* Apply fixes from StyleCI

* fix: mark existing policies as accepted for all users, then set the default value to false for new users

* Apply fixes from StyleCI

* chore: resolve merge conflict

---------

Co-authored-by: Rafał Całka <[email protected]>
Co-authored-by: rafaucau <[email protected]>
Co-authored-by: Davide Iadeluca <[email protected]>
Co-authored-by: flarum-bot <[email protected]>
  • Loading branch information
5 people authored Sep 29, 2024
1 parent 156bea9 commit aa2f624
Show file tree
Hide file tree
Showing 22 changed files with 523 additions and 41 deletions.
152 changes: 152 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,158 @@ You can customize who can skip the modal without accepting the new terms immedia

Admins can see the date at which a user accepted the terms by going to their profile page and selecting the terms button in the dropdown menu. You can customize who can see those dates in the permissions.

## For developers

You can easily add a custom field in PolicyEdit component to integrate fof/terms with other extensions. In
`fof-terms-policies`, there is a column, `additionalInfo` dedicated to save your custom data into `fof/terms` database.
Data is stored inside one, global JSON object, allowing multiple extensions to save their data.

```
additionalInfo JSON object example
{
"fof/extension-1": "extension1 data",
"fof/extension-2": true,
"fof/extension-3": {"extension-3-boolval": false, "extension-3-stringval": "extension-3 data"}
}
```

You can save any value, as long as it is a primitive or a valid JSON object.

To add your field to `additionalInfo`, you must follow these steps:
1) Choose a custom **key** value, it is recommended to select Your extension's name to avoid naming conflicts.
2) Prepare a component, which You would want to insert into `PolicyEdit`
3) Extend `PolicyEdit.prototype`'s `fields` method, and add Your component, wrapped inside `ExtensionData` component:
```js
import { extend } from 'flarum/common/extend';
import PolicyEdit from 'fof/terms/components/PolicyEdit';
import ExtensionData from 'fof/terms/components/ExtensionData';

export default function() {
extend(PolicyEdit.prototype, 'fields', function (items) {
const key = 'fof/extension-1';
const priority = 81;

items.add(
key,
<ExtensionData
keyattr={key}
policy={this.policy}
setDirty={() => {
this.dirty = true;
}}
>
{({ keyattr, policy, updateAttribute }) =>
<YourComponent
keyattr={keyattr}
policy={policy}
updateAttribute={updateAttribute}
/>
}
</ExtensionData>,
priority
)
});
}
```
As shown above, `ExtensionData` component takes three props:
1) `keyattr` - specified key, usually Your extension's name,
2) `policy` - reference to `policy` object,
3) `setDirty` - reference to function that allows saving the policy, if any change is made

Your component should also take three props:
1) `keyattr` - same as in above
2) `policy` - same as above
3) `updateAttribute` - reference to `ExtensionData`'s method that manages saving Your data into database
( it is a bit different than `PolicyEdit`'s updateAttribute method )


Your component could look something like this:
```js
import Component from 'flarum/common/Component';

export default class YourComponent extends Component {
oninit(vnode) {
super.oninit(vnode);
this.keyattr = vnode.attrs.keyattr;
this.policy = vnode.attrs.policy;
this.updateAttribute = vnode.attrs.updateAttribute;
}

view() {
return (
<>
<label>{this.keyattr}</label>
<textarea
class={'FormControl'}
value={this.policy.additionalInfo()[this.keyattr] || ''}
oninput={(val) => {
this.updateAttribute(val.target.value);
}}
/>
</>
);
}
}
```
This example shows a way to save data only as string format: `key: <string>`, however if You want to use data in a more
sophisticated format, there are some rules that should be followed. Let's say You want to save a JSON object instead of
simple string, in a such form:
`{"boolval": <boolean>, "stringval": <string>}`.

Here is an example how to obtain such behaviour:

```js
import Component from 'flarum/common/Component';
import Switch from 'flarum/common/components/Switch';

export default class YourSophisticatedComponent extends Component {
oninit(vnode) {
super.oninit(vnode);
this.keyattr = vnode.attrs.keyattr;
this.policy = vnode.attrs.policy;
this.updateAttribute = vnode.attrs.updateAttribute;
}

view() {
return (
<>
<label>{this.keyattr}</label>
<Switch
state={this.policy.additionalInfo()[this.keyattr]?.boolval || false}
onchange={(val) => {
let objectAttributes = this.policy.additionalInfo()[this.keyattr];
if (objectAttributes === undefined) {
objectAttributes = {};
}
objectAttributes['boolval'] = val;
this.updateAttribute(objectAttributes);
}}
>
boolval
</Switch>
<textarea
class={'FormControl'}
value={this.policy.additionalInfo()[this.keyattr]?.stringval || ''}
oninput={(val) => {
let objectAttributes = this.policy.additionalInfo()[this.keyattr];
if (objectAttributes === undefined) {
objectAttributes = {};
}
objectAttributes['stringval'] = val.target.value;
this.updateAttribute(objectAttributes);
}}
/>
</>
);
}
}
```
Note that `oninput` handler is a bit more complicated - in order to save some subvalue, you need to fetch the whole
JSON object, assign its subvalue, and then call `this.updateAttribute` method.
As mentioned above, it is possible to store every value imaginable, as long as it is a primitive, or valid JSON object.
## Data Export
In case you want to export the data (for your GDPR logs for example), a JSON and CSV export is available.
Expand Down
20 changes: 6 additions & 14 deletions extend.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
use Flarum\Api\Serializer\ForumSerializer;
use Flarum\Database\AbstractModel;
use Flarum\Extend;
use Flarum\User\Event\Registered;
use Flarum\User\User;
use FoF\Terms\Middlewares\RegisterMiddleware;
use FoF\Terms\Repositories\PolicyRepository;
Expand All @@ -29,7 +28,8 @@
->js(__DIR__.'/js/dist/admin.js')
->css(__DIR__.'/resources/less/admin.less'),
(new Extend\Frontend('forum'))
->js(__DIR__.'/js/dist/forum.js'),
->js(__DIR__.'/js/dist/forum.js')
->css(__DIR__.'/resources/less/forum.less'),

new Extend\Locales(__DIR__.'/resources/locale'),

Expand All @@ -40,6 +40,7 @@
->patch('/fof/terms/policies/{id:[0-9]+}', 'fof.terms.api.policies.update', Controllers\PolicyUpdateController::class)
->delete('/fof/terms/policies/{id:[0-9]+}', 'fof.terms.api.policies.delete', Controllers\PolicyDeleteController::class)
->post('/fof/terms/policies/{id:[0-9]+}/accept', 'fof.terms.api.policies.accept', Controllers\PolicyAcceptController::class)
->post('/fof/terms/policies/{id:[0-9]+}/decline', 'fof.terms.api.policies.decline', Controllers\PolicyDeclineController::class)
->get('/fof/terms/policies/{id:[0-9]+}/export.{format:json|csv}', 'fof.terms.api.policies.export', Controllers\PolicyExportController::class),

(new Extend\Middleware('forum'))
Expand All @@ -48,25 +49,16 @@
(new Extend\Model(User::class))
->relationship('fofTermsPolicies', function (AbstractModel $user): BelongsToMany {
return $user->belongsToMany(Policy::class, 'fof_terms_policy_user')->withPivot('accepted_at');
})
->relationship('fofTermsPoliciesState', function (AbstractModel $user): BelongsToMany {
return $user->belongsToMany(Policy::class, 'fof_terms_policy_user')->withPivot('is_accepted');
}),

(new Extend\User())
->permissionGroups(function ($actor, $groupIds) {
return PermissionGroupProcessor::process($actor, $groupIds);
}),

(new Extend\Event())
->listen(Registered::class, function (Registered $event) {
/**
* @var PolicyRepository $policies
*/
$policies = resolve(PolicyRepository::class);

// When a user registers, we automatically accept all policies
// We assume the checkboxes validation has been properly done pre-registration by the middleware
$policies->acceptAll($event->user);
}),

(new Extend\Policy())
->modelPolicy(Policy::class, Access\PolicyPolicy::class)
->modelPolicy(User::class, Access\UserPolicy::class),
Expand Down
32 changes: 32 additions & 0 deletions js/src/admin/components/ExtensionData.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Component from 'flarum/common/Component';

export default class ExtensionData extends Component {
oninit(vnode) {
super.oninit(vnode);
this.keyattr = vnode.attrs.keyattr;
this.policy = vnode.attrs.policy;
this.setDirty = vnode.attrs.setDirty;
this.children = vnode.children;

this.updateAttribute = this.updateAttribute.bind(this); // Bind this to updateAttribute
}

view() {
let children =
typeof this.children[0] === 'function'
? this.children[0]({ keyattr: this.keyattr, policy: this.policy, updateAttribute: this.updateAttribute })
: this.children;

return <div class={'Form-group'}>{children}</div>;
}

updateAttribute(value) {
let attributes = this.policy.additional_info();
attributes[this.keyattr] = value;
this.policy.pushAttributes({
['additional_info']: attributes,
});

this.setDirty();
}
}
32 changes: 26 additions & 6 deletions js/src/admin/components/PolicyEdit.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import extractText from 'flarum/common/utils/extractText';
import ItemList from 'flarum/common/utils/ItemList';
import withAttr from 'flarum/common/utils/withAttr';
import Button from 'flarum/common/components/Button';
import Switch from 'flarum/common/components/Switch';

/* global m, dayjs */

Expand All @@ -26,6 +27,8 @@ export default class PolicyEdit {
url: '',
update_message: '',
terms_updated_at: '',
optional: false,
additional_info: {},
},
});
}
Expand Down Expand Up @@ -84,7 +87,7 @@ export default class PolicyEdit {
type: 'submit',
className: 'Button Button--danger',
loading: this.processing,
onclick: this.deletePolicy.bind(this),
onclick: (event) => this.deletePolicy(event),
},
app.translator.trans('fof-terms.admin.buttons.delete-policy')
)
Expand Down Expand Up @@ -166,6 +169,26 @@ export default class PolicyEdit {
85
);

fields.add(
'optional',
<>
<div class={'Form-group'}>
<div class={' fof-terms-optional-checkbox'}>
<label class={'Form-group>label'}>{app.translator.trans('fof-terms.admin.policies.optional')}</label>
<Switch
className={'fof-terms-Switch-off'}
state={this.policy.optional()}
onchange={() => {
this.updateAttribute('optional', !this.policy.optional());
}}
/>
</div>
<div class={'helpText'}>{app.translator.trans('fof-terms.admin.policies.optional-help')}</div>
</div>
</>,
83
);

if (this.policy.exists) {
fields.add(
'export-url',
Expand Down Expand Up @@ -205,7 +228,6 @@ export default class PolicyEdit {
this.policy.pushAttributes({
[attribute]: value,
});

this.dirty = true;
}

Expand All @@ -215,11 +237,8 @@ export default class PolicyEdit {

savePolicy(event) {
event.preventDefault();

this.processing = true;

const createNewRecord = !this.policy.exists;

this.policy
.save(this.policy.data.attributes)
.then(() => {
Expand All @@ -240,7 +259,8 @@ export default class PolicyEdit {
});
}

deletePolicy() {
deletePolicy(event) {
event.preventDefault();
if (
!confirm(
extractText(
Expand Down
2 changes: 2 additions & 0 deletions js/src/admin/components/index.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import PolicyEdit from './PolicyEdit';
import PolicyList from './PolicyList';
import TermsSettingsPage from './TermsSettingsPage';
import ExtensionData from './ExtensionData';

export const components = {
PolicyEdit,
PolicyList,
TermsSettingsPage,
ExtensionData,
};
2 changes: 2 additions & 0 deletions js/src/common/models/Policy.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export default class Policy extends Model {
url = Model.attribute('url');
update_message = Model.attribute('update_message');
terms_updated_at = Model.attribute('terms_updated_at');
optional = Model.attribute('optional');
additional_info = Model.attribute('additional_info');
form_key = computed('id', (id) => 'fof_terms_policy_' + id);

apiEndpoint() {
Expand Down
4 changes: 2 additions & 2 deletions js/src/forum/components/AcceptPoliciesModal.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,15 +88,15 @@ export default class AcceptPoliciesModal extends Modal {
Button.component(
{
className: 'Button Button--primary',
disabled: !this[policy.form_key()],
disabled: !this[policy.form_key()] && !policy.optional(),
onclick: () => {
// We need to save the "must accept" property before performing the request
// Because an updated user serializer will be returned
const hadToAcceptToInteract = app.session.user.fofTermsPoliciesMustAccept();

app
.request({
url: app.forum.attribute('apiUrl') + policy.apiEndpoint() + '/accept',
url: app.forum.attribute('apiUrl') + policy.apiEndpoint() + (this[policy.form_key()] ? '/accept' : '/decline'),
method: 'POST',
errorHandler: this.onerror.bind(this),
})
Expand Down
Loading

0 comments on commit aa2f624

Please sign in to comment.