Version: current

       1 <?php
       2 /**
       3  * The member class which represents the users of the system
       4  * @package sapphire
       5  * @subpackage security
       6  */
       7 class Member extends DataObject {
       8
       9     static $db = array(
      10         'FirstName' => 'Varchar',
      11         'Surname' => 'Varchar',
      12         'Email' => 'Varchar',
      13         'Password' => 'Varchar(160)',
      14         'RememberLoginToken' => 'Varchar(50)',
      15         'NumVisit' => 'Int',
      16         'LastVisited' => 'SS_Datetime',
      17         'Bounced' => 'Boolean', // Note: This does not seem to be used anywhere.
      18         'AutoLoginHash' => 'Varchar(30)',
      19         'AutoLoginExpired' => 'SS_Datetime',
      20         // This is an arbitrary code pointing to a PasswordEncryptor instance,
      21         // not an actual encryption algorithm.
      22         // Warning: Never change this field after its the first password hashing without
      23         // providing a new cleartext password as well.
      24         'PasswordEncryption' => "Varchar(50)",
      25         'Salt' => 'Varchar(50)',
      26         'PasswordExpiry' => 'Date',
      27         'LockedOutUntil' => 'SS_Datetime',
      28         'Locale' => 'Varchar(6)',
      29         // handled in registerFailedLogin(), only used if $lock_out_after_incorrect_logins is set
      30         'FailedLoginCount' => 'Int',
      31     );
      32
      33     static $belongs_many_many = array(
      34         'Groups' => 'Group',
      35     );
      36
      37     static $has_one = array();
      38
      39     static $has_many = array();
      40
      41     static $many_many = array();
      42
      43     static $many_many_extraFields = array();
      44
      45     static $default_sort = '"Surname", "FirstName"';
      46
      47     static $indexes = array(
      48         'Email' => true,
      49         //'AutoLoginHash' => Array('type'=>'unique', 'value'=>'AutoLoginHash', 'ignoreNulls'=>true) //Removed due to duplicate null values causing MSSQL problems
      50     );
      51
      52     static $notify_password_change = false;
      53
      54     /**
      55      * All searchable database columns
      56      * in this object, currently queried
      57      * with a "column LIKE '%keywords%'
      58      * statement.
      59      *
      60      * @var array
      61      * @todo Generic implementation of $searchable_fields on DataObject,
      62      * with definition for different searching algorithms
      63      * (LIKE, FULLTEXT) and default FormFields to construct a searchform.
      64      */
      65     static $searchable_fields = array(
      66         'FirstName',
      67         'Surname',
      68         'Email',
      69     );
      70
      71     static $summary_fields = array(
      72         'FirstName' => 'First Name',
      73         'Surname' => 'Last Name',
      74         'Email' => 'Email',
      75     );
      76
      77     /**
      78      * @var Array See {@link set_title_columns()}
      79      */
      80     static $title_format = null;
      81
      82     /**
      83      * The unique field used to identify this member.
      84      * By default, it's "Email", but another common
      85      * field could be Username.
      86      *
      87      * @var string
      88      */
      89     protected static $unique_identifier_field = 'Email';
      90
      91     /**
      92      * {@link PasswordValidator} object for validating user's password
      93      */
      94     protected static $password_validator = null;
      95
      96     /**
      97      * The number of days that a password should be valid for.
      98      * By default, this is null, which means that passwords never expire
      99      */
     100     protected static $password_expiry_days = null;
     101
     102     protected static $lock_out_after_incorrect_logins = null;
     103
     104     /**
     105      * If this is set, then a session cookie with the given name will be set on log-in,
     106      * and cleared on logout.
     107      */
     108     protected static $login_marker_cookie = null;
     109
     110
     111     /**
     112      * Ensure the locale is set to something sensible by default.
     113      */
     114     public function populateDefaults() {
     115         parent::populateDefaults();
     116         $this->Locale = i18n::get_locale();
     117     }
     118
     119     function requireDefaultRecords() {
     120         // Default groups should've been built by Group->requireDefaultRecords() already
     121
     122         // Find or create ADMIN group
     123         $adminGroups = Permission::get_groups_by_permission('ADMIN');
     124         if(!$adminGroups) {
     125             singleton('Group')->requireDefaultRecords();
     126             $adminGroups = Permission::get_groups_by_permission('ADMIN');
     127         }
     128         $adminGroup = $adminGroups->First();
     129
     130         // Add a default administrator to the first ADMIN group found (most likely the default
     131         // group created through Group->requireDefaultRecords()).
     132         $admins = Permission::get_members_by_permission('ADMIN');
     133         if(!$admins) {
     134             // Leave 'Email' and 'Password' are not set to avoid creating
     135             // persistent logins in the database. See Security::setDefaultAdmin().
     136             $admin = Object::create('Member');
     137             $admin->FirstName = _t('Member.DefaultAdminFirstname', 'Default Admin');
     138             $admin->write();
     139             $admin->Groups()->add($adminGroup);
     140         }
     141     }
     142
     143     /**
     144      * If this is called, then a session cookie will be set to "1" whenever a user
     145      * logs in.  This lets 3rd party tools, such as apache's mod_rewrite, detect
     146      * whether a user is logged in or not and alter behaviour accordingly.
     147      *
     148      * One known use of this is to bypass static caching for logged in users.  This is
     149      * done by putting this into _config.php
     150      * <pre>
     151      * Member::set_login_marker_cookie("SS_LOGGED_IN");
     152      * </pre>
     153      *
     154      * And then adding this condition to each of the rewrite rules that make use of
     155      * the static cache.
     156      * <pre>
     157      * RewriteCond %{HTTP_COOKIE} !SS_LOGGED_IN=1
     158      * </pre>
     159      *
     160      * @param $cookieName string The name of the cookie to set.
     161      */
     162     static function set_login_marker_cookie($cookieName) {
     163         self::$login_marker_cookie = $cookieName;
     164     }
     165
     166     /**
     167      * Check if the passed password matches the stored one (if the member is not locked out).
     168      *
     169      * @param  string $password
     170      * @return ValidationResult
     171      */
     172     public function checkPassword($password) {
     173         $result = $this->canLogIn();
     174         // <inxo>
     175         if(!$result->valid()){
     176             return $result;
     177         }
     178         // </inxo>
     179         $spec = Security::encrypt_password(
     180             $password,
     181             $this->Salt,
     182             $this->PasswordEncryption,
     183             $this
     184         );
     185         $e = $spec['encryptor'];
     186
     187         if(!$e->compare($this->Password, $spec['password'])) {
     188             $result->error(_t (
     189                 'Member.ERRORWRONGCRED',
     190                 'That doesn\'t seem to be the right e-mail address or password. Please try again.'
     191             ));
     192         }
     193
     194         return $result;
     195     }
     196
     197     /**
     198      * Returns a valid {@link ValidationResult} if this member can currently log in, or an invalid
     199      * one with error messages to display if the member is locked out.
     200      *
     201      * You can hook into this with a "canLogIn" method on an attached extension.
     202      *
     203      * @return ValidationResult
     204      */
     205     public function canLogIn() {
     206         $result = new ValidationResult();
     207
     208         if($this->isLockedOut()) {
     209             $result->error(_t (
     210                 'Member.ERRORLOCKEDOUT',
     211                 'Your account has been temporarily disabled because of too many failed attempts at ' .
     212                 'logging in. Please try again in 20 minutes.'
     213             ));
     214         }
     215
     216         $this->extend('canLogIn', $result);
     217         return $result;
     218     }
     219
     220     /**
     221      * Returns true if this user is locked out
     222      */
     223     public function isLockedOut() {
     224         return $this->LockedOutUntil && time() < strtotime($this->LockedOutUntil);
     225     }
     226
     227     /**
     228      * Regenerate the session_id.
     229      * This wrapper is here to make it easier to disable calls to session_regenerate_id(), should you need to.
     230      * They have caused problems in certain
     231      * quirky problems (such as using the Windmill 0.3.6 proxy).
     232      */
     233     static function session_regenerate_id() {
     234         // This can be called via CLI during testing.
     235         if(Director::is_cli()) return;
     236
     237         $file = '';
     238         $line = '';
     239
     240         // @ is to supress win32 warnings/notices when session wasn't cleaned up properly
     241         // There's nothing we can do about this, because it's an operating system function!
     242         if(!headers_sent($file, $line)) @session_regenerate_id(true);
     243     }
     244
     245     /**
     246      * Get the field used for uniquely identifying a member
     247      * in the database. {@see Member::$unique_identifier_field}
     248      *
     249      * @return string
     250      */
     251     static function get_unique_identifier_field() {
     252         return self::$unique_identifier_field;
     253     }
     254
     255     /**
     256      * Set the field used for uniquely identifying a member
     257      * in the database. {@see Member::$unique_identifier_field}
     258      *
     259      * @param $field The field name to set as the unique field
     260      */
     261     static function set_unique_identifier_field($field) {
     262         self::$unique_identifier_field = $field;
     263     }
     264
     265     /**
     266      * Set a {@link PasswordValidator} object to use to validate member's passwords.
     267      */
     268     static function set_password_validator($pv) {
     269         self::$password_validator = $pv;
     270     }
     271
     272     /**
     273      * Returns the current {@link PasswordValidator}
     274      */
     275     static function password_validator() {
     276         return self::$password_validator;
     277     }
     278
     279     /**
     280      * Set the number of days that a password should be valid for.
     281      * Set to null (the default) to have passwords never expire.
     282      */
     283     static function set_password_expiry($days) {
     284         self::$password_expiry_days = $days;
     285     }
     286
     287     /**
     288      * Configure the security system to lock users out after this many incorrect logins
     289      */
     290     static function lock_out_after_incorrect_logins($numLogins) {
     291         self::$lock_out_after_incorrect_logins = $numLogins;
     292     }
     293
     294
     295     function isPasswordExpired() {
     296         if(!$this->PasswordExpiry) return false;
     297         return strtotime(date('Y-m-d')) >= strtotime($this->PasswordExpiry);
     298     }
     299
     300     /**
     301      * Logs this member in
     302      *
     303      * @param bool $remember If set to TRUE, the member will be logged in automatically the next time.
     304      */
     305     function logIn($remember = false) {
     306         self::session_regenerate_id();
     307
     308         Session::set("loggedInAs", $this->ID);
     309         // This lets apache rules detect whether the user has logged in
     310         if(self::$login_marker_cookie) Cookie::set(self::$login_marker_cookie, 1, 0);
     311
     312         $this->NumVisit++;
     313
     314         if($remember) {
     315             $token = substr(md5(uniqid(rand(), true)), 0, 49 - strlen($this->ID));
     316             $this->RememberLoginToken = $token;
     317             Cookie::set('alc_enc', $this->ID . ':' . $token, 90, null, null, null, true);
     318         } else {
     319             $this->RememberLoginToken = null;
     320             Cookie::set('alc_enc', null);
     321             Cookie::forceExpiry('alc_enc');
     322         }
     323
     324         // Clear the incorrect log-in count
     325         if(self::$lock_out_after_incorrect_logins) {
     326             $this->FailedLoginCount = 0;
     327         }
     328
     329         // Don't set column if its not built yet (the login might be precursor to a /dev/build...)
     330         if(array_key_exists('LockedOutUntil', DB::fieldList('Member'))) {
     331             $this->LockedOutUntil = null;
     332         }
     333
     334         $this->write();
     335
     336         // Audit logging hook
     337         $this->extend('memberLoggedIn');
     338     }
     339
     340     /**
     341      * Check if the member ID logged in session actually
     342      * has a database record of the same ID. If there is
     343      * no logged in user, FALSE is returned anyway.
     344      *
     345      * @return boolean TRUE record found FALSE no record found
     346      */
     347     static function logged_in_session_exists() {
     348         if($id = Member::currentUserID()) {
     349             if($member = DataObject::get_by_id('Member', $id)) {
     350                 if($member->exists()) return true;
     351             }
     352         }
     353
     354         return false;
     355     }
     356
     357     /**
     358      * Log the user in if the "remember login" cookie is set
     359      *
     360      * The <i>remember login token</i> will be changed on every successful
     361      * auto-login.
     362      */
     363     static function autoLogin() {
     364         // Don't bother trying this multiple times
     365         self::$_already_tried_to_auto_log_in = true;
     366
     367         if(strpos(Cookie::get('alc_enc'), ':') && !Session::get("loggedInAs")) {
     368             list($uid, $token) = explode(':', Cookie::get('alc_enc'), 2);
     369             $SQL_uid = Convert::raw2sql($uid);
     370
     371             $member = DataObject::get_one("Member", "\"Member\".\"ID\" = '$SQL_uid'");
     372
     373             // check if autologin token matches
     374             if($member && (!$member->RememberLoginToken || $member->RememberLoginToken != $token)) {
     375                 $member = null;
     376             }
     377
     378             if($member) {
     379                 self::session_regenerate_id();
     380                 Session::set("loggedInAs", $member->ID);
     381                 // This lets apache rules detect whether the user has logged in
     382                 if(self::$login_marker_cookie) Cookie::set(self::$login_marker_cookie, 1, 0, null, null, false, true);
     383
     384                 $token = substr(md5(uniqid(rand(), true)), 0, 49 - strlen($member->ID));
     385                 $member->RememberLoginToken = $token;
     386                 Cookie::set('alc_enc', $member->ID . ':' . $token, 90, null, null, false, true);
     387
     388                 $member->NumVisit++;
     389                 $member->write();
     390
     391                 // Audit logging hook
     392                 $member->extend('memberAutoLoggedIn');
     393             }
     394         }
     395     }
     396
     397     /**
     398      * Logs this member out.
     399      */
     400     function logOut() {
     401         Session::clear("loggedInAs");
     402         if(self::$login_marker_cookie) Cookie::set(self::$login_marker_cookie, null, 0);
     403         self::session_regenerate_id();
     404
     405         $this->extend('memberBeforeLoggedOut');
     406
     407         $this->RememberLoginToken = null;
     408         Cookie::set('alc_enc', null);
     409         Cookie::forceExpiry('alc_enc');
     410
     411         $this->write();
     412
     413         // Audit logging hook
     414         $this->extend('memberLoggedOut');
     415     }
     416
     417
     418     /**
     419      * Generate an auto login hash
     420      *
     421      * This creates an auto login hash that can be used to reset the password.
     422      *
     423      * @param int $lifetime The lifetime of the auto login hash in days (by default 2 days)
     424      *
     425      * @todo Make it possible to handle database errors such as a "duplicate key" error
     426      */
     427     function generateAutologinHash($lifetime = 2) {
     428
     429         do {
     430             $hash = substr(base_convert(md5(uniqid(mt_rand(), true)), 16, 36),
     431                                          0, 30);
     432         } while(DataObject::get_one('Member', "\"AutoLoginHash\" = '$hash'"));
     433
     434         $this->AutoLoginHash = $hash;
     435         $this->AutoLoginExpired = date('Y-m-d', time() + (86400 * $lifetime));
     436
     437         $this->write();
     438     }
     439
     440     /**
     441      * Return the member for the auto login hash
     442      *
     443      * @param bool $login Should the member be logged in?
     444      */
     445     static function member_from_autologinhash($RAW_hash, $login = false) {
     446         $SQL_hash = Convert::raw2sql($RAW_hash);
     447
     448         $member = DataObject::get_one('Member',"\"AutoLoginHash\"='" . $SQL_hash . "' AND \"AutoLoginExpired\" > " . DB::getConn()->now());
     449
     450         if($login && $member)
     451             $member->logIn();
     452
     453         return $member;
     454     }
     455
     456
     457     /**
     458      * Send signup, change password or forgot password informations to an user
     459      *
     460      * @param string $type Information type to send ("signup", "changePassword" or "forgotPassword")
     461      * @param array $data Additional data to pass to the email (can be used in the template)
     462      */
     463     function sendInfo($type = 'signup', $data = null) {
     464         switch($type) {
     465             case "signup":
     466                 $e = Object::create('Member_SignupEmail');
     467                 break;
     468             case "changePassword":
     469                 $e = Object::create('Member_ChangePasswordEmail');
     470                 break;
     471             case "forgotPassword":
     472                 $e = Object::create('Member_ForgotPasswordEmail');
     473                 break;
     474         }
     475
     476         if(is_array($data)) {
     477             foreach($data as $key => $value)
     478                 $e->$key = $value;
     479         }
     480
     481         $e->populateTemplate($this);
     482         $e->setTo($this->Email);
     483         $e->send();
     484     }
     485
     486     /**
     487      * Returns the fields for the member form - used in the registration/profile module.
     488      * It should return fields that are editable by the admin and the logged-in user.
     489      *
     490      * @return FieldSet Returns a {@link FieldSet} containing the fields for
     491      *                  the member form.
     492      * @param bool $newUser - new user flag (for registration)
     493      */
     494     public function getMemberFormFields($newUser = false) {
     495         $fields = parent::getFrontendFields();
     496
     497         $fields->replaceField('Password', $password = new ConfirmedPasswordField (
     498             'Password',
     499             $this->fieldLabel('Password'),
     500             null,
     501             null,
     502             (bool) $this->ID
     503         ));
     504         $password->setCanBeEmpty($this->ID);
     505
     506         $fields->replaceField('Locale', new DropdownField (
     507             'Locale',
     508             $this->fieldLabel('Locale'),
     509             i18n::get_existing_translations()
     510         ));
     511
     512         $fields->removeByName('RememberLoginToken');
     513         $fields->removeByName('NumVisit');
     514         $fields->removeByName('LastVisited');
     515         $fields->removeByName('Bounced');
     516         $fields->removeByName('AutoLoginHash');
     517         $fields->removeByName('AutoLoginExpired');
     518         $fields->removeByName('PasswordEncryption');
     519         $fields->removeByName('Salt');
     520         $fields->removeByName('PasswordExpiry');
     521         $fields->removeByName('FailedLoginCount');
     522         $fields->removeByName('LastViewed');
     523         $fields->removeByName('LockedOutUntil');
     524
     525         $this->extend('updateMemberFormFields', $fields);
     526         return $fields;
     527     }
     528
     529     function getValidator() {
     530         $obj = new Member_Validator();
     531         if (!$this->IsInDB())
     532             $obj->addRequiredfield('Password');
     533         $this->extend('updateValidator', $obj);
     534         return $obj;
     535     }
     536
     537
     538     /**
     539      * Returns the current logged in user
     540      *
     541      * @return bool|Member Returns the member object of the current logged in
     542      *                     user or FALSE.
     543      */
     544     static function currentUser() {
     545         $id = Member::currentUserID();
     546         if($id) {
     547             return DataObject::get_one("Member", "\"Member\".\"ID\" = $id");
     548         }
     549     }
     550
     551
     552     /**
     553      * Get the ID of the current logged in user
     554      *
     555      * @return int Returns the ID of the current logged in user or 0.
     556      */
     557     static function currentUserID() {
     558         $id = Session::get("loggedInAs");
     559         if(!$id && !self::$_already_tried_to_auto_log_in) {
     560             self::autoLogin();
     561             $id = Session::get("loggedInAs");
     562         }
     563
     564         return is_numeric($id) ? $id : 0;
     565     }
     566     private static $_already_tried_to_auto_log_in = false;
     567
     568
     569     /*
     570      * Generate a random password, with randomiser to kick in if there's no words file on the
     571      * filesystem.
     572      *
     573      * @return string Returns a random password.
     574      */
     575     static function create_new_password() {
     576         if(file_exists(Security::get_word_list())) {
     577             $words = file(Security::get_word_list());
     578
     579             list($usec, $sec) = explode(' ', microtime());
     580             srand($sec + ((float) $usec * 100000));
     581
     582             $word = trim($words[rand(0,sizeof($words)-1)]);
     583             $number = rand(10,999);
     584
     585             return $word . $number;
     586         } else {
     587             $random = rand();
     588             $string = md5($random);
     589             $output = substr($string, 0, 6);
     590             return $output;
     591         }
     592     }
     593
     594     /**
     595      * Event handler called before writing to the database.
     596      */
     597     function onBeforeWrite() {
     598         if($this->SetPassword) $this->Password = $this->SetPassword;
     599
     600         // If a member with the same "unique identifier" already exists with a different ID, don't allow merging.
     601         // Note: This does not a full replacement for safeguards in the controller layer (e.g. in a registration form),
     602         // but rather a last line of defense against data inconsistencies.
     603         $identifierField = self::$unique_identifier_field;
     604         if($this->$identifierField) {
     605             // Note: Same logic as Member_Validator class
     606             $idClause = ($this->ID) ? sprintf(" AND \"Member\".\"ID\" <> %d", (int)$this->ID) : '';
     607             $existingRecord = DataObject::get_one(
     608                 'Member',
     609                 sprintf(
     610                     "\"%s\" = '%s' %s",
     611                     $identifierField,
     612                     Convert::raw2sql($this->$identifierField),
     613                     $idClause
     614                 )
     615             );
     616             if($existingRecord) {
     617                 throw new ValidationException(new ValidationResult(false, sprintf(
     618                     _t(
     619                         'Member.ValidationIdentifierFailed',
     620                         'Can\'t overwrite existing member #%d with identical identifier (%s = %s))',
     621                         PR_MEDIUM,
     622                         'The values in brackets show a fieldname mapped to a value, usually denoting an existing email address'
     623                     ),
     624                     $existingRecord->ID,
     625                     $identifierField,
     626                     $this->$identifierField
     627                 )));
     628             }
     629         }
     630
     631         // We don't send emails out on dev/tests sites to prevent accidentally spamming users.
     632         // However, if TestMailer is in use this isn't a risk.
     633         if(
     634             (Director::isLive() || Email::mailer() instanceof TestMailer)
     635             && $this->isChanged('Password')
     636             && $this->record['Password']
     637             && Member::$notify_password_change
     638         ) {
     639             $this->sendInfo('changePassword');
     640         }
     641
     642         // The test on $this->ID is used for when records are initially created.
     643         // Note that this only works with cleartext passwords, as we can't rehash
     644         // existing passwords.
     645         if(!$this->ID || $this->isChanged('Password')) {
     646             // Password was changed: encrypt the password according the settings
     647             $encryption_details = Security::encrypt_password(
     648                 $this->Password, // this is assumed to be cleartext
     649                 $this->Salt,
     650                 $this->PasswordEncryption,
     651                 $this
     652             );
     653             // Overwrite the Password property with the hashed value
     654             $this->Password = $encryption_details['password'];
     655             $this->Salt = $encryption_details['salt'];
     656             $this->PasswordEncryption = $encryption_details['algorithm'];
     657
     658             // If we haven't manually set a password expiry
     659             if(!$this->isChanged('PasswordExpiry')) {
     660                 // then set it for us
     661                 if(self::$password_expiry_days) {
     662                     $this->PasswordExpiry = date('Y-m-d', time() + 86400 * self::$password_expiry_days);
     663                 } else {
     664                     $this->PasswordExpiry = null;
     665                 }
     666             }
     667         }
     668
     669         // save locale
     670         if(!$this->Locale) {
     671             $this->Locale = i18n::get_locale();
     672         }
     673
     674         parent::onBeforeWrite();
     675     }
     676
     677     function onAfterWrite() {
     678         parent::onAfterWrite();
     679
     680         if($this->isChanged('Password')) {
     681             MemberPassword::log($this);
     682         }
     683     }
     684
     685
     686     /**
     687      * Check if the member is in one of the given groups.
     688      *
     689      * @param array|DataObjectSet $groups Collection of {@link Group} DataObjects to check
     690      * @param boolean $strict Only determine direct group membership if set to true (Default: false)
     691      * @return bool Returns TRUE if the member is in one of the given groups, otherwise FALSE.
     692      */
     693     public function inGroups($groups, $strict = false) {
     694         if($groups) foreach($groups as $group) {
     695             if($this->inGroup($group, $strict)) return true;
     696         }
     697
     698         return false;
     699     }
     700
     701
     702     /**
     703      * Check if the member is in the given group or any parent groups.
     704      *
     705      * @param int|Group|string $group Group instance, Group Code or ID
     706      * @param boolean $strict Only determine direct group membership if set to TRUE (Default: FALSE)
     707      * @return bool Returns TRUE if the member is in the given group, otherwise FALSE.
     708      */
     709     public function inGroup($group, $strict = false) {
     710         if(is_numeric($group)) {
     711             $groupCheckObj = DataObject::get_by_id('Group', $group);
     712         } elseif(is_string($group)) {
     713             $SQL_group = Convert::raw2sql($group);
     714             $groupCheckObj = DataObject::get_one('Group', "\"Code\" = '{$SQL_group}'");
     715         } elseif($group instanceof Group) {
     716             $groupCheckObj = $group;
     717         } else {
     718             user_error('Member::inGroup(): Wrong format for $group parameter', E_USER_ERROR);
     719         }
     720
     721         if(!$groupCheckObj) return false;
     722
     723         $groupCandidateObjs = ($strict) ? $this->getManyManyComponents("Groups") : $this->Groups();
     724         if($groupCandidateObjs) foreach($groupCandidateObjs as $groupCandidateObj) {
     725             if($groupCandidateObj->ID == $groupCheckObj->ID) return true;
     726         }
     727
     728         return false;
     729     }
     730
     731     /**
     732      * Returns true if this user is an administrator.
     733      * Administrators have access to everything.
     734      *
     735      * @deprecated Use Permission::check('ADMIN') instead
     736      * @return Returns TRUE if this user is an administrator.
     737      */
     738     function isAdmin() {
     739         return Permission::checkMember($this, 'ADMIN');
     740     }
     741
     742     /**
     743         *  Edit Profile Link
     744         * @return String
     745         */
     746     function EditProfileLink(){
     747         if($this->extend('EditProfileLink')){
     748             $a = $this->extend('EditProfileLink');
     749             return $a[0];
     750         }
     751         return 'myprofile';
     752     }
     753
     754     /**
     755      * @param Array $columns Column names on the Member record to show in {@link getTitle()}.
     756      * @param String $sep Separator
     757      */
     758     static function set_title_columns($columns, $sep = ' ') {
     759         if (!is_array($columns)) $columns = array($columns);
     760         self::$title_format = array('columns' => $columns, 'sep' => $sep);
     761     }
     762
     763     //------------------- HELPER METHODS -----------------------------------//
     764
     765     /**
     766      * Get the complete name of the member, by default in the format "<Surname>, <FirstName>".
     767      * Falls back to showing either field on its own.
     768      *
     769      * You can overload this getter with {@link set_title_format()}
     770      * and {@link set_title_sql()}.
     771      *
     772      * @return string Returns the first- and surname of the member. If the ID
     773      *  of the member is equal 0, only the surname is returned.
     774      */
     775     public function getTitle() {
     776         if (self::$title_format) {
     777             $values = array();
     778             foreach(self::$title_format['columns'] as $col) {
     779                 $values[] = $this->getField($col);
     780             }
     781             return join(self::$title_format['sep'], $values);
     782         }
     783         if($this->getField('ID') === 0)
     784             return $this->getField('Surname');
     785         else{
     786             if($this->getField('Surname') && $this->getField('FirstName')){
     787                 return $this->getField('Surname') . ', ' . $this->getField('FirstName');
     788             }elseif($this->getField('Surname')){
     789                 return $this->getField('Surname');
     790             }elseif($this->getField('FirstName')){
     791                 return $this->getField('FirstName');
     792             }else{
     793                 return null;
     794             }
     795         }
     796     }
     797
     798     /**
     799      * Return a SQL CONCAT() fragment suitable for a SELECT statement.
     800      * Useful for custom queries which assume a certain member title format.
     801      *
     802      * @param String $tableName
     803      * @return String SQL
     804      */
     805     static function get_title_sql($tableName = 'Member') {
     806
     807         if (self::$title_format) {
     808             $columnsWithTablename = array();
     809             foreach(self::$title_format['columns'] as $column) {
     810                 $columnsWithTablename[] = "\"$tableName\".\"$column\"";
     811             }
     812
     813             return "(\"".join("'".self::$title_format['sep']."'" || $columnsWithTablename)."\")";
     814         } else {
     815             return "(\"$tableName\".\"Surname\" || ' ' || \"$tableName\".\"FirstName\")";
     816         }
     817     }
     818
     819
     820     /**
     821      * Get the complete name of the member
     822      *
     823      * @return string Returns the first- and surname of the member.
     824      */
     825     public function getName() {
     826         return $this->FirstName . ' ' . $this->Surname;
     827     }
     828
     829
     830     /**
     831      * Set first- and surname
     832      *
     833      * This method assumes that the last part of the name is the surname, e.g.
     834      * <i>A B C</i> will result in firstname <i>A B</i> and surname <i>C</i>
     835      *
     836      * @param string $name The name
     837      */
     838     public function setName($name) {
     839         $nameParts = explode(' ', $name);
     840         $this->Surname = array_pop($nameParts);
     841         $this->FirstName = join(' ', $nameParts);
     842     }
     843
     844
     845     /**
     846      * Alias for {@link setName}
     847      *
     848      * @param string $name The name
     849      * @see setName()
     850      */
     851     public function splitName($name) {
     852         return $this->setName($name);
     853     }
     854
     855     //---------------------------------------------------------------------//
     856
     857
     858     /**
     859      * Get a "many-to-many" map that holds for all members their group
     860      * memberships
     861      *
     862      * @return Member_GroupSet Returns a map holding for all members their
     863      *                         group memberships.
     864      */
     865     public function Groups() {
     866         $groups = $this->getManyManyComponents("Groups");
     867         $groupIDs = $groups->column();
     868         $collatedGroups = array();
     869
     870         if($groups) {
     871             foreach($groups as $group) {
     872                 $collatedGroups = array_merge((array)$collatedGroups, $group->collateAncestorIDs());
     873             }
     874         }
     875
     876         $table = "Group_Members";
     877
     878         if(count($collatedGroups) > 0) {
     879             $collatedGroups = implode(", ", array_unique($collatedGroups));
     880                         // FIX for subsites module
     881             $unfilteredGroups = singleton('Group')->instance_get("`Group`.`ID` IN ($collatedGroups)", "`Group`.`ID`", "", "", "Member_GroupSet");
     882             $result = new ComponentSet();
     883
     884             // Only include groups where allowedIPAddress() returns true
     885             $ip = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : null;
     886             foreach($unfilteredGroups as $group) {
     887                 if($group->allowedIPAddress($ip)) $result->push($group);
     888             }
     889         } else {
     890             $result = new Member_GroupSet();
     891         }
     892
     893         $result->setComponentInfo("many-to-many", $this, "Member", $table, "Group");
     894
     895         return $result;
     896     }
     897
     898
     899     /**
     900      * Get member SQLMap
     901      *
     902      * @param string $filter Filter for the SQL statement (WHERE clause)
     903      * @param string $sort Sorting function (ORDER clause)
     904      * @param string $blank Shift a blank member in the items
     905      * @return SQLMap Returns an SQLMap that returns all Member data.
     906      *
     907      * @todo Improve documentation of this function! (Markus)
     908      */
     909     public function map($filter = "", $sort = "", $blank="") {
     910         $ret = new SQLMap(singleton('Member')->extendedSQL($filter, $sort));
     911         if($blank) {
     912             $blankMember = Object::create('Member');
     913             $blankMember->Surname = $blank;
     914             $blankMember->ID = 0;
     915
     916             $ret->getItems()->shift($blankMember);
     917         }
     918
     919         return $ret;
     920     }
     921
     922
     923     /**
     924      * Get a member SQLMap of members in specific groups
     925      *
     926      * @param mixed $groups Optional groups to include in the map. If NULL is
     927      *                      passed, all groups are returned, i.e.
     928      *                      {@link map()} will be called.
     929      * @return SQLMap Returns an SQLMap that returns all Member data.
     930      * @see map()
     931      *
     932      * @todo Improve documentation of this function! (Markus)
     933      */
     934     public static function mapInGroups($groups = null) {
     935         if(!$groups)
     936             return Member::map();
     937
     938         $groupIDList = array();
     939
     940         if(is_a($groups, 'DataObjectSet')) {
     941             foreach( $groups as $group )
     942                 $groupIDList[] = $group->ID;
     943         } elseif(is_array($groups)) {
     944             $groupIDList = $groups;
     945         } else {
     946             $groupIDList[] = $groups;
     947         }
     948
     949         if(empty($groupIDList))
     950             return Member::map();
     951
     952         return new SQLMap(singleton('Member')->extendedSQL(
     953             "\"GroupID\" IN (" . implode( ',', $groupIDList ) .
     954             ")", "Surname, FirstName", "", "INNER JOIN \"Group_Members\" ON \"MemberID\"=\"Member\".\"ID\""));
     955     }
     956
     957
     958     /**
     959      * Get a map of all members in the groups given that have CMS permissions
     960      *
     961      * If no groups are passed, all groups with CMS permissions will be used.
     962      *
     963      * @param array $groups Groups to consider or NULL to use all groups with
     964      *                      CMS permissions.
     965      * @return SQLMap Returns a map of all members in the groups given that
     966      *                have CMS permissions.
     967      */
     968     public static function mapInCMSGroups($groups = null) {
     969         if(!$groups || $groups->Count() == 0) {
     970             $perms = array('ADMIN', 'CMS_ACCESS_AssetAdmin');
     971
     972             $cmsPerms = singleton('CMSMain')->providePermissions();
     973
     974             if(!empty($cmsPerms)) {
     975                 $perms = array_unique(array_merge($perms, array_keys($cmsPerms)));
     976             }
     977
     978             $SQL_perms = "'" . implode("', '", Convert::raw2sql($perms)) . "'";
     979
     980             $groups = DataObject::get('Group', "", "",
     981                 "INNER JOIN \"Permission\" ON \"Permission\".\"GroupID\" = \"Group\".\"ID\" AND \"Permission\".\"Code\" IN ($SQL_perms)");
     982         }
     983
     984         $groupIDList = array();
     985
     986         if(is_a($groups, 'DataObjectSet')) {
     987             foreach($groups as $group) {
     988                 $groupIDList[] = $group->ID;
     989             }
     990         } elseif(is_array($groups)) {
     991             $groupIDList = $groups;
     992         }
     993
     994         $filterClause = ($groupIDList)
     995             ? "\"GroupID\" IN (" . implode( ',', $groupIDList ) . ")"
     996             : "";
     997
     998         return new SQLMap(singleton('Member')->extendedSQL($filterClause,
     999             "Surname, FirstName", "",
    1000             "INNER JOIN \"Group_Members\" ON \"MemberID\"=\"Member\".\"ID\" INNER JOIN \"Group\" ON \"Group\".\"ID\"=\"GroupID\""));
    1001     }
    1002
    1003
    1004     /**
    1005      * Get the groups in which the member is NOT in
    1006      *
    1007      * When passed an array of groups, and a component set of groups, this
    1008      * function will return the array of groups the member is NOT in.
    1009      *
    1010      * @param array $groupList An array of group code names.
    1011      * @param array $memberGroups A component set of groups (if set to NULL,
    1012      *                                                      $this->groups() will be used)
    1013      * @return array Groups in which the member is NOT in.
    1014      */
    1015     public function memberNotInGroups($groupList, $memberGroups = null){
    1016         if(!$memberGroups) $memberGroups = $this->Groups();
    1017
    1018         foreach($memberGroups as $group) {
    1019             if(in_array($group->Code, $groupList)) {
    1020                 $index = array_search($group->Code, $groupList);
    1021                 unset($groupList[$index]);
    1022             }
    1023         }
    1024
    1025         return $groupList;
    1026     }
    1027
    1028
    1029     /**
    1030      * Return a {@link FieldSet} of fields that would appropriate for editing
    1031      * this member.
    1032      *
    1033      * @return FieldSet Return a FieldSet of fields that would appropriate for
    1034      *                  editing this member.
    1035      */
    1036     public function getCMSFields() {
    1037         $fields = parent::getCMSFields();
    1038
    1039         $mainFields = $fields->fieldByName("Root")->fieldByName("Main")->Children;
    1040
    1041         $password = new ConfirmedPasswordField(
    1042             'Password',
    1043             null,
    1044             null,
    1045             null,
    1046             true // showOnClick
    1047         );
    1048         $password->setCanBeEmpty(true);
    1049         if(!$this->ID) $password->showOnClick = false;
    1050         $mainFields->replaceField('Password', $password);
    1051
    1052         $mainFields->insertBefore(
    1053             new HeaderField('MemberDetailsHeader',_t('Member.PERSONALDETAILS', "Personal Details", PR_MEDIUM, 'Headline for formfields')),
    1054             'FirstName'
    1055         );
    1056
    1057         $mainFields->insertBefore(
    1058             new HeaderField('MemberUserDetailsHeader',_t('Member.USERDETAILS', "User Details", PR_MEDIUM, 'Headline for formfields')),
    1059             'Email'
    1060         );
    1061
    1062         $mainFields->replaceField('Locale', new DropdownField(
    1063             "Locale",
    1064             _t('Member.INTERFACELANG', "Interface Language", PR_MEDIUM, 'Language of the CMS'),
    1065             i18n::get_existing_translations()
    1066         ));
    1067
    1068         $mainFields->removeByName('Bounced');
    1069         $mainFields->removeByName('RememberLoginToken');
    1070         $mainFields->removeByName('AutoLoginHash');
    1071         $mainFields->removeByName('AutoLoginExpired');
    1072         $mainFields->removeByName('PasswordEncryption');
    1073         $mainFields->removeByName('PasswordExpiry');
    1074         $mainFields->removeByName('LockedOutUntil');
    1075
    1076         if(!self::$lock_out_after_incorrect_logins) {
    1077             $mainFields->removeByName('FailedLoginCount');
    1078         }
    1079
    1080         $mainFields->removeByName('Salt');
    1081         $mainFields->removeByName('NumVisit');
    1082         $mainFields->removeByName('LastVisited');
    1083
    1084         $fields->removeByName('Subscriptions');
    1085
    1086         // Groups relation will get us into logical conflicts because
    1087         // Members are displayed within  group edit form in SecurityAdmin
    1088         $fields->removeByName('Groups');
    1089
    1090         if(Permission::check('EDIT_PERMISSIONS')) {
    1091             $groupsField = new TreeMultiselectField('Groups', false, 'Group');
    1092             $fields->findOrMakeTab('Root.Groups', singleton('Group')->i18n_plural_name());
    1093             $fields->addFieldToTab('Root.Groups', $groupsField);
    1094
    1095             // Add permission field (readonly to avoid complicated group assignment logic).
    1096             // This should only be available for existing records, as new records start
    1097             // with no permissions until they have a group assignment anyway.
    1098             if($this->ID) {
    1099                 $permissionsField = new PermissionCheckboxSetField_Readonly(
    1100                     'Permissions',
    1101                     singleton('Permission')->i18n_plural_name(),
    1102                     'Permission',
    1103                     'GroupID',
    1104                     // we don't want parent relationships, they're automatically resolved in the field
    1105                     $this->getManyManyComponents('Groups')
    1106                 );
    1107                 $fields->findOrMakeTab('Root.Permissions', singleton('Permission')->i18n_plural_name());
    1108                 $fields->addFieldToTab('Root.Permissions', $permissionsField);
    1109             }
    1110         }
    1111
    1112         $this->extend('updateCMSFields', $fields);
    1113
    1114         return $fields;
    1115     }
    1116
    1117     /**
    1118      *
    1119      * @param boolean $includerelations a boolean value to indicate if the labels returned include relation fields
    1120      *
    1121      */
    1122     function fieldLabels($includerelations = true) {
    1123         $labels = parent::fieldLabels($includerelations);
    1124
    1125         $labels['FirstName'] = _t('Member.FIRSTNAME', 'First Name');
    1126         $labels['Surname'] = _t('Member.SURNAME', 'Surname');
    1127         $labels['Email'] = _t('Member.EMAIL', 'Email');
    1128         $labels['Password'] = _t('Member.db_Password', 'Password');
    1129         $labels['NumVisit'] = _t('Member.db_NumVisit', 'Number of Visits');
    1130         $labels['LastVisited'] = _t('Member.db_LastVisited', 'Last Visited Date');
    1131         $labels['PasswordExpiry'] = _t('Member.db_PasswordExpiry', 'Password Expiry Date', PR_MEDIUM, 'Password expiry date');
    1132         $labels['LockedOutUntil'] = _t('Member.db_LockedOutUntil', 'Locked out until', PR_MEDIUM, 'Security related date');
    1133         $labels['Locale'] = _t('Member.db_Locale', 'Interface Locale');
    1134         $labels['Title'] = _t('Member.TITLE', 'Full Name');
    1135         $labels['Created'] = _t('Member.db_Created', 'Created');
    1136         if($includerelations){
    1137             $labels['Groups'] = _t('Member.belongs_many_many_Groups', 'Groups', PR_MEDIUM, 'Security Groups this member belongs to');
    1138         }
    1139         return $labels;
    1140     }
    1141
    1142     /**
    1143      * Users can view their own record.
    1144      * Otherwise they'll need ADMIN or CMS_ACCESS_SecurityAdmin permissions.
    1145      * This is likely to be customized for social sites etc. with a looser permission model.
    1146      */
    1147     function canView($member = null) {
    1148         if(!$member || !(is_a($member, 'Member')) || is_numeric($member)) $member = Member::currentUser();
    1149
    1150         // decorated access checks
    1151         $results = $this->extend('canView', $member);
    1152         if($results && is_array($results)) {
    1153             if(!min($results)) return false;
    1154             else return true;
    1155         }
    1156
    1157         // members can usually edit their own record
    1158         if($member && $this->ID == $member->ID) return true;
    1159
    1160         if(
    1161             Permission::checkMember($member, 'ADMIN')
    1162             || Permission::checkMember($member, 'CMS_ACCESS_SecurityAdmin')
    1163         ) {
    1164             return true;
    1165         }
    1166
    1167         return false;
    1168     }
    1169
    1170     /**
    1171      * Users can edit their own record.
    1172      * Otherwise they'll need ADMIN or CMS_ACCESS_SecurityAdmin permissions
    1173      */
    1174     function canEdit($member = null) {
    1175         if(!$member || !(is_a($member, 'Member')) || is_numeric($member)) $member = Member::currentUser();
    1176
    1177         // decorated access checks
    1178         $results = $this->extend('canEdit', $member);
    1179         if($results && is_array($results)) {
    1180             if(!min($results)) return false;
    1181             else return true;
    1182         }
    1183
    1184         // No member found
    1185         if(!($member && $member->exists())) return false;
    1186
    1187         return $this->canView($member);
    1188     }
    1189
    1190     /**
    1191      * Users can edit their own record.
    1192      * Otherwise they'll need ADMIN or CMS_ACCESS_SecurityAdmin permissions
    1193      */
    1194     function canDelete($member = null) {
    1195         if(!$member || !(is_a($member, 'Member')) || is_numeric($member)) $member = Member::currentUser();
    1196
    1197         // decorated access checks
    1198         $results = $this->extend('canDelete', $member);
    1199         if($results && is_array($results)) {
    1200             if(!min($results)) return false;
    1201             else return true;
    1202         }
    1203
    1204         // No member found
    1205         if(!($member && $member->exists())) return false;
    1206
    1207         return $this->canEdit($member);
    1208     }
    1209
    1210
    1211     /**
    1212      * Validate this member object.
    1213      */
    1214     function validate() {
    1215         $valid = parent::validate();
    1216
    1217         if(!$this->ID || $this->isChanged('Password')) {
    1218             if($this->Password && self::$password_validator) {
    1219                 $valid->combineAnd(self::$password_validator->validate($this->Password, $this));
    1220             }
    1221         }
    1222
    1223         if((!$this->ID && $this->SetPassword) || $this->isChanged('SetPassword')) {
    1224             if($this->SetPassword && self::$password_validator) {
    1225                 $valid->combineAnd(self::$password_validator->validate($this->SetPassword, $this));
    1226             }
    1227         }
    1228         $this->extend('updateValidation', $valid);
    1229         return $valid;
    1230     }
    1231
    1232     /**
    1233      * Change password. This will cause rehashing according to
    1234      * the `PasswordEncryption` property.
    1235      *
    1236      * @param String $password Cleartext password
    1237      */
    1238     function changePassword($password) {
    1239         $this->Password = $password;
    1240         $valid = $this->validate();
    1241
    1242         if($valid->valid()) {
    1243             $this->AutoLoginHash = null;
    1244             $this->write();
    1245         }
    1246
    1247         return $valid;
    1248     }
    1249
    1250     /**
    1251      * Tell this member that someone made a failed attempt at logging in as them.
    1252      * This can be used to lock the user out temporarily if too many failed attempts are made.
    1253      */
    1254     function registerFailedLogin() {
    1255         if(self::$lock_out_after_incorrect_logins) {
    1256             // Keep a tally of the number of failed log-ins so that we can lock people out
    1257             $this->FailedLoginCount = $this->FailedLoginCount + 1;
    1258             $this->write();
    1259
    1260             if($this->FailedLoginCount >= self::$lock_out_after_incorrect_logins) {
    1261                 $this->LockedOutUntil = date('Y-m-d H:i:s', time() + 15*60);
    1262                 $this->write();
    1263             }
    1264         }
    1265     }
    1266
    1267     /**
    1268      * Get the HtmlEditorConfig for this user to be used in the CMS.
    1269      * This is set by the group. If multiple configurations are set,
    1270      * the one with the highest priority wins.
    1271      *
    1272      * @return string
    1273      */
    1274     function getHtmlEditorConfigForCMS() {
    1275         $currentName = '';
    1276         $currentPriority = 0;
    1277
    1278         foreach($this->Groups() as $group) {
    1279             $configName = $group->HtmlEditorConfig;
    1280             if($configName) {
    1281                 $config = HtmlEditorConfig::get($group->HtmlEditorConfig);
    1282                 if($config && $config->getOption('priority') > $currentPriority) {
    1283                     $currentName = $configName;
    1284                 }
    1285             }
    1286         }
    1287
    1288         // If can't find a suitable editor, just default to cms
    1289         return $currentName ? $currentName : 'cms';
    1290     }
    1291
    1292 }
    1293
    1294 /**
    1295  * Special kind of {@link ComponentSet} that has special methods for
    1296  * manipulating a user's membership
    1297  * @package sapphire
    1298  * @subpackage security
    1299  */
    1300 class Member_GroupSet extends ComponentSet {
    1301     /**
    1302      * Control group membership with a number of checkboxes.
    1303      *  - If the checkbox fields are present in $data, then the member will be
    1304      *    added to the group with the same codename.
    1305      *  - If the checkbox fields are *NOT* present in $data, then the member
    1306      *    will be removed from the group with the same codename.
    1307      *
    1308      * @param array $checkboxes An array list of the checkbox fieldnames (only
    1309      *                            values are used). E.g. array(0, 1, 2)
    1310      * @param array $data The form data. Uually in the format array(0 => 2)
    1311      *                    (just pass the checkbox data from your form)
    1312      */
    1313     function setByCheckboxes(array $checkboxes, array $data) {
    1314         foreach($checkboxes as $checkbox) {
    1315             if($data[$checkbox]) {
    1316                 $add[] = $checkbox;
    1317             } else {
    1318                 $remove[] = $checkbox;
    1319             }
    1320         }
    1321
    1322         if($add)
    1323             $this->addManyByCodename($add);
    1324
    1325         if($remove)
    1326             $this->removeManyByCodename($remove);
    1327     }
    1328
    1329
    1330     /**
    1331      * Allows you to set groups based on a CheckboxSetField
    1332      *
    1333      * Pass the form element from your post data directly to this method, and
    1334      * it will update the groups and add and remove the member as appropriate.
    1335      *
    1336      * On the form setup:
    1337      *
    1338      * <code>
    1339      * $fields->push(
    1340      *   new CheckboxSetField(
    1341      *     "NewsletterSubscriptions",
    1342      *     "Receive email notification of events in ",
    1343      *     $sourceitems = DataObject::get("NewsletterType")->toDropDownMap("GroupID","Title"),
    1344      *     $selectedgroups = $member->Groups()->Map("ID","ID")
    1345      *   )
    1346      * );
    1347      * </code>
    1348      *
    1349      * On the form handler:
    1350      *
    1351      * <code>
    1352      * $groups = $member->Groups();
    1353      * $checkboxfield = $form->Fields()->fieldByName("NewsletterSubscriptions");
    1354      * $groups->setByCheckboxSetField($checkboxfield);
    1355      * </code>
    1356      *
    1357      * @param CheckboxSetField $checkboxsetfield The CheckboxSetField (with
    1358      *                                           data) from your form.
    1359      */
    1360     function setByCheckboxSetField(CheckboxSetField $checkboxsetfield) {
    1361         // Get the values from the formfield.
    1362         $values = $checkboxsetfield->Value();
    1363         $sourceItems = $checkboxsetfield->getSource();
    1364
    1365         if($sourceItems) {
    1366             // If (some) values are present, add and remove as necessary.
    1367             if($values) {
    1368                 // update the groups based on the selections
    1369                 foreach($sourceItems as $k => $item) {
    1370                     if(in_array($k,$values)) {
    1371                         $add[] = $k;
    1372                     } else {
    1373                         $remove[] = $k;
    1374                     }
    1375                 }
    1376
    1377             // else we should be removing all from the necessary groups.
    1378             } else {
    1379                 $remove = array_keys($sourceItems);
    1380             }
    1381
    1382             if($add)
    1383                 $this->addManyByGroupID($add);
    1384
    1385             if($remove)
    1386                 $this->RemoveManyByGroupID($remove);
    1387
    1388         } else {
    1389             USER_ERROR("Member::setByCheckboxSetField() - No source items could be found for checkboxsetfield " .
    1390                                  $checkboxsetfield->Name(), E_USER_WARNING);
    1391         }
    1392     }
    1393
    1394
    1395     /**
    1396      * Adds this member to the groups based on the group IDs
    1397      *
    1398      * @param array $ids Group identifiers.
    1399      */
    1400     function addManyByGroupID($groupIds){
    1401         $groups = $this->getGroupsFromIDs($groupIds);
    1402         if($groups) {
    1403             foreach($groups as $group) {
    1404                 $this->add($group);
    1405             }
    1406         }
    1407     }
    1408
    1409
    1410     /**
    1411      * Removes the member from many groups based on the group IDs
    1412      *
    1413      * @param array $ids Group identifiers.
    1414      */
    1415     function removeManyByGroupID($groupIds) {
    1416         $groups = $this->getGroupsFromIDs($groupIds);
    1417         if($groups) {
    1418             foreach($groups as $group) {
    1419                 $this->remove($group);
    1420             }
    1421         }
    1422     }
    1423
    1424
    1425     /**
    1426      * Returns the groups from an array of group IDs
    1427      *
    1428      * @param array $ids Group identifiers.
    1429      * @return mixed Returns the groups from the array of Group IDs.
    1430      */
    1431     function getGroupsFromIDs($ids){
    1432         if($ids && count($ids) > 1) {
    1433             return DataObject::get("Group", "\"ID\" IN (" . implode(",", $ids) . ")");
    1434         } else {
    1435             return DataObject::get_by_id("Group", $ids[0]);
    1436         }
    1437     }
    1438
    1439
    1440     /**
    1441      * Adds this member to the groups based on the group codenames
    1442      *
    1443      * @param array $codenames Group codenames
    1444      */
    1445     function addManyByCodename($codenames) {
    1446         $groups = $this->codenamesToGroups($codenames);
    1447         if($groups) {
    1448             foreach($groups as $group){
    1449                 $this->add($group);
    1450             }
    1451         }
    1452     }
    1453
    1454
    1455     /**
    1456      * Removes this member from the groups based on the group codenames
    1457      *
    1458      * @param array $codenames Group codenames
    1459      */
    1460     function removeManyByCodename($codenames) {
    1461         $groups = $this->codenamesToGroups($codenames);
    1462         if($groups) {
    1463             foreach($groups as $group) {
    1464                 $this->remove($group);
    1465             }
    1466         }
    1467     }
    1468
    1469
    1470     /**
    1471      * Helper function to return the appropriate groups via a codenames
    1472      *
    1473      * @param array $codenames Group codenames
    1474      * @return array Returns the the appropriate groups.
    1475      */
    1476     protected function codenamesToGroups($codenames) {
    1477         $list = "'" . implode("', '", $codenames) . "'";
    1478         $output = DataObject::get("Group", "\"Code\" IN ($list)");
    1479
    1480         // Some are missing - throw warnings
    1481         if(!$output || ($output->Count() != sizeof($list))) {
    1482             foreach($codenames as $codename)
    1483                 $missing[$codename] = $codename;
    1484
    1485             if($output) {
    1486                 foreach($output as $record)
    1487                     unset($missing[$record->Code]);
    1488             }
    1489
    1490             if($missing)
    1491                 user_error("The following group-codes aren't matched to any groups: " .
    1492                                      implode(", ", $missing) .
    1493                                      ".  You probably need to link up the correct group codes in phpMyAdmin",
    1494                                      E_USER_WARNING);
    1495         }
    1496
    1497         return $output;
    1498     }
    1499 }
    1500
    1501
    1502
    1503 /**
    1504  * Form for editing a member profile.
    1505  * @package sapphire
    1506  * @subpackage security
    1507  */
    1508 class Member_ProfileForm extends Form {
    1509
    1510     function __construct($controller, $name, $member) {
    1511         Requirements::clear();
    1512         Requirements::css(CMS_DIR . '/css/typography.css');
    1513         Requirements::css(CMS_DIR . '/css/cms_right.css');
    1514         Requirements::javascript(SAPPHIRE_DIR . "/thirdparty/prototype/prototype.js");
    1515         Requirements::javascript(SAPPHIRE_DIR . "/thirdparty/behaviour/behaviour.js");
    1516         Requirements::javascript(SAPPHIRE_DIR . "/javascript/prototype_improvements.js");
    1517         Requirements::javascript(THIRDPARTY_DIR . "/scriptaculous/scriptaculous.js");
    1518         Requirements::javascript(THIRDPARTY_DIR . "/scriptaculous/controls.js");
    1519         Requirements::javascript(SAPPHIRE_DIR . "/javascript/layout_helpers.js");
    1520         Requirements::css(SAPPHIRE_DIR . "/css/Form.css");
    1521
    1522         Requirements::css(SAPPHIRE_DIR . "/css/MemberProfileForm.css");
    1523
    1524
    1525         $fields = singleton('Member')->getCMSFields();
    1526         $fields->push(new HiddenField('ID','ID',$member->ID));
    1527
    1528         $actions = new FieldSet(
    1529             new FormAction('dosave', _t('CMSMain.SAVE', 'Save'))
    1530         );
    1531
    1532         $validator = new Member_Validator();
    1533
    1534         parent::__construct($controller, $name, $fields, $actions, $validator);
    1535
    1536         $this->loadDataFrom($member);
    1537     }
    1538
    1539     function dosave($data, $form) {
    1540         // don't allow ommitting or changing the ID
    1541         if(!isset($data['ID']) || $data['ID'] != Member::currentUserID()) {
    1542             return Director::redirectBack();
    1543         }
    1544
    1545         $SQL_data = Convert::raw2sql($data);
    1546         $member = DataObject::get_by_id("Member", $SQL_data['ID']);
    1547
    1548         if($SQL_data['Locale'] != $member->Locale) {
    1549             $form->addErrorMessage("Generic", _t('Member.REFRESHLANG'),"good");
    1550         }
    1551
    1552         $form->saveInto($member);
    1553         $member->write();
    1554
    1555         $closeLink = sprintf(
    1556             '<small><a href="' . $_SERVER['HTTP_REFERER'] . '" onclick="javascript:window.top.GB_hide(); return false;">(%s)</a></small>',
    1557             _t('ComplexTableField.CLOSEPOPUP', 'Close Popup')
    1558         );
    1559         $message = _t('Member.PROFILESAVESUCCESS', 'Successfully saved.') . ' ' . $closeLink;
    1560         $form->sessionMessage($message, 'good');
    1561
    1562         Director::redirectBack();
    1563     }
    1564 }
    1565
    1566 /**
    1567  * Class used as template to send an email to new members
    1568  * @package sapphire
    1569  * @subpackage security
    1570  */
    1571 class Member_SignupEmail extends Email {
    1572     protected $from = '';  // setting a blank from address uses the site's default administrator email
    1573     protected $subject = '';
    1574     protected $body = '';
    1575
    1576     function __construct() {
    1577         parent::__construct();
    1578         $this->subject = _t('Member.EMAILSIGNUPSUBJECT', "Thanks for signing up");
    1579         $this->body = '
    1580             <h1>' . _t('Member.GREETING','Welcome') . ', $FirstName.</h1>
    1581             <p>' . _t('Member.EMAILSIGNUPINTRO1','Thanks for signing up to become a new member, your details are listed below for future reference.') . '</p>
    1582
    1583             <p>' . _t('Member.EMAILSIGNUPINTRO2','You can login to the website using the credentials listed below')  . ':
    1584                 <ul>
    1585                     <li><strong>' . _t('Member.EMAIL') . '</strong>$Email</li>
    1586                     <li><strong>' . _t('Member.PASSWORD') . ':</strong>$Password</li>
    1587                 </ul>
    1588             </p>
    1589
    1590             <h3>' . _t('Member.CONTACTINFO','Contact Information') . '</h3>
    1591             <ul>
    1592                 <li><strong>' . _t('Member.NAME','Name')  . ':</strong> $FirstName $Surname</li>
    1593                 <% if Phone %>
    1594                     <li><strong>' . _t('Member.PHONE','Phone') . ':</strong> $Phone</li>
    1595                 <% end_if %>
    1596
    1597                 <% if Mobile %>
    1598                     <li><strong>' . _t('Member.MOBILE','Mobile') . ':</strong> $Mobile</li>
    1599                 <% end_if %>
    1600
    1601                 <li><strong>' . _t('Member.ADDRESS','Address') . ':</strong>
    1602                 <br/>
    1603                 $Number $Street $StreetType<br/>
    1604                 $Suburb<br/>
    1605                 $City $Postcode
    1606                 </li>
    1607
    1608             </ul>
    1609         ';
    1610     }
    1611 }
    1612
    1613
    1614
    1615 /**
    1616  * Class used as template to send an email saying that the password has been
    1617  * changed
    1618  * @package sapphire
    1619  * @subpackage security
    1620  */
    1621 class Member_ChangePasswordEmail extends Email {
    1622     protected $from = '';   // setting a blank from address uses the site's default administrator email
    1623     protected $subject = '';
    1624     protected $ss_template = 'ChangePasswordEmail';
    1625
    1626     function __construct() {
    1627         parent::__construct();
    1628         $this->subject = _t('Member.SUBJECTPASSWORDCHANGED', "Your password has been changed", PR_MEDIUM, 'Email subject');
    1629     }
    1630 }
    1631
    1632
    1633
    1634 /**
    1635  * Class used as template to send the forgot password email
    1636  * @package sapphire
    1637  * @subpackage security
    1638  */
    1639 class Member_ForgotPasswordEmail extends Email {
    1640     protected $from = '';  // setting a blank from address uses the site's default administrator email
    1641     protected $subject = '';
    1642     protected $ss_template = 'ForgotPasswordEmail';
    1643
    1644     function __construct() {
    1645         parent::__construct();
    1646         $this->subject = _t('Member.SUBJECTPASSWORDRESET', "Your password reset link", PR_MEDIUM, 'Email subject');
    1647     }
    1648 }
    1649
    1650 /**
    1651  * Member Validator
    1652  * @package sapphire
    1653  * @subpackage security
    1654  */
    1655 class Member_Validator extends RequiredFields {
    1656
    1657     protected $customRequired = array('FirstName', 'Email'); //, 'Password');
    1658
    1659
    1660     /**
    1661      * Constructor
    1662      */
    1663     public function __construct() {
    1664         $required = func_get_args();
    1665         if(isset($required[0]) && is_array($required[0])) {
    1666             $required = $required[0];
    1667         }
    1668         $required = array_merge($required, $this->customRequired);
    1669
    1670         parent::__construct($required);
    1671     }
    1672
    1673
    1674     /**
    1675      * Check if the submitted member data is valid (server-side)
    1676      *
    1677      * Check if a member with that email doesn't already exist, or if it does
    1678      * that it is this member.
    1679      *
    1680      * @param array $data Submitted data
    1681      * @return bool Returns TRUE if the submitted data is valid, otherwise
    1682      *              FALSE.
    1683      */
    1684     function php($data) {
    1685         $valid = parent::php($data);
    1686
    1687         $identifierField = Member::get_unique_identifier_field();
    1688
    1689         $SQL_identifierField = Convert::raw2sql($data[$identifierField]);
    1690         $member = DataObject::get_one('Member', "\"$identifierField\" = '{$SQL_identifierField}'");
    1691
    1692         // if we are in a complex table field popup, use ctf[childID], else use ID
    1693         if(isset($_REQUEST['ctf']['childID'])) {
    1694             $id = $_REQUEST['ctf']['childID'];
    1695         } elseif(isset($_REQUEST['ctf']['ID'])) {
    1696             $id = $_REQUEST['ctf']['ID'];
    1697         } elseif(isset($_REQUEST['ID'])) {
    1698             $id = $_REQUEST['ID'];
    1699         } else {
    1700             $id = null;
    1701         }
    1702
    1703         if(is_object($member) && (($id && $member->ID != $id)||(!$id && $member->ID))) {
    1704             $uniqueField = $this->form->dataFieldByName($identifierField);
    1705             $this->validationError(
    1706                 $uniqueField->id(),
    1707                 sprintf(
    1708                     _t(
    1709                         'Member.VALIDATIONMEMBEREXISTS',
    1710                         'A member already exists with the same %s'
    1711                     ),
    1712                     strtolower(singleton('Member')->fieldLabel($identifierField))
    1713                 ),
    1714                 'required'
    1715             );
    1716             $valid = false;
    1717         }
    1718
    1719         // Execute the validators on the extensions
    1720         if($this->extension_instances) {
    1721             foreach($this->extension_instances as $extension) {
    1722                 if(method_exists($extension, 'hasMethod') && $extension->hasMethod('updatePHP')) {
    1723                     $valid &= $extension->updatePHP($data, $this->form);
    1724                 }
    1725             }
    1726         }
    1727
    1728         return $valid;
    1729     }
    1730
    1731
    1732     /**
    1733      * Check if the submitted member data is valid (client-side)
    1734      *
    1735      * @param array $data Submitted data
    1736      * @return bool Returns TRUE if the submitted data is valid, otherwise
    1737      *              FALSE.
    1738      */
    1739     function javascript() {
    1740         $js = parent::javascript();
    1741
    1742         // Execute the validators on the extensions
    1743         if($this->extension_instances) {
    1744             foreach($this->extension_instances as $extension) {
    1745                 if(method_exists($extension, 'hasMethod') && $extension->hasMethod('updateJavascript')) {
    1746                     $extension->updateJavascript($js, $this->form);
    1747                 }
    1748             }
    1749         }
    1750
    1751         return $js;
    1752     }
    1753
    1754 }
    1755 ?>

[Raise a SilverStripe Framework issue/bug](https://github.com/silverstripe/silverstripe-framework/issues/new)
- [Raise a SilverStripe CMS issue/bug](https://github.com/silverstripe/silverstripe-cms/issues/new)
- Please use the Silverstripe Forums to ask development related questions. -