Subversion Repositories web.active

Rev

Go to most recent revision | Blame | Compare with Previous | Last modification | View Log | Download

<?php namespace ProcessWire;

/**
 * ProcessWire User Process
 *
 * For more details about how Process modules work, please see: 
 * /wire/core/Process.php 
 * 
 * ProcessWire 3.x, Copyright 2016 by Ryan Cramer
 * https://processwire.com
 * 
 * @property int $maxAjaxQty
 *
 */

class ProcessUser extends ProcessPageType {

    static public function getModuleInfo() {
        return array(
            'title'      => __('Users', __FILE__), // getModuleInfo title
            'version'    => 107,
            'summary'    => __('Manage system users', __FILE__), // getModuleInfo summary
            'permanent'  => true,
            'permission' => 'user-admin',
            'icon'       => 'group',
            'useNavJSON' => true,
            'searchable' => 'users' 
        );
    }

    /**
     * Construct and set default config values
     * 
     */
    public function __construct() {
        $this->set("maxAjaxQty", 25);
        return parent::__construct();
    }

    /**
     * Init and prepare for execute methods
     * 
     */
    public function init() {
        $this->wire('pages')->addHookBefore('save', $this, 'hookPageSave');
        parent::init();
        if($this->lister) $this->lister->addHookBefore('execute', $this, 'hookListerExecute');

        // make this field translatable
        $roles = $this->wire('fields')->get('roles');
        $roles->label = $this->_('Roles');
        $roles->description = $this->_("User will inherit the permissions assigned to each role. You may assign multiple roles to a user. When accessing a page, the user will only inherit permissions from the roles that are also assigned to the page's template."); // Roles description
    }

    /**
     * Determine whether Lister should be used or not
     * 
     * @return bool
     * 
     */
    protected function useLister() {
        return $this->wire('user')->hasPermission('page-lister'); 
    }

    /**
     * Output JSON list of navigation items for this (intended to for ajax use)
     * 
     * @param array $options
     * @return string|array
     *
     */
    public function ___executeNavJSON(array $options = array()) {

        $max = $this->maxAjaxQty > 0 && $this->maxAjaxQty <= 100 ? (int) $this->maxAjaxQty : 50;

        if(empty($options) && $this->pages->count("id>0") > $max) {
            $user = $this->wire('user');
            $userAdminAll = $user->isSuperuser() ? $this->wire('pages')->newNullPage() : $this->wire('permissions')->get('user-admin-all');
            /** @var PagePermissions $pagePermissions */
            $pagePermissions = $this->wire('modules')->get('PagePermissions');
            $items = array();
            foreach($this->wire('roles') as $role) {
                if($userAdminAll->id && !$pagePermissions->userCanAssignRole($role)) continue;
                $cnt = $this->pages->count("roles=$role");
                $item = array(
                    'id'   => $role->id,
                    'name' => $role->name,
                    'cnt'  => sprintf($this->_n('%d user', '%d users', $cnt), $cnt)
                );
                $items[] = $item;
            }
            $options['itemLabel'] = 'name';
            $options['itemLabel2'] = 'cnt';
            $options['add'] = 'add/';
            $options['items'] = $items;
            $options['edit'] = "?roles={id}";
        }

        return parent::___executeNavJSON($options);
    }

    /**
     * Hook to ProcessPageLister::execute method to adjust selector to show specific roles
     * 
     * @param HookEvent $event
     * 
     */
    public function hookListerExecute($event) {
        
        $role = (int) $this->wire('session')->get($this, 'listerRole');
        if(!$role) return;
    
        $lister = $event->object;
        $defaultSelector = $lister->defaultSelector;
        if(strpos($defaultSelector, 'roles=') !== false) {
            $defaultSelector = preg_replace('/\broles=\d*/', "roles=$role", $defaultSelector);
        } else {
            $defaultSelector .= ", roles=$role";
        }
        $lister->defaultSelector = $defaultSelector;
    }

