Repository: incubator-zeppelin Updated Branches: refs/heads/master 486ed2375 -> de5f55a3c
Auto-suggestion feature for notebook permissions ### What is this PR for? This PR will provide user-friendly way to give notebook permissions. It will show suggestions on the basis of usernames matching the input characters. It will communicate with Shiro to fetch usernames from the configured realms and select usernames matching the input character and shows it to the user as suggestions. Currently, it works with shiro.ini file, LDAP, JDBC. In future we can extend it to work with other realms too. ### What type of PR is it? Improvement ### Todos ### What is the Jira issue? [ZEPPELIN-933](https://issues.apache.org/jira/browse/ZEPPELIN-933) ### How should this be tested? 1. Authenticate Zeppelin with any of the realms mention above(LDAP, JDBC,ini). 2. In notebook permission section, try giving permissions to some notebook and press some initial characters . It should show matching users as suggestions and you should be able to select users from the suggestions. 3. You should be able to give usernames separated by comma and finally save it. ### Screenshots (if appropriate)  ### Questions: * Does the licenses files need update?no * Is there breaking changes for older versions?no * Does this needs documentation?maybe Author: Ravi Ranjan <[email protected]> Author: Prabhjyot Singh <[email protected]> Closes #937 from ravicodder/auto-suggestion and squashes the following commits: 4b4c826 [Ravi Ranjan] Merge branch 'master' of https://github.com/apache/incubator-zeppelin into auto-suggestion b4ef169 [Ravi Ranjan] use width in palce of margin-right 30bdae4 [Ravi Ranjan] Add log and make UI look better b7333d6 [Ravi Ranjan] reduce the size of autosugesstion and add sepreator 95a420e [Ravi Ranjan] change codeing style 140af69 [Ravi Ranjan] Merge branch 'master' of https://github.com/apache/incubator-zeppelin into auto-suggestion 6627019 [Ravi Ranjan] Merge branch 'master' of https://github.com/apache/incubator-zeppelin into auto-suggestion cdc50b3 [Ravi Ranjan] Modified notbook.html , include alert on save, modify condition to identify the realm 80ace5f [Prabhjyot Singh] aa ef3658c [Ravi Ranjan] add GetUserList.java file 060f27a [Ravi Ranjan] add auto-suggest option Project: http://git-wip-us.apache.org/repos/asf/incubator-zeppelin/repo Commit: http://git-wip-us.apache.org/repos/asf/incubator-zeppelin/commit/de5f55a3 Tree: http://git-wip-us.apache.org/repos/asf/incubator-zeppelin/tree/de5f55a3 Diff: http://git-wip-us.apache.org/repos/asf/incubator-zeppelin/diff/de5f55a3 Branch: refs/heads/master Commit: de5f55a3c6460a7941baa14fa10b8fc7c5da76e4 Parents: 486ed23 Author: Ravi Ranjan <[email protected]> Authored: Mon Jun 6 10:47:07 2016 +0530 Committer: Prabhjyot Singh <[email protected]> Committed: Tue Jun 7 10:48:16 2016 +0530 ---------------------------------------------------------------------- .../org/apache/zeppelin/rest/GetUserList.java | 153 +++++++++++ .../apache/zeppelin/rest/SecurityRestApi.java | 62 ++++- .../src/app/notebook/notebook.controller.js | 256 +++++++++++++++++-- zeppelin-web/src/app/notebook/notebook.css | 63 +++++ zeppelin-web/src/app/notebook/notebook.html | 46 +++- 5 files changed, 552 insertions(+), 28 deletions(-) ---------------------------------------------------------------------- http://git-wip-us.apache.org/repos/asf/incubator-zeppelin/blob/de5f55a3/zeppelin-server/src/main/java/org/apache/zeppelin/rest/GetUserList.java ---------------------------------------------------------------------- diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/rest/GetUserList.java b/zeppelin-server/src/main/java/org/apache/zeppelin/rest/GetUserList.java new file mode 100644 index 0000000..c603fe9 --- /dev/null +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/rest/GetUserList.java @@ -0,0 +1,153 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.zeppelin.rest; + +import org.apache.commons.lang3.reflect.FieldUtils; +import org.apache.shiro.realm.jdbc.JdbcRealm; +import org.apache.shiro.realm.ldap.JndiLdapContextFactory; +import org.apache.shiro.realm.ldap.JndiLdapRealm; +import org.apache.shiro.realm.text.IniRealm; +import org.apache.shiro.util.JdbcUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.naming.NamingEnumeration; +import javax.naming.directory.Attributes; +import javax.naming.directory.SearchControls; +import javax.naming.directory.SearchResult; +import javax.naming.ldap.LdapContext; +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/** + * This is class which help fetching users from different realms. + * getUserList() function is overloaded and according to the realm passed to the function it + * extracts users from its respective realm + */ +public class GetUserList { + + private static final Logger LOG = LoggerFactory.getLogger(GetUserList.class); + + /** + * function to extract users from shiro.ini + */ + public List<String> getUserList(IniRealm r) { + List<String> userList = new ArrayList<>(); + Map getIniUser = r.getIni().get("users"); + Iterator it = getIniUser.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry pair = (Map.Entry) it.next(); + userList.add(pair.getKey().toString()); + } + return userList; + } + + /** + * function to extract users from LDAP + */ + public List<String> getUserList(JndiLdapRealm r) { + List<String> userList = new ArrayList<>(); + String userDnTemplate = r.getUserDnTemplate(); + String userDn[] = userDnTemplate.split(",", 2); + String userDnPrefix = userDn[0].split("=")[0]; + String userDnSuffix = userDn[1]; + JndiLdapContextFactory CF = (JndiLdapContextFactory) r.getContextFactory(); + try { + LdapContext ctx = CF.getSystemLdapContext(); + SearchControls constraints = new SearchControls(); + constraints.setSearchScope(SearchControls.SUBTREE_SCOPE); + String[] attrIDs = {userDnPrefix}; + constraints.setReturningAttributes(attrIDs); + NamingEnumeration result = ctx.search(userDnSuffix, "(objectclass=*)", constraints); + while (result.hasMore()) { + Attributes attrs = ((SearchResult) result.next()).getAttributes(); + if (attrs.get(userDnPrefix) != null) { + String currentUser = attrs.get(userDnPrefix).toString(); + userList.add(currentUser.split(":")[1]); + } + } + } catch (Exception e) { + LOG.error("Error retrieving User list from Ldap Realm", e); + } + return userList; + } + + /** + * function to extract users from JDBCs + */ + public List<String> getUserList(JdbcRealm obj) { + List<String> userlist = new ArrayList<>(); + PreparedStatement ps = null; + ResultSet rs = null; + DataSource dataSource = null; + String authQuery = ""; + String retval[]; + String tablename = ""; + String username = ""; + String userquery = ""; + try { + dataSource = (DataSource) FieldUtils.readField(obj, "dataSource", true); + authQuery = (String) FieldUtils.readField(obj, "DEFAULT_AUTHENTICATION_QUERY", true); + LOG.info(authQuery); + String authQueryLowerCase = authQuery.toLowerCase(); + retval = authQueryLowerCase.split("from", 2); + if (retval.length >= 2) { + retval = retval[1].split("with|where", 2); + tablename = retval[0]; + retval = retval[1].split("where", 2); + if (retval.length >= 2) + retval = retval[1].split("=", 2); + else + retval = retval[0].split("=", 2); + username = retval[0]; + } + + if (username.equals("") || tablename.equals("")){ + return userlist; + } + + userquery = "select " + username + " from " + tablename; + + } catch (IllegalAccessException e) { + LOG.error("Error while accessing dataSource for JDBC Realm", e); + return null; + } + + try { + Connection con = dataSource.getConnection(); + ps = con.prepareStatement(userquery); + rs = ps.executeQuery(); + while (rs.next()) { + userlist.add(rs.getString(1)); + } + } catch (Exception e) { + LOG.error("Error retrieving User list from JDBC Realm", e); + } finally { + JdbcUtils.closeResultSet(rs); + JdbcUtils.closeStatement(ps); + } + return userlist; + } + +} http://git-wip-us.apache.org/repos/asf/incubator-zeppelin/blob/de5f55a3/zeppelin-server/src/main/java/org/apache/zeppelin/rest/SecurityRestApi.java ---------------------------------------------------------------------- diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/rest/SecurityRestApi.java b/zeppelin-server/src/main/java/org/apache/zeppelin/rest/SecurityRestApi.java index 342cb00..e344956 100644 --- a/zeppelin-server/src/main/java/org/apache/zeppelin/rest/SecurityRestApi.java +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/rest/SecurityRestApi.java @@ -17,6 +17,13 @@ package org.apache.zeppelin.rest; + +import org.apache.shiro.realm.Realm; +import org.apache.shiro.realm.jdbc.JdbcRealm; +import org.apache.shiro.realm.ldap.JndiLdapRealm; +import org.apache.shiro.realm.text.IniRealm; +import org.apache.shiro.util.ThreadContext; +import org.apache.shiro.web.mgt.DefaultWebSecurityManager; import org.apache.zeppelin.annotation.ZeppelinApi; import org.apache.zeppelin.conf.ZeppelinConfiguration; import org.apache.zeppelin.server.JsonResponse; @@ -27,11 +34,10 @@ import org.slf4j.LoggerFactory; import javax.ws.rs.GET; import javax.ws.rs.Path; +import javax.ws.rs.PathParam; import javax.ws.rs.Produces; import javax.ws.rs.core.Response; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; +import java.util.*; /** * Zeppelin security rest api endpoint. @@ -81,4 +87,54 @@ public class SecurityRestApi { LOG.warn(response.toString()); return response.build(); } + + /** + * Get userlist + * Returns list of all user from available realms + * + * @return 200 response + */ + @GET + @Path("userlist/{searchText}") + public Response getUserList(@PathParam("searchText") String searchText) { + + List<String> usersList = new ArrayList<>(); + try { + GetUserList getUserListObj = new GetUserList(); + DefaultWebSecurityManager defaultWebSecurityManager; + String key = ThreadContext.SECURITY_MANAGER_KEY; + defaultWebSecurityManager = (DefaultWebSecurityManager) ThreadContext.get(key); + Collection<Realm> realms = defaultWebSecurityManager.getRealms(); + List realmsList = new ArrayList(realms); + for (int i = 0; i < realmsList.size(); i++) { + String name = ((Realm) realmsList.get(i)).getName(); + if (name.equals("iniRealm")) { + usersList.addAll(getUserListObj.getUserList((IniRealm) realmsList.get(i))); + } else if (name.equals("ldapRealm")) { + usersList.addAll(getUserListObj.getUserList((JndiLdapRealm) realmsList.get(i))); + } else if (name.equals("jdbcRealm")) { + usersList.addAll(getUserListObj.getUserList((JdbcRealm) realmsList.get(i))); + } + } + + } catch (Exception e) { + LOG.error("Exception in retrieving Users from realms ", e); + } + List<String> autoSuggestList = new ArrayList<>(); + Collections.sort(usersList); + int maxLength = 0; + for (int i = 0; i < usersList.size(); i++) { + String userLowerCase = usersList.get(i).toLowerCase(); + String searchTextLowerCase = searchText.toLowerCase(); + if (userLowerCase.indexOf(searchTextLowerCase) != -1) { + maxLength++; + autoSuggestList.add(usersList.get(i)); + } + if (maxLength == 5) { + break; + } + } + return new JsonResponse<>(Response.Status.OK, "", autoSuggestList).build(); + } + } http://git-wip-us.apache.org/repos/asf/incubator-zeppelin/blob/de5f55a3/zeppelin-web/src/app/notebook/notebook.controller.js ---------------------------------------------------------------------- diff --git a/zeppelin-web/src/app/notebook/notebook.controller.js b/zeppelin-web/src/app/notebook/notebook.controller.js index 4149311..32c659f 100644 --- a/zeppelin-web/src/app/notebook/notebook.controller.js +++ b/zeppelin-web/src/app/notebook/notebook.controller.js @@ -43,6 +43,18 @@ angular.module('zeppelinWebApp').controller('NotebookCtrl', var connectedOnce = false; + // user auto complete related + $scope.suggestions = []; + $scope.selectIndex = -1; + var selectedUser = ''; + var selectedUserIndex = 0; + var previousSelectedList = []; + var previousSelectedListOwners = []; + var previousSelectedListReaders = []; + var previousSelectedListWriters = []; + var searchText = []; + $scope.role = ''; + $scope.$on('setConnectedStatus', function(event, param) { if(connectedOnce && param){ initNotebook(); @@ -683,36 +695,57 @@ angular.module('zeppelinWebApp').controller('NotebookCtrl', } }; - $scope.savePermissions = function() { - $http.put(baseUrlSrv.getRestApiBase() + '/notebook/' +$scope.note.id + '/permissions', + function convertPermissionsToArray() { + if (!angular.isArray($scope.permissions.owners)) { + $scope.permissions.owners = $scope.permissions.owners.split(','); + } + if (!angular.isArray($scope.permissions.readers)) { + $scope.permissions.readers = $scope.permissions.readers.split(','); + } + if (!angular.isArray($scope.permissions.writers)) { + $scope.permissions.writers = $scope.permissions.writers.split(','); + } + } + + $scope.savePermissions = function () { + convertPermissionsToArray(); + $http.put(baseUrlSrv.getRestApiBase() + '/notebook/' + $scope.note.id + '/permissions', $scope.permissions, {withCredentials: true}). - success(function(data, status, headers, config) { - console.log('Note permissions %o saved', $scope.permissions); - $scope.showPermissions = false; - }). - error(function(data, status, headers, config) { - console.log('Error %o %o', status, data.message); - BootstrapDialog.show({ + success(function (data, status, headers, config) { + console.log('Note permissions %o saved', $scope.permissions); + BootstrapDialog.alert({ closable: true, - title: 'Insufficient privileges', + title: 'Permissions Saved Successfully!!!', + message: 'Owners : ' + $scope.permissions.owners + '\n\n' + 'Readers : ' + $scope.permissions.readers + '\n\n' + 'Writers : ' + $scope.permissions.writers + }); + $scope.showPermissions = false; + }). + error(function (data, status, headers, config) { + console.log('Error %o %o', status, data.message); + BootstrapDialog.show({ + closable: true, + title: 'Insufficient privileges', message: data.message, - buttons: [{ + buttons: [ + { label: 'Login', - action: function(dialog) { - dialog.close(); - angular.element('#loginModal').modal({ - show: 'true' - }); + action: function (dialog) { + dialog.close(); + angular.element('#loginModal').modal({ + show: 'true' + }); } - }, { + }, + { label: 'Cancel', - action: function(dialog){ - dialog.close(); + action: function (dialog) { + dialog.close(); } - }] + } + ] + }); }); - }); - }; + }; $scope.togglePermissions = function() { if ($scope.showPermissions) { @@ -739,4 +772,183 @@ angular.module('zeppelinWebApp').controller('NotebookCtrl', } }; + function checkPreviousRole(role) { + var i = 0; + if (role !== $scope.role) { + if ($scope.role === 'owners') { + previousSelectedListOwners = []; + for (i = 0; i < previousSelectedList.length; i++) { + previousSelectedListOwners[i] = previousSelectedList[i]; + } + } + if ($scope.role === 'readers') { + previousSelectedListReaders = []; + for (i = 0; i < previousSelectedList.length; i++) { + previousSelectedListReaders[i] = previousSelectedList[i]; + } + } + if ($scope.role === 'writers') { + previousSelectedListWriters = []; + for (i = 0; i < previousSelectedList.length; i++) { + previousSelectedListWriters[i] = previousSelectedList[i]; + } + } + + $scope.role = role; + previousSelectedList = []; + if (role === 'owners') { + for (i = 0; i < previousSelectedListOwners.length; i++) { + previousSelectedList[i] = previousSelectedListOwners[i]; + } + } + if (role === 'readers') { + for (i = 0; i < previousSelectedListReaders.length; i++) { + previousSelectedList[i] = previousSelectedListReaders[i]; + } + } + if (role === 'writers') { + for (i = 0; i < previousSelectedListWriters.length; i++) { + previousSelectedList[i] = previousSelectedListWriters[i]; + } + } + } + } + + + function convertToArray(role) { + if (role === 'owners') { + searchText = $scope.permissions.owners.split(','); + } + else if (role === 'readers') { + searchText = $scope.permissions.readers.split(','); + } + else if (role === 'writers') { + searchText = $scope.permissions.writers.split(','); + } + for (var i = 0; i < searchText.length; i++) { + searchText[i] = searchText[i].trim(); + } + } + + + function convertToString(role) { + if (role === 'owners') { + $scope.permissions.owners = searchText.join(); + } + else if (role === 'readers') { + $scope.permissions.readers = searchText.join(); + } + else if (role === 'writers') { + $scope.permissions.writers = searchText.join(); + } + } + + function getSuggestions (searchQuery) { + $scope.suggestions =[]; + $http.get(baseUrlSrv.getRestApiBase() + '/security/userlist/' + searchQuery ).then(function + (response) { + var userlist = angular.fromJson(response.data).body; + for (var k in userlist) { + $scope.suggestions.push(userlist[k]); + } + }); + } + + function updatePreviousList() { + for (var i = 0; i < searchText.length; i++) { + previousSelectedList[i] = searchText[i]; + } + } + + + var getChangedIndex = function() { + if (previousSelectedList.length === 0) { + selectedUserIndex = searchText.length - 1; + } + else { + for (var i = 0; i < searchText.length; i++) { + if (previousSelectedList[i] !== searchText[i]) { + selectedUserIndex = i; + previousSelectedList = []; + break; + } + } + } + updatePreviousList(); + }; + + // function to find suggestion list on change + $scope.search = function(role) { + convertToArray(role); + checkPreviousRole(role); + getChangedIndex(); + $scope.selectIndex = -1; + $scope.suggestions = []; + selectedUser = searchText[selectedUserIndex]; + if(selectedUser !== ''){ + getSuggestions(selectedUser); + } + else + { + $scope.suggestions = []; + } + }; + + + var checkIfSelected = function() { + if (($scope.suggestions.length === 0) && ($scope.selectIndex < 0 || $scope.selectIndex >= $scope.suggestions.length) || ( $scope.suggestions.length !== 0 && ( $scope.selectIndex < 0 || $scope.selectIndex >= $scope.suggestions.length ))) { + searchText[selectedUserIndex] = selectedUser; + $scope.suggestions = []; + return true; + } + else { + return false; + } + }; + + + $scope.checkKeyDown = function(event, role) { + if (event.keyCode === 40) { + event.preventDefault(); + if ($scope.selectIndex + 1 !== $scope.suggestions.length) { + $scope.selectIndex++; + } + } + else if (event.keyCode === 38) { + event.preventDefault(); + + if ($scope.selectIndex - 1 !== -1) { + $scope.selectIndex--; + + } + } + else if (event.keyCode === 13) { + event.preventDefault(); + if (!checkIfSelected()) { + selectedUser = $scope.suggestions[$scope.selectIndex]; + searchText[selectedUserIndex] = $scope.suggestions[$scope.selectIndex]; + updatePreviousList(); + convertToString(role); + $scope.suggestions = []; + } + } + }; + + $scope.checkKeyUp = function(event) { + if (event.keyCode !== 8 || event.keyCode !== 46) { + if (searchText[selectedUserIndex] === '') { + $scope.suggestions = []; + } + } + }; + + + $scope.assignValueAndHide = function(index, role) { + searchText[selectedUserIndex] = $scope.suggestions[index]; + updatePreviousList(); + convertToString(role); + $scope.suggestions = []; + }; + + }); http://git-wip-us.apache.org/repos/asf/incubator-zeppelin/blob/de5f55a3/zeppelin-web/src/app/notebook/notebook.css ---------------------------------------------------------------------- diff --git a/zeppelin-web/src/app/notebook/notebook.css b/zeppelin-web/src/app/notebook/notebook.css index 9169e80..6cdc407 100644 --- a/zeppelin-web/src/app/notebook/notebook.css +++ b/zeppelin-web/src/app/notebook/notebook.css @@ -154,6 +154,20 @@ border: 1px solid #E5E5E5; } +.permissions .owners { + width:60px; + display: inline-block; +} + +.permissions .readers { + width:60px; + display: inline-block; +} + +.permissions .writers { + width:60px; + display: inline-block; +} /* Note Setting Panel */ @@ -222,3 +236,52 @@ text-decoration: none; cursor: default; } + +.userlist { + width: 230px; + font-family: Georgia, Times, serif; + font-size: 15px; + position: absolute; + z-index: 9999; +} + +.userlist ul { + list-style: none; +} + +.userlist ul li { + box-shadow: 3px 3px 5px #888888; + display: list-item; + text-decoration: none; + color: #000000; + background-color: #FFFFFF; + line-height: 30px; + border-bottom-style: none; + border-bottom-width: 1px; + border-bottom:1px #CCCCCC solid; + padding-left: 10px; + cursor: pointer; +} + +.userlist ul li:first-child { + border-top-right-radius: 5px; + border-top-left-radius: 5px; +} + +.userlist ul li:last-child { + border-bottom-right-radius: 5px; + border-bottom-left-radius: 5px; +} + +.userlist ul li strong { + margin-right: 10px; +} + +.userlist li:hover { + background-color: #E0E0E0; +} + +.userlist li:active, +.userlist li.active { + background-color: #428BCA; +} http://git-wip-us.apache.org/repos/asf/incubator-zeppelin/blob/de5f55a3/zeppelin-web/src/app/notebook/notebook.html ---------------------------------------------------------------------- diff --git a/zeppelin-web/src/app/notebook/notebook.html b/zeppelin-web/src/app/notebook/notebook.html index d4b72bc..de8cbdf 100644 --- a/zeppelin-web/src/app/notebook/notebook.html +++ b/zeppelin-web/src/app/notebook/notebook.html @@ -70,9 +70,49 @@ limitations under the License. </p> <div class="permissionsForm" data-ng-model="permissions"> - <p>Owners : <input ng-list ng-model="permissions.owners" placeholder="*"> Owners can change permissions, read and write the note. </p> - <p>Readers : <input ng-list ng-model="permissions.readers" placeholder="*"> Readers can only read the note.</p> - <p>Writers : <input ng-list ng-model="permissions.writers" placeholder="*"> Writers can read and write the note.</p> + <p><span class="owners">Owners </span><input ng-model="permissions.owners" + placeholder="search for users" + class="input" ng-change="search('owners')" + ng-keydown="checkKeyDown($event,'owners')" + ng-keyup="checkKeyUp($event)"> Owners can change permissions,read + and write the note.</p> + <div ng-if="role === 'owners'" class="userlist" > + <ul> + <li ng-repeat="suggestion in suggestions" + ng-class="{active : selectIndex === $index }" + ng-click="assignValueAndHide($index,'owners')" > + {{suggestion}} + </li> + </ul> + </div> + <p><span class="readers">Readers </span><input ng-model="permissions.readers" + placeholder="search for users" + class="input" ng-change="search('readers')" + ng-keydown="checkKeyDown($event,'readers')" + ng-keyup="checkKeyUp($event)"> Readers can only read the note.</p> + <div ng-if="role === 'readers'" class="userlist"> + <ul> + <li ng-repeat="suggestion in suggestions" + ng-class="{active : selectIndex === $index }" + ng-click="assignValueAndHide($index,'readers')" > + {{suggestion}} + </li> + </ul> + </div> + <p><span class="writers">Writers </span><input ng-model="permissions.writers" + placeholder="search for users" + class="input" ng-change="search('writers')" + ng-keydown="checkKeyDown($event,'writers')" + ng-keyup="checkKeyUp($event)"> Writers can read and write the note.</p> + <div ng-if="role === 'writers'" class="userlist"> + <ul> + <li ng-repeat="suggestion in suggestions" + ng-class="{active : selectIndex === $index }" + ng-click="assignValueAndHide($index,'writers')"> + {{suggestion}} + </li> + </ul> + </div> </div> </div> <br />
