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 7 Jan 2009 00:59:52 -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 7 Jan 2009 00:59:52 -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 7 Jan 2009 00:59:52 -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. } @@ -203,7 +203,7 @@ 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))); // Delete user. - user_delete(array(), $user->uid); + user_cancel(array(), $user->uid, USER_CANCEL_ANONYMIZE); // 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))); 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 7 Jan 2009 00:59:52 -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 7 Jan 2009 00:59:52 -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 7 Jan 2009 01:16:01 -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 7 Jan 2009 00:59:52 -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 7 Jan 2009 03:57:16 -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 14:35:59 -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,19 @@ 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(); + // User e-mail settings. $form['email'] = array( '#type' => 'fieldset', @@ -243,7 +256,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 +388,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 7 Jan 2009 16:52:12 -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,96 @@ 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 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 was invoked. + * The following properties can be defined for each method: + * - #title: The title to use for the radio button. + * - #description: (optional) A description to display as confirmation form + * description, if the current user attempts to cancel the own account and + * is not allowed to select the account cancellation method. Note: This + * description is not displayed for the radio button. + * - #access: (optional) A boolean value indicating whether the user can access + * a method. This property should not be set on the 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 only 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 +202,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 7 Jan 2009 14:39:20 -0000 @@ -17,6 +17,27 @@ define('USERNAME_MAX_LENGTH', 60); define('EMAIL_MAX_LENGTH', 64); /** + * Account cancellation method: Disable user account and keep all contents. + */ +define('USER_CANCEL_BLOCK', 0); + +/** + * Account cancellation method: Disable user account and unpublish all contents. + */ +define('USER_CANCEL_BLOCK_UNPUBLISH', 1); + +/** + * Account cancellation method: Remove user account and anonymize all contents. + */ +define('USER_CANCEL_ANONYMIZE', 2); + +/** + * Account cancellation method: Remove user account and remove all contents. + */ +define('USER_CANCEL_DELETE', 3); + + +/** * Invokes hook_user() in every module. * * We cannot use module_invoke() for this, because the arguments need to @@ -587,6 +608,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 +977,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 +1121,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 +1493,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 +1654,106 @@ function _user_edit_submit($account, &$e } /** - * Delete a user. + * Cancel a user account. + * + * 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 to delete. + * @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 +1826,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 +1914,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 +2028,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 +2037,60 @@ 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(); + + // 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 +2284,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 +2336,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 +2348,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 7 Jan 2009 16:52:33 -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 account cancellation methods. + * + * This function generates all available account cancellation methods as form + * elements and allows modules to modify the default methods. Each method must + * define a '#title', and can optionally define '#access' (to limit access to a + * method to certain users only) as well as '#description'. Please note that + * the description is NOT used for the radio button, but is displayed as the + * confirmation form description instead to further explain the consequences of + * cancelling the own account to the user. + * + * @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.'), + ), + USER_CANCEL_BLOCK_UNPUBLISH => array( + '#title' => t('Disable the account and unpublish all content.'), + '#description' => t('All your content will be unpublished.'), + ), + 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('All 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('All your content will 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); + foreach (element_children($methods) as $method) { + $item = &$methods[$method]; + $item['#type'] = 'radio'; + $item['#return_value'] = $method; + $item['#parents'] = array('user_cancel_method'); + $item['#default_value'] = $default_method; + $item['#required'] = TRUE; + } + return $methods; +} + +/** + * 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 7 Jan 2009 13:10:45 -0000 @@ -151,45 +151,333 @@ 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->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('All your content will be unpublished.'), 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('All 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('All your content will 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.')); } }