    /**
     * Get settings to be used for Lister
     * 
     * @param ProcessPageLister $lister
     * @param string $selector
     * @return array
     * 
     */
    protected function getListerSettings(ProcessPageLister $lister, $selector) {
        
        $settings = parent::getListerSettings($lister, $selector);
        $selector = '';
        $role = (int) $this->wire('input')->get('roles');
        $user = $this->wire('user');
        $session = $this->wire('session');
        $ajax = $this->wire('config')->ajax;

        if($role) {
            $lister->resetLister();
            $session->set($this, 'listerRole', $role);
        } else if(!$ajax) {
            if((int) $session->get($this, 'listerRole') > 0) {
                $lister->resetLister();
                $session->set($this, 'listerRole', 0);
            }
        } else {
            $role = $session->get($this, 'listerRole');
        }

        if(!$role && !$user->isSuperuser()) {
            $userAdminAll = $this->wire('permissions')->get('user-admin-all');
            if($userAdminAll->id && !$user->hasPermission($userAdminAll)) {
                // system has user-admin-all permission, and user doesn't have it
                // so limit them only to the permission user-admin-[role] roles that they have assigned
                $roles = array();
                foreach($user->getPermissions() as $permission) {
                    if(strpos($permission->name, 'user-admin-') !== 0) continue;
                    $roleName = str_replace('user-admin-', '', $permission->name);
                    $rolePage = $this->wire('roles')->get($roleName);
                    if($rolePage->id) $roles[] = $rolePage->id;
                }
                // allow them to view users that only have guest role
                $guestRoleID = $this->wire('config')->guestUserRolePageID;
                $guestUserID = $this->wire('config')->guestUserPageID;
                $selector .= ", id!=$guestUserID, roles=(roles.count=1, roles=$guestRoleID)";
                if(count($roles)) {
                    $selector .= ", roles=(roles=" . implode('|', $roles) . ")"; // string of | separated role IDs
                }
            }
        }

        $settings['initSelector'] .= $selector;
        $settings['defaultSelector'] = "name%=, roles=" . ($role ? $role : '');
        $settings['delimiters'] = array('roles' => ', ');

        return $settings;
    }

    /**
     * Get the Page being edited, when applicable
     * 
     * @return NullPage|Page
     * 
     */
    public function getPage() {
        $page = parent::getPage();
        if($page->id && !$page->get('_rolesPrevious') && $this->wire('input')->post('roles') !== null) {
            $page->setQuietly('_rolesPrevious', clone $page->roles); 
        }
        return $page;
    }

    /**
     * Return array of roles editable by current user for user $page
     * 
     * @param User $page
     * @return array of role names indexed by role ID
     * 
     */
    protected function getEditableRoles(User $page) {
        /** @var User $user */
        $user = $this->wire('user');
        $superuser = $user->isSuperuser();
        $editableRoles = array();
        $userAdminAll = $this->wire('permissions')->get('user-admin-all');
        
        foreach($this->wire('roles') as $role) {
            if($role->name == 'guest') continue;
            // if non-superuser editing a user, don't allow them to assign new roles with user-admin permission, 
            // unless the user already has the role checked, OR the non-superuser has user-admin-all permission
            if(!$superuser && $role->hasPermission('user-admin') && !$page->hasPermission('user-admin')) {
                if($userAdminAll->id && $user->hasPermission($userAdminAll)) {
                    // allow it if the non-superuser making edits has user-admin-all
                } else {
                    // do not allow
                    continue;
                }
            }
            $editableRoles[$role->id] = $role->name;
        }

        if(!$superuser) {
            if($userAdminAll->id && !$user->hasPermission($userAdminAll)) {
                foreach($editableRoles as $roleID => $roleName) {
                    if(!$user->hasPermission("user-admin-$roleName")) {
                        unset($editableRoles[$roleID]);
                    }
                }
            }
            /*
            $numEditableRoles = 0;
            foreach($page->roles as $role) {
                if(isset($editableRoles[$role->id])) $numEditableRoles++;
            }
            if($numEditableRoles == 1 && count($editableRoles) == 2) {
                // if there is only one editable role here, then removal of it would
                // prevent this user from being able to make further edits, so we 
                // count it as not-editable in that case
                foreach($page->roles as $role) {
                    if($role->name == 'guest') continue;
                    unset($editableRoles[$role->id]);
                }
            }
            */
        }
        
        return $editableRoles;
    }

