Subversion Repositories web.active

Rev

Rev 22 | 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 2023 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' 
    );
  }
  
  protected $rolesSelector = '';

  /**
   * 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');
      $this->lister->addHookAfter('getSelector', $this, 'hookListerGetSelector');
    }

    // 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);
  }
  
  /**
   * Add item of this page type
   *
   * @return string
   *
   */
  public function ___executeAdd() {

    // use parent method if there are no custom user parents or templates
    $config = $this->wire()->config;
    if(count($config->usersPageIDs) < 2 && count($config->userTemplateIDs) < 2) return parent::___executeAdd();
    
    $input = $this->wire()->input;
    $pages = $this->wire()->pages;
    $parentId = (int) $input->get('parent_id');
    $parent = $parentId ? $pages->get($parentId) : null;
    $userTemplates = $this->pages->getTemplates();
    $userParentIds = $this->pages->getParentIDs();

    // if requested parent not one allowed by config.usersPageIDs then disregard it
    if($parent && !in_array($parent->id, $userParentIds, true)) $parent = null;

    // delegate to ProcessPageAdd
    $editor = $this->wire()->modules->getModule('ProcessPageAdd'); /** @var ProcessPageAdd $editor */
    $this->editor = $editor;
    $editor->setEditor($this); // set us as the parent editor

    // identify parent(s) allowed
    if(count($userParentIds) > 1 && $parent) {
      // more than one parent allowed but only one requested
      $parents = $pages->newPageArray();
      $parents->add($parent);
      $editor->parent_id = $parent->id;
      $editor->setPredefinedParents($parents);
    } else {
      // one or more parents allowed
      $editor->setPredefinedParents($pages->getById($userParentIds));
    }

    // identify template(s) allowed
    if(count($userTemplates) < 2) {
      // only one user template allowed
      $editor->template = $this->template;
    } else if($parent) {
      // parent specified, reduce to only allowed templates for parent
      $childTemplates = $parent->template->childTemplates;
      if(count($childTemplates)) {
        foreach($userTemplates as $key => $template) {
          /** @var Template $template */
          if(!in_array($template->id, $childTemplates)) unset($userTemplates[$key]);
        }
      }
      if(!count($userTemplates)) $userTemplates = array($this->template);
    }

    $editor->setPredefinedTemplates($userTemplates);

    try {
      $out = $editor->execute();
    } catch(\Exception $e) {
      $out = '';
      $this->error($e->getMessage());
      if($input->is('POST')) {
        $this->wire()->session->location("./" . ($parentId ? "?parent_id=$parentId" : ""));
      }
    }

    $this->browserTitle($this->page->get('title|name') . " > $this->addLabel");

    return $out;
  }
  

  /**
   * Hook to ProcessPageLister::execute method to adjust selector to show specific roles
   * 
   * @param HookEvent $event
   * 
   */
  public function hookListerExecute($event) {
    
    $role = (int) $this->wire()->session->getFor($this, 'listerRole');
    if(!$role) return;

    /** @var ProcessPageLister $lister */
    $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;
  }

  /**
   * Hook after ProcessPageLister::getSelector
   * 
   * @param HookEvent $event
   * 
   */
  public function hookListerGetSelector(HookEvent $event) {
    if(empty($this->rolesSelector)) return;
    $selector = $event->return;
    if(strpos($selector, $this->rolesSelector) !== false) return;
    $event->return .= $this->rolesSelector;
  }

  /**
   * 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 = '';
    $rolesSelector = '';
    $filterRoleId = (int) $this->wire()->input->get('roles');
    $filterRole = $filterRoleId ? $this->wire()->roles->get($filterRoleId) : null;
    $user = $this->wire()->user;
    $session = $this->wire()->session;
    $config = $this->wire()->config;
    $ajax = $config->ajax;
  
    if($filterRole && $filterRole->id) {
      $lister->resetLister();
      $session->setFor($this, 'listerRole', $filterRole->id);
    } else if(!$ajax) {
      if((int) $session->getFor($this, 'listerRole') > 0) {
        $lister->resetLister();
        $session->setFor($this, 'listerRole', 0);
      }
    } else {
      $filterRoleId = $session->getFor($this, 'listerRole');
      $filterRole = $this->wire()->roles->get($filterRoleId);
    }
    
    if(!$filterRole || !$filterRole->id) $filterRole = null;

    if(!$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();
        $rolesSelector .= ", roles!=" . $config->superUserRolePageID;
        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] = $rolePage->id;
        }
        // allow them to view users that only have guest role
        $guestRoleID = $config->guestUserRolePageID;
        $guestUserID = $config->guestUserPageID;
        $rolesSelector .= ", id!=$guestUserID, roles=(roles.count=1, roles=$guestRoleID)";
        if(count($roles)) {
          $rolesSelector .= ", roles=(roles=" . implode('|', $roles) . ")"; // string of | separated role IDs
          if($filterRole && !isset($roles[$filterRole->id])) $filterRole = null;
        }
        $selector .= $rolesSelector;
        $this->rolesSelector = $rolesSelector;
      }
    }

    $settings['initSelector'] .= $selector;
    $settings['defaultSelector'] = "name%=, roles=" . ($filterRole ? $filterRole->id : '');
    $settings['delimiters'] = array('roles' => ', ');
    $settings['allowBookmarks'] = true;

    return $settings;
  }

  /**
   * Get the Page being edited, when applicable
   * 
   * @return NullPage|Page
   * 
   */
  public function getPage() {
    $page = parent::getPage();
    if(!$page instanceof User) {
      if(wireInstanceOf($page, 'RepeaterPage')) {
        /** @var RepeaterPage $page */
        $page = $page->getForPageRoot();
      }
    }
    /** @var User $page */
    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) {
    
    $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 string
   * 
   */
  public function ___executeEdit() {

    $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) {
    /** @var InputfieldPage $inputfield */
    $inputfield = $event->object;
    if($inputfield->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) {

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

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

    // don't allow removal of the guest role
    if(!$page->roles->has("name=guest")) {
      $page->roles->add($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
    
    $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);
    }
  }
  
}