Index: CHANGELOG.txt =================================================================== RCS file: /cvs/drupal/drupal/CHANGELOG.txt,v retrieving revision 1.292 diff -u -p -r1.292 CHANGELOG.txt --- CHANGELOG.txt 22 Dec 2008 19:38:30 -0000 1.292 +++ CHANGELOG.txt 7 Jan 2009 00:59:52 -0000 @@ -32,6 +32,7 @@ Drupal 7.0, xxxx-xx-xx (development vers * Redesigned password strength validator. * Redesigned the add content type screen. * Highlight duplicate URL aliases. + * Added configurable ability for users to cancel their own accounts. - Performance: * Improved performance on uncached page views by loading multiple core objects in a single database query. Index: modules/comment/comment.module =================================================================== RCS file: /cvs/drupal/drupal/modules/comment/comment.module,v retrieving revision 1.680 diff -u -p -r1.680 comment.module --- modules/comment/comment.module 4 Jan 2009 16:19:39 -0000 1.680 +++ modules/comment/comment.module 8 Jan 2009 07:09:57 -0000 @@ -697,17 +697,31 @@ function comment_nodeapi_rss_item($node) } /** - * Implementation of hook_user_delete(). + * Implementation of hook_user_cancel(). */ -function comment_user_delete(&$edit, &$user, $category = NULL) { - db_update('comment') - ->fields(array('uid' => 0)) - ->condition('uid', $user->uid) - ->execute(); - db_update('node_comment_statistics') - ->fields(array('last_comment_uid' => 0)) - ->condition('last_comment_uid', $user->uid) - ->execute(); +function comment_user_cancel(&$edit, &$account, $method) { + switch ($method) { + case 'user_cancel_block_unpublish': + db_update('comment')->fields(array('status' => 0))->condition('uid', $account->uid)->execute(); + db_update('node_comment_statistics')->fields(array('last_comment_uid' => 0))->condition('last_comment_uid', $account->uid)->execute(); + break; + + case 'user_cancel_anonymize': + db_update('comment')->fields(array('uid' => 0))->condition('uid', $account->uid)->execute(); + db_update('node_comment_statistics')->fields(array('last_comment_uid' => 0))->condition('last_comment_uid', $account->uid)->execute(); + break; + + case 'user_cancel_delete': + module_load_include('inc', 'comment', 'comment.admin'); + $comments = db_select('comment', 'c')->fields('c', array('cid'))->condition('uid', $account->uid)->execute()->fetchCol(); + foreach ($comments as $cid) { + $comment = comment_load($cid); + // Delete the comment and its replies. + _comment_delete_thread($comment); + _comment_update_node_statistics($comment->nid); + } + break; + } } /** Index: modules/dblog/dblog.module =================================================================== RCS file: /cvs/drupal/drupal/modules/dblog/dblog.module,v retrieving revision 1.31 diff -u -p -r1.31 dblog.module --- modules/dblog/dblog.module 28 Dec 2008 20:41:19 -0000 1.31 +++ modules/dblog/dblog.module 8 Jan 2009 07:10:06 -0000 @@ -101,10 +101,18 @@ function dblog_cron() { } /** - * Implementation of hook_user_delete(). + * Implementation of hook_user_cancel(). */ -function dblog_user_delete(&$edit, &$user) { - db_query('UPDATE {watchdog} SET uid = 0 WHERE uid = %d', $user->uid); +function dblog_user_cancel(&$edit, &$account, $method) { + switch ($method) { + case 'user_cancel_anonymize': + db_update('watchdog')->fields(array('uid' => 0))->condition('uid', $account->uid)->execute(); + break; + + case 'user_cancel_delete': + db_delete('watchdog')->condition('uid', $account->uid)->execute(); + break; + } } function _dblog_get_message_types() { Index: modules/dblog/dblog.test =================================================================== RCS file: /cvs/drupal/drupal/modules/dblog/dblog.test,v retrieving revision 1.13 diff -u -p -r1.13 dblog.test --- modules/dblog/dblog.test 24 Dec 2008 10:38:41 -0000 1.13 +++ modules/dblog/dblog.test 8 Jan 2009 07:10:11 -0000 @@ -165,7 +165,7 @@ class DBLogTestCase extends DrupalWebTes $this->doNode('page'); $this->doNode('poll'); - // When a user is deleted, any content they created remains but the + // When a user account is canceled, any content they created remains but the // uid = 0. Their blog entry shows as "'s blog" on the home page. Records // in the watchdog table related to that user have the uid set to zero. } @@ -202,8 +202,13 @@ class DBLogTestCase extends DrupalWebTes } $count_before = (isset($ids)) ? count($ids) : 0; $this->assertTrue($count_before > 0, t('DBLog contains @count records for @name', array('@count' => $count_before, '@name' => $user->name))); + + // Login the admin user. + $this->drupalLogin($this->big_user); // Delete user. - user_delete(array(), $user->uid); + // We need to POST here to invoke batch_process() in the internal browser. + $this->drupalPost('user/' . $user->uid . '/cancel', array('user_cancel_method' => 'user_cancel_anonymize'), t('Cancel account')); + // Count rows that have uids for the user. $count = db_result(db_query('SELECT COUNT(wid) FROM {watchdog} WHERE uid = %d', $user->uid)); $this->assertTrue($count == 0, t('DBLog contains @count records for @name', array('@count' => $count, '@name' => $user->name))); @@ -225,8 +230,6 @@ class DBLogTestCase extends DrupalWebTes } $this->assertTrue(!isset($ids), t('DBLog contains no records for @name', array('@name' => $user->name))); - // Login the admin user. - $this->drupalLogin($this->big_user); // View the dblog report. $this->drupalGet('admin/reports/dblog'); $this->assertResponse(200); Index: modules/node/node.module =================================================================== RCS file: /cvs/drupal/drupal/modules/node/node.module,v retrieving revision 1.1009 diff -u -p -r1.1009 node.module --- modules/node/node.module 4 Jan 2009 19:56:51 -0000 1.1009 +++ modules/node/node.module 8 Jan 2009 07:10:19 -0000 @@ -1476,11 +1476,41 @@ function node_ranking() { } /** - * Implementation of hook_user_delete(). + * Implementation of hook_user_cancel(). */ -function node_user_delete(&$edit, &$user) { - db_query('UPDATE {node} SET uid = 0 WHERE uid = %d', $user->uid); - db_query('UPDATE {node_revision} SET uid = 0 WHERE uid = %d', $user->uid); +function node_user_cancel(&$edit, &$account, $method) { + switch ($method) { + case 'user_cancel_block_unpublish': + // Unpublish nodes (current revisions). + module_load_include('inc', 'node', 'node.admin'); + $nodes = db_select('node', 'n')->fields('n', array('nid'))->condition('uid', $account->uid)->execute()->fetchCol(); + node_mass_update($nodes, array('status' => 0)); + break; + + case 'user_cancel_anonymize': + // Anonymize nodes (current revisions). + module_load_include('inc', 'node', 'node.admin'); + $nodes = db_select('node', 'n')->fields('n', array('nid'))->condition('uid', $account->uid)->execute()->fetchCol(); + node_mass_update($nodes, array('uid' => 0)); + // Anonymize old revisions. + db_update('node_revision')->fields(array('uid' => 0))->condition('uid', $account->uid)->execute(); + // Clean history. + db_delete('history')->condition('uid', $account->uid)->execute(); + break; + + case 'user_cancel_delete': + // Delete nodes (current revisions). + // @todo Introduce node_mass_delete() or make node_mass_update() more flexible. + $nodes = db_select('node', 'n')->fields('n', array('nid'))->condition('uid', $account->uid)->execute()->fetchCol(); + foreach ($nodes as $nid) { + node_delete($nid); + } + // Delete old revisions. + db_delete('node_revision')->condition('uid', $account->uid)->execute(); + // Clean history. + db_delete('history')->condition('uid', $account->uid)->execute(); + break; + } } /** Index: modules/poll/poll.module =================================================================== RCS file: /cvs/drupal/drupal/modules/poll/poll.module,v retrieving revision 1.283 diff -u -p -r1.283 poll.module --- modules/poll/poll.module 31 Dec 2008 12:02:23 -0000 1.283 +++ modules/poll/poll.module 8 Jan 2009 07:10:28 -0000 @@ -815,9 +815,17 @@ function poll_cancel($form, &$form_state } /** - * Implementation of hook_user_delete(). + * Implementation of hook_user_cancel(). */ -function poll_user_delete(&$edit, &$user) { - db_query('UPDATE {poll_vote} SET uid = 0 WHERE uid = %d', $user->uid); +function poll_user_cancel(&$edit, &$account, $method) { + switch ($method) { + case 'user_cancel_anonymize': + db_update('poll_vote')->fields(array('uid' => 0))->condition('uid', $account->uid)->execute(); + break; + + case 'user_cancel_delete': + db_delete('poll_vote')->condition('uid', $account->uid)->execute(); + break; + } } Index: modules/profile/profile.module =================================================================== RCS file: /cvs/drupal/drupal/modules/profile/profile.module,v retrieving revision 1.248 diff -u -p -r1.248 profile.module --- modules/profile/profile.module 16 Dec 2008 23:57:33 -0000 1.248 +++ modules/profile/profile.module 8 Jan 2009 07:10:34 -0000 @@ -259,10 +259,15 @@ function profile_user_categories(&$edit, } /** - * Implementation of hook_user_delete(). + * Implementation of hook_user_cancel(). */ -function profile_user_delete(&$edit, &$user, $category = NULL) { - db_query('DELETE FROM {profile_value} WHERE uid = %d', $user->uid); +function profile_user_cancel(&$edit, &$account, $method) { + switch ($method) { + case 'user_cancel_anonymize': + case 'user_cancel_delete': + db_delete('profile_value')->condition('uid', $account->uid)->execute(); + break; + } } function profile_load_profile(&$user) { Index: modules/statistics/statistics.module =================================================================== RCS file: /cvs/drupal/drupal/modules/statistics/statistics.module,v retrieving revision 1.292 diff -u -p -r1.292 statistics.module --- modules/statistics/statistics.module 26 Dec 2008 14:23:38 -0000 1.292 +++ modules/statistics/statistics.module 8 Jan 2009 07:10:39 -0000 @@ -182,10 +182,18 @@ function statistics_menu() { } /** - * Implementation of hook_user_delete(). + * Implementation of hook_user_cancel(). */ -function statistics_user_delete(&$edit, &$user, $category) { - db_query('UPDATE {accesslog} SET uid = 0 WHERE uid = %d', $user->uid); +function statistics_user_cancel(&$edit, &$account, $method) { + switch ($method) { + case 'user_cancel_anonymize': + db_update('accesslog')->fields(array('uid' => 0))->condition('uid', $account->uid)->execute(); + break; + + case 'user_cancel_delete': + db_delete('accesslog')->condition('uid', $account->uid)->execute(); + break; + } } /** Index: modules/trigger/trigger.module =================================================================== RCS file: /cvs/drupal/drupal/modules/trigger/trigger.module,v retrieving revision 1.26 diff -u -p -r1.26 trigger.module --- modules/trigger/trigger.module 7 Jan 2009 03:52:32 -0000 1.26 +++ modules/trigger/trigger.module 8 Jan 2009 07:10:45 -0000 @@ -441,10 +441,15 @@ function trigger_user_update(&$edit, &$a } /** - * Implementation of hook_user_delete(). + * Implementation of hook_user_cancel(). */ -function trigger_user_delete(&$edit, &$account, $category) { - _trigger_user('delete', $edit, $account, $category); +function trigger_user_cancel(&$edit, &$account, $method) { + switch ($method) { + case 'user_cancel_anonymize': + case 'user_cancel_delete': + _trigger_user('delete', $edit, $account, $method); + break; + } } /** Index: modules/user/user.admin.inc =================================================================== RCS file: /cvs/drupal/drupal/modules/user/user.admin.inc,v retrieving revision 1.33 diff -u -p -r1.33 user.admin.inc --- modules/user/user.admin.inc 16 Nov 2008 15:10:49 -0000 1.33 +++ modules/user/user.admin.inc 7 Jan 2009 18:21:49 -0000 @@ -15,8 +15,8 @@ function user_admin($callback_arg = '') $output = drupal_get_form('user_register'); break; default: - if (!empty($_POST['accounts']) && isset($_POST['operation']) && ($_POST['operation'] == 'delete')) { - $output = drupal_get_form('user_multiple_delete_confirm'); + if (!empty($_POST['accounts']) && isset($_POST['operation']) && ($_POST['operation'] == 'cancel')) { + $output = drupal_get_form('user_multiple_cancel_confirm'); } else { $output = drupal_get_form('user_filter_form'); @@ -235,6 +235,30 @@ function user_admin_settings() { $form['registration']['user_email_verification'] = array('#type' => 'checkbox', '#title' => t('Require e-mail verification when a visitor creates an account'), '#default_value' => variable_get('user_email_verification', TRUE), '#description' => t('If this box is checked, new users will be required to validate their e-mail address prior to logging into the site, and will be assigned a system-generated password. With it unchecked, users will be logged in immediately upon registering, and may select their own passwords during registration.')); $form['registration']['user_registration_help'] = array('#type' => 'textarea', '#title' => t('User registration guidelines'), '#default_value' => variable_get('user_registration_help', ''), '#description' => t('This text is displayed at the top of the user registration form and is useful for helping or instructing your users.')); + // Account cancellation settings. + module_load_include('inc', 'user', 'user.pages'); + $form['cancel'] = array( + '#type' => 'fieldset', + '#title' => t('Account cancellation settings'), + ); + $form['cancel']['user_cancel_method'] = array( + '#type' => 'item', + '#title' => t('When cancelling a user account'), + '#description' => t('This default applies to all users who want to cancel their accounts. Users with the %select-cancel-method or %administer-users permissions can override this default method.', array('%select-cancel-method' => t('Select method for cancelling account'), '%administer-users' => t('Administer users'), '@permissions-url' => url('admin/user/permissions'))), + ); + $form['cancel']['user_cancel_method'] += user_cancel_methods(); + foreach (element_children($form['cancel']['user_cancel_method']) as $element) { + // Remove all account cancellation methods that have #access defined, as + // those cannot be configured as default method. + if (isset($form['cancel']['user_cancel_method'][$element]['#access'])) { + $form['cancel']['user_cancel_method'][$element]['#access'] = FALSE; + } + // Remove the description (only displayed on the confirmation form). + else { + unset($form['cancel']['user_cancel_method'][$element]['#description']); + } + } + // User e-mail settings. $form['email'] = array( '#type' => 'fieldset', @@ -243,7 +267,7 @@ function user_admin_settings() { ); // These email tokens are shared for all settings, so just define // the list once to help ensure they stay in sync. - $email_token_help = t('Available variables are:') . ' !username, !site, !password, !uri, !uri_brief, !mailto, !date, !login_uri, !edit_uri, !login_url.'; + $email_token_help = t('Available variables are:') . ' !username, !site, !password, !uri, !uri_brief, !mailto, !date, !login_uri, !edit_uri, !login_url, !cancel_url.'; $form['email']['admin_created'] = array( '#type' => 'fieldset', @@ -375,28 +399,48 @@ function user_admin_settings() { '#rows' => 3, ); - $form['email']['deleted'] = array( + $form['email']['cancel_confirm'] = array( + '#type' => 'fieldset', + '#title' => t('Account cancellation confirmation email'), + '#collapsible' => TRUE, + '#collapsed' => TRUE, + '#description' => t('Customize e-mail messages sent to users when they attempt to cancel their accounts.') . ' ' . $email_token_help, + ); + $form['email']['cancel_confirm']['user_mail_cancel_confirm_subject'] = array( + '#type' => 'textfield', + '#title' => t('Subject'), + '#default_value' => _user_mail_text('cancel_confirm_subject'), + '#maxlength' => 180, + ); + $form['email']['cancel_confirm']['user_mail_cancel_confirm_body'] = array( + '#type' => 'textarea', + '#title' => t('Body'), + '#default_value' => _user_mail_text('cancel_confirm_body'), + '#rows' => 3, + ); + + $form['email']['canceled'] = array( '#type' => 'fieldset', - '#title' => t('Account deleted email'), + '#title' => t('Account canceled email'), '#collapsible' => TRUE, '#collapsed' => TRUE, - '#description' => t('Enable and customize e-mail messages sent to users when their accounts are deleted.') . ' ' . $email_token_help, + '#description' => t('Enable and customize e-mail messages sent to users when their accounts are canceled.') . ' ' . $email_token_help, ); - $form['email']['deleted']['user_mail_status_deleted_notify'] = array( + $form['email']['canceled']['user_mail_status_canceled_notify'] = array( '#type' => 'checkbox', - '#title' => t('Notify user when account is deleted.'), - '#default_value' => variable_get('user_mail_status_deleted_notify', FALSE), + '#title' => t('Notify user when account is canceled.'), + '#default_value' => variable_get('user_mail_status_canceled_notify', FALSE), ); - $form['email']['deleted']['user_mail_status_deleted_subject'] = array( + $form['email']['canceled']['user_mail_status_canceled_subject'] = array( '#type' => 'textfield', '#title' => t('Subject'), - '#default_value' => _user_mail_text('status_deleted_subject'), + '#default_value' => _user_mail_text('status_canceled_subject'), '#maxlength' => 180, ); - $form['email']['deleted']['user_mail_status_deleted_body'] = array( + $form['email']['canceled']['user_mail_status_canceled_body'] = array( '#type' => 'textarea', '#title' => t('Body'), - '#default_value' => _user_mail_text('status_deleted_body'), + '#default_value' => _user_mail_text('status_canceled_body'), '#rows' => 3, ); Index: modules/user/user.api.php =================================================================== RCS file: /cvs/drupal/drupal/modules/user/user.api.php,v retrieving revision 1.1 diff -u -p -r1.1 user.api.php --- modules/user/user.api.php 25 Nov 2008 02:37:33 -0000 1.1 +++ modules/user/user.api.php 8 Jan 2009 07:13:40 -0000 @@ -23,8 +23,6 @@ * (probably along with 'insert') if you want to reuse some information from * the user object. * - "categories": A set of user information categories is requested. - * - "delete": The user account is being deleted. The module should remove its - * custom additions to the user object from the database. * - "form": The user account edit form is about to be displayed. The module * should present the form elements it wishes to inject into the form. * - "insert": The user account is being added. The module should save its @@ -88,6 +86,98 @@ function hook_user($op, &$edit, &$accoun } /** + * Act on user account cancellations. + * + * The user account is being canceled. Depending on the account cancellation + * method, the module should either do nothing, unpublish content, anonymize + * content, or delete content and data belonging to the canceled user account. + * + * Expensive operations should be added to the global batch. + * + * @param &$edit + * The array of form values submitted by the user. + * @param &$account + * The user object on which the operation is being performed. + * @param $method + * The account cancellation method. + * + * @see user_cancel_methods() + * @see hook_user_cancel_methods_alter() + * @see user_cancel() + */ +function hook_user_cancel(&$edit, &$account, $method) { + switch ($method) { + case 'user_cancel_block_unpublish': + // Unpublish nodes (current revisions). + module_load_include('inc', 'node', 'node.admin'); + $nodes = db_select('node', 'n')->fields('n', array('nid'))->condition('uid', $account->uid)->execute()->fetchCol(); + node_mass_update($nodes, array('status' => 0)); + break; + + case 'user_cancel_anonymize': + // Anonymize nodes (current revisions). + module_load_include('inc', 'node', 'node.admin'); + $nodes = db_select('node', 'n')->fields('n', array('nid'))->condition('uid', $account->uid)->execute()->fetchCol(); + node_mass_update($nodes, array('uid' => 0)); + // Anonymize old revisions. + db_update('node_revision')->fields(array('uid' => 0))->condition('uid', $account->uid)->execute(); + // Clean history. + db_delete('history')->condition('uid', $account->uid)->execute(); + break; + + case 'user_cancel_delete': + // Delete nodes (current revisions). + $nodes = db_select('node', 'n')->fields('n', array('nid'))->condition('uid', $account->uid)->execute()->fetchCol(); + foreach ($nodes as $nid) { + node_delete($nid); + } + // Delete old revisions. + db_delete('node_revision')->condition('uid', $account->uid)->execute(); + // Clean history. + db_delete('history')->condition('uid', $account->uid)->execute(); + break; + } +} + +/** + * Modify account cancellation methods. + * + * By implementing this hook, modules are able to add, customize, or remove + * account cancellation methods. All defined methods are turned into radio + * button form elements by user_cancel_methods() after this hook is invoked. + * The following properties can be defined for each method: + * - title: The radio button's title. + * - description: (optional) A description to display on the confirmation form + * if the user is not allowed to select the account cancellation method. The + * description is NOT used for the radio button, but instead should provide + * additional explanation to the user seeking to cancel their account. + * - access: (optional) A boolean value indicating whether the user can access + * a method. If #access is defined, the method cannot be configured as default + * method. + * + * @param &$methods + * An array containing user account cancellation methods, keyed by method id. + * + * @see user_cancel_methods() + * @see user_cancel_confirm_form() + */ +function hook_user_cancel_methods_alter(&$methods) { + // Limit access to disable account and unpublish content method. + $methods['user_cancel_block_unpublish']['access'] = user_access('administer site configuration'); + + // Remove the anonymization method. + unset($methods['user_cancel_anonymize']); + + // Add a custom zero-out method. + $methods['mymodule_zero_out'] = array( + 'title' => t('Delete the account and remove all content.'), + 'description' => t('All your content will be replaced by empty strings.'), + // access should be used for administrative methods only. + 'access' => user_access('access zero-out account cancellation method'), + ); +} + +/** * Add mass user operations. * * This hook enables modules to inject custom operations into the mass operations @@ -114,8 +204,8 @@ function hook_user_operations() { 'label' => t('Block the selected users'), 'callback' => 'user_user_operations_block', ), - 'delete' => array( - 'label' => t('Delete the selected users'), + 'cancel' => array( + 'label' => t('Cancel the selected user accounts'), ), ); return $operations; Index: modules/user/user.install =================================================================== RCS file: /cvs/drupal/drupal/modules/user/user.install,v retrieving revision 1.15 diff -u -p -r1.15 user.install --- modules/user/user.install 20 Nov 2008 06:56:17 -0000 1.15 +++ modules/user/user.install 7 Jan 2009 04:57:55 -0000 @@ -360,6 +360,34 @@ function user_update_7002(&$sandbox) { } /** + * Update user settings for cancelling user accounts. + * + * Prior to 7.x, users were not able to cancel their accounts. When + * administrators deleted an account, all contents were assigned to uid 0, + * which is the same as the USER_CANCEL_ANONYMIZE method now. + */ +function user_update_7003() { + $ret = array(); + // Set the default account cancellation method. + variable_set('user_cancel_method', USER_CANCEL_ANONYMIZE); + // Re-assign notification setting. + if ($setting = variable_get('user_mail_status_deleted_notify', FALSE)) { + variable_set('user_mail_status_canceled_notify', $setting); + variable_del('user_mail_status_deleted_notify'); + } + // Re-assign "Account deleted" mail strings to "Account canceled" mail. + if ($setting = variable_get('user_mail_status_deleted_subject', FALSE)) { + variable_set('user_mail_status_canceled_subject', $setting); + variable_del('user_mail_status_deleted_subject'); + } + if ($setting = variable_get('user_mail_status_deleted_body', FALSE)) { + variable_set('user_mail_status_canceled_body', $setting); + variable_del('user_mail_status_deleted_body'); + } + return $ret; +} + +/** * @} End of "defgroup user-updates-6.x-to-7.x" * The next series of updates should start at 8000. */ Index: modules/user/user.module =================================================================== RCS file: /cvs/drupal/drupal/modules/user/user.module,v retrieving revision 1.952 diff -u -p -r1.952 user.module --- modules/user/user.module 4 Jan 2009 16:10:48 -0000 1.952 +++ modules/user/user.module 8 Jan 2009 07:11:36 -0000 @@ -16,6 +16,7 @@ define('USERNAME_MAX_LENGTH', 60); */ define('EMAIL_MAX_LENGTH', 64); + /** * Invokes hook_user() in every module. * @@ -587,6 +588,14 @@ function user_perm() { 'title' => t('Change own username'), 'description' => t('Select a different username.'), ), + 'cancel account' => array( + 'title' => t('Cancel account'), + 'description' => t('Remove or disable own user account and unpublish, anonymize, or remove own submissions depending on the configured user settings.', array('@user-settings-url' => url('admin/user/settings'))), + ), + 'select account cancellation method' => array( + 'title' => t('Select method for cancelling own account'), + 'description' => t('Select the method for cancelling own user account. %warning', array('%warning' => t('Warning: Give to trusted roles only; this permission has security implications.'))), + ), ); } @@ -948,6 +957,16 @@ function user_edit_access($account) { return (($GLOBALS['user']->uid == $account->uid) || user_access('administer users')) && $account->uid > 0; } +/** + * Menu access callback; limit access to account cancellation pages. + * + * Limit access to users with the 'cancel account' permission or administrative + * users, and prevent the anonymous user from cancelling the account. + */ +function user_cancel_access($account) { + return ((($GLOBALS['user']->uid == $account->uid) && user_access('cancel account')) || user_access('administer users')) && $account->uid > 0; +} + function user_load_self($arg) { $arg[1] = user_load($GLOBALS['user']->uid); return $arg; @@ -1082,12 +1101,21 @@ function user_menu() { 'weight' => -10, ); - $items['user/%user/delete'] = array( - 'title' => 'Delete', + $items['user/%user/cancel'] = array( + 'title' => 'Cancel account', 'page callback' => 'drupal_get_form', - 'page arguments' => array('user_confirm_delete', 1), - 'access callback' => 'user_access', - 'access arguments' => array('administer users'), + 'page arguments' => array('user_cancel_confirm_form', 1), + 'access callback' => 'user_cancel_access', + 'access arguments' => array(1), + 'type' => MENU_CALLBACK, + ); + + $items['user/%user/cancel/confirm/%/%'] = array( + 'title' => 'Confirm account cancellation', + 'page callback' => 'user_cancel_confirm', + 'page arguments' => array(1, 4, 5), + 'access callback' => 'user_cancel_access', + 'access arguments' => array(1), 'type' => MENU_CALLBACK, ); @@ -1445,6 +1473,17 @@ function user_pass_reset_url($account) { return url("user/reset/$account->uid/$timestamp/" . user_pass_rehash($account->pass, $timestamp, $account->login), array('absolute' => TRUE)); } +/** + * Generate a URL to confirm an account cancellation request. + * + * @see user_mail_tokens() + * @see user_cancel_confirm() + */ +function user_cancel_url($account) { + $timestamp = REQUEST_TIME; + return url("user/$account->uid/cancel/confirm/$timestamp/" . user_pass_rehash($account->pass, $timestamp, $account->login), array('absolute' => TRUE)); +} + function user_pass_rehash($password, $timestamp, $login) { return md5($timestamp . $password . $login); } @@ -1595,21 +1634,106 @@ function _user_edit_submit($account, &$e } /** - * Delete a user. + * Cancel a user account. * - * @param $edit An array of submitted form values. - * @param $uid The user ID of the user to delete. + * Since the user cancellation process needs to be run in a batch, either + * Form API will invoke it, or batch_process() needs to be invoked after calling + * this function and should define the path to redirect to. + * + * @param $edit + * An array of submitted form values. + * @param $uid + * The user ID of the user account to cancel. + * @param $method + * The account cancellation method to use. + * + * @see _user_cancel() */ -function user_delete($edit, $uid) { +function user_cancel($edit, $uid, $method) { + global $user; + $account = user_load(array('uid' => $uid)); - drupal_session_destroy_uid($uid); - _user_mail_notify('status_deleted', $account); - module_invoke_all('user_delete', $edit, $account); - db_query('DELETE FROM {users} WHERE uid = %d', $uid); - db_query('DELETE FROM {users_roles} WHERE uid = %d', $uid); - db_query('DELETE FROM {authmap} WHERE uid = %d', $uid); - $variables = array('%name' => $account->name, '%email' => '<' . $account->mail . '>'); - watchdog('user', 'Deleted user: %name %email.', $variables, WATCHDOG_NOTICE); + + if (!$account) { + drupal_set_message(t('The user account %id does not exist.', array('%id' => $uid)), 'error'); + watchdog('user', 'Attempted to cancel non-existing user account: %id.', array('%id' => $uid), WATCHDOG_ERROR); + return; + } + + // Initialize batch (to set title). + $batch = array( + 'title' => t('Cancelling account'), + 'operations' => array(), + ); + batch_set($batch); + + // Allow modules to add further sets to this batch. + module_invoke_all('user_cancel', $edit, $account, $method); + + // Finish the batch and actually cancel the account. + $batch = array( + 'title' => t('Cancelling user account'), + 'operations' => array( + array('_user_cancel', array($edit, $account, $method)), + ), + ); + batch_set($batch); + + // Batch processing is either handled via Form API or has to be invoked + // manually. +} + +/** + * Last batch processing step for cancelling a user account. + * + * Since batch and session API require a valid user account, the actual + * cancellation of a user account needs to happen last. + * + * @see user_cancel() + */ +function _user_cancel($edit, $account, $method) { + global $user; + + switch ($method) { + case 'user_cancel_block': + case 'user_cancel_block_unpublish': + default: + // Send account blocked notification if option was checked. + if (!empty($edit['user_cancel_notify'])) { + _user_mail_notify('status_blocked', $account); + } + db_update('users')->fields(array('status' => 0))->condition('uid', $account->uid)->execute(); + drupal_set_message(t('%name has been disabled.', array('%name' => $account->name))); + break; + + case 'user_cancel_anonymize': + case 'user_cancel_delete': + // Send account canceled notification if option was checked. + if (!empty($edit['user_cancel_notify'])) { + _user_mail_notify('status_canceled', $account); + } + db_delete('users')->condition('uid', $account->uid)->execute(); + db_delete('users_roles')->condition('uid', $account->uid)->execute(); + db_delete('authmap')->condition('uid', $account->uid)->execute(); + drupal_set_message(t('%name has been deleted.', array('%name' => $account->name))); + $variables = array('%name' => $account->name, '%email' => '<' . $account->mail . '>'); + watchdog('user', 'Deleted user: %name %email.', $variables, WATCHDOG_NOTICE); + break; + } + + // After cancelling account, ensure that user is logged out. + if ($account->uid == $user->uid) { + // Destroy the current session. + session_destroy(); + // Load the anonymous user. + $user = drupal_anonymous_user(); + } + else { + drupal_session_destroy_uid($account->uid); + } + + // Clear the cache for anonymous users. + cache_clear_all(); } /** @@ -1682,10 +1806,28 @@ function _user_mail_text($key, $language return t('Account details for !username at !site (blocked)', $variables, $langcode); case 'status_blocked_body': return t("!username,\n\nYour account on !site has been blocked.", $variables, $langcode); - case 'status_deleted_subject': - return t('Account details for !username at !site (deleted)', $variables, $langcode); - case 'status_deleted_body': - return t("!username,\n\nYour account on !site has been deleted.", $variables, $langcode); + + case 'cancel_confirm_subject': + return t('Account cancellation request for !username at !site', $variables, $langcode); + case 'cancel_confirm_body': + return t("!username, + +A request to cancel your account has been made at !site. + +You may now cancel your account on !uri_brief by clicking this link or copying and pasting it into your browser: + +!cancel_url + +NOTE: The cancellation of your account is not reversible. + +This link expires in one day and nothing will happen if it is not used.", $variables, $langcode); + + case 'status_canceled_subject': + return t('Account details for !username at !site (canceled)', $variables, $langcode); + case 'status_canceled_body': + return t("!username, + +Your account on !site has been canceled.", $variables, $langcode); } } } @@ -1752,8 +1894,8 @@ function user_user_operations($form_stat 'label' => t('Block the selected users'), 'callback' => 'user_user_operations_block', ), - 'delete' => array( - 'label' => t('Delete the selected users'), + 'cancel' => array( + 'label' => t('Cancel the selected user accounts'), ), ); @@ -1866,7 +2008,7 @@ function user_multiple_role_edit($accoun } } -function user_multiple_delete_confirm(&$form_state) { +function user_multiple_cancel_confirm(&$form_state) { $edit = $form_state['post']; $form['accounts'] = array('#prefix' => '', '#tree' => TRUE); @@ -1875,20 +2017,64 @@ function user_multiple_delete_confirm(&$ $user = db_result(db_query('SELECT name FROM {users} WHERE uid = %d', $uid)); $form['accounts'][$uid] = array('#type' => 'hidden', '#value' => $uid, '#prefix' => '
  • ', '#suffix' => check_plain($user) . "
  • \n"); } - $form['operation'] = array('#type' => 'hidden', '#value' => 'delete'); + + $form['operation'] = array('#type' => 'hidden', '#value' => 'cancel'); + + module_load_include('inc', 'user', 'user.pages'); + $form['user_cancel_method'] = array( + '#type' => 'item', + '#title' => t('When cancelling these accounts'), + ); + $form['user_cancel_method'] += user_cancel_methods(); + // Remove method descriptions. + foreach (element_children($form['user_cancel_method']) as $element) { + unset($form['user_cancel_method'][$element]['#description']); + } + + // Allow to send the account cancellation confirmation mail. + $form['user_cancel_confirm'] = array( + '#type' => 'checkbox', + '#title' => t('Require e-mail confirmation to cancel account.'), + '#default_value' => FALSE, + '#description' => t('When enabled, the user must confirm the account cancellation via e-mail.'), + ); + // Also allow to send account canceled notification mail, if enabled. + $form['user_cancel_notify'] = array( + '#type' => 'checkbox', + '#title' => t('Notify user when account is canceled.'), + '#default_value' => FALSE, + '#access' => variable_get('user_mail_status_canceled_notify', FALSE), + '#description' => t('When enabled, the user will receive an e-mail notification after the account has been cancelled.'), + ); return confirm_form($form, - t('Are you sure you want to delete these users?'), + t('Are you sure you want to cancel these user accounts?'), 'admin/user/user', t('This action cannot be undone.'), - t('Delete all'), t('Cancel')); + t('Cancel accounts'), t('Cancel')); } -function user_multiple_delete_confirm_submit($form, &$form_state) { +/** + * Submit handler for mass-account cancellation form. + * + * @see user_multiple_cancel_confirm() + * @see user_cancel_confirm_form_submit() + */ +function user_multiple_cancel_confirm_submit($form, &$form_state) { + global $user; + if ($form_state['values']['confirm']) { foreach ($form_state['values']['accounts'] as $uid => $value) { - user_delete($form_state['values'], $uid); + // Prevent user administrators from deleting themselves without confirmation. + if ($uid == $user->uid) { + $admin_form_state = $form_state; + unset($admin_form_state['values']['user_cancel_confirm']); + $admin_form_state['values']['_account'] = $user; + user_cancel_confirm_form_submit(array(), $admin_form_state); + } + else { + user_cancel($form_state['values'], $uid, $form_state['values']['user_cancel_method']); + } } - drupal_set_message(t('The users have been deleted.')); } $form_state['redirect'] = 'admin/user/user'; return; @@ -2082,6 +2268,7 @@ function user_mail_tokens($account, $lan '!username' => $account->name, '!site' => variable_get('site_name', 'Drupal'), '!login_url' => user_pass_reset_url($account), + '!cancel_url' => user_cancel_url($account), '!uri' => $base_url, '!uri_brief' => preg_replace('!^https?://!', '', $base_url), '!mailto' => $account->mail, @@ -2133,7 +2320,8 @@ function user_preferred_language($accoun * 'password_reset': Password recovery request * 'status_activated': Account activated * 'status_blocked': Account blocked - * 'status_deleted': Account deleted + * 'cancel_confirm': Account cancellation request + * 'status_canceled': Account canceled * * @param $account * The user object of the account being notified. Must contain at @@ -2144,8 +2332,8 @@ function user_preferred_language($accoun * The return value from drupal_mail_send(), if ends up being called. */ function _user_mail_notify($op, $account, $language = NULL) { - // By default, we always notify except for deleted and blocked. - $default_notify = ($op != 'status_deleted' && $op != 'status_blocked'); + // By default, we always notify except for canceled and blocked. + $default_notify = ($op != 'status_canceled' && $op != 'status_blocked'); $notify = variable_get('user_mail_' . $op . '_notify', $default_notify); if ($notify) { $params['account'] = $account; Index: modules/user/user.pages.inc =================================================================== RCS file: /cvs/drupal/drupal/modules/user/user.pages.inc,v retrieving revision 1.25 diff -u -p -r1.25 user.pages.inc --- modules/user/user.pages.inc 30 Dec 2008 16:43:20 -0000 1.25 +++ modules/user/user.pages.inc 8 Jan 2009 07:18:22 -0000 @@ -228,9 +228,10 @@ function user_edit($account, $category = * @ingroup forms * @see user_profile_form_validate() * @see user_profile_form_submit() - * @see user_edit_delete_submit() + * @see user_cancel_confirm_form_submit() */ function user_profile_form($form_state, $account, $category = 'account') { + global $user; $edit = (empty($form_state['values'])) ? (array)$account : $form_state['values']; @@ -238,12 +239,12 @@ function user_profile_form($form_state, $form['_category'] = array('#type' => 'value', '#value' => $category); $form['_account'] = array('#type' => 'value', '#value' => $account); $form['submit'] = array('#type' => 'submit', '#value' => t('Save'), '#weight' => 30); - if (user_access('administer users')) { - $form['delete'] = array( + if (($account->uid == $user->uid && user_access('cancel account')) || user_access('administer users')) { + $form['cancel'] = array( '#type' => 'submit', - '#value' => t('Delete'), + '#value' => t('Cancel account'), '#weight' => 31, - '#submit' => array('user_edit_delete_submit'), + '#submit' => array('user_edit_cancel_submit'), ); } $form['#attributes']['enctype'] = 'multipart/form-data'; @@ -270,7 +271,7 @@ function user_profile_form_validate($for function user_profile_form_submit($form, &$form_state) { $account = $form_state['values']['_account']; $category = $form_state['values']['_category']; - unset($form_state['values']['_account'], $form_state['values']['op'], $form_state['values']['submit'], $form_state['values']['delete'], $form_state['values']['form_token'], $form_state['values']['form_id'], $form_state['values']['_category'], $form_state['values']['form_build_id']); + unset($form_state['values']['_account'], $form_state['values']['op'], $form_state['values']['submit'], $form_state['values']['cancel'], $form_state['values']['form_token'], $form_state['values']['form_id'], $form_state['values']['_category'], $form_state['values']['form_build_id']); user_module_invoke('submit', $form_state['values'], $account, $category); user_save($account, $form_state['values'], $category); @@ -282,45 +283,212 @@ function user_profile_form_submit($form, } /** - * Submit function for the 'Delete' button on the user edit form. + * Submit function for the 'Cancel account' button on the user edit form. */ -function user_edit_delete_submit($form, &$form_state) { +function user_edit_cancel_submit($form, &$form_state) { $destination = ''; if (isset($_REQUEST['destination'])) { $destination = drupal_get_destination(); unset($_REQUEST['destination']); } - // Note: We redirect from user/uid/edit to user/uid/delete to make the tabs disappear. - $form_state['redirect'] = array("user/" . $form_state['values']['_account']->uid . "/delete", $destination); + // Note: We redirect from user/uid/edit to user/uid/cancel to make the tabs disappear. + $form_state['redirect'] = array("user/" . $form_state['values']['_account']->uid . "/cancel", $destination); } /** - * Form builder; confirm form for user deletion. + * Form builder; confirm form for cancelling user account. * * @ingroup forms - * @see user_confirm_delete_submit() + * @see user_edit_cancel_submit() */ -function user_confirm_delete(&$form_state, $account) { +function user_cancel_confirm_form(&$form_state, $account) { + global $user; $form['_account'] = array('#type' => 'value', '#value' => $account); + // Display account cancellation method selection, if allowed. + $default_method = variable_get('user_cancel_method', 'user_cancel_block'); + $admin_access = user_access('administer users'); + $can_select_method = $admin_access || user_access('select account cancellation method'); + $form['user_cancel_method'] = array( + '#type' => 'item', + '#title' => ($account->uid == $user->uid ? t('When cancelling your account') : t('When cancelling the account')), + '#access' => $can_select_method, + ); + $form['user_cancel_method'] += user_cancel_methods(); + + // Allow user administrators to skip the account cancellation confirmation + // mail (by default), as long as they do not attempt to cancel their own + // account. + $override_access = $admin_access && ($account->uid != $user->uid); + $form['user_cancel_confirm'] = array( + '#type' => 'checkbox', + '#title' => t('Require e-mail confirmation to cancel account.'), + '#default_value' => ($override_access ? FALSE : TRUE), + '#access' => $override_access, + '#description' => t('When enabled, the user must confirm the account cancellation via e-mail.'), + ); + // Also allow to send account canceled notification mail, if enabled. + $default_notify = variable_get('user_mail_status_canceled_notify', FALSE); + $form['user_cancel_notify'] = array( + '#type' => 'checkbox', + '#title' => t('Notify user when account is canceled.'), + '#default_value' => ($override_access ? FALSE : $default_notify), + '#access' => $override_access && $default_notify, + '#description' => t('When enabled, the user will receive an e-mail notification after the account has been cancelled.'), + ); + + // Prepare confirmation form page title and description. + if ($account->uid == $user->uid) { + $question = t('Are you sure you want to cancel your account?'); + } + else { + $question = t('Are you sure you want to cancel the account %name?', array('%name' => $account->name)); + } + if ($can_select_method) { + $description = t('Select the method to cancel the account above.'); + foreach (element_children($form['user_cancel_method']) as $element) { + unset($form['user_cancel_method'][$element]['#description']); + } + } + else { + // The radio button #description is used as description for the confirmation + // form. + foreach (element_children($form['user_cancel_method']) as $element) { + if ($form['user_cancel_method'][$element]['#default_value'] == $form['user_cancel_method'][$element]['#return_value']) { + $description = $form['user_cancel_method'][$element]['#description']; + } + unset($form['user_cancel_method'][$element]['#description']); + } + } + return confirm_form($form, - t('Are you sure you want to delete the account %name?', array('%name' => $account->name)), + $question, 'user/' . $account->uid, - t('All submissions made by this user will be attributed to the anonymous account. This action cannot be undone.'), - t('Delete'), t('Cancel')); + $description . ' ' . t('This action cannot be undone.'), + t('Cancel account'), t('Cancel')); } /** - * Submit function for the confirm form for user deletion. + * Submit handler for the account cancellation confirm form. + * + * @see user_cancel_confirm_form() + * @see user_multiple_cancel_confirm_submit() */ -function user_confirm_delete_submit($form, &$form_state) { - user_delete($form_state['values'], $form_state['values']['_account']->uid); - drupal_set_message(t('%name has been deleted.', array('%name' => $form_state['values']['_account']->name))); +function user_cancel_confirm_form_submit($form, &$form_state) { + global $user; + $account = $form_state['values']['_account']; - if (!isset($_REQUEST['destination'])) { - $form_state['redirect'] = 'admin/user/user'; + // Cancel account immediately, if the current user has administrative + // privileges, no confirmation mail shall be sent, and the user does not + // attempt to cancel the own account. + if (user_access('administer users') && empty($form_state['values']['user_cancel_confirm']) && $account->uid != $user->uid) { + user_cancel($form_state['values'], $account->uid, $form_state['values']['user_cancel_method']); + + if (!isset($_REQUEST['destination'])) { + $form_state['redirect'] = 'admin/user/user'; + } + } + else { + // Store cancelling method and whether to notify the user in $account for + // user_cancel_confirm(). + $edit = array( + 'user_cancel_method' => $form_state['values']['user_cancel_method'], + 'user_cancel_notify' => $form_state['values']['user_cancel_notify'], + ); + $account = user_save($account, $edit); + _user_mail_notify('cancel_confirm', $account); + drupal_set_message(t('A confirmation request to cancel your account has been sent to your e-mail address.')); + + if (!isset($_REQUEST['destination'])) { + $form_state['redirect'] = "user/$account->uid"; + } + } +} + +/** + * Helper function to return available account cancellation methods. + * + * Please refer to the documentation of hook_user_cancel_methods_alter(). + * + * @return + * An array containing all account cancellation methods as form elements. + * + * @see hook_user_cancel_methods_alter() + * @see user_admin_settings() + * @see user_cancel_confirm_form() + * @see user_multiple_cancel_confirm() + */ +function user_cancel_methods() { + $methods = array( + 'user_cancel_block' => array( + 'title' => t('Disable the account and keep all content.'), + 'description' => t('Your account will be blocked and you will no longer be able to log in. All of your content will remain attributed to your user name.'), + ), + 'user_cancel_block_unpublish' => array( + 'title' => t('Disable the account and unpublish all content.'), + 'description' => t('Your account will be blocked and you will no longer be able to log in. All of your content will be hidden from everyone but administrators.'), + ), + 'user_cancel_anonymize' => array( + 'title' => t('Delete the account and make all content belong to the %anonymous-name user.', array('%anonymous-name' => variable_get('anonymous', t('Anonymous')))), + 'description' => t('Your account will be removed and all account information deleted. All of your content will be assigned to the %anonymous-name user.', array('%anonymous-name' => variable_get('anonymous', t('Anonymous')))), + ), + 'user_cancel_delete' => array( + 'title' => t('Delete the account and all content.'), + 'description' => t('Your account will be removed and all account information deleted. All of your content will also be deleted.'), + 'access' => user_access('administer users'), + ), + ); + // Allow modules to customize account cancellation methods. + drupal_alter('user_cancel_methods', $methods); + + // Turn all methods into real form elements. + $default_method = variable_get('user_cancel_method', 'user_cancel_block'); + $form = array(); + foreach ($methods as $name => $method) { + $form[$name] = array( + '#type' => 'radio', + '#title' => $method['title'], + '#description' => (isset($method['description']) ? $method['description'] : NULL), + '#return_value' => $name, + '#default_value' => $default_method, + '#parents' => array('user_cancel_method'), + '#required' => TRUE, + ); + } + return $form; +} + +/** + * Menu callback; Cancel a user account via e-mail confirmation link. + * + * @see user_cancel_confirm_form() + * @see user_cancel_url() + */ +function user_cancel_confirm($account, $timestamp = 0, $hashed_pass = '') { + // Time out in seconds until cancel URL expires; 24 hours = 86400 seconds. + $timeout = 86400; + $current = REQUEST_TIME; + + // Basic validation of arguments. + if (isset($account->user_cancel_method) && !empty($timestamp) && !empty($hashed_pass)) { + // Validate expiration and hashed password/login. + if ($timestamp <= $current && $current - $timestamp < $timeout && $account->uid && $timestamp >= $account->login && $hashed_pass == user_pass_rehash($account->pass, $timestamp, $account->login)) { + $edit = array( + 'user_cancel_notify' => isset($account->user_cancel_notify) ? $account->user_cancel_notify : variable_get('user_mail_status_canceled_notify', FALSE), + ); + user_cancel($edit, $account->uid, $account->user_cancel_method); + // Since user_cancel() is not invoked via Form API, batch processing needs + // to be invoked manually and should redirect to the front page after + // completion. + batch_process(''); + } + else { + drupal_set_message(t('You have tried to use an account cancellation link that has expired. Please request a new one using the form below.')); + drupal_goto("user/$account->uid/cancel"); + } } + drupal_access_denied(); } function user_edit_validate($form, &$form_state) { @@ -336,7 +504,7 @@ function user_edit_validate($form, &$for function user_edit_submit($form, &$form_state) { $account = $form_state['values']['_account']; $category = $form_state['values']['_category']; - unset($form_state['values']['_account'], $form_state['values']['op'], $form_state['values']['submit'], $form_state['values']['delete'], $form_state['values']['form_token'], $form_state['values']['form_id'], $form_state['values']['_category'], $form_state['values']['form_build_id']); + unset($form_state['values']['_account'], $form_state['values']['op'], $form_state['values']['submit'], $form_state['values']['cancel'], $form_state['values']['form_token'], $form_state['values']['form_id'], $form_state['values']['_category'], $form_state['values']['form_build_id']); user_module_invoke('submit', $form_state['values'], $account, $category); user_save($account, $form_state['values'], $category); Index: modules/user/user.test =================================================================== RCS file: /cvs/drupal/drupal/modules/user/user.test,v retrieving revision 1.24 diff -u -p -r1.24 user.test --- modules/user/user.test 30 Dec 2008 16:43:20 -0000 1.24 +++ modules/user/user.test 8 Jan 2009 07:23:25 -0000 @@ -151,45 +151,334 @@ class UserValidationTestCase extends Dru } } - -class UserDeleteTestCase extends DrupalWebTestCase { +class UserCancelTestCase extends DrupalWebTestCase { function getInfo() { return array( - 'name' => t('User delete'), - 'description' => t('Registers a user and deletes it.'), - 'group' => t('User') + 'name' => t('Cancel account'), + 'description' => t('Ensure that account cancellation methods work as expected.'), + 'group' => t('User'), ); } /** - * Registers a user and deletes it. + * Attempt to cancel account without permission. */ - function testUserRegistration() { - // Set user registration to "Visitors can create accounts and no administrator approval is required." - variable_set('user_register', 1); + function testUserCancelWithoutPermission() { + variable_set('user_cancel_method', 'user_cancel_anonymize'); - $edit = array(); - $edit['name'] = $this->randomName(); - $edit['mail'] = $edit['name'] . '@example.com'; - $this->drupalPost('user/register', $edit, t('Create new account')); - $this->assertText(t('Your password and further instructions have been sent to your e-mail address.'), t('User registered successfully.')); + // Create a user. + $account = $this->drupalCreateUser(array()); + $this->drupalLogin($account); + // Load real user object. + $account = user_load($account->uid); - $user = user_load($edit); + // Create a node. + $node = $this->drupalCreateNode(array('uid' => $account->uid)); - // Create admin user to delete registered user. + // Attempt to cancel account. + $this->drupalGet('user/' . $account->uid . '/edit'); + $this->assertNoRaw(t('Cancel account'), t('No cancel account button displayed.')); + + // Attempt bogus account cancellation request confirmation. + $timestamp = $account->login; + $this->drupalGet("user/$account->uid/cancel/confirm/$timestamp/" . user_pass_rehash($account->pass, $timestamp, $account->login)); + $this->assertResponse(403, t('Bogus cancelling request rejected.')); + $this->assertTrue(user_load(array('uid' => $account->uid, 'status' => 1)), t('User account was not canceled.')); + + // Confirm user's content has not been altered. + $test_node = node_load($node->nid, NULL, TRUE); + $this->assertTrue(($test_node->uid == $account->uid && $test_node->status == 1), t('Node of the user has not been altered.')); + } + + /** + * Attempt invalid account cancellations. + */ + function testUserCancelInvalid() { + variable_set('user_cancel_method', 'user_cancel_anonymize'); + + // Create a user. + $account = $this->drupalCreateUser(array('cancel account')); + $this->drupalLogin($account); + // Load real user object. + $account = user_load($account->uid); + + // Create a node. + $node = $this->drupalCreateNode(array('uid' => $account->uid)); + + // Attempt to cancel account. + $this->drupalPost('user/' . $account->uid . '/edit', NULL, t('Cancel account')); + + // Confirm account cancellation. + $timestamp = time(); + $this->drupalPost(NULL, NULL, t('Cancel account')); + $this->assertText(t('A confirmation request to cancel your account has been sent to your e-mail address.'), t('Account cancellation request mailed message displayed.')); + + // Attempt bogus account cancellation request confirmation. + $bogus_timestamp = $timestamp + 60; + $this->drupalGet("user/$account->uid/cancel/confirm/$bogus_timestamp/" . user_pass_rehash($account->pass, $bogus_timestamp, $account->login)); + $this->assertText(t('You have tried to use an account cancellation link that has expired. Please request a new one using the form below.'), t('Bogus cancelling request rejected.')); + $this->assertTrue(user_load(array('uid' => $account->uid, 'status' => 1)), t('User account was not canceled.')); + + // Attempt expired account cancellation request confirmation. + $bogus_timestamp = $timestamp - 86400 - 60; + $this->drupalGet("user/$account->uid/cancel/confirm/$bogus_timestamp/" . user_pass_rehash($account->pass, $bogus_timestamp, $account->login)); + $this->assertText(t('You have tried to use an account cancellation link that has expired. Please request a new one using the form below.'), t('Expired cancel account request rejected.')); + $this->assertTrue(user_load(array('uid' => $account->uid, 'status' => 1)), t('User account was not canceled.')); + + // Confirm user's content has not been altered. + $test_node = node_load($node->nid, NULL, TRUE); + $this->assertTrue(($test_node->uid == $account->uid && $test_node->status == 1), t('Node of the user has not been altered.')); + } + + /** + * Disable account and keep all content. + */ + function testUserBlock() { + variable_set('user_cancel_method', 'user_cancel_block'); + + // Create a user. + $account = $this->drupalCreateUser(array('cancel account')); + $this->drupalLogin($account); + // Load real user object. + $account = user_load($account->uid); + + // Attempt to cancel account. + $this->drupalGet('user/' . $account->uid . '/edit'); + $this->drupalPost(NULL, NULL, t('Cancel account')); + $this->assertText(t('Are you sure you want to cancel your account?'), t('Confirmation form to cancel account displayed.')); + $this->assertText(t('Your account will be blocked and you will no longer be able to log in. All of your content will remain attributed to your user name.'), t('Informs that all content will be remain as is.')); + $this->assertNoText(t('Select the method to cancel the account above.'), t('Does not allow user to select account cancellation method.')); + + // Confirm account cancellation. + $timestamp = time(); + $this->drupalPost(NULL, NULL, t('Cancel account')); + $this->assertText(t('A confirmation request to cancel your account has been sent to your e-mail address.'), t('Account cancellation request mailed message displayed.')); + + // Confirm account cancellation request. + $this->drupalGet("user/$account->uid/cancel/confirm/$timestamp/" . user_pass_rehash($account->pass, $timestamp, $account->login)); + $this->assertTrue(user_load(array('uid' => $account->uid, 'status' => 0)), t('User has been blocked.')); + + // Confirm user is logged out. + $this->assertNoText($account->name, t('Logged out.')); + } + + /** + * Disable account and unpublish all content. + */ + function testUserBlockUnpublish() { + variable_set('user_cancel_method', 'user_cancel_block_unpublish'); + + // Create a user. + $account = $this->drupalCreateUser(array('cancel account')); + $this->drupalLogin($account); + // Load real user object. + $account = user_load($account->uid); + + // Create a node with two revisions. + $node = $this->drupalCreateNode(array('uid' => $account->uid)); + $settings = get_object_vars($node); + $settings['revision'] = 1; + $node = $this->drupalCreateNode($settings); + + // Attempt to cancel account. + $this->drupalGet('user/' . $account->uid . '/edit'); + $this->drupalPost(NULL, NULL, t('Cancel account')); + $this->assertText(t('Are you sure you want to cancel your account?'), t('Confirmation form to cancel account displayed.')); + $this->assertText(t('Your account will be blocked and you will no longer be able to log in. All of your content will be hidden from everyone but administrators.'), t('Informs that all content will be unpublished.')); + + // Confirm account cancellation. + $timestamp = time(); + $this->drupalPost(NULL, NULL, t('Cancel account')); + $this->assertText(t('A confirmation request to cancel your account has been sent to your e-mail address.'), t('Account cancellation request mailed message displayed.')); + + // Confirm account cancellation request. + $this->drupalGet("user/$account->uid/cancel/confirm/$timestamp/" . user_pass_rehash($account->pass, $timestamp, $account->login)); + $this->assertTrue(user_load(array('uid' => $account->uid, 'status' => 0)), t('User has been blocked.')); + + // Confirm user's content has been unpublished. + $test_node = node_load($node->nid, NULL, TRUE); + $this->assertTrue($test_node->status == 0, t('Node of the user has been unpublished.')); + $test_node = node_load($node->nid, $node->vid, TRUE); + $this->assertTrue($test_node->status == 0, t('Node revision of the user has been unpublished.')); + + // Confirm user is logged out. + $this->assertNoText($account->name, t('Logged out.')); + } + + /** + * Delete account and anonymize all content. + */ + function testUserAnonymize() { + variable_set('user_cancel_method', 'user_cancel_anonymize'); + + // Create a user. + $account = $this->drupalCreateUser(array('cancel account')); + $this->drupalLogin($account); + // Load real user object. + $account = user_load($account->uid); + + // Create a simple node. + $node = $this->drupalCreateNode(array('uid' => $account->uid)); + + // Create a node with two revisions, the initial one belonging to the + // cancelling user. + $revision_node = $this->drupalCreateNode(array('uid' => $account->uid)); + $revision = $revision_node->vid; + $settings = get_object_vars($revision_node); + $settings['revision'] = 1; + $settings['uid'] = 1; // Set new/current revision to someone else. + $revision_node = $this->drupalCreateNode($settings); + + // Attempt to cancel account. + $this->drupalGet('user/' . $account->uid . '/edit'); + $this->drupalPost(NULL, NULL, t('Cancel account')); + $this->assertText(t('Are you sure you want to cancel your account?'), t('Confirmation form to cancel account displayed.')); + $this->assertRaw(t('Your account will be removed and all account information deleted. All of your content will be assigned to the %anonymous-name user.', array('%anonymous-name' => variable_get('anonymous', t('Anonymous')))), t('Informs that all content will be attributed to anonymous account.')); + + // Confirm account cancellation. + $timestamp = time(); + $this->drupalPost(NULL, NULL, t('Cancel account')); + $this->assertText(t('A confirmation request to cancel your account has been sent to your e-mail address.'), t('Account cancellation request mailed message displayed.')); + + // Confirm account cancellation request. + $this->drupalGet("user/$account->uid/cancel/confirm/$timestamp/" . user_pass_rehash($account->pass, $timestamp, $account->login)); + $this->assertFalse(user_load($account->uid), t('User is not found in the database.')); + + // Confirm that user's content has been attributed to anonymous user. + $test_node = node_load($node->nid, NULL, TRUE); + $this->assertTrue(($test_node->uid == 0 && $test_node->status == 1), t('Node of the user has been attributed to anonymous user.')); + $test_node = node_load($revision_node->nid, $revision, TRUE); + $this->assertTrue(($test_node->uid == 0 && $test_node->status == 1), t('Node revision of the user has been attributed to anonymous user.')); + $test_node = node_load($revision_node->nid, NULL, TRUE); + $this->assertTrue(($test_node->uid != 0 && $test_node->status == 1), t("Current revision of the user's node was not attributed to anonymous user.")); + + // Confirm that user is logged out. + $this->assertNoText($account->name, t('Logged out.')); + } + + /** + * Delete account and remove all content. + */ + function testUserDelete() { + variable_set('user_cancel_method', 'user_cancel_delete'); + + // Create a user. + $account = $this->drupalCreateUser(array('cancel account')); + $this->drupalLogin($account); + // Load real user object. + $account = user_load($account->uid); + + // Create a simple node. + $node = $this->drupalCreateNode(array('uid' => $account->uid)); + + // Create comment. + module_load_include('test', 'comment'); + $comment = CommentHelperCase::postComment($node, '', $this->randomName(32), TRUE, TRUE); + $this->assertTrue(comment_load($comment->id), t('Comment found.')); + + // Create a node with two revisions, the initial one belonging to the + // cancelling user. + $revision_node = $this->drupalCreateNode(array('uid' => $account->uid)); + $revision = $revision_node->vid; + $settings = get_object_vars($revision_node); + $settings['revision'] = 1; + $settings['uid'] = 1; // Set new/current revision to someone else. + $revision_node = $this->drupalCreateNode($settings); + + // Attempt to cancel account. + $this->drupalGet('user/' . $account->uid . '/edit'); + $this->drupalPost(NULL, NULL, t('Cancel account')); + $this->assertText(t('Are you sure you want to cancel your account?'), t('Confirmation form to cancel account displayed.')); + $this->assertText(t('Your account will be removed and all account information deleted. All of your content will also be deleted.'), t('Informs that all content will be deleted.')); + + // Confirm account cancellation. + $timestamp = time(); + $this->drupalPost(NULL, NULL, t('Cancel account')); + $this->assertText(t('A confirmation request to cancel your account has been sent to your e-mail address.'), t('Account cancellation request mailed message displayed.')); + + // Confirm account cancellation request. + $this->drupalGet("user/$account->uid/cancel/confirm/$timestamp/" . user_pass_rehash($account->pass, $timestamp, $account->login)); + $this->assertFalse(user_load($account->uid), t('User is not found in the database.')); + + // Confirm that user's content has been deleted. + $this->assertFalse(node_load($node->nid, NULL, TRUE), t('Node of the user has been deleted.')); + $this->assertFalse(node_load($node->nid, $revision, TRUE), t('Node revision of the user has been deleted.')); + $this->assertTrue(node_load($revision_node->nid, NULL, TRUE), t("Current revision of the user's node was not deleted.")); + $this->assertFalse(comment_load($comment->id), t('Comment of the user has been deleted.')); + + // Confirm that user is logged out. + $this->assertNoText($account->name, t('Logged out.')); + } + + /** + * Create an administrative user and delete another user. + */ + function testUserCancelByAdmin() { + variable_set('user_cancel_method', 'user_cancel_anonymize'); + + // Create a regular user. + $account = $this->drupalCreateUser(array()); + + // Create administrative user. $admin_user = $this->drupalCreateUser(array('administer users')); $this->drupalLogin($admin_user); - // Delete user. - $this->drupalGet('user/' . $user->uid . '/edit'); - $this->drupalPost(NULL, NULL, t('Delete')); - $this->assertRaw(t('Are you sure you want to delete the account %name?', array('%name' => $user->name)), t('[confirm deletion] Asks for confirmation.')); - $this->assertText(t('All submissions made by this user will be attributed to the anonymous account. This action cannot be undone.'), t('[confirm deletion] Inform that all submissions will be attributed to anonymous account.')); + // Delete regular user. + $this->drupalGet('user/' . $account->uid . '/edit'); + $this->drupalPost(NULL, NULL, t('Cancel account')); + $this->assertRaw(t('Are you sure you want to cancel the account %name?', array('%name' => $account->name)), t('Confirmation form to cancel account displayed.')); + $this->assertText(t('Select the method to cancel the account above.'), t('Allows to select account cancellation method.')); // Confirm deletion. - $this->drupalPost(NULL, NULL, t('Delete')); - $this->assertRaw(t('%name has been deleted.', array('%name' => $user->name)), t('User deleted')); - $this->assertFalse(user_load($edit), t('User is not found in the database')); + $this->drupalPost(NULL, NULL, t('Cancel account')); + $this->assertRaw(t('%name has been deleted.', array('%name' => $account->name)), t('User deleted.')); + $this->assertFalse(user_load($account->uid), t('User is not found in the database.')); + } + + /** + * Create an administrative user and mass-delete other users. + */ + function testMassUserCancelByAdmin() { + variable_set('user_cancel_method', 'user_cancel_anonymize'); + // Enable account cancellation notification. + variable_set('user_mail_status_canceled_notify', TRUE); + + // Create administrative user. + $admin_user = $this->drupalCreateUser(array('administer users')); + $this->drupalLogin($admin_user); + + // Create some users. + $users = array(); + for ($i = 0; $i < 3; $i++) { + $account = $this->drupalCreateUser(array()); + $users[$account->uid] = $account; + } + + // Cancel user accounts, including own one. + $edit = array(); + $edit['operation'] = 'cancel'; + foreach ($users as $uid => $account) { + $edit['accounts['. $uid .']'] = TRUE; + } + $edit['accounts['. $admin_user->uid .']'] = TRUE; + $this->drupalPost('admin/user/user', $edit, t('Update')); + $this->assertText(t('Are you sure you want to cancel these user accounts?'), t('Confirmation form to cancel accounts displayed.')); + $this->assertText(t('When cancelling these accounts'), t('Allows to select account cancellation method.')); + $this->assertText(t('Require e-mail confirmation to cancel account.'), t('Allows to send confirmation mail.')); + $this->assertText(t('Notify user when account is canceled.'), t('Allows to send notification mail.')); + + // Confirm deletion. + $this->drupalPost(NULL, NULL, t('Cancel accounts')); + $status = TRUE; + foreach ($users as $account) { + $status = $status && (strpos($this->content, t('%name has been deleted.', array('%name' => $account->name))) !== FALSE); + $status = $status && !user_load($account->uid); + } + $this->assertTrue($status, t('Users deleted and not found in the database.')); + + // Ensure that admin account was not cancelled. + $this->assertText(t('A confirmation request to cancel your account has been sent to your e-mail address.'), t('Account cancellation request mailed message displayed.')); + $this->assertTrue(user_load(array('uid' => $admin_user->uid, 'status' => 1)), t('Administrative user is found in the database and enabled.')); } }