    /**
     * Edit user
     * 
     * @return array|string
     * 
     */
    public function ___executeEdit() {

        /** @var User $user */
        $user = $this->wire('user');
        
        if(!$user->isSuperuser()) {
            // prevent showing superuser role at all
            $this->addHookAfter('InputfieldPage::getSelectablePages', $this, 'hookGetSelectablePages'); 
        }
        
        $this->addHookAfter('ProcessPageEdit::buildForm', $this, 'hookPageEditBuildForm'); 
        
        $out = parent::___executeEdit();
        
        /** @var User $page Available only after executeEdit() */
        $page = $this->getPage();

        $this->wire('config')->js('ProcessUser', array(
            'editableRoles' => array_keys($this->getEditableRoles($page)),
            'notEditableAlert' => $this->_('You may not change this role'),
        ));

        return $out; 
    }

    /**
     * Hook to InputfieldPage::getSelectablePages to target the "roles" field
     * 
     * @param HookEvent $event
     * 
     */
    public function hookGetSelectablePages($event) {
        if($event->object->attr('name') != 'roles') return;
        $suRoleID = $this->wire('config')->superUserRolePageID;
        foreach($event->return as $role) {
            if($role->id == $suRoleID) $event->return->remove($role);
        }
    }

    /**
     * Hook to ProcessPageEdit::buildForm to adjust User edit form before presenting to user
     * 
     * @param HookEvent $event
     * 
     */
    public function hookPageEditBuildForm(HookEvent $event) {
        $form = $event->return;
        /** @var InputfieldSelect $theme */
        $theme = $form->getChildByName('admin_theme');
        if(!$theme) return;
        if(!$theme->attr('value')) {
            $theme->attr('value', $this->wire('config')->defaultAdminTheme); 
        }
    }
    
    /**
     * Hook before Pages::save()
     * 
     * Perform a security check to make sure that a non-superuser isn't assigning superuser access to 
     * themselves or someone else. Plus perform addition role add/remove checks. 
     *
     * @param HookEvent $event
     * @throws WireException
     * 
     */
    public function hookPageSave(HookEvent $event) {

        $arguments = $event->arguments; 
        $page = $arguments[0]; 
        
        if(!$page instanceof User && !in_array($page->template->id, $this->wire('config')->userTemplateIDs)) {
            // don't handle anything other than User page saves
            return;
        }

        $pages = $this->wire('pages');
        $user = $this->wire('user');
        $superuser = $user->isSuperuser();
        $suRole = $this->wire('roles')->get($this->wire('config')->superUserRolePageID); 

        // don't allow removal of the guest role
        if(!$page->roles->has("name=guest")) {
            $page->roles->add($this->wire('roles')->get('guest'));  
        }

        // check if user is editing themself
        if($user->id == $page->id) {
            // if so, we have to get a fresh copy of their account to see what it had before they changed it
            $copy = clone $page;        // keep a copy that doesn't go through the uncache process
            $pages->uncache($page);     // take it out of the cache
            $user = $pages->get($page->id); // get a fresh copy of their account from the DB (pre-modified)
            $pages->cache($copy);       // put the modified version back into the cache
            $arguments[0] = $copy;      // restore it to the arguments sent to $pages->save 
            $event->arguments = $arguments;
            $page = $copy;

            // don't let superusers remove their superuser role
            if($superuser && !$page->roles->has($suRole)) {
                throw new WireException($this->_("You may not remove the superuser role from yourself")); 
            }
        } 

        if(!$superuser) {
            if($page->roles->has("name=superuser") || $page->roles->has($suRole)) { 
                throw new WireException($this->_("You may not assign the superuser role"));
            }
            $this->checkSaveRoles($user, $page);
            $this->checkSaveUserAdminAll($user, $page);
        }
    }

