Commit 14ce6e55 authored by Robert Lyon's avatar Robert Lyon Committed by Gerrit Code Review

Merge changes from topic 'GDPR'

* changes:
  Bug 1734169: Use modal for are you sure question
  Bug 1734169: Suspend user if privacy statement is refused
  Bug 1734174: Add the after login privacy page
parents 4dd27b91 658da452
......@@ -751,6 +751,69 @@ function auth_get_available_auth_types($institution=null) {
function auth_check_required_fields() {
global $USER, $SESSION;
// for the case we are mascarading as the user and we want to return to be admin user
$restoreadmin = param_integer('restore', 0);
$loginanyway = false;
if ($USER->get('parentuser') && param_exists('loginanyway')) {
$USER->loginanyway = true;
}
if ($USER->get('loginanyway')) {
$loginanyway = true;
}
// Privacy statement.
if (get_config('institutionstrictprivacy') && !$USER->has_latest_agreement() && !$restoreadmin && !$loginanyway) {
// Get all institutions of a user.
$userinstitutions = array_keys($USER->get('institutions'));
// Include the 'mahara' institution so that we may show the site privacy statement as well.
array_push($userinstitutions, 'mahara');
// Check if there are new privacies that need to be accepted.
$latestversions = get_latest_privacy_versions($userinstitutions, true);
foreach ($latestversions as $privacy) {
$elements[$privacy->institution . 'text'] = array(
'type' => 'markup',
'value' => '<h2>' . ($privacy->institution == 'mahara' ? get_string('siteprivacystatement', 'admin') : get_string('institutionprivacystatement', 'admin')) . '</h2>' . $privacy->content,
);
$elements[$privacy->institution . 'id'] = array(
'type' => 'hidden',
'value' => $privacy->id,
);
$elements[$privacy->institution] = array(
'type' => 'switchbox',
'title' => get_string('privacyagreement', 'admin'),
'description' => $privacy->agreed ? get_string('privacyagreedto', 'admin', format_date(strtotime($privacy->agreedtime))) : '',
'defaultvalue' => $privacy->agreed ? true : false,
'disabled' => $privacy->agreed ? true : false,
'required' => true,
);
$elements[$privacy->institution . 'switch'] = array(
'type' => 'hidden',
'value' => $privacy->agreed ? 'disabled' : 'enabled',
);
}
$elements['submit'] = array(
'class' => 'btn-primary',
'type' => 'submit',
'value' => get_string('savechanges', 'admin')
);
$form = pieform(array(
'name' => 'agreetoprivacy',
'elements' => $elements,
));
define('TITLE', get_string('privacy', 'admin'));
$smarty = smarty();
setpageicon($smarty, 'icon-umbrella');
if ($USER->get('parentuser')) {
$smarty->assign('loginanyway',
get_string('loginasoverrideprivacyaccept', 'admin',
'<strong><a class="" href="' . get_config('wwwroot') . '?loginanyway">', '</a></strong>'));
}
$smarty->assign('form', $form);
$smarty->display('account/useracceptprivacy.tpl');
exit;
}
if (defined('NOCHECKREQUIREDFIELDS') || $SESSION->get('nocheckrequiredfields') === true) {
return;
}
......@@ -766,8 +829,7 @@ function auth_check_required_fields() {
}
// Check if the user wants to log in anyway
if ($USER->get('passwordchange') && $USER->get('parentuser') && param_exists('loginanyway')) {
$USER->loginanyway = true;
if ($USER->get('passwordchange') && $loginanyway) {
$changepassword = false;
}
......@@ -1137,6 +1199,40 @@ function requiredfields_submit(Pieform $form, $values) {
redirect();
}
function agreetoprivacy_submit(Pieform $form, $values) {
global $USER, $SESSION;
$userinstitutions = array_keys($USER->get('institutions'));
array_push($userinstitutions, 'mahara');
$hasrefused = param_integer('hasrefused', 0);
foreach ($userinstitutions as $institution) {
// check if the institution has a privacy statement
// if not, it depends on the site one and we can skip it
// if yes, check if the user has already accepted it (switch is disabled)
if (!isset($values[$institution]) || $values[$institution . 'switch'] == 'disabled') {
continue;
}
try {
$agreed = (empty($values[$institution]) ? 0 : $values[$institution]);
save_user_reply_to_agreement($USER->get('id'), $values[$institution . 'id'], $agreed);
$SESSION->add_ok_msg(get_string('agreementsaved', 'admin'));
if ($hasrefused) {
suspend_user($USER->get('id'), 'privacyrefusal');
$SESSION->add_ok_msg(get_string('usersuspended', 'admin'));
$USER->logout();
redirect();
}
}
catch (SQLException $e) {
$SESSION->add_ok_msg(get_string('savefailed', 'admin'));
}
}
$USER->renew();
redirect();
}
/**
* Creates and displays the transient login page.
*
......
......@@ -11,6 +11,7 @@
defined('INTERNAL') || die();
define('MAXLOGINTRIES', 5);
require_once(get_config('docroot') . 'lib/user.php');
$put = array();
......@@ -607,6 +608,38 @@ class User {
return ($this->get('logout_time') > 0 ? true : false);
}
/**
* Determines if the user has accepted the latest Privacy statement
*
* @return boolean
*/
public function has_latest_agreement() {
global $USER;
// If users are logged in they cannot be logged out for site upgrade.
// We need to check if table exists otherwise we get error message about usr_agreement table
// not existing.
require_once('ddl.php');
if (!table_exists(new XMLDBTable("usr_agreement"))) {
return true;
}
$userinstitutions = array_keys($USER->get('institutions'));
// Include the 'mahara' institution so that we may show the site privacy statement as well.
array_push($userinstitutions, 'mahara');
// Check if there are new privacies that need to be accepted.
$latestversions = get_latest_privacy_versions($userinstitutions, true);
$hasagreement = true;
foreach ($latestversions as $key => $version) {
// Check if there are privacy statements the user needs to agree
if (!$version->agreed) {
$hasagreement = false;
}
}
return $hasagreement;
}
public function to_stdclass() {
$this->stdclass = new StdClass;
reset($this->defaults);
......
......@@ -139,8 +139,6 @@ function formStartProcessing(form, btn) {
processingStart();
var button = jQuery(btn);
if (button.length) {
button.text(get_string('processing') + ' ...');
// we add a hidden input field so the "disabled" button still gets to
// pass its value through
var node = jQuery('<input type="hidden" />').attr({
......@@ -148,6 +146,12 @@ function formStartProcessing(form, btn) {
'name': button.attr('name')
});
button.after(node);
if (button.prop("tagName").toLowerCase() == 'input') {
button.prop('value', get_string('processing') + ' ...');
}
else {
button.text(get_string('processing') + ' ...');
}
button.prop('disabled', true);
button.blur();
......@@ -174,6 +178,22 @@ function formStopProcessing(form, btn) {
processingStop();
}
// This is to style the in processing button back to it's original state
// Takes a jQuery object
function formAbortProcessing(jbtn) {
processingStop();
var button = jbtn;
var buttonnext = button.next('input');
if (button.length) {
// reset the form button back to it's pre-submitted state
button.prop('disabled', false);
if (buttonnext.attr('name') == button.attr('name')) {
button.prop('value', buttonnext.prop('value'));
buttonnext.remove();
}
}
}
function formError(form, data) {
displayMessage(data.message, 'error', true);
scrollTo(0, 0);
......
......@@ -894,6 +894,7 @@ $string['loginasdenied'] = 'Attempt to log in as another user without permission
$string['loginastwice'] = 'Attempt to log in as another user when already logged in as another user';
$string['loginasrestorenodata'] = 'No user data to restore';
$string['loginasoverridepasswordchange'] = 'As you are masquerading as another user, you may choose to %slog in anyway%s ignoring the password change screen.';
$string['loginasoverrideprivacyaccept'] = 'As you are masquerading as another user, you may choose to %slog in anyway%s ignoring the accept privacy statement screen.';
// Institutions
$string['Add'] = 'Add';
......@@ -1346,3 +1347,12 @@ $string['versionfor'] = 'Privacy statement for version "%s" is as follows:';
$string['institutionprivacystatement'] = 'Institution privacy statement';
$string['userprivacypagedescription'] = 'Displayed are the current privacy statements to which you consented.';
$string['lastupdated'] = 'Last updated on';
$string['newprivacy'] = 'Before entering your account, please read the privacy statement displayed below.';
$string['privacyagreement'] = 'I consent to this privacy statement';
$string['privacyagreementdescription'] = 'By choosing "Yes", you give your consent to the clauses of the privacy statement above.';
$string['privacyagreedto'] = 'You agreed to this privacy statement in %s.';
$string['agreementsaved'] = 'Agreement saved';
$string['refuseprivacy'] = 'Refuse privacy statement';
$string['confirmprivacyrefusal'] = 'Are you really sure you wish to continue?';
$string['privacyrefusaldetails'] = 'If you do not consent to the privacy statement, your account will be suspended.';
$string['privacyrefusal'] = 'Refused to consent to the privacy statement.';
......@@ -866,6 +866,7 @@ $string['youraccounthasbeensuspendedtextcron'] = 'Your account at %s has been su
$string['youraccounthasbeensuspendedreasontext'] = "Your account at %s has been suspended by %s. Reason:\n\n%s";
$string['youraccounthasbeensuspendedreasontextcron'] = "Your account at %s has been suspended. Reason:\n\n%s";
$string['youraccounthasbeenunsuspendedtext2'] = 'Your account at %s has been unsuspended. You may once again log in and use the site.'; // can't provide a login link because we don't know how they log in - it might be by xmlrpc
$string['youraccounthasbeensuspendedtext3'] = 'Your account on %s has been suspended because you refused to consent to the privacy statement';
// size of stuff
$string['sizemb'] = 'MB';
......
......@@ -5646,5 +5646,15 @@ function xmldb_core_upgrade($oldversion=0) {
create_table($table);
}
if ($oldversion < 2018013000) {
log_debug('Auto accept the privacy agreement for all site admins');
$sitecontentid = get_field('site_content_version', 'id', 'type', 'privacy', 'institution', 'mahara');
$admins = get_site_admins();
foreach ($admins as $admin) {
save_user_reply_to_agreement($admin->id, $sitecontentid, 1);
}
}
return $status;
}
......@@ -817,6 +817,9 @@ function core_install_lastcoredata_defaults() {
set_profile_field($user->id, 'email', $user->email);
set_profile_field($user->id, 'firstname', $user->firstname);
set_profile_field($user->id, 'lastname', $user->lastname);
// Accept the user privacy agreement on install
$sitecontentid = get_field('site_content_version', 'id', 'type', 'privacy', 'institution', 'mahara');
save_user_reply_to_agreement($user->id, $sitecontentid, 1);
handle_event('createuser', $user, array('password'));
activity_add_admin_defaults(array($user->id));
db_commit();
......
......@@ -1426,7 +1426,7 @@ function suspend_user($suspendeduserid, $reason, $suspendinguserid=null) {
$suspendrec = new StdClass;
$suspendrec->id = $suspendeduserid;
$suspendrec->suspendedcusr = $suspendinguserid;
$suspendrec->suspendedreason = $reason;
$suspendrec->suspendedreason = $reason == 'privacyrefusal' ? get_string($reason, 'admin') : $reason;
$suspendrec->suspendedctime = db_format_timestamp(time());
update_record('usr', $suspendrec, 'id');
......@@ -1449,6 +1449,10 @@ function suspend_user($suspendeduserid, $reason, $suspendinguserid=null) {
get_config('sitename'), display_name($suspendinguserid, $suspendeduserid));
}
}
else if ($reason == 'privacyrefusal') {
$message->message = get_string_from_language($lang, 'youraccounthasbeensuspendedtext3', 'mahara',
get_config('sitename'));
}
else {
if ($iscron) {
// Suspended by a cron task
......@@ -3220,23 +3224,48 @@ function get_site_admins() {
}
/**
* Returns a list of the latest privacy statements of each institution the current user belongs to.
* Returns a list of the latest privacy statements of each institution the current user belongs to (including mahara).
*
* @param $institutions an array of the institutions to which the current user belongs to.
* @returns array of stdclass objects containing the latest privacy statements the user has agreed to.
* @param $ignoreagreevalue a boolean if true, get all the latest Privacy Statements of the institutions the user belongs to (including mahara)
* if false, get just the latest privacy statements the user has agreed to.
*
* @returns array of stdclass objects containing the latest privacy statements.
*/
function get_latest_privacy_versions($institutions = array()) {
function get_latest_privacy_versions($institutions = array(), $ignoreagreevalue = false) {
global $USER;
// Get the latest Privacy Statements the user has agreed to.
$joinsql = $ignoreagreevalue ? 'LEFT JOIN' : 'JOIN';
$latestversions = get_records_sql_assoc("
SELECT s.id, s.version, s.content, s.ctime, s.institution
SELECT s.id, s.version, s.content, s.ctime, s.institution, u.agreed, u.ctime AS agreedtime,
CASE s.institution WHEN 'mahara' THEN 1 ELSE 2 END as type
FROM {site_content_version} s
INNER JOIN (SELECT MAX(id) as current, institution
FROM {site_content_version}
GROUP BY institution) s2 ON s.institution = s2.institution AND s.id = s2.current
JOIN {usr_agreement} u ON s2.current = u.sitecontentid AND u.usr = ?
WHERE s.institution IN (" . join(',',array_map('db_quote',$institutions)) . ")", array($USER->get('id')));
{$joinsql} {usr_agreement} u ON s2.current = u.sitecontentid AND u.usr = ? AND u.agreed = 1
WHERE s.institution IN (" . join(',',array_map('db_quote',$institutions)) . ")
ORDER BY type", array($USER->get('id')));
return $latestversions;
}
/**
* Saves a user's reply to privacy agreement
*
*/
function save_user_reply_to_agreement($userid, $sitecontentid, $agreed) {
$usragreement = new StdClass;
$usragreement->usr = $userid;
$usragreement->sitecontentid = $sitecontentid;
$usragreement->ctime = db_format_timestamp(time());
$usragreement->agreed = $agreed;
if ($oldrecord = get_field('usr_agreement', 'id', 'sitecontentid', $sitecontentid, 'usr', $userid)) {
update_record('usr_agreement', $usragreement, array('id' => $oldrecord));
}
else {
insert_record('usr_agreement', $usragreement);
}
return true;
}
......@@ -16,7 +16,7 @@ $config = new stdClass();
// See https://wiki.mahara.org/wiki/Developer_Area/Version_Numbering_Policy
// For upgrades on stable branches, increment the version by one. On master, use the date.
$config->version = 2018011000;
$config->version = 2018013000;
$config->series = '18.04';
$config->release = '18.04dev';
$config->minupgradefrom = 2015030409;
......
......@@ -283,6 +283,14 @@ EOD;
require_once('activity.php');
activity_add_admin_defaults(array($user->id));
}
// Use the institution's privacy option if exists
$instprivacy = get_field('site_content_version', 'id', 'type', 'privacy', 'institution', $record['institution']);
$siteprivacy = get_field('site_content_version', 'id', 'type', 'privacy', 'institution', 'mahara');
// Accept the user privacy agreement
$sitecontentid = $instprivacy ? $instprivacy : $siteprivacy;
$agreed = !empty($record['agreement']) ? (bool)$record['agreement'] : 1; // accept by default
save_user_reply_to_agreement($user->id, $sitecontentid, $agreed);
if ($record['institution'] != 'mahara') {
if ($record['role'] == 'admin') {
......
{include file="header.tpl"}
{if $loginanyway}
<p class="lead">
{$loginanyway|safe}
</p>
{/if}
<div class="lead">{str tag="newprivacy" section="admin"}</div>
<div>{$form|safe}</div>
{* Modal form *}
<div tabindex="0" class="modal fade" id="privacy-confirm-form">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="btn close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title">
{str tag=refuseprivacy section=admin}
</h4>
</div>
<div class="modal-body">
<p><strong>{str tag=privacyrefusaldetails section=admin}</strong></p>
<p>{str tag=confirmprivacyrefusal section=admin}</p>
<div class="btn-group">
<button id="confirm-no-button" type="button" class="btn btn-default">{str tag="yes"}</button>
<button id="back-button" type="button" class="btn btn-default">{str tag="no"}</button>
</div>
</div>
</div>
</div>
</div>
<script type="application/javascript">
var acceptprivacy = false;
$j("#agreetoprivacy").on('submit', function(event) {
if ($j("#agreetoprivacy input:checkbox").length == $j("#agreetoprivacy input:checkbox:checked").length) {
acceptprivacy = true;
}
if (!acceptprivacy) {
event.preventDefault();
event.stopPropagation();
processingStop();
$j("#privacy-confirm-form").modal('show');
}
});
$j("#confirm-no-button").on('click', function() {
acceptprivacy = true;
$j("#privacy-confirm-form").modal('hide');
formAbortProcessing($j("#agreetoprivacy_submit"));
$j('<input />').attr('type', 'hidden').attr('name', "hasrefused").attr('value', "1").appendTo('#agreetoprivacy');
// settimeout to 0 so it waits for everything else to finish before trigger the submit button
setTimeout(function() {
$j('#agreetoprivacy_submit').trigger( "click" );
}, 0);
});
$j("#back-button").on('click', function() {
formAbortProcessing($j("#agreetoprivacy_submit"));
$j("#privacy-confirm-form").modal('hide');
});
$('.modal').on('shown.bs.modal', function() {
$('#confirm-no-button').focus();
});
$('.modal').on('hidden.bs.modal', function() {
if (!acceptprivacy) {
formAbortProcessing($j("#agreetoprivacy_submit"));
$('#agreetoprivacy_submit').focus();
}
});
</script>
{include file="footer.tpl"}
......@@ -57,39 +57,39 @@
{/if}
<div id="loading-box" class="loading-box hidden"></div>
</div>
<div class="nav-toggle-area">
{if $MAINNAV}
<button class="main-nav-toggle navbar-toggle collapsed" role="button" data-toggle="collapse" data-target=".nav-main" aria-expanded="false" aria-controls="nav-main" title='{str tag="mainmenu"}'>
<span class="sr-only">{str tag="showmainmenu"}</span>
<span class="icon icon-bars icon-lg" role="presentation" aria-hidden="true"></span>
</button>
{/if}
{if $MAINNAVADMIN}
<button class="admin-toggle navbar-toggle collapsed" role="button" data-toggle="collapse" data-target=".nav-main-admin" aria-expanded="false" aria-controls="nav-main-admin" title='{str tag="adminmenu"}'>
<span class="sr-only">{str tag="showadminmenu"}</span>
<span class="icon icon-wrench icon-large" role="presentation" aria-hidden="true"></span>
</button>
{/if}
{if $LOGGEDIN}
<a href="{profile_url($USER)}" class="user-icon" title='{str tag="profilepage"}'>
<img src="{profile_icon_url user=$USER maxheight=25 maxwidth=25}">
</a>
<button class="user-toggle navbar-toggle" role="button" data-toggle="collapse" data-target=".nav-main-user" aria-expanded="false" aria-controls="nav-main-user" title='{str tag="usermenu"}'>
<span class="sr-only">{str tag="showusermenu"}</span>
<span class="icon icon-chevron-down collapsed"></span>
<div class="nav-toggle-area">
{if $MAINNAV}
<button class="main-nav-toggle navbar-toggle collapsed" role="button" data-toggle="collapse" data-target=".nav-main" aria-expanded="false" aria-controls="nav-main" title='{str tag="mainmenu"}'>
<span class="sr-only">{str tag="showmainmenu"}</span>
<span class="icon icon-bars icon-lg" role="presentation" aria-hidden="true"></span>
</button>
{/if}
{if $MAINNAVADMIN}
<button class="admin-toggle navbar-toggle collapsed" role="button" data-toggle="collapse" data-target=".nav-main-admin" aria-expanded="false" aria-controls="nav-main-admin" title='{str tag="adminmenu"}'>
<span class="sr-only">{str tag="showadminmenu"}</span>
<span class="icon icon-wrench icon-large" role="presentation" aria-hidden="true"></span>
</button>
{/if}
{if $LOGGEDIN}
<a href="{profile_url($USER)}" class="user-icon" title='{str tag="profilepage"}'>
<img src="{profile_icon_url user=$USER maxheight=25 maxwidth=25}">
</a>
<button class="user-toggle navbar-toggle" role="button" data-toggle="collapse" data-target=".nav-main-user" aria-expanded="false" aria-controls="nav-main-user" title='{str tag="usermenu"}'>
<span class="sr-only">{str tag="showusermenu"}</span>
<span class="icon icon-chevron-down collapsed"></span>
</button>
{/if}
<!-- HIDE WHEN ON DESKTOP -->
{if !$nosearch && ($LOGGEDIN || $publicsearchallowed)}
<button class="search-toggle navbar-toggle collapsed" role="button" data-toggle="collapse" data-target=".navbar-form" aria-expanded="false" aria-controls="navbar-form">
<span class="icon icon-search icon-lg" role="presentation" aria-hidden="true"></span>
<span class="nav-title sr-only">{str tag="showsearch"}</span>
</button>
{/if}
<!-- HIDE WHEN ON DESKTOP -->
{if !$nosearch && ($LOGGEDIN || $publicsearchallowed)}
<button class="search-toggle navbar-toggle collapsed" role="button" data-toggle="collapse" data-target=".navbar-form" aria-expanded="false" aria-controls="navbar-form">
<span class="icon icon-search icon-lg" role="presentation" aria-hidden="true"></span>
<span class="nav-title sr-only">{str tag="showsearch"}</span>
</button>
{/if}
</div>
{/if}
</div>
{include file="header/topright.tpl"}
{include file="header/navigation.tpl"}
{include file="header/topright.tpl"}
{include file="header/navigation.tpl"}
</div>
</div>
</header>
......
@javascript @core @gdpr
Feature: Strict privacy switch
As a new user logging in for the first time
When strict privacy is enabled
I should be required to accept the privacy statement
Background:
# And the following site settings are set:
#| 'usersallowedmultipleinstitutions' | 0 |
# | 'institutionstrictprivacy' | 1 |
Scenario: Create user who logs in with strict privacy enabled
Given I log in as "admin" with password "Kupuhipa1"
And I choose "Site options" in "Configure site" from administration menu
And I expand "Institution settings" node
# Need to disable multiple inst first, or set strict privacy doesn't work.
And I disable the switch "Users allowed multiple institutions"
And I enable the switch "Strict privacy"
# Check this worked as otherwise there is no point in continuing
And the field "Strict privacy" matches value "1"
And I press "Update site options"
# Background adding of user doesn't work for this test
And I choose "Add user" in "Users" from administration menu
And I set the following fields to these values:
| First name | Bob |
| Last name | One |
| Email | UserB@example.com |
| Username | bob |
| password | Kupuhipa1 |
And I press "Create user"
And I disable the switch "Force password change on next login"
And I enable the switch "Disable email"
And I press "Save changes"
And I log out
Given I log in as "bob" with password "Kupuhipa1"
Then I should see "Before entering your account, please read the privacy statement displayed below."
# Try to ignore privacy statement
And I choose "Pages and collections" in "Portfolio" from main menu
Then I should see "Before entering your account, please read the privacy statement displayed below."
And I press "Save changes"
Then I should see "If you do not consent to the privacy statement, your account will be suspended."
Then I press "No"
# consent to privacy statement
And I enable the switch "I consent to this privacy statement"
And I press "Save changes"
Then I should see "Welcome"
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment