diff --git a/Controller/GrouperGroupsController.php b/Controller/GrouperGroupsController.php index 76bedd8..b9749a0 100644 --- a/Controller/GrouperGroupsController.php +++ b/Controller/GrouperGroupsController.php @@ -40,7 +40,7 @@ class GrouperGroupsController extends GrouperLiteWidgetAppController public $helpers = array('Html', 'Form', 'Flash'); // Dynamic properties are deprecated, so we will define the property here - protected $userId = null; + private $userId = null; public $uses = array( 'GrouperLiteWidget.GrouperGroup', @@ -67,8 +67,8 @@ public function addSubscriber(): void $this->layout = null; $this->autoRender = false; - $groupName = urldecode($this->request->query['group']); - $addUserId = urldecode($this->request->query['userId']); + $groupName = $this->request->data['group']; + $addUserId = $this->request->data['userId']; // Need to see if coming from AdHoc or from a WG (Working Group) $groupNameFormatted = strpos($groupName, ':') === false ? 'ref:incommon-collab:' . $groupName . ':users' @@ -146,6 +146,14 @@ public function beforeRender() { $this->set('vv_coid', $this->cur_co['Co']['id']); } + /** + * @return null + */ + public function getUserId() + { + return $this->userId; + } + /** * Perform a "keyword" search for CO People, sort of like the CO Dashboard * cross controller search, but intended specifically for "people finder" @@ -234,7 +242,7 @@ public function groupSubscribers(): void /** * Listing of all Grouper Groups owned/admin by User Or search those Grouper Groups */ - public function groupOwnerApi() { + public function groupOwnerApi(): void { //Set initial setting $arguments = [ 'userId' => $this->userId, @@ -261,7 +269,7 @@ public function groupOwnerApi() { CakeLog::write('error', __METHOD__ . "::{$errorHint}: " . var_export($e->getMessage(), true)); $this->restResponse(HttpStatusCodesEnum::HTTP_INTERNAL_SERVER_ERROR, ErrorsEnum::Exception); - $this->set('groupsowners', []); + $this->set('groupowners', []); $this->Flash->set(_txt('pl.grouperlite.message.flash.owner-group-failed'), array('key' => 'error')); return; } @@ -275,7 +283,7 @@ public function groupOwnerApi() { * This includes self-joined Optin Groups, as well as required Groups User cannot leave * */ - public function groupMemberApi() { + public function groupMemberApi(): void { //Set initial setting $arguments = [ 'userId' => $this->userId, @@ -404,7 +412,7 @@ public function index(): void * @return array|bool Permissions * @since COmanage Registry v3.2.0 */ - function isAuthorized(): array|bool + public function isAuthorized(): array|bool { $roles = $this->Role->calculateCMRoles(); $cfg = $this->CoGrouperLiteWidget->getConfig(); @@ -420,31 +428,32 @@ function isAuthorized(): array|bool && is_array($identifiers) && isset($identifiers['Identifier']['identifier']) ) { - $this->userId = $identifiers['Identifier']['identifier']; + $this->setUserId($identifiers['Identifier']['identifier']); } // Determine what operations this user can perform // Construct the permission set for this user, which will also be passed to the view. - //Note: Leaving in current format, in case need to restrict certain pages, can just remove true and add params. - $p = array(); - - $p['index'] = true; - $p['groupowner'] = true; - $p['groupownerapi'] = true; - $p['groupoptin'] = true; - $p['groupoptinapi'] = true; - $p['groupmember'] = true; - $p['groupmemberapi'] = true; - $p['getBaseConfig'] = true; - $p['groupSubscribers'] = true; - $p['addSubscriber'] = true; - $p['findSubscriber'] = true; - $p['removeSubscriber'] = true; - - $p['groupCreate'] = true; - $p['joinGroup'] = true; - $p['leaveGroup'] = true; - $p['groupcreatetemplate'] = true; + $p = []; + + $p['index'] = ($roles['cmadmin'] || $roles['coadmin'] || $roles['comember']); + $p['groupowner'] = ($roles['cmadmin'] || $roles['coadmin'] || $roles['comember']); + $p['groupownerapi'] = ($roles['cmadmin'] || $roles['coadmin'] || $roles['comember']); + $p['groupoptin'] = ($roles['cmadmin'] || $roles['coadmin'] || $roles['comember']); + $p['groupoptinapi'] = ($roles['cmadmin'] || $roles['coadmin'] || $roles['comember']); + $p['groupmember'] = ($roles['cmadmin'] || $roles['coadmin'] || $roles['comember']); + $p['groupmemberapi'] = ($roles['cmadmin'] || $roles['coadmin'] || $roles['comember']); + $p['getBaseConfig'] = ($roles['cmadmin'] || $roles['coadmin'] || $roles['comember']); + $p['groupSubscribers'] = ($roles['cmadmin'] || $roles['coadmin'] || $roles['comember']); + $p['addSubscriber'] = ($roles['cmadmin'] || $roles['coadmin'] || $roles['comember']); + $p['findSubscriber'] = ($roles['cmadmin'] || $roles['coadmin'] || $roles['comember']); + $p['usermanager'] = ($roles['cmadmin'] || $roles['coadmin'] || $roles['comember']); + $p['usermanagerapi'] = ($roles['cmadmin'] || $roles['coadmin'] || $roles['comember']); + $p['removeSubscriber'] = ($roles['cmadmin'] || $roles['coadmin'] || $roles['comember']); + + $p['groupCreate'] = ($roles['cmadmin'] || $roles['coadmin'] || $roles['comember']); + $p['joinGroup'] = ($roles['cmadmin'] || $roles['coadmin'] || $roles['comember']); + $p['leaveGroup'] = ($roles['cmadmin'] || $roles['coadmin'] || $roles['comember']); + $p['groupcreatetemplate'] = ($roles['cmadmin'] || $roles['coadmin'] || $roles['comember']); $this->set('permissions', $p); @@ -596,6 +605,55 @@ public function restResponse(int $status, $this->response->send(); } + /** + * @param null $userId + */ + private function setUserId($userId): void + { + $this->userId = $userId; + } + + /** + * UserManager vue route for rendering + * + * @return void + */ + public function userManager(): void + { + $this->render('index'); + } + + /** + * Display all Groups for the user i manage + */ + public function userManagerApi(): void + { + //Set initial setting + $cfg = $this->CoGrouperLiteWidget->getConfig(); + + if (!isset($this->request->query['memberid'])) { + $this->restResponse(HttpStatusCodesEnum::HTTP_BAD_REQUEST, ErrorsEnum::Exception); + } + + $memberId = urldecode($this->request->query['memberid']); + + try { + $groupsimanage = $this->GrouperGroup->getManagedUsers($this->userId, + $memberId, + $cfg); + } catch (Exception $e) { + CakeLog::write('error', __METHOD__ . '::get Managed Users: ' . var_export($e->getMessage(), true)); + $this->restResponse(HttpStatusCodesEnum::HTTP_INTERNAL_SERVER_ERROR, ErrorsEnum::Exception); + + $this->set('groupsimanage', []); + return; + } + + $this->set(compact('groupsimanage')); + $this->set('_serialize', 'groupsimanage'); + + } + /** * Override the default sanity check performed in AppController * diff --git a/Lib/lang.php b/Lib/lang.php index 34bad66..d5e490b 100644 --- a/Lib/lang.php +++ b/Lib/lang.php @@ -121,6 +121,7 @@ 'pl.grouperlite.action.view-members' => 'View members', 'pl.grouperlite.action.grouper' => 'Grouper', 'pl.grouperlite.action.members' => 'Members', + 'pl.grouperlite.action.memberships' => 'Memberships', 'pl.grouperlite.action.close' => 'Close', 'pl.grouperlite.action.clear' => 'Clear', 'pl.grouperlite.action.add-user' => 'Add', @@ -163,7 +164,7 @@ 'pl.grouperlite.form.template.value.positive' => 'Yes', 'pl.grouperlite.form.template.value.negative' => 'No', - 'pl.grouperlite.search.tags.text' => 'Search', + 'pl.grouperlite.title.search' => 'Search', 'pl.grouperlite.pagination.counter' => 'Viewing {:start}-{:end} of {:count}', 'pl.grouperlite.attributes.zero-state' => 'No Attributes Associated to this Group.', diff --git a/Model/GrouperGroup.php b/Model/GrouperGroup.php index 49172c6..1468b07 100644 --- a/Model/GrouperGroup.php +++ b/Model/GrouperGroup.php @@ -217,8 +217,10 @@ public function findForPicker(int $coId, string $mode, ?string $term): array $idArr = $p['Identifier']; $emailArr = $p['EmailAddress']; $email = ''; + $email_short = ''; $emailLabel = ''; $id = ''; + $id_short = ''; $idLabel = ''; // Iterate over the email array @@ -228,6 +230,7 @@ public function findForPicker(int $coId, string $mode, ?string $term): array foreach($emailArr as $e) { if($e['type'] == $pickerEmailType) { $email = $e['mail']; + $email_short = mb_strimwidth($e['mail'], 0, 30, '...'); break; } } @@ -243,7 +246,8 @@ public function findForPicker(int $coId, string $mode, ?string $term): array } foreach($idArr as $i) { if($i['type'] == $pickerIdentifierType) { - $id = mb_strimwidth($i['identifier'], 0, 30, '...'); + $id_short = mb_strimwidth($i['identifier'], 0, 30, '...'); + $id = $i['identifier']; break; } } @@ -255,9 +259,13 @@ public function findForPicker(int $coId, string $mode, ?string $term): array 'value' => $p['CoPerson']['id'], 'label' => $label, 'email' => $email, + 'emailShort' => $email_short, 'emailLabel' => $emailLabel, + 'emailType' => $pickerEmailType, 'identifier' => $id, - 'identifierLabel' => $idLabel + 'identifierShort' => $id_short, + 'identifierLabel' => $idLabel, + 'identifierType' => $pickerIdentifierType ); } } @@ -318,6 +326,55 @@ public function getOwnedGroups(string $userId, array $cfg): array return $this->removeDuplicates($resultsAdmin, $resultsUpdate); } + /** + * Return all Grouper Groups that + * - the User(me) has a role of owner/admin + * - the User(member User) is a member + * + * @param string $managerId + * @param string $memberId + * @param array $cfg + * + * @return array + * @throws GrouperLiteWidgetException + * @since COmanage Registry v4.4.0 + */ + public function getManagedUsers(string $managerId, string $memberId, array $cfg): array { + if(empty($managerId) || empty($memberId)) { + return false; + } + + $this->initApi($cfg); + + try { + $resultsManagerAdmin = $this->grouperAPI->getUserMemberships($managerId, $managerId, GrouperGroupTypeEnum::ADMIN); + $resultsManagerUpdate = $this->grouperAPI->getUserMemberships($managerId, $managerId, GrouperGroupTypeEnum::UPDATE); + } catch (Exception $e) { + CakeLog::write('error', __METHOD__ . ': An error occurred'); + throw $e; + } + + $managerGroupSet = $this->removeDuplicates($resultsManagerAdmin, $resultsManagerUpdate); + + try { + // Groups the user is a member of + $membersGroup = $this->grouperAPI->getUserGroups($managerId, $memberId); + } catch (Exception $e) { + CakeLog::write('error', __METHOD__ . ': An error occurred'); + throw $e; + } + + // Extract the names of the Groups the member-user is a member of + $memberGroupNames = Hash::extract($membersGroup, '{n}.name'); + // Return the groups the user can join and is not a member of + return array_values( // Restart indexing from 0(zero) on the final array + array_filter( // Return the groups the member-user is a member + $managerGroupSet, + static fn($value) => in_array($value['name'], $memberGroupNames) + ) + ); + } + /** * Potential use was for creating adhoc group by a user, not associated to WG. * diff --git a/View/CoGrouperLiteWidgets/display.ctp b/View/CoGrouperLiteWidgets/display.ctp index ccf0c76..8cb6201 100644 --- a/View/CoGrouperLiteWidgets/display.ctp +++ b/View/CoGrouperLiteWidgets/display.ctp @@ -91,6 +91,18 @@ $idsuffix = rand(); 'glid' => $glid ) ); ?>" + }, + { + label: "", + url: "Html->url( + array( + 'plugin' => $pl, + 'controller' => 'grouper_groups', + 'action' => 'usermanager', + 'co' => $coid, + 'glid' => $glid + ) + ); ?>" } ] } diff --git a/View/GrouperGroups/base.ctp b/View/GrouperGroups/base.ctp index 8301abc..ef2c37d 100644 --- a/View/GrouperGroups/base.ctp +++ b/View/GrouperGroups/base.ctp @@ -16,6 +16,7 @@ echo $this->Html->meta( array('inline' => false) ); +print $this->Html->script('GrouperLiteWidget.autocomplete.grouperplugin') . PHP_EOL; print $this->element('GrouperLiteWidget.base-styles'); print $this->Html->css('GrouperLiteWidget.co-grouper-plugin') . PHP_EOL; diff --git a/View/GrouperGroups/index.ctp b/View/GrouperGroups/index.ctp index 48ed270..138e8a3 100644 --- a/View/GrouperGroups/index.ctp +++ b/View/GrouperGroups/index.ctp @@ -14,6 +14,7 @@ import GroupMember from 'webroot ?>grouper_lite_widget/js/page/GroupMember.js'; import GroupOptin from 'webroot ?>grouper_lite_widget/js/page/GroupOptin.js'; import GroupOwner from 'webroot ?>grouper_lite_widget/js/page/GroupOwner.js'; + import UserManager from 'webroot ?>grouper_lite_widget/js/page/UserManager.js'; ", removeSubscriberSuccess: "", getSubscriberError: "", - searchTag: "", peoplePickerPlaceHolder: "", + memberships: "", noaccess: "", empty: "", join: "", @@ -102,6 +107,7 @@ memberships: "", optin: "", owner: "", + manager: "", }, columns: _txt('pl.grouperlite.table.name'), @@ -124,6 +130,7 @@ add: "webroot ?>grouper_lite_widget/grouper_groups/addSubscriber/co:/glid:", group: "webroot ?>grouper_lite_widget/grouper_groups/groupSubscribers/co:/glid:", memberships: "webroot ?>grouper_lite_widget/grouper_groups/groupmemberapi/co:/glid:", + managing: "webroot ?>grouper_lite_widget/grouper_groups/usermanagerapi/co:/glid:", optin: "webroot ?>grouper_lite_widget/grouper_groups/groupoptinapi/co:/glid:", owner: "webroot ?>grouper_lite_widget/grouper_groups/groupownerapi/co:/glid:", }, diff --git a/webroot/css/co-grouper-plugin.css b/webroot/css/co-grouper-plugin.css index 4736499..9b1cebf 100644 --- a/webroot/css/co-grouper-plugin.css +++ b/webroot/css/co-grouper-plugin.css @@ -327,4 +327,12 @@ a.list-group-item-action:hover .fa { right: 38px; bottom: 10px; margin-right: -26px; +} + +.grouper_groups #co-loading span, +.grouper_groups #co-loading-redirect span, +.grouper_groups .co-loading-mini span { + animation: 1.2s linear infinite both loading; + background-color: var(--teal); + display: inline-block; } \ No newline at end of file diff --git a/webroot/js/autocomplete.grouperplugin.js b/webroot/js/autocomplete.grouperplugin.js new file mode 100644 index 0000000..0b37dc9 --- /dev/null +++ b/webroot/js/autocomplete.grouperplugin.js @@ -0,0 +1,21 @@ +$.widget( "ui.autocomplete", $.ui.autocomplete, { + _renderMenu: function( ul, items ) { + var that = this; + $.each( items, function( index, item ) { + that._renderItemData( ul, item ); + }); + }, + _renderItem: function( ul, item ) { + let itemMarkup = '
'; + itemMarkup += '
' + item.label + '
'; + if(item?.emailShort != '') { + itemMarkup += '
' + item.emailLabel + '' + item.emailShort + '
'; + } + if(item?.identifierShort != '') { + itemMarkup += '
' + item.identifierLabel + '' + item.identifierShort + '
'; + } + itemMarkup += '
'; + + return $("
  • ").append(itemMarkup).appendTo(ul); + } +}); \ No newline at end of file diff --git a/webroot/js/autocomplete.js b/webroot/js/autocomplete.js index a0a5958..52d81c7 100644 --- a/webroot/js/autocomplete.js +++ b/webroot/js/autocomplete.js @@ -2,9 +2,17 @@ export default { props: { - group: { + action: { type: String, - default: "" + default: "addUser" + }, + icon: { + type: String, + default: 'add' + }, + activeBtn: { + type: Boolean, + default: true } }, inject: ['txt', 'api'], @@ -13,34 +21,60 @@ export default { search: '', val: '', item: null, + limit: 15, + minLength: 3, + url: '' }; }, methods: { - addUser() { - this.$emit('add', this.item); + performAction() { + this.$emit('callback', this.item); + }, + toKebabCase(str) { + return str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); + } + }, + computed: { + btnTxt() { + return eval(`this.txt.${this.action}`) ?? eval(`this.txt.${this.icon}`) + }, + isBtnDisabled() { + // The minimum length that i start search is 3. So we only enable the button when + // the input text value has at least three characters + return this.activeBtn || (this.search.length < 3) } }, mounted(el) { - const input = $(this.$el).find('#add-user-input'); + const input = $(this.$el).find(`#${this.toKebabCase(this.action)}-input`); + this.url = `${this.api.find}?co=${this.api.co}&mode=${this.api.mode}&page=${this.page}&limit=${this.limit}` input.autocomplete({ - source: `${this.api.find}?co=${this.api.co}&mode=${this.api.mode}`, - minLength: 3, - maxShowItems: 15, - focus: ( event, ui ) => { - $("#grouper-add-member-search-container .co-loading-mini").hide(); - return false; - }, - open: function (event, ui) { - $("#grouper-add-member-search-container .co-loading-mini").hide(); - }, - create: function (event, ui) { - $("#add-user-input").focus(); - }, - close: function (event, ui) { - $("#grouper-add-member-search-container .co-loading-mini").hide(); + source: ( request, response ) => { + $("#grouper-search-container .co-loading-mini").show(); + $.ajax({ + url: this.url, + type: 'GET', + dataType: "json", + data: { + // XXX Change the term key to any other query key that fits your needs. + term: request.term + }, + success: function (data) { + $("#grouper-search-container .co-loading-mini").hide(); + // If i have more data from before append at the end + response( data ); + }, + error: function(data) { + $("#grouper-search-container .co-loading-mini").hide(); + console.log('Autocomplete ajax error:', data) + generateFlash('Find action failed', 'error'); + } + }); }, - search: function (event, ui) { - $("#grouper-add-member-search-container .co-loading-mini").show(); + delay: 1000, + minLength: this.minLength, + maxShowItems: this.limit, + create: (event, ui) => { + $(`#${this.toKebabCase(this.action)}-input`).focus(); }, // XXX We need access to the parent data object. // As a result we have to use arrow function syntax (ES6) @@ -48,15 +82,15 @@ export default { this.val = ui.item.identifier; this.item = ui.item; this.search = `${ui.item.label} (${ui.item.value})`; - $("#addUserbutton").prop('disabled', false).focus(); + $(`#${this.toKebabCase(this.action)}-btn`).prop('disabled', false).focus(); return false; }, - }).autocomplete( "instance" )._renderItem = formatCoPersonAutoselectItem; + }) }, template: /*html*/` -
    +
    -
    -
    diff --git a/webroot/js/grouper-groups-view.js b/webroot/js/grouper-groups-view.js index e2a508d..7d75678 100644 --- a/webroot/js/grouper-groups-view.js +++ b/webroot/js/grouper-groups-view.js @@ -25,6 +25,10 @@ export default { { path: `/groupowner/co:${this.api.co}/glid:${this.api.glid}`, label: this.txt.tabs.owner + }, + { + path: `/usermanager/co:${this.api.co}/glid:${this.api.glid}`, + label: this.txt.tabs.manager } ] } diff --git a/webroot/js/groups-table.js b/webroot/js/groups-table.js index 49fe26a..89ba9fd 100644 --- a/webroot/js/groups-table.js +++ b/webroot/js/groups-table.js @@ -6,7 +6,10 @@ export default { Table ], data() {}, - computed: { + methods: { + groupStatus(status) { + return status === 'T' ? 'Enabled' : 'Disabled' + } }, created() {}, template: /*html*/` @@ -25,9 +28,10 @@ export default { — {{ group.description || txt.descrZeroState }} - {{ group.enabled && group.enabled === 'T' ? 'Enabled' : 'Disabled' }} + {{ groupStatus(group?.enabled) }} + + - + + + + {{ txt.grouper }}   diff --git a/webroot/js/members.js b/webroot/js/members.js index 2fa0a09..9c3edac 100644 --- a/webroot/js/members.js +++ b/webroot/js/members.js @@ -116,7 +116,7 @@ export default { const formData = new FormData(); formData.append("userId", id); formData.append("group", name); - const resp = await fetch(`${this.api.add}?group=${name}&userId=${id}`, { + const resp = await fetch(`${this.api.add}`, { method: "POST", headers: { "Accept": "application/json", @@ -163,7 +163,7 @@ export default {
    - +
    diff --git a/webroot/js/page/GroupMember.js b/webroot/js/page/GroupMember.js index a6f781b..09da695 100644 --- a/webroot/js/page/GroupMember.js +++ b/webroot/js/page/GroupMember.js @@ -23,6 +23,7 @@ export default { }, methods: { showSubscribers(group) { + // Create and show the modal this.$refs.members.show(group); }, }, @@ -31,11 +32,6 @@ export default { setQueryParam('view', newValue); } }, - computed: { - routePath() { - return `/groupmember/co:${this.api.co}/glid:${this.api.glid}`; - } - }, mounted() { let view = hasQueryParam('view') ? getQueryParam('view') : 'adhoc'; if (view !== 'working' && view !== 'adhoc') { diff --git a/webroot/js/page/UserManager.js b/webroot/js/page/UserManager.js new file mode 100644 index 0000000..256f868 --- /dev/null +++ b/webroot/js/page/UserManager.js @@ -0,0 +1,104 @@ +import PageCount from '../pagecount.js'; +import Pagination from '../pagination.js'; +import GroupsTable from '../groups-table.js'; +import Members from '../members.js'; +import Autocomplete from '../autocomplete.js'; +import Loader from '../loader.js'; + +export default { + components: { + PageCount, + Pagination, + GroupsTable, + Members, + Autocomplete, + Loader + }, + data() { + return { + loading: false, + results: [], + user: {}, + rowLoading: false, + searched: false + } + }, + inject: ['api', 'txt'], + methods: { + reset() { + this.searched = false + // If you are in the middle of a row action we do not want to clear + // the results because we are breaking the user experience + if(!this.rowLoading) { + this.results = [] + } + }, + async removeUser(group) { + const {name, displayExtension} = group; + const { identifier: id, label: username, email } = this.user; + // This will trigger the inline loader + group.loading = true; + this.rowLoading = true; + const resp = await fetch(`${this.api.remove}?group=${(name)}&userId=${id}`, { + method: "DELETE", + headers: { + "Accept": "application/json" + } + }); + if (resp.ok) { + this.subscribers = []; + await this.findGroupsForManagedUser(this.user); + generateFlash(`${username ?? email} ${this.txt.removeSubscriberSuccess} ${(displayExtension)}`, 'success'); + } else { + this.disabled = [ ...this.disabled, id ]; + generateFlash(this.txt.removeSubscriberError, 'error'); + let errorResponse = await resp.json(); + generateFlash(`${errorResponse.message}`, 'error'); + } + }, + async findGroupsForManagedUser(user) { + this.user = user + // Reset + this.reset() + const { identifier: id } = user; + this.loading = !this.rowLoading && true; + const resp = await fetch(`${this.api.managing}?memberid=${id}`, { + headers: { + "Accept": "application/json", + }, + method: "GET" + }); + if (resp.ok) { + this.results = await resp.json(); + generateFlash(`${id} has ${this.results.length} ${this.txt.memberships}`, 'success'); + } else { + let errorResponse = await resp.json(); + generateFlash(`${errorResponse.message}`, 'error'); + } + + this.searched = true; + this.loading = false; + } + }, + template: /*html*/` + + + + + + ` +} \ No newline at end of file diff --git a/webroot/js/pagecount.js b/webroot/js/pagecount.js index 1b41421..5f9c83e 100644 --- a/webroot/js/pagecount.js +++ b/webroot/js/pagecount.js @@ -11,12 +11,16 @@ export default { total: { type: Number, default: 100 + }, + searched: { + type: Boolean, + default: true } }, inject: ['txt'], template: /*html*/`
    -
    +