    /**
     * Perform a general check for saving the roles field, making sure added/removed roles are okay
     * 
     * @param User $user
     * @param User|Page $page
     * 
     */
    protected function checkSaveRoles(User $user, Page $page) {
        
        if($user->isSuperuser()) return;
        
        /** @var PageArray $rolesPrevious Set to page by the ProcessUser::getPage() method */
        $rolesPrevious = $page->get('_rolesPrevious');
        if(!$rolesPrevious || ((string) $rolesPrevious) === ((string) $page->roles)) return;

        $editableRoles = $this->getEditableRoles($page);
        $addedRoles = array();
        $removedRoles = array();

        // determine added and removed roles
        foreach($page->roles as $role) {
            if(!$rolesPrevious->has($role)) $addedRoles[$role->id] = $role;
        }
        foreach($rolesPrevious as $role) {
            if(!$page->roles->has($role)) $removedRoles[$role->id] = $role;
        }
        
        // if any added or removed roles are not consistent with editable roles, then reverse the change
        // this is not likely to ever occur but is here for redundancy
        foreach($addedRoles as $role) {
            if($role->name == 'guest') continue;
            if(!isset($editableRoles[$role->id])) {
                $page->roles->remove($role);
                $this->error("Role $role->name may not be added"); 
            }
        }
        foreach($removedRoles as $role) {
            if(!isset($editableRoles[$role->id])) {
                $page->roles->add($role);
                $this->error("Role $role->name may not be removed"); 
            }
        }
    }

    /**
     * Perform checks for when "user-admin-all" permission is installed and user does not have it
     * 
     * @param User $user
     * @param User|Page $page
     * 
     */
    protected function checkSaveUserAdminAll(User $user, Page $page) {
        
        if($user->isSuperuser()) return;
        
        $userAdminAll = $this->wire('permissions')->get('user-admin-all');
        if(!$userAdminAll->id || $user->hasPermission($userAdminAll)) return;
        
        // user-admin-all permission is installed and user doesn't have it
        // check that the role assignments are valid
        
        /** @var Pages $pages */
        $pages = $this->wire('pages');
        $changedUser = $page;
        $pages->uncache($page, array('shallow' => true));
        $originalUser = $this->wire('users')->get($page->id); // get a fresh, unmodified copy
        if(!$originalUser->id) return;
        
        /** @var PagePermissions $pagePermissions */
        $pagePermissions = $this->wire('modules')->get('PagePermissions');
        $removedRoles = array();

        foreach($originalUser->roles as $role) {
            if(!$changedUser->roles->has($role)) {
                // role was removed
                if(!$pagePermissions->userCanAssignRole($role)) {
                    $changedUser->roles->add($role);
                    $this->error(sprintf($this->_('You are not allowed to remove role: %s'), $role->name));
                } else {
                    $removedRoles[] = $role;
                }
            }
        }
        
        foreach($changedUser->roles as $role) {
            if(!$originalUser->roles->has($role)) {
                // role was added
                if(!$pagePermissions->userCanAssignRole($role)) {
                    $changedUser->roles->remove($role);
                    $this->error(sprintf($this->_('You are not allowed to add role: %s'), $role->name));
                }
            }
        }

        if(count($removedRoles) && !$changedUser->editable()) {
            $this->error($this->_('You removed role(s) that that will prevent your edit access to this user. Roles have been restored.'));
            foreach($removedRoles as $role) $changedUser->roles->add($role);
        }
    }
    
}