Skip to content

[policies] Add infrastructure to track user decisions to policies #9818

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
32 changes: 32 additions & 0 deletions SQL/0000-00-00-schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -1426,6 +1426,38 @@ CREATE TABLE `user_account_history` (
PRIMARY KEY (`ID`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;


-- ********************************
-- tables for policies
-- ********************************

CREATE TABLE policies (
PolicyID INT AUTO_INCREMENT PRIMARY KEY,
Name VARCHAR(255) NOT NULL,
Version VARCHAR(50) NOT NULL,
ModuleID INT NOT NULL,
PolicyRenewalTime INT DEFAULT 7,
PolicyRenewalTimeUnit enum('D','Y','M','H') DEFAULT 'D',
Content TEXT NULL,
SwalTitle VARCHAR(255) DEFAULT 'Terms Of Use',
HeaderButton enum('Y','N') DEFAULT 'Y',
HeaderButtonText VARCHAR(255) DEFAULT 'Terms Of Use',
Active enum('Y','N') DEFAULT 'Y',
AcceptButtonText VARCHAR(255) DEFAULT '',
DeclineButtonText VARCHAR(255) DEFAULT '',
CreatedAt DATETIME DEFAULT CURRENT_TIMESTAMP,
UpdatedAt DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

CREATE TABLE user_policy_decision (
ID INT AUTO_INCREMENT PRIMARY KEY,
UserID INT NOT NULL,
PolicyID INT NOT NULL,
Decision enum('Accepted','Declined') NOT NULL,
DecisionDate DATETIME DEFAULT CURRENT_TIMESTAMP
);


-- ********************************
-- user_login_history tables
-- ********************************
Expand Down
2 changes: 2 additions & 0 deletions SQL/9999-99-99-drop_tables.sql
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ DROP TABLE IF EXISTS `server_processes`;
DROP TABLE IF EXISTS `StatisticsTabs`;
DROP TABLE IF EXISTS `user_login_history`;
DROP TABLE IF EXISTS `user_account_history`;
DROP TABLE IF EXISTS `policies`;
DROP TABLE IF EXISTS `user_policy_decision`;
DROP TABLE IF EXISTS `data_integrity_flag`;
DROP TABLE IF EXISTS `certification_training_quiz_answers`;
DROP TABLE IF EXISTS `certification_training_quiz_questions`;
Expand Down
27 changes: 27 additions & 0 deletions SQL/New_patches/2025-05-22-Introduce-Policy-Decisions.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
CREATE TABLE policies (
PolicyID INT AUTO_INCREMENT PRIMARY KEY,
Name VARCHAR(255) NOT NULL,
Version VARCHAR(50) NOT NULL,
ModuleID INT NOT NULL, -- Show in the header for a module
PolicyRenewalTime INT DEFAULT 7, -- Number of days before the policy is renewed
PolicyRenewalTimeUnit enum('D','Y','M','H') DEFAULT 'D', -- Unit of the renewal time
Content TEXT NULL,
SwalTitle VARCHAR(255) DEFAULT 'Terms Of Use',
HeaderButton enum('Y','N') DEFAULT 'Y',
HeaderButtonText VARCHAR(255) DEFAULT 'Terms Of Use',
Active enum('Y','N') DEFAULT 'Y',
AcceptButton enum('Y','N') DEFAULT 'Y',
AcceptButtonText VARCHAR(255) DEFAULT 'Accept',
DeclineButton enum('Y','N') DEFAULT 'Y',
DeclineButtonText VARCHAR(255) DEFAULT 'Decline',
CreatedAt DATETIME DEFAULT CURRENT_TIMESTAMP,
UpdatedAt DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

CREATE TABLE user_policy_decision (
ID INT AUTO_INCREMENT PRIMARY KEY,
UserID INT NOT NULL,
PolicyID INT NOT NULL,
Decision enum('Accepted','Declined') NOT NULL,
DecisionDate DATETIME DEFAULT CURRENT_TIMESTAMP
);
108 changes: 108 additions & 0 deletions jsx/PolicyButton.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* This file contains React component for the Policy Button.
*
* @author Saagar Arya
* @version 2.0.0
*/

import React from 'react';
import PropTypes from 'prop-types';
import Swal from 'sweetalert2';

/**
* PolicyButton Component
*
* @param {object} props - The component props.
* @param {object} props.onClickPolicy - The policy object containing title
* and content that should appear when the button is pressed.
* @param {object} [props.buttonStyle] - Optional style object for the button.
* @param {object} [props.popUpPolicy] - Optional policy object for pop-up
* policy that needs renewal.
* @param {string} [props.buttonText] - Optional text for the button.
* @param {boolean} [props.anon] - Optional flag to indicate if the user is anonymous.
* @param {function} [props.callback] - Optional callback function to execute after the policy decision.
*/
const PolicyButton = ({
onClickPolicy,
popUpPolicy,
buttonStyle,
buttonText,
anon=false,
callback=() => {},
}) => {
if (popUpPolicy && popUpPolicy.needsRenewal) {
fireSwal(popUpPolicy);
}
if (onClickPolicy) {
return <a
className="hidden-xs hidden-sm"
id="onClickPolicyButton"
style={{...buttonStyle}}
href="#"
onClick={() => {
fireSwal(onClickPolicy, anon, callback);
}}
>
{buttonText || onClickPolicy.HeaderButtonText}
</a>;
}
};

const fireSwal = (policy, anon, callback) => {
Swal.fire({
title: policy.SwalTitle,
html: policy.Content,
confirmButtonText: policy.AcceptButtonText,
cancelButtonText: policy.DeclineButtonText,
showCancelButton: policy.DeclineButtonText,
allowOutsideClick: false,
}).then((decision) => {
if (callback) {
callback(decision);
}
if (!anon) {
fetch(
loris.BaseURL +
'/policy_tracker/policies',
{
method: 'POST',
credentials: 'same-origin',
body: JSON.stringify({
...policy,
decision: decision.value == true ? 'Accepted' : 'Declined',
}),
headers: {
'Content-Type': 'application/json',
},
});
if (decision.value != true) {
window.location.href = loris.BaseURL;
}
}
});
};

PolicyButton.propTypes = {
onClickPolicy: PropTypes.shape({
SwalTitle: PropTypes.string.isRequired,
Content: PropTypes.string.isRequired,
AcceptButtonText: PropTypes.string.isRequired,
DeclineButtonText: PropTypes.string.isRequired,
}).isRequired,
onClickPolicy: PropTypes.object.isRequired,
popUpPolicy: PropTypes.shape({
needsRenewal: PropTypes.bool,
SwalTitle: PropTypes.string,
Content: PropTypes.string,
AcceptButtonText: PropTypes.string,
DeclineButtonText: PropTypes.string,
}),
buttonStyle: PropTypes.object,
buttonText: PropTypes.string,
anon: PropTypes.bool,
callback: PropTypes.func,
};

window.PolicyButton = PolicyButton;

export {PolicyButton, fireSwal};
13 changes: 13 additions & 0 deletions modules/login/jsx/loginIndex.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
ButtonElement,
} from 'jsx/Form';
import SummaryStatistics from './summaryStatistics';
import {PolicyButton} from 'jsx/PolicyButton';

/**
* Login form.
Expand Down Expand Up @@ -210,6 +211,16 @@ class Login extends Component {
class={'col-xs-12 col-sm-12 col-md-12 text-danger'}
/>
) : null;
const policy = this.state.component.requestAccount.policy;
const policyButton = policy ?
<PolicyButton
onClickPolicy={
this.state.component.requestAccount.policy
}
style={{marginTop: '10px'}}
anon={true}
/>
: null;
const oidc = this.state.oidc ? this.getOIDCLinks() : '';
const login = (
<div>
Expand Down Expand Up @@ -257,6 +268,8 @@ class Login extends Component {
<br/>
<a onClick={() => this.setMode('request')}
style={{cursor: 'pointer'}}>Request Account</a>
<br />
{policyButton}
</div>
{oidc}
<div className={'help-text'}>
Expand Down
32 changes: 31 additions & 1 deletion modules/login/jsx/requestAccount.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
CheckboxElement,
ButtonElement,
} from 'jsx/Form';
import PolicyButton from 'jsx/PolicyButton';

/**
* Request account form.
Expand Down Expand Up @@ -45,8 +46,10 @@ class RequestAccount extends Component {
? this.props.data.captcha
: '',
error: '',
viewedPolicy: false,
},
request: false,
policy: this.props.data.policy || null,
};
this.setForm = this.setForm.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
Expand Down Expand Up @@ -75,7 +78,16 @@ class RequestAccount extends Component {
*/
handleSubmit(e) {
e.preventDefault();

if (this.props.data.policy && !this.state.form.viewedPolicy) {
let title = this.props.data.policy.SwalTitle;
swal.fire({
title: title + ' not accepted',
text: 'You must accept the ' + title + ' before requesting an account.',
icon: 'error',
});
e.stopPropagation();
return;
}
const state = JSON.parse(JSON.stringify(this.state));
fetch(
window.location.origin + '/login/Signup', {
Expand Down Expand Up @@ -156,6 +168,23 @@ class RequestAccount extends Component {
</span>
</div>
) : null;
const policy = this.state.policy ? (
<PolicyButton
onClickPolicy={this.state.policy}
popUpPolicy={this.state.policy}
buttonStyle={{marginTop: '10px'}}
buttonText={'View ' + this.state.policy.HeaderButtonText}
anon={true}
callback={() => {
this.setState({
form: {
...this.state.form,
viewedPolicy: true,
},
});
}}
/>
) : null;
const request = !this.state.request ? (
<div>
<FormElement
Expand Down Expand Up @@ -231,6 +260,7 @@ class RequestAccount extends Component {
onUserInput={this.setForm}
offset={'col-sm-offset-2'}
/>
{policy}
{captcha}
<ButtonElement
label={'Request Account'}
Expand Down
4 changes: 3 additions & 1 deletion modules/login/php/authentication.class.inc
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,9 @@ class Authentication extends \NDB_Page implements ETagCalculator
}
$requestAccountData['site'] = \Utility::getSiteList();
$requestAccountData['project'] = \Utility::getProjectList();
$values['requestAccount'] = $requestAccountData;
$loris = $request->getAttribute("loris");
$requestAccountData['policy'] = $this->getHeaderPolicy($loris);
$values['requestAccount'] = $requestAccountData;

if ($this->loris->hasModule('oidc')) {
$DB = $this->loris->getDatabaseConnection();
Expand Down
1 change: 1 addition & 0 deletions modules/login/test/Request_Account_test_plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
3. Verify that verification code is enforced (if and only if re-captcha has been activated by the project)
4. Verify that clicking "Submit" button with valid form data will load page acknowledging receipt (Thank you page)
5. Verify "Return to Loris login page" link on Thank you page works
6. Verify that if a policy is added to the project for module `login`, the policy is displayed on the Request Account form page, and it is required that the policy has been viewed to request an account.

### Approving new User Account Request:
6. Log in as another user who has permission: `user_accounts` (User Management) and does not have permission: `user_accounts_multisite` (Across all sites create and edit users). Verify that new account request notification is counted in Dashboard (count has incremented).
Expand Down
6 changes: 6 additions & 0 deletions modules/policy_tracker/help/policy_tracker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Policy Tracker

This module provides an endpoint for other pages to manage policy acceptance.

### Future work:
A Filterable Data Table to display the policies that have been accepted by users.
77 changes: 77 additions & 0 deletions modules/policy_tracker/php/module.class.inc
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php declare(strict_types=1);

/**
* This serves as a hint to LORIS that this module is a real module.
* It handles the basic routing for the module.
*
* PHP Version 8
*
* @category API
* @package Main
* @subpackage Login
* @author Saagar Arya <saagar.arya@mcin.ca>
* @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3
* @link https://www.github.com/aces/Loris/
*/
namespace LORIS\policy_tracker;
use \Psr\Http\Message\ServerRequestInterface;
use \Psr\Http\Message\ResponseInterface;

/**
* Class module implements the basic LORIS module functionality
*
* @category Core
* @package Main
* @subpackage Login
* @author Saagar Arya <saagar.arya@mcin.ca>
* @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3
* @link https://www.github.com/aces/Loris/
*/
class Module extends \Module
{
/**
* Implements the PSR15 RequestHandler interface for this module. The API
* module does some preliminary verification of the request, converts the
* version from the URL to a request attribute, and then falls back on the
* default LORIS page handler.
*
* @param ServerRequestInterface $request The incoming PSR7 request
*
* @return ResponseInterface The outgoing PSR7 response
*/
public function handle(ServerRequestInterface $request) : ResponseInterface
{
$body = json_decode($request->getBody()->getContents(), true);
switch ($request->getMethod()) {
case 'POST':
$module = $this->loris->getModule($body['ModuleName'] ?? '');
$page = new \NDB_Page(
$this->loris,
$module,
'',
'',
'',
);
$page->saveUserPolicyDecision(
$this->loris,
$body['PolicyName'] ?? '',
$body['decision'] ?? ''
);
return new \LORIS\Http\Response\JSON\OK(
['message' => 'Policy decision saved successfully']
);
default:
return new \LORIS\Http\Response\JSON\MethodNotAllowed(['POST']);
}
}

/**
* {@inheritDoc}
*
* @return string The human readable name for this module
*/
public function getLongName() : string
{
return dgettext("policy_tracker", "Policy Tracker");
}
}
Loading
Loading