mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-09-07 10:11:17 +02:00
The bundle is generated by running “webpack” directly - gulp is no longer needed as an intermediary. The resulting config is a lot shorter, although more configuration is pushed into lib.js. Modules are shared between javascript files as global variables. Although this global state pollution is a bit messy, I don’t think it’s practically any worse than the previous state, and it highlights the heavy coupling between the different packages we have in place. Reducing the width of the coupling between the core javascript and add-on modules would probably be a better way of dealing with this than replacing global variables with some other kind of global state. The web pack execution seems roughly twice as fast - if I clear out my framework/client/dist/js folder, it takes 13.3s to rebuild. However, it’s not rebuilding other files inside dist, only the bundle files. CSS files are now included from javascript and incorporated into bundle.css by the webpack. Although the style-loader is helpful in some dev workflows (it allows live reload), it introduces a flash of unstyled content which makes it inappropriate for production. Instead ExtractTextPlugin is used to write all the aggregated CSS into a single bundle.css file. A style-loader-based configuration could be introduced for dev environments, if we make use of the webpack live reloader in the future. Note that the following features have been removed as they don't appear to be necessary when using Webpack: - UMD module generation - thirdparty dist file copying LeftAndMain.js deps: Without it, ssui.core.js gets loaded too late, which leads e.g. to buttons being initialised without this added behaviour.
558 lines
18 KiB
PHP
Executable File
558 lines
18 KiB
PHP
Executable File
<?php
|
|
|
|
namespace SilverStripe\Security;
|
|
|
|
use SilverStripe\Admin\SecurityAdmin;
|
|
use SilverStripe\Core\Config\Config;
|
|
use SilverStripe\Core\Convert;
|
|
use SilverStripe\Forms\Form;
|
|
use SilverStripe\Forms\GridField\GridFieldAddExistingAutocompleter;
|
|
use SilverStripe\Forms\GridField\GridFieldDetailForm;
|
|
use SilverStripe\Forms\TextField;
|
|
use SilverStripe\Forms\DropdownField;
|
|
use SilverStripe\Forms\TextareaField;
|
|
use SilverStripe\Forms\Tab;
|
|
use SilverStripe\Forms\TabSet;
|
|
use SilverStripe\Forms\FieldList;
|
|
use SilverStripe\Forms\LiteralField;
|
|
use SilverStripe\Forms\ListboxField;
|
|
use SilverStripe\Forms\HiddenField;
|
|
use SilverStripe\Forms\HTMLEditor\HTMLEditorConfig;
|
|
use SilverStripe\Forms\GridField\GridFieldConfig_RelationEditor;
|
|
use SilverStripe\Forms\GridField\GridFieldButtonRow;
|
|
use SilverStripe\Forms\GridField\GridFieldExportButton;
|
|
use SilverStripe\Forms\GridField\GridFieldPrintButton;
|
|
use SilverStripe\Forms\GridField\GridField;
|
|
use SilverStripe\ORM\ArrayList;
|
|
use SilverStripe\ORM\DataObject;
|
|
use SilverStripe\ORM\DataQuery;
|
|
use SilverStripe\ORM\HasManyList;
|
|
use SilverStripe\ORM\Hierarchy\Hierarchy;
|
|
use SilverStripe\ORM\ManyManyList;
|
|
use SilverStripe\ORM\UnsavedRelationList;
|
|
use SilverStripe\View\Requirements;
|
|
|
|
/**
|
|
* A security group.
|
|
*
|
|
* @property string Title Name of the group
|
|
* @property string Description Description of the group
|
|
* @property string Code Group code
|
|
* @property string Locked Boolean indicating whether group is locked in security panel
|
|
* @property int Sort
|
|
* @property string HtmlEditorConfig
|
|
*
|
|
* @property int ParentID ID of parent group
|
|
*
|
|
* @method Group Parent() Return parent group
|
|
* @method HasManyList Permissions() List of group permissions
|
|
* @method HasManyList Groups() List of child groups
|
|
* @method ManyManyList Roles() List of PermissionRoles
|
|
* @mixin Hierarchy
|
|
*/
|
|
class Group extends DataObject {
|
|
|
|
private static $db = array(
|
|
"Title" => "Varchar(255)",
|
|
"Description" => "Text",
|
|
"Code" => "Varchar(255)",
|
|
"Locked" => "Boolean",
|
|
"Sort" => "Int",
|
|
"HtmlEditorConfig" => "Text"
|
|
);
|
|
|
|
private static $has_one = array(
|
|
"Parent" => "SilverStripe\\Security\\Group",
|
|
);
|
|
|
|
private static $has_many = array(
|
|
"Permissions" => "SilverStripe\\Security\\Permission",
|
|
"Groups" => "SilverStripe\\Security\\Group"
|
|
);
|
|
|
|
private static $many_many = array(
|
|
"Members" => "SilverStripe\\Security\\Member",
|
|
"Roles" => "SilverStripe\\Security\\PermissionRole",
|
|
);
|
|
|
|
private static $extensions = array(
|
|
"SilverStripe\\ORM\\Hierarchy\\Hierarchy",
|
|
);
|
|
|
|
private static $table_name = "Group";
|
|
|
|
public function populateDefaults() {
|
|
parent::populateDefaults();
|
|
|
|
if(!$this->Title) $this->Title = _t('SecurityAdmin.NEWGROUP',"New Group");
|
|
}
|
|
|
|
public function getAllChildren() {
|
|
$doSet = new ArrayList();
|
|
|
|
$children = Group::get()->filter("ParentID", $this->ID);
|
|
foreach($children as $child) {
|
|
$doSet->push($child);
|
|
$doSet->merge($child->getAllChildren());
|
|
}
|
|
|
|
return $doSet;
|
|
}
|
|
|
|
/**
|
|
* Caution: Only call on instances, not through a singleton.
|
|
* The "root group" fields will be created through {@link SecurityAdmin->EditForm()}.
|
|
*
|
|
* @return FieldList
|
|
*/
|
|
public function getCMSFields() {
|
|
$fields = new FieldList(
|
|
new TabSet("Root",
|
|
new Tab('Members', _t('SecurityAdmin.MEMBERS', 'Members'),
|
|
new TextField("Title", $this->fieldLabel('Title')),
|
|
$parentidfield = DropdownField::create( 'ParentID',
|
|
$this->fieldLabel('Parent'),
|
|
Group::get()->exclude('ID', $this->ID)->map('ID', 'Breadcrumbs')
|
|
)->setEmptyString(' '),
|
|
new TextareaField('Description', $this->fieldLabel('Description'))
|
|
),
|
|
|
|
$permissionsTab = new Tab('Permissions', _t('SecurityAdmin.PERMISSIONS', 'Permissions'),
|
|
$permissionsField = new PermissionCheckboxSetField(
|
|
'Permissions',
|
|
false,
|
|
'SilverStripe\\Security\\Permission',
|
|
'GroupID',
|
|
$this
|
|
)
|
|
)
|
|
)
|
|
);
|
|
|
|
$parentidfield->setDescription(
|
|
_t('Group.GroupReminder', 'If you choose a parent group, this group will take all it\'s roles')
|
|
);
|
|
|
|
// Filter permissions
|
|
// TODO SecurityAdmin coupling, not easy to get to the form fields through GridFieldDetailForm
|
|
$permissionsField->setHiddenPermissions((array)Config::inst()->get('SilverStripe\\Admin\\SecurityAdmin', 'hidden_permissions'));
|
|
|
|
if($this->ID) {
|
|
$group = $this;
|
|
$config = GridFieldConfig_RelationEditor::create();
|
|
$config->addComponent(new GridFieldButtonRow('after'));
|
|
$config->addComponents(new GridFieldExportButton('buttons-after-left'));
|
|
$config->addComponents(new GridFieldPrintButton('buttons-after-left'));
|
|
/** @var GridFieldAddExistingAutocompleter $autocompleter */
|
|
$autocompleter = $config->getComponentByType('SilverStripe\\Forms\\GridField\\GridFieldAddExistingAutocompleter');
|
|
/** @skipUpgrade */
|
|
$autocompleter
|
|
->setResultsFormat('$Title ($Email)')
|
|
->setSearchFields(array('FirstName', 'Surname', 'Email'));
|
|
/** @var GridFieldDetailForm $detailForm */
|
|
$detailForm = $config->getComponentByType('SilverStripe\\Forms\\GridField\\GridFieldDetailForm');
|
|
$detailForm
|
|
->setValidator(Member_Validator::create())
|
|
->setItemEditFormCallback(function($form, $component) use($group) {
|
|
/** @var Form $form */
|
|
$record = $form->getRecord();
|
|
$groupsField = $form->Fields()->dataFieldByName('DirectGroups');
|
|
if($groupsField) {
|
|
// If new records are created in a group context,
|
|
// set this group by default.
|
|
if($record && !$record->ID) {
|
|
$groupsField->setValue($group->ID);
|
|
} elseif($record && $record->ID) {
|
|
// TODO Mark disabled once chosen.js supports it
|
|
// $groupsField->setDisabledItems(array($group->ID));
|
|
$form->Fields()->replaceField('DirectGroups',
|
|
$groupsField->performReadonlyTransformation());
|
|
}
|
|
}
|
|
});
|
|
$memberList = GridField::create('Members',false, $this->DirectMembers(), $config)
|
|
->addExtraClass('members_grid');
|
|
// @todo Implement permission checking on GridField
|
|
//$memberList->setPermissions(array('edit', 'delete', 'export', 'add', 'inlineadd'));
|
|
$fields->addFieldToTab('Root.Members', $memberList);
|
|
}
|
|
|
|
// Only add a dropdown for HTML editor configurations if more than one is available.
|
|
// Otherwise Member->getHtmlEditorConfigForCMS() will default to the 'cms' configuration.
|
|
$editorConfigMap = HTMLEditorConfig::get_available_configs_map();
|
|
if(count($editorConfigMap) > 1) {
|
|
$fields->addFieldToTab('Root.Permissions',
|
|
new DropdownField(
|
|
'HtmlEditorConfig',
|
|
'HTML Editor Configuration',
|
|
$editorConfigMap
|
|
),
|
|
'Permissions'
|
|
);
|
|
}
|
|
|
|
if(!Permission::check('EDIT_PERMISSIONS')) {
|
|
$fields->removeFieldFromTab('Root', 'Permissions');
|
|
}
|
|
|
|
// Only show the "Roles" tab if permissions are granted to edit them,
|
|
// and at least one role exists
|
|
if(Permission::check('APPLY_ROLES') && DataObject::get('SilverStripe\\Security\\PermissionRole')) {
|
|
$fields->findOrMakeTab('Root.Roles', _t('SecurityAdmin.ROLES', 'Roles'));
|
|
$fields->addFieldToTab('Root.Roles',
|
|
new LiteralField(
|
|
"",
|
|
"<p>" .
|
|
_t(
|
|
'SecurityAdmin.ROLESDESCRIPTION',
|
|
"Roles are predefined sets of permissions, and can be assigned to groups.<br />"
|
|
. "They are inherited from parent groups if required."
|
|
) . '<br />' .
|
|
sprintf(
|
|
'<a href="%s" class="add-role">%s</a>',
|
|
SecurityAdmin::singleton()->Link('show/root#Root_Roles'),
|
|
// TODO This should include #Root_Roles to switch directly to the tab,
|
|
// but tabstrip.js doesn't display tabs when directly adressed through a URL pragma
|
|
_t('Group.RolesAddEditLink', 'Manage roles')
|
|
) .
|
|
"</p>"
|
|
)
|
|
);
|
|
|
|
// Add roles (and disable all checkboxes for inherited roles)
|
|
$allRoles = PermissionRole::get();
|
|
if(!Permission::check('ADMIN')) {
|
|
$allRoles = $allRoles->filter("OnlyAdminCanApply", 0);
|
|
}
|
|
if($this->ID) {
|
|
$groupRoles = $this->Roles();
|
|
$inheritedRoles = new ArrayList();
|
|
$ancestors = $this->getAncestors();
|
|
foreach($ancestors as $ancestor) {
|
|
$ancestorRoles = $ancestor->Roles();
|
|
if($ancestorRoles) $inheritedRoles->merge($ancestorRoles);
|
|
}
|
|
$groupRoleIDs = $groupRoles->column('ID') + $inheritedRoles->column('ID');
|
|
$inheritedRoleIDs = $inheritedRoles->column('ID');
|
|
} else {
|
|
$groupRoleIDs = array();
|
|
$inheritedRoleIDs = array();
|
|
}
|
|
|
|
$rolesField = ListboxField::create('Roles', false, $allRoles->map()->toArray())
|
|
->setDefaultItems($groupRoleIDs)
|
|
->setAttribute('data-placeholder', _t('Group.AddRole', 'Add a role for this group'))
|
|
->setDisabledItems($inheritedRoleIDs);
|
|
if(!$allRoles->count()) {
|
|
$rolesField->setAttribute('data-placeholder', _t('Group.NoRoles', 'No roles found'));
|
|
}
|
|
$fields->addFieldToTab('Root.Roles', $rolesField);
|
|
}
|
|
|
|
$fields->push($idField = new HiddenField("ID"));
|
|
|
|
$this->extend('updateCMSFields', $fields);
|
|
|
|
return $fields;
|
|
}
|
|
|
|
/**
|
|
* @param bool $includerelations Indicate if the labels returned include relation fields
|
|
* @return array
|
|
*/
|
|
public function fieldLabels($includerelations = true) {
|
|
$labels = parent::fieldLabels($includerelations);
|
|
$labels['Title'] = _t('SecurityAdmin.GROUPNAME', 'Group name');
|
|
$labels['Description'] = _t('Group.Description', 'Description');
|
|
$labels['Code'] = _t('Group.Code', 'Group Code', 'Programmatical code identifying a group');
|
|
$labels['Locked'] = _t('Group.Locked', 'Locked?', 'Group is locked in the security administration area');
|
|
$labels['Sort'] = _t('Group.Sort', 'Sort Order');
|
|
if($includerelations){
|
|
$labels['Parent'] = _t('Group.Parent', 'Parent Group', 'One group has one parent group');
|
|
$labels['Permissions'] = _t('Group.has_many_Permissions', 'Permissions', 'One group has many permissions');
|
|
$labels['Members'] = _t('Group.many_many_Members', 'Members', 'One group has many members');
|
|
}
|
|
|
|
return $labels;
|
|
}
|
|
|
|
/**
|
|
* Get many-many relation to {@link Member},
|
|
* including all members which are "inherited" from children groups of this record.
|
|
* See {@link DirectMembers()} for retrieving members without any inheritance.
|
|
*
|
|
* @param String $filter
|
|
* @return ManyManyList
|
|
*/
|
|
public function Members($filter = '') {
|
|
// First get direct members as a base result
|
|
$result = $this->DirectMembers();
|
|
|
|
// Unsaved group cannot have child groups because its ID is still 0.
|
|
if(!$this->exists()) return $result;
|
|
|
|
// Remove the default foreign key filter in prep for re-applying a filter containing all children groups.
|
|
// Filters are conjunctive in DataQuery by default, so this filter would otherwise overrule any less specific
|
|
// ones.
|
|
if(!($result instanceof UnsavedRelationList)) {
|
|
$result = $result->alterDataQuery(function($query){
|
|
/** @var DataQuery $query */
|
|
$query->removeFilterOn('Group_Members');
|
|
});
|
|
}
|
|
// Now set all children groups as a new foreign key
|
|
$groups = Group::get()->byIDs($this->collateFamilyIDs());
|
|
$result = $result->forForeignID($groups->column('ID'))->where($filter);
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Return only the members directly added to this group
|
|
*/
|
|
public function DirectMembers() {
|
|
return $this->getManyManyComponents('Members');
|
|
}
|
|
|
|
/**
|
|
* Return a set of this record's "family" of IDs - the IDs of
|
|
* this record and all its descendants.
|
|
*
|
|
* @return array
|
|
*/
|
|
public function collateFamilyIDs() {
|
|
if (!$this->exists()) {
|
|
throw new \InvalidArgumentException("Cannot call collateFamilyIDs on unsaved Group.");
|
|
}
|
|
|
|
$familyIDs = array();
|
|
$chunkToAdd = array($this->ID);
|
|
|
|
while($chunkToAdd) {
|
|
$familyIDs = array_merge($familyIDs,$chunkToAdd);
|
|
|
|
// Get the children of *all* the groups identified in the previous chunk.
|
|
// This minimises the number of SQL queries necessary
|
|
$chunkToAdd = Group::get()->filter("ParentID", $chunkToAdd)->column('ID');
|
|
}
|
|
|
|
return $familyIDs;
|
|
}
|
|
|
|
/**
|
|
* Returns an array of the IDs of this group and all its parents
|
|
*
|
|
* @return array
|
|
*/
|
|
public function collateAncestorIDs() {
|
|
$parent = $this;
|
|
$items = [];
|
|
while(isset($parent) && $parent instanceof Group) {
|
|
$items[] = $parent->ID;
|
|
$parent = $parent->Parent;
|
|
}
|
|
return $items;
|
|
}
|
|
|
|
/**
|
|
* This isn't a decendant of SiteTree, but needs this in case
|
|
* the group is "reorganised";
|
|
*/
|
|
public function cmsCleanup_parentChanged() {
|
|
}
|
|
|
|
/**
|
|
* Override this so groups are ordered in the CMS
|
|
*/
|
|
public function stageChildren() {
|
|
return Group::get()
|
|
->filter("ParentID", $this->ID)
|
|
->exclude("ID", $this->ID)
|
|
->sort('"Sort"');
|
|
}
|
|
|
|
public function getTreeTitle() {
|
|
if($this->hasMethod('alternateTreeTitle')) {
|
|
return $this->alternateTreeTitle();
|
|
}
|
|
return htmlspecialchars($this->Title, ENT_QUOTES);
|
|
}
|
|
|
|
/**
|
|
* Overloaded to ensure the code is always descent.
|
|
*
|
|
* @param string
|
|
*/
|
|
public function setCode($val){
|
|
$this->setField("Code", Convert::raw2url($val));
|
|
}
|
|
|
|
public function validate() {
|
|
$result = parent::validate();
|
|
|
|
// Check if the new group hierarchy would add certain "privileged permissions",
|
|
// and require an admin to perform this change in case it does.
|
|
// This prevents "sub-admin" users with group editing permissions to increase their privileges.
|
|
if($this->Parent()->exists() && !Permission::check('ADMIN')) {
|
|
$inheritedCodes = Permission::get()
|
|
->filter('GroupID', $this->Parent()->collateAncestorIDs())
|
|
->column('Code');
|
|
$privilegedCodes = Config::inst()->get('SilverStripe\\Security\\Permission', 'privileged_permissions');
|
|
if(array_intersect($inheritedCodes, $privilegedCodes)) {
|
|
$result->error(sprintf(
|
|
_t(
|
|
'Group.HierarchyPermsError',
|
|
'Can\'t assign parent group "%s" with privileged permissions (requires ADMIN access)'
|
|
),
|
|
$this->Parent()->Title
|
|
));
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
public function onBeforeWrite() {
|
|
parent::onBeforeWrite();
|
|
|
|
// Only set code property when the group has a custom title, and no code exists.
|
|
// The "Code" attribute is usually treated as a more permanent identifier than database IDs
|
|
// in custom application logic, so can't be changed after its first set.
|
|
if(!$this->Code && $this->Title != _t('SecurityAdmin.NEWGROUP',"New Group")) {
|
|
$this->setCode($this->Title);
|
|
}
|
|
}
|
|
|
|
public function onBeforeDelete() {
|
|
parent::onBeforeDelete();
|
|
|
|
// if deleting this group, delete it's children as well
|
|
foreach($this->Groups() as $group) {
|
|
$group->delete();
|
|
}
|
|
|
|
// Delete associated permissions
|
|
foreach($this->Permissions() as $permission) {
|
|
$permission->delete();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks for permission-code CMS_ACCESS_SecurityAdmin.
|
|
* If the group has ADMIN permissions, it requires the user to have ADMIN permissions as well.
|
|
*
|
|
* @param $member Member
|
|
* @return boolean
|
|
*/
|
|
public function canEdit($member = null) {
|
|
if(!$member || !(is_a($member, 'SilverStripe\\Security\\Member')) || is_numeric($member)) $member = Member::currentUser();
|
|
|
|
// extended access checks
|
|
$results = $this->extend('canEdit', $member);
|
|
if($results && is_array($results)) if(!min($results)) return false;
|
|
|
|
if(
|
|
// either we have an ADMIN
|
|
(bool)Permission::checkMember($member, "ADMIN")
|
|
|| (
|
|
// or a privileged CMS user and a group without ADMIN permissions.
|
|
// without this check, a user would be able to add himself to an administrators group
|
|
// with just access to the "Security" admin interface
|
|
Permission::checkMember($member, "CMS_ACCESS_SecurityAdmin") &&
|
|
!Permission::get()->filter(array('GroupID' => $this->ID, 'Code' => 'ADMIN'))->exists()
|
|
)
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Checks for permission-code CMS_ACCESS_SecurityAdmin.
|
|
*
|
|
* @param $member Member
|
|
* @return boolean
|
|
*/
|
|
public function canView($member = null) {
|
|
if(!$member || !(is_a($member, 'SilverStripe\\Security\\Member')) || is_numeric($member)) $member = Member::currentUser();
|
|
|
|
// extended access checks
|
|
$results = $this->extend('canView', $member);
|
|
if($results && is_array($results)) if(!min($results)) return false;
|
|
|
|
// user needs access to CMS_ACCESS_SecurityAdmin
|
|
if(Permission::checkMember($member, "CMS_ACCESS_SecurityAdmin")) return true;
|
|
|
|
return false;
|
|
}
|
|
|
|
public function canDelete($member = null) {
|
|
if(!$member || !(is_a($member, 'SilverStripe\\Security\\Member')) || is_numeric($member)) $member = Member::currentUser();
|
|
|
|
// extended access checks
|
|
$results = $this->extend('canDelete', $member);
|
|
if($results && is_array($results)) if(!min($results)) return false;
|
|
|
|
return $this->canEdit($member);
|
|
}
|
|
|
|
/**
|
|
* Returns all of the children for the CMS Tree.
|
|
* Filters to only those groups that the current user can edit
|
|
*/
|
|
public function AllChildrenIncludingDeleted() {
|
|
/** @var Hierarchy $extInstance */
|
|
$extInstance = $this->getExtensionInstance('SilverStripe\\ORM\\Hierarchy\\Hierarchy');
|
|
$extInstance->setOwner($this);
|
|
$children = $extInstance->AllChildrenIncludingDeleted();
|
|
$extInstance->clearOwner();
|
|
|
|
$filteredChildren = new ArrayList();
|
|
|
|
if($children) foreach($children as $child) {
|
|
if($child->canView()) $filteredChildren->push($child);
|
|
}
|
|
|
|
return $filteredChildren;
|
|
}
|
|
|
|
/**
|
|
* Add default records to database.
|
|
*
|
|
* This function is called whenever the database is built, after the
|
|
* database tables have all been created.
|
|
*/
|
|
public function requireDefaultRecords() {
|
|
parent::requireDefaultRecords();
|
|
|
|
// Add default author group if no other group exists
|
|
$allGroups = DataObject::get('SilverStripe\\Security\\Group');
|
|
if(!$allGroups->count()) {
|
|
$authorGroup = new Group();
|
|
$authorGroup->Code = 'content-authors';
|
|
$authorGroup->Title = _t('Group.DefaultGroupTitleContentAuthors', 'Content Authors');
|
|
$authorGroup->Sort = 1;
|
|
$authorGroup->write();
|
|
Permission::grant($authorGroup->ID, 'CMS_ACCESS_CMSMain');
|
|
Permission::grant($authorGroup->ID, 'CMS_ACCESS_AssetAdmin');
|
|
Permission::grant($authorGroup->ID, 'CMS_ACCESS_ReportAdmin');
|
|
Permission::grant($authorGroup->ID, 'SITETREE_REORGANISE');
|
|
}
|
|
|
|
// Add default admin group if none with permission code ADMIN exists
|
|
$adminGroups = Permission::get_groups_by_permission('ADMIN');
|
|
if(!$adminGroups->count()) {
|
|
$adminGroup = new Group();
|
|
$adminGroup->Code = 'administrators';
|
|
$adminGroup->Title = _t('Group.DefaultGroupTitleAdministrators', 'Administrators');
|
|
$adminGroup->Sort = 0;
|
|
$adminGroup->write();
|
|
Permission::grant($adminGroup->ID, 'ADMIN');
|
|
}
|
|
|
|
// Members are populated through Member->requireDefaultRecords()
|
|
}
|
|
|
|
